Compare commits
No commits in common. "c55806d180443a1543da85559c738bcf2d4fe7d7" and "dd0aa2d706a1233cfd0f9564938e304645f3357b" have entirely different histories.
c55806d180
...
dd0aa2d706
|
|
@ -27,8 +27,7 @@
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"vite-plugin-svgr": "^4.3.0",
|
"vite-plugin-svgr": "^4.3.0",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1"
|
||||||
"antd": "^5.24.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|
|
||||||
128
src/App.jsx
128
src/App.jsx
|
|
@ -1,115 +1,48 @@
|
||||||
import React, { useState, useMemo, useEffect } from "react";
|
import React, { useState, useMemo } from "react";
|
||||||
import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress, Typography } from "@mui/material";
|
import { ThemeProvider, CssBaseline, Switch, Box } from "@mui/material";
|
||||||
import Dashboard from "./Components/Layout/Dashboard";
|
import Dashboard from "./Components/Layout/Dashboard";
|
||||||
import LoginModal from "./Components/UI/LoginModal";
|
import LoginModal from "./Components/UI/LoginModal";
|
||||||
import { lightTheme, darkTheme } from "./Style/theme";
|
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() {
|
function App() {
|
||||||
const [authState, setAuthState] = useState({
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
isAuthenticated: false,
|
const [showLoginModal, setShowLoginModal] = useState(true);
|
||||||
isLoading: true,
|
|
||||||
user: null
|
|
||||||
});
|
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(
|
const [isDarkMode, setIsDarkMode] = useState(
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
);
|
);
|
||||||
|
|
||||||
const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]);
|
const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleLogin = () => {
|
||||||
const verifyAuth = async () => {
|
setIsAuthenticated(true);
|
||||||
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);
|
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 (
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
<Box sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0, left: 0, right: 0, bottom: 0,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexDirection: 'column',
|
|
||||||
zIndex: 9999,
|
|
||||||
bgcolor: 'background.default'
|
|
||||||
}}>
|
|
||||||
<CircularProgress />
|
|
||||||
<Typography sx={{ mt: 2 }}>
|
|
||||||
Проверка авторизации...
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
{!authState.isAuthenticated ? (
|
{!isAuthenticated && showLoginModal ? (
|
||||||
<>
|
<>
|
||||||
<Box sx={{
|
{/* Логотип */}
|
||||||
position: "fixed",
|
<Box
|
||||||
top: 24,
|
component="div"
|
||||||
left: "50%",
|
sx={{
|
||||||
transform: "translateX(-50%)",
|
position: "fixed",
|
||||||
zIndex: 1200,
|
top: 24,
|
||||||
'& svg': { width: 400, height: 'auto' }
|
left: "50%", // Сдвигаем начало логотипа в центр
|
||||||
}}>
|
transform: "translateX(-50%)", // Смещаем назад на половину ширины логотипа
|
||||||
|
zIndex: 1200,
|
||||||
|
'& svg': {
|
||||||
|
width: 400,
|
||||||
|
height: 'auto'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Logo />
|
<Logo />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<LoginModal
|
<LoginModal
|
||||||
open={showLoginModal}
|
|
||||||
onLogin={handleLogin}
|
onLogin={handleLogin}
|
||||||
onClose={() => setShowLoginModal(false)}
|
onClose={() => setShowLoginModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -119,14 +52,13 @@ function App() {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
bgcolor: "background.default"
|
bgcolor: "background.default",
|
||||||
|
color: "text.primary"
|
||||||
}}>
|
}}>
|
||||||
<Dashboard
|
<Dashboard />
|
||||||
user={authState.user}
|
<Box sx={{ position: "absolute", top: 10, right: 10 }}>
|
||||||
onLogout={handleLogout}
|
<Switch checked={isDarkMode} onChange={() => setIsDarkMode((prev) => !prev)} />
|
||||||
isDarkMode={isDarkMode}
|
</Box>
|
||||||
setIsDarkMode={setIsDarkMode}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
const ChartSkeleton = () => (
|
|
||||||
<Box sx={{
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<Box sx={{ position: 'absolute', right: '20px', top: '20px' }}>
|
|
||||||
<Skeleton variant="circular" width={16} height={16} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
|
||||||
<Skeleton variant="text" width="40%" height={30} />
|
|
||||||
<Skeleton variant="text" width="30%" height={30} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Skeleton variant="rectangular" width="100%" height={300} />
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, gap: 2 }}>
|
|
||||||
{[1, 2, 3, 4].map((_, i) => (
|
|
||||||
<Skeleton key={i} variant="rounded" width={80} height={36} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer, ReferenceArea } from 'recharts';
|
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer, ReferenceArea } from 'recharts';
|
||||||
import { Skeleton } from '@mui/material';
|
|
||||||
import { HOUR, DAY } from './constants';
|
import { HOUR, DAY } from './constants';
|
||||||
|
|
||||||
const TIME_FORMATS = {
|
const TIME_FORMATS = {
|
||||||
LONG: 'dd.MM HH:mm', // Для диапазона > 24 часов
|
LONG: 'dd.MM HH:mm', // Для диапазона > 24 часов
|
||||||
MEDIUM: 'HH:mm', // Для диапазона > 1 часа
|
MEDIUM: 'HH:mm', // Для диапазона > 1 часа
|
||||||
|
|
@ -53,6 +51,7 @@ const LineChartComponent = ({
|
||||||
? Object.keys(displayData[0]).filter(k => !['timestamp', 'time', 'fullTime'].includes(k))
|
? Object.keys(displayData[0]).filter(k => !['timestamp', 'time', 'fullTime'].includes(k))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Функция для определения оптимального формата времени в зависимости от диапазона
|
||||||
const getTimeFormat = () => {
|
const getTimeFormat = () => {
|
||||||
if (!data.length) return TIME_FORMATS.SHORT;
|
if (!data.length) return TIME_FORMATS.SHORT;
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ const LineChartComponent = ({
|
||||||
const handleMouseDown = (e) => {
|
const handleMouseDown = (e) => {
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
|
|
||||||
|
// Получаем индекс точки по координатам
|
||||||
const activeIndex = e.activeTooltipIndex;
|
const activeIndex = e.activeTooltipIndex;
|
||||||
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
|
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
|
||||||
|
|
||||||
|
|
@ -113,6 +113,7 @@ const LineChartComponent = ({
|
||||||
const startIndex = Math.min(selectionArea.startIndex, selectionArea.endIndex);
|
const startIndex = Math.min(selectionArea.startIndex, selectionArea.endIndex);
|
||||||
const endIndex = Math.max(selectionArea.startIndex, selectionArea.endIndex);
|
const endIndex = Math.max(selectionArea.startIndex, selectionArea.endIndex);
|
||||||
|
|
||||||
|
// Нормализуем индексы к диапазону [0, 1] для родительского компонента
|
||||||
const normalizedStart = startIndex / (data.length - 1);
|
const normalizedStart = startIndex / (data.length - 1);
|
||||||
const normalizedEnd = endIndex / (data.length - 1);
|
const normalizedEnd = endIndex / (data.length - 1);
|
||||||
|
|
||||||
|
|
@ -151,18 +152,7 @@ const LineChartComponent = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
return (
|
return <div style={{ padding: '20px', textAlign: 'center' }}>Нет данных для отображения</div>;
|
||||||
<Box sx={{
|
|
||||||
position: 'relative',
|
|
||||||
height: '400px',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '20px'
|
|
||||||
}}>
|
|
||||||
<Skeleton variant="text" width="60%" height={30} sx={{ mb: 2 }} />
|
|
||||||
<Skeleton variant="rectangular" width="100%" height="calc(100% - 50px)" />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -213,7 +203,7 @@ const LineChartComponent = ({
|
||||||
{instanceKeys.map((instance, index) => (
|
{instanceKeys.map((instance, index) => (
|
||||||
<Line
|
<Line
|
||||||
key={instance}
|
key={instance}
|
||||||
type=""
|
type="monotone"
|
||||||
dataKey={instance}
|
dataKey={instance}
|
||||||
name={instance}
|
name={instance}
|
||||||
stroke={colors[index % colors.length]}
|
stroke={colors[index % colors.length]}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,11 @@
|
||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { webSocketManager } from './WebSocketManager';
|
import { io } from 'socket.io-client';
|
||||||
import LineChartComponent from './Components/LineChartComponent';
|
import LineChartComponent from './Components/LineChartComponent';
|
||||||
import { TimeRangeSelector } from './Components/TimeRangeSelector';
|
import { TimeRangeSelector } from './Components/TimeRangeSelector';
|
||||||
import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator';
|
import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator';
|
||||||
import { CurrentRangeDisplay } from './Components/CurrentRangeDisplay';
|
import { CurrentRangeDisplay } from './Components/CurrentRangeDisplay';
|
||||||
import { TIME_RANGES, COLORS, SECOND, MINUTE, HOUR, DAY } from './Components/constants';
|
import { TIME_RANGES, COLORS, SECOND, MINUTE, HOUR, DAY } from './Components/constants';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Skeleton from '@mui/material/Skeleton';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
|
|
||||||
|
|
||||||
// Компонент Skeleton для графика
|
|
||||||
const ChartSkeleton = () => (
|
|
||||||
<Box sx={{
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<Box sx={{ position: 'absolute', right: '20px', top: '20px' }}>
|
|
||||||
<Skeleton variant="circular" width={16} height={16} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
|
||||||
<Skeleton variant="text" width="40%" height={30} />
|
|
||||||
<Skeleton variant="text" width="30%" height={30} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Skeleton variant="rectangular" width="100%" height={300} />
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, gap: 2 }}>
|
|
||||||
{[1, 2, 3, 4].map((_, i) => (
|
|
||||||
<Skeleton key={i} variant="rounded" width={80} height={36} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
const PrometheusChart = ({ metricName }) => {
|
const PrometheusChart = ({ metricName }) => {
|
||||||
const [chartData, setChartData] = useState(null);
|
const [chartData, setChartData] = useState(null);
|
||||||
|
|
@ -50,6 +19,7 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
const [isSelectingRange, setIsSelectingRange] = useState(false);
|
const [isSelectingRange, setIsSelectingRange] = useState(false);
|
||||||
const [lastCustomRange, setLastCustomRange] = useState(null);
|
const [lastCustomRange, setLastCustomRange] = useState(null);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
|
const socketRef = useRef(null);
|
||||||
const debounceRef = useRef(null);
|
const debounceRef = useRef(null);
|
||||||
|
|
||||||
const formatTime = useCallback((timestamp, rangeSeconds) => {
|
const formatTime = useCallback((timestamp, rangeSeconds) => {
|
||||||
|
|
@ -100,65 +70,8 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
return 1800; // > 24 часов
|
return 1800; // > 24 часов
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
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(() => {
|
const fetchData = useCallback(() => {
|
||||||
|
|
||||||
if (isSelectingRange) return;
|
if (isSelectingRange) return;
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
@ -166,24 +79,116 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
const end = now;
|
const end = now;
|
||||||
const step = calculateStep(start, end);
|
const step = calculateStep(start, end);
|
||||||
|
|
||||||
webSocketManager.getMetricsRange(metricName, start, end, step)
|
if (socketRef.current?.connected) {
|
||||||
.then(data => {
|
socketRef.current.emit('get-metrics', {
|
||||||
processMetricsData({ metric: metricName, data });
|
metric: metricName,
|
||||||
})
|
start,
|
||||||
.catch(error => {
|
end,
|
||||||
console.error('Error fetching metrics:', error);
|
step,
|
||||||
|
_t: Date.now()
|
||||||
});
|
});
|
||||||
}, [metricName, selectedRange.value, isSelectingRange, calculateStep, processMetricsData]);
|
}
|
||||||
|
}, [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;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchCustomRangeData = useCallback(async () => {
|
const fetchCustomRangeData = useCallback(async () => {
|
||||||
// Добавляем проверку на валидность дат
|
|
||||||
if (!startDate || !endDate || startDate >= endDate) {
|
|
||||||
console.error('Invalid date range');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = Math.floor(startDate.getTime() / 1000);
|
const start = Math.floor(startDate.getTime() / 1000);
|
||||||
const end = Math.ceil(endDate.getTime() / 1000); // Используем Math.ceil для конечной даты
|
const end = Math.floor(endDate.getTime() / 1000);
|
||||||
const rangeSeconds = end - start;
|
const rangeSeconds = end - start;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -197,17 +202,17 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.length) {
|
if (response.data?.length) {
|
||||||
// Добавляем нормализацию timestamp
|
// Преобразуем данные перед передачей в processMetricsData
|
||||||
const processedData = response.data.map(item => ({
|
const processedData = response.data.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
timestamp: item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000,
|
timestamp: item.timestamp, // оставляем в секундах - processMetricsData конвертирует
|
||||||
value: parseFloat(item.value)
|
value: item.value.toString()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
processMetricsData({
|
processMetricsData({
|
||||||
metric: metricName,
|
metric: metricName,
|
||||||
data: processedData
|
data: processedData
|
||||||
}, true);
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при получении кастомных данных:', error);
|
console.error('Ошибка при получении кастомных данных:', error);
|
||||||
|
|
@ -215,7 +220,8 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
}, [metricName, startDate, endDate, calculateStep, processMetricsData]);
|
}, [metricName, startDate, endDate, calculateStep, processMetricsData]);
|
||||||
|
|
||||||
|
|
||||||
const handleRangeChange = useCallback(async (event) => {
|
const handleRangeChange = useCallback((event) => {
|
||||||
|
// Очищаем текущий интервал
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
|
|
@ -224,10 +230,9 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
const selectedValue = event.target.value;
|
const selectedValue = event.target.value;
|
||||||
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
|
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
|
||||||
|
|
||||||
// Полный сброс состояния перед загрузкой новых данных
|
|
||||||
setChartData(null);
|
|
||||||
setSelectedRange(range);
|
setSelectedRange(range);
|
||||||
setUseCustomRange(false);
|
setUseCustomRange(false);
|
||||||
|
setChartData(null);
|
||||||
setSelectedGraphRange(null);
|
setSelectedGraphRange(null);
|
||||||
setFilteredData(null);
|
setFilteredData(null);
|
||||||
|
|
||||||
|
|
@ -235,12 +240,20 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
setEndDate(now);
|
setEndDate(now);
|
||||||
setStartDate(new Date(now.getTime() - range.value * 1000));
|
setStartDate(new Date(now.getTime() - range.value * 1000));
|
||||||
|
|
||||||
// Ждем завершения обновления состояния перед загрузкой
|
// Переподключение сокета
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
if (!socketRef.current?.connected) {
|
||||||
fetchData();
|
socketRef.current?.connect();
|
||||||
}, [fetchData]);
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCustomRangeChange = useCallback(() => {
|
const handleCustomRangeChange = useCallback(() => {
|
||||||
|
// Отключаем WebSocket соединение
|
||||||
|
if (socketRef.current?.connected) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем интервал обновления
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
|
|
@ -253,7 +266,25 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
fetchCustomRangeData();
|
fetchCustomRangeData();
|
||||||
}, [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) => {
|
const interpolateData = useCallback((data, targetPointCount) => {
|
||||||
if (!data || data.length < 2) return data;
|
if (!data || data.length < 2) return data;
|
||||||
|
|
@ -306,6 +337,10 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
setIsSelectingRange(true);
|
setIsSelectingRange(true);
|
||||||
setSelectedGraphRange(range);
|
setSelectedGraphRange(range);
|
||||||
|
|
||||||
|
// Отключаем автоматические обновления
|
||||||
|
if (socketRef.current?.connected) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
}
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
|
|
@ -331,55 +366,23 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
setIsSelectingRange(false);
|
setIsSelectingRange(false);
|
||||||
}, [chartData, interpolateData, formatTime]);
|
}, [chartData, interpolateData, formatTime]);
|
||||||
|
|
||||||
const handleResetZoom = useCallback(() => {
|
|
||||||
setSelectedGraphRange(null);
|
|
||||||
setFilteredData(null);
|
|
||||||
setIsSelectingRange(false);
|
|
||||||
|
|
||||||
if (useCustomRange) {
|
|
||||||
fetchCustomRangeData();
|
|
||||||
} else {
|
|
||||||
fetchData();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastCustomRange) {
|
|
||||||
handleRangeSelect(lastCustomRange);
|
|
||||||
}
|
|
||||||
}, [fetchData, fetchCustomRangeData, useCustomRange, lastCustomRange, handleRangeSelect]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Обработчик данных с сервера
|
const socket = setupWebSocket();
|
||||||
const handleMetricsData = (data) => {
|
|
||||||
if (!useCustomRange) {
|
|
||||||
processMetricsData({ metric: metricName, data });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Подписываемся на обновления метрики
|
|
||||||
const unsubscribe = webSocketManager.subscribe(metricName, handleMetricsData);
|
|
||||||
|
|
||||||
// Подписываемся на изменения статуса соединения
|
|
||||||
const unsubscribeStatus = webSocketManager.onConnectionStatusChange(setConnectionStatus);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Отписываемся при размонтировании компонента
|
clearInterval(intervalRef.current);
|
||||||
unsubscribe();
|
socket.disconnect();
|
||||||
unsubscribeStatus();
|
|
||||||
|
|
||||||
// Очищаем интервал обновления
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [metricName, useCustomRange, processMetricsData]);
|
}, [setupWebSocket]);
|
||||||
|
|
||||||
|
|
||||||
|
// Обновим useEffect для кастомного диапазона
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (useCustomRange && !isSelectingRange) {
|
if (useCustomRange && !isSelectingRange) {
|
||||||
|
// Очищаем предыдущий таймер
|
||||||
if (debounceRef.current) {
|
if (debounceRef.current) {
|
||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Устанавливаем новый таймер с задержкой 500 мс
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
fetchCustomRangeData();
|
fetchCustomRangeData();
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
@ -403,11 +406,12 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Очищаем предыдущий интервал
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Запускаем сразу и затем по интервалу
|
||||||
fetchDataWrapper();
|
fetchDataWrapper();
|
||||||
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
|
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
|
||||||
|
|
||||||
|
|
@ -439,21 +443,11 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
}, [selectedGraphRange, chartData, interpolateData]);
|
}, [selectedGraphRange, chartData, interpolateData]);
|
||||||
|
|
||||||
if (chartData === null) {
|
if (chartData === null) {
|
||||||
return <ChartSkeleton />;
|
return <div style={{ padding: '20px', textAlign: 'center' }}>Loading data...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(chartData).length === 0) {
|
if (Object.keys(chartData).length === 0) {
|
||||||
return (
|
return <div style={{ padding: '20px', textAlign: 'center' }}>No data available</div>;
|
||||||
<Box sx={{
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
No data available
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -497,3 +491,4 @@ const PrometheusChart = ({ metricName }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(PrometheusChart);
|
export default React.memo(PrometheusChart);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
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();
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import DatePicker from 'react-datepicker';
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
|
||||||
|
|
||||||
const DateRangeSelector = ({
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
onStartDateChange,
|
|
||||||
onEndDateChange,
|
|
||||||
onApply
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
marginTop: 10,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
padding: '15px',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
marginBottom: '10px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#555'
|
|
||||||
}}>
|
|
||||||
Укажите диапазон дат:
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '10px',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
alignItems: 'flex-end'
|
|
||||||
}}>
|
|
||||||
<div style={{ flex: '1 1 200px' }}>
|
|
||||||
<DatePicker
|
|
||||||
selected={startDate}
|
|
||||||
onChange={onStartDateChange}
|
|
||||||
showTimeSelect
|
|
||||||
timeFormat="HH:mm"
|
|
||||||
timeIntervals={15}
|
|
||||||
dateFormat="yyyy-MM-dd HH:mm"
|
|
||||||
placeholderText="Начальная дата"
|
|
||||||
customInput={
|
|
||||||
<input style={{
|
|
||||||
backgroundColor: '#f9f9f9',
|
|
||||||
color: "#555",
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
border: '1px solid #ddd'
|
|
||||||
}} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '1 1 200px' }}>
|
|
||||||
<DatePicker
|
|
||||||
selected={endDate}
|
|
||||||
onChange={onEndDateChange}
|
|
||||||
showTimeSelect
|
|
||||||
timeFormat="HH:mm"
|
|
||||||
timeIntervals={15}
|
|
||||||
dateFormat="yyyy-MM-dd HH:mm"
|
|
||||||
placeholderText="Конечная дата"
|
|
||||||
customInput={
|
|
||||||
<input style={{
|
|
||||||
backgroundColor: '#f9f9f9',
|
|
||||||
color: "#555",
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
border: '1px solid #ddd'
|
|
||||||
}} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onApply}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: '#4a6baf',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
flex: '0 0 auto',
|
|
||||||
height: '36px'
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => e.target.style.backgroundColor = '#3a5a9f'}
|
|
||||||
onMouseOut={(e) => e.target.style.backgroundColor = '#4a6baf'}
|
|
||||||
>
|
|
||||||
Применить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DateRangeSelector;
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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 (
|
|
||||||
<div style={{ width: '100%', height: `${height}px` }}>
|
|
||||||
{title && <h3>{title}</h3>}
|
|
||||||
{description && (
|
|
||||||
<p style={{ marginTop: -10, color: '#666' }}>{description}</p>
|
|
||||||
)}
|
|
||||||
{metaInfo && (
|
|
||||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 10 }}>
|
|
||||||
{metaInfo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResponsiveContainer width="100%" height="80%">
|
|
||||||
<LineChart
|
|
||||||
data={data}
|
|
||||||
margin={{
|
|
||||||
top: 5,
|
|
||||||
right: 30,
|
|
||||||
left: 20,
|
|
||||||
bottom: 5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showGrid && <CartesianGrid strokeDasharray="3 3" />}
|
|
||||||
<XAxis
|
|
||||||
dataKey="timestamp"
|
|
||||||
tickFormatter={customXAxisFormatter || ((timestamp) => new Date(timestamp).toLocaleTimeString())}
|
|
||||||
/>
|
|
||||||
{customYAxis || <YAxis />}
|
|
||||||
<Tooltip
|
|
||||||
content={customTooltip}
|
|
||||||
labelFormatter={(timestamp) => new Date(timestamp).toLocaleString()}
|
|
||||||
/>
|
|
||||||
{showLegend && <Legend />}
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey={dataKey}
|
|
||||||
stroke={lineColor}
|
|
||||||
activeDot={{ r: 8 }}
|
|
||||||
name={title}
|
|
||||||
/>
|
|
||||||
{additionalLines.map((lineProps, index) => (
|
|
||||||
<Line key={index} {...lineProps} />
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LineChartComponent;
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Radio.Group
|
|
||||||
value={mode}
|
|
||||||
onChange={(e) => setMode(e.target.value)}
|
|
||||||
buttonStyle="solid"
|
|
||||||
style={{ marginBottom: 10 }}
|
|
||||||
>
|
|
||||||
<Radio.Button value="realtime">Режим реального времени</Radio.Button>
|
|
||||||
<Radio.Button value="historical">Исторические данные</Radio.Button>
|
|
||||||
</Radio.Group>
|
|
||||||
|
|
||||||
{mode === 'historical' && (
|
|
||||||
<DateRangeSelector
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
onStartDateChange={setStartDate}
|
|
||||||
onEndDateChange={setEndDate}
|
|
||||||
onApply={handleCustomRangeApply}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === 'realtime' && isLiveUpdating && (
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
danger
|
|
||||||
onClick={() => setMode('historical')}
|
|
||||||
style={{ marginTop: 10 }}
|
|
||||||
>
|
|
||||||
Остановить обновление
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{device && <Tag color="geekblue">Устройство: {device}</Tag>}
|
|
||||||
{module && <Tag color="purple">Модуль: {module.split('$')[1]}</Tag>}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div>Загрузка графика...</div>
|
|
||||||
) : error ? (
|
|
||||||
<div>Ошибка: {error}</div>
|
|
||||||
) : chartData.length === 0 ? (
|
|
||||||
<div>Нет данных для метрики: {metricName}</div>
|
|
||||||
) : (
|
|
||||||
<LineChartComponent
|
|
||||||
data={chartData}
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
metaInfo={metaInfo}
|
|
||||||
height={chartHeight}
|
|
||||||
additionalFilters={{
|
|
||||||
device,
|
|
||||||
module
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PrometheusChart;
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Box, styled } from "@mui/material";
|
import { Box, styled } from "@mui/material";
|
||||||
|
import SidebarMenu from "./SidebarMenu";
|
||||||
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
||||||
import generateTabContent from "../TreeChart/tabContent";
|
import generateTabContent from "../TreeChart/tabContent";
|
||||||
import CustomTabs from "../UI/MUItabs";
|
import CustomTabs from "../UI/MUItabs";
|
||||||
|
|
@ -7,10 +8,8 @@ import useTabs from "../hooks/useTabs";
|
||||||
import useSidebarResize from "../hooks/useSidebarResize";
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
import TabContent from "../hooks/TabContent";
|
import TabContent from "../hooks/TabContent";
|
||||||
import menuData from "../TreeChart/menuData.json";
|
import menuData from "../TreeChart/menuData.json";
|
||||||
import SidebarMenuWrapper from "./SidebarMenuWrapper";
|
|
||||||
import MetricTabContent from "./MetricTabContent"
|
|
||||||
|
|
||||||
// Стилизованные компоненты
|
// Создаем стилизованные компоненты
|
||||||
const DashboardContainer = styled(Box)(({ theme }) => ({
|
const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
|
|
@ -20,25 +19,46 @@ const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||||
color: theme.palette.text.primary,
|
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 }) => ({
|
const MainContent = styled(Box)(({ theme }) => ({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
padding: theme.spacing(2.5),
|
padding: theme.spacing(2.5), // 20px
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
backgroundColor: theme.palette.background.default,
|
backgroundColor: theme.palette.background.default,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const Content = styled(Box)(({ theme }) => ({
|
const Content = styled(Box)(({ theme }) => ({
|
||||||
backgroundColor: theme.palette.custom.modalBackground,
|
backgroundColor: theme.palette.custom.modalBackground,
|
||||||
|
padding: theme.spacing(2.5),
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
|
boxShadow: theme.shadows[2],
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
color: theme.palette.custom.modalText,
|
color: theme.palette.custom.modalText,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
|
const Dashboard = () => {
|
||||||
const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная");
|
const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная");
|
||||||
|
const { sidebarWidth, startResizing } = useSidebarResize(250);
|
||||||
const [tabContent, setTabContent] = useState({});
|
const [tabContent, setTabContent] = useState({});
|
||||||
const [treeData1, setTreeData1] = useState(menuData);
|
const [treeData1, setTreeData1] = useState(menuData);
|
||||||
const [treeData2, setTreeData2] = useState(menuData);
|
const [treeData2, setTreeData2] = useState(menuData);
|
||||||
|
|
@ -80,103 +100,40 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [treeData1, treeData2]);
|
}, [treeData1, treeData2]);
|
||||||
|
|
||||||
const handleMenuSelect = (item) => {
|
|
||||||
const tabId = `tab_${item.id}`;
|
|
||||||
const tabTitle = item.title || 'Новая вкладка';
|
|
||||||
|
|
||||||
// Если это метрика, создаём специальный контент с графиком
|
|
||||||
const tabContent = item.metric
|
|
||||||
? <MetricTabContent
|
|
||||||
metricInfo={{
|
|
||||||
name: item.metric,
|
|
||||||
filters: item.filters,
|
|
||||||
title: item.title,
|
|
||||||
description: item.description,
|
|
||||||
context: {
|
|
||||||
device: item.filters?.device,
|
|
||||||
source_id: item.filters?.source_id,
|
|
||||||
parent: item // для построения пути
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
: <div style={{ padding: 20 }}>Контент для <strong>{item.title}</strong></div>;
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<DashboardContainer>
|
<DashboardContainer>
|
||||||
{/* Сайдбар */}
|
{/* Сайдбар */}
|
||||||
<SidebarMenuWrapper
|
<SidebarWrapper sx={{ width: sidebarWidth }}>
|
||||||
isDarkMode={isDarkMode}
|
<SidebarMenu
|
||||||
setIsDarkMode={setIsDarkMode}
|
data={treeData1}
|
||||||
onMenuSelect={handleMenuSelect}
|
onOpenTab={handleOpenTab}
|
||||||
/>
|
sidebarWidth={sidebarWidth}
|
||||||
|
startResizing={startResizing}
|
||||||
|
/>
|
||||||
|
<SidebarResizer onMouseDown={startResizing} />
|
||||||
|
</SidebarWrapper>
|
||||||
|
|
||||||
{/* Основной контент */}
|
{/* Основной контент */}
|
||||||
<Box sx={{
|
<MainContent>
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flexGrow: 1,
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
{/* Вкладки */}
|
{/* Вкладки */}
|
||||||
<Box sx={{
|
<CustomTabs
|
||||||
borderBottom: 1,
|
tabs={tabs}
|
||||||
borderColor: 'divider',
|
activeTab={activeTab}
|
||||||
backgroundColor: 'background.default',
|
onTabClick={setActiveTab}
|
||||||
zIndex: 1,
|
onCloseTab={handleCloseTab}
|
||||||
transform: 'translateY(31px)'
|
/>
|
||||||
}}>
|
|
||||||
<CustomTabs
|
|
||||||
tabs={tabs}
|
|
||||||
activeTab={activeTab}
|
|
||||||
onTabClick={setActiveTab}
|
|
||||||
onCloseTab={handleCloseTab}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Остальной контент */}
|
{/* Контент вкладки */}
|
||||||
<MainContent>
|
<Content>
|
||||||
{/* Контент вкладки */}
|
<TabContent
|
||||||
<Content>
|
activeTab={activeTab}
|
||||||
<TabContent
|
statusHistories={statusHistories}
|
||||||
activeTab={activeTab}
|
treeData1={treeData1}
|
||||||
statusHistories={statusHistories}
|
tabContent={tabContent}
|
||||||
treeData1={treeData1}
|
handleOpenTab={handleOpenTab}
|
||||||
tabContent={tabContent}
|
/>
|
||||||
handleOpenTab={handleOpenTab}
|
</Content>
|
||||||
tabs={tabs}
|
</MainContent>
|
||||||
/>
|
|
||||||
</Content>
|
|
||||||
</MainContent>
|
|
||||||
</Box>
|
|
||||||
</DashboardContainer>
|
</DashboardContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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 (
|
|
||||||
<div style={{ padding: 16 }}>
|
|
||||||
<PrometheusChart
|
|
||||||
metricInfo={metricInfo}
|
|
||||||
chartHeight={600}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MetricTabContent;
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// SidebarMenu.jsx
|
import React, { useState } from "react";
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
|
|
@ -7,54 +6,44 @@ import {
|
||||||
styled,
|
styled,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Box,
|
Box
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Button,
|
|
||||||
TextField
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Menu as MenuIcon
|
||||||
|
} from "@mui/icons-material";
|
||||||
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
||||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
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 SidebarMenu = ({
|
const SidebarResizer = styled('div')(({ theme }) => ({
|
||||||
data,
|
width: "5px",
|
||||||
isDarkMode,
|
cursor: "ew-resize",
|
||||||
setIsDarkMode,
|
backgroundColor: 'transparent',
|
||||||
onEditItem,
|
height: "100%",
|
||||||
onSelectItem,
|
position: "absolute",
|
||||||
editModalOpen,
|
right: 0,
|
||||||
editingItem,
|
top: 0,
|
||||||
onCloseEditModal,
|
transition: 'background-color 0.2s',
|
||||||
onSaveChanges
|
'&:hover': {
|
||||||
}) => {
|
backgroundColor: theme.palette.primary.main,
|
||||||
|
},
|
||||||
|
zIndex: 2
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
const handleToggleCollapse = () => {
|
const handleToggleCollapse = () => {
|
||||||
setCollapsed(!collapsed);
|
setCollapsed(!collapsed);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SidebarResizer = styled('div')(({ theme }) => ({
|
const handleSelectItem = (id, title, children) => {
|
||||||
width: '4px',
|
onOpenTab(id, title, children);
|
||||||
cursor: 'ew-resize',
|
};
|
||||||
backgroundColor: 'transparent',
|
|
||||||
'&:hover': {
|
const drawerWidth = collapsed ? 64 : sidebarWidth;
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
},
|
|
||||||
height: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -62,17 +51,17 @@ const SidebarMenu = ({
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
width: drawerWidth,
|
||||||
transition: 'width 0.3s ease',
|
transition: 'width 0.3s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
sx={{
|
sx={{
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
width: drawerWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
'& .MuiDrawer-paper': {
|
'& .MuiDrawer-paper': {
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
width: drawerWidth,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
|
@ -84,150 +73,79 @@ const SidebarMenu = ({
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Заголовок с логотипом */}
|
{/* Кнопка сворачивания/разворачивания */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
justifyContent: 'flex-end',
|
||||||
justifyContent: 'center', // Центрируем содержимое
|
|
||||||
p: 1,
|
p: 1,
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
backgroundColor: 'custom.sidebar',
|
backgroundColor: 'custom.sidebar'
|
||||||
height: 80, // Фиксированная высота
|
|
||||||
position: 'relative' // Для позиционирования кнопки
|
|
||||||
}}>
|
}}>
|
||||||
{/* Логотип (занимает все пространство) */}
|
|
||||||
<Box sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
'& svg': {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
padding: collapsed ? '8px' : '12px',
|
|
||||||
objectFit: 'contain'
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{collapsed ? (
|
|
||||||
<LogoSmall style={{
|
|
||||||
color: 'inherit' // Наследует цвет темы
|
|
||||||
}} />
|
|
||||||
) : (
|
|
||||||
<LogoFull style={{
|
|
||||||
color: 'inherit' // Наследует цвет темы
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Кнопка сворачивания (абсолютное позиционирование) */}
|
|
||||||
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleToggleCollapse}
|
onClick={handleToggleCollapse}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
color: 'custom.sidebarText',
|
color: 'custom.sidebarText',
|
||||||
'&:hover': { backgroundColor: 'custom.sidebarHover' },
|
'&:hover': {
|
||||||
position: 'absolute',
|
backgroundColor: 'custom.sidebarHover',
|
||||||
right: 8,
|
}
|
||||||
top: '50%',
|
|
||||||
transform: 'translateY(-50%)'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
{collapsed ? (
|
||||||
|
hovered ? <ChevronRight /> : <MenuIcon />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Основное содержимое меню */}
|
{/* Содержимое меню */}
|
||||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<Box sx={{
|
||||||
<List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
|
flexGrow: 1,
|
||||||
{data && (
|
display: 'flex',
|
||||||
<MenuItem
|
flexDirection: 'column',
|
||||||
item={data}
|
overflow: 'hidden'
|
||||||
collapsed={collapsed}
|
}}>
|
||||||
level={0}
|
<List sx={{
|
||||||
onEdit={onEditItem}
|
overflowY: 'auto',
|
||||||
onSelectItem={onSelectItem}
|
overflowX: 'hidden',
|
||||||
/>
|
flex: '1 1 auto'
|
||||||
|
}}>
|
||||||
|
{!collapsed && (
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Меню
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
<MenuItem
|
||||||
|
item={data}
|
||||||
|
onSelectItem={handleSelectItem}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<SidebarFooter
|
{/* Футер */}
|
||||||
collapsed={collapsed}
|
{!collapsed && (
|
||||||
isDarkMode={isDarkMode}
|
<SidebarFooter />
|
||||||
setIsDarkMode={setIsDarkMode}
|
)}
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Ресайзер */}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<SidebarResizer onMouseDown={startResizing} />
|
<SidebarResizer onMouseDown={startResizing} />
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* Модальное окно редактирования */}
|
|
||||||
<EditMenuItemDialog
|
|
||||||
open={editModalOpen}
|
|
||||||
item={editingItem}
|
|
||||||
onClose={onCloseEditModal}
|
|
||||||
onSave={onSaveChanges}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
|
||||||
<DialogTitle>Редактирование элемента меню</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Название"
|
|
||||||
name="title"
|
|
||||||
value={formData.title || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="ID"
|
|
||||||
name="id"
|
|
||||||
value={formData.id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
{/* Дополнительные поля для редактирования */}
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose}>Отмена</Button>
|
|
||||||
<Button onClick={handleSubmit} variant="contained" color="primary">
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SidebarMenu;
|
export default SidebarMenu;
|
||||||
|
|
@ -1,23 +1,17 @@
|
||||||
// MenuItem.jsx
|
import React from "react";
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Collapse,
|
Collapse,
|
||||||
List,
|
List,
|
||||||
styled,
|
styled
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
MenuItem as MuiMenuItem
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { ExpandLess, ExpandMore, Folder, FolderOpen, Edit } from "@mui/icons-material";
|
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
||||||
import StatusIndicator from "./StatusIndicator";
|
|
||||||
|
|
||||||
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
paddingLeft: theme.spacing(2 + level * 2),
|
paddingLeft: theme.spacing(2 + level * 2),
|
||||||
position: 'relative',
|
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: theme.palette.action.hover,
|
backgroundColor: theme.palette.action.hover,
|
||||||
},
|
},
|
||||||
|
|
@ -26,59 +20,49 @@ const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
const IconWrapper = styled('div')(({ theme }) => ({
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
cursor: "pointer",
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
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 hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
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) => {
|
const handleToggle = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleOpenTab = (e) => {
|
||||||
if (onSelectItem) {
|
e.stopPropagation();
|
||||||
onSelectItem(item);
|
const allChildren = getAllChildren(item);
|
||||||
}
|
onSelectItem(item.id, item.title, allChildren);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StyledListItem
|
<StyledListItem
|
||||||
component="div"
|
component="div"
|
||||||
onClick={hasChildren ? handleToggle : handleClick}
|
onClick={hasChildren ? handleToggle : handleOpenTab}
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
level={level}
|
level={level}
|
||||||
sx={{
|
sx={{
|
||||||
pl: collapsed ? 2 : 2 + level * 2,
|
pl: collapsed ? 2 : 2 + level * 2, // Адаптируем отступы
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!collapsed && <StatusIndicator status={item.status} />}
|
|
||||||
|
|
||||||
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
|
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
|
||||||
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
<IconWrapper onClick={handleOpenTab}>
|
||||||
|
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
||||||
|
</IconWrapper>
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && ( // Показываем текст только в развернутом состоянии
|
||||||
<>
|
<>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={item.title}
|
primary={item.title}
|
||||||
|
|
@ -87,47 +71,18 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
||||||
|
|
||||||
{level > 0 && (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEditClick();
|
|
||||||
}}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
<Edit fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</StyledListItem>
|
</StyledListItem>
|
||||||
|
|
||||||
<Menu
|
{hasChildren && !collapsed && ( // Показываем детей только в развернутом состоянии
|
||||||
open={contextMenu !== null}
|
|
||||||
onClose={handleCloseContextMenu}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition={
|
|
||||||
contextMenu !== null
|
|
||||||
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MuiMenuItem onClick={handleEditClick}>
|
|
||||||
<Edit fontSize="small" sx={{ mr: 1 }} /> Редактировать
|
|
||||||
</MuiMenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
{hasChildren && !collapsed && (
|
|
||||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||||
<List component="div" disablePadding>
|
<List component="div" disablePadding>
|
||||||
{item.items.map((child, index) => (
|
{item.items.map((child, index) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={child.id ?? index}
|
key={index}
|
||||||
item={child}
|
item={child}
|
||||||
onSelectItem={onSelectItem}
|
onSelectItem={onSelectItem}
|
||||||
onEdit={onEdit}
|
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
|
|
@ -139,4 +94,16 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Вспомогательная функция (остается без изменений)
|
||||||
|
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;
|
export default MenuItem;
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Brightness4, Brightness7 } from "@mui/icons-material";
|
|
||||||
import { IconButton, Tooltip } from "@mui/material";
|
|
||||||
import {
|
import {
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
styled,
|
styled
|
||||||
Switch,
|
|
||||||
Box
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
const FooterList = styled(List)(({ theme }) => ({
|
const FooterList = styled(List)(({ theme }) => ({
|
||||||
|
|
@ -22,54 +18,28 @@ const FooterListItem = styled(ListItem)(({ theme }) => ({
|
||||||
backgroundColor: theme.palette.custom.sidebarHover,
|
backgroundColor: theme.palette.custom.sidebarHover,
|
||||||
},
|
},
|
||||||
padding: theme.spacing(1, 2),
|
padding: theme.spacing(1, 2),
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SidebarFooter = ({ collapsed, isDarkMode, setIsDarkMode }) => {
|
const SidebarFooter = () => {
|
||||||
return (
|
return (
|
||||||
<FooterList>
|
<FooterList>
|
||||||
{!collapsed && (
|
<FooterListItem button>
|
||||||
<FooterListItem button>
|
<ListItemText
|
||||||
<ListItemText
|
primary="Помощь"
|
||||||
primary="Помощь"
|
primaryTypographyProps={{
|
||||||
primaryTypographyProps={{
|
color: 'custom.sidebarText',
|
||||||
color: 'custom.sidebarText',
|
variant: 'body2'
|
||||||
variant: 'body2'
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</FooterListItem>
|
||||||
</FooterListItem>
|
<FooterListItem button>
|
||||||
)}
|
<ListItemText
|
||||||
<FooterListItem>
|
primary="Настройка"
|
||||||
{!collapsed && (
|
primaryTypographyProps={{
|
||||||
<ListItemText
|
color: 'custom.sidebarText',
|
||||||
primary="Настройка"
|
variant: 'body2'
|
||||||
primaryTypographyProps={{
|
}}
|
||||||
color: 'custom.sidebarText',
|
/>
|
||||||
variant: 'body2'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Tooltip title="Переключить тему">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
|
||||||
sx={{ color: 'custom.sidebarText' }}
|
|
||||||
>
|
|
||||||
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
{!collapsed && (
|
|
||||||
<Switch
|
|
||||||
checked={isDarkMode}
|
|
||||||
onChange={() => setIsDarkMode(!isDarkMode)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</FooterListItem>
|
</FooterListItem>
|
||||||
</FooterList>
|
</FooterList>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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 (
|
|
||||||
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Box p={2}>
|
|
||||||
<Typography color="error">Error loading menu: {error}</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!menuData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenu
|
|
||||||
data={menuData}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
setIsDarkMode={setIsDarkMode}
|
|
||||||
onEditItem={(item) => {
|
|
||||||
setEditingItem(item);
|
|
||||||
setEditModalOpen(true);
|
|
||||||
}}
|
|
||||||
onSelectItem={onMenuSelect}
|
|
||||||
editModalOpen={editModalOpen}
|
|
||||||
editingItem={editingItem}
|
|
||||||
onCloseEditModal={() => setEditModalOpen(false)}
|
|
||||||
onSaveChanges={handleSaveChanges}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SidebarMenuWrapper;
|
|
||||||
|
|
@ -45,10 +45,12 @@ const FlowChart = ({ data }) => {
|
||||||
const findAndCollapseLastLevelParents = (items) => {
|
const findAndCollapseLastLevelParents = (items) => {
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
if (item.items && item.items.length > 0) {
|
if (item.items && item.items.length > 0) {
|
||||||
|
// Проверяем, есть ли у детей свои дети
|
||||||
const hasGrandchildren = item.items.some(child =>
|
const hasGrandchildren = item.items.some(child =>
|
||||||
child.items && child.items.length > 0
|
child.items && child.items.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Если у детей нет своих детей - это родители последнего уровня
|
||||||
if (!hasGrandchildren) {
|
if (!hasGrandchildren) {
|
||||||
toggleNodeCollapse(item.id);
|
toggleNodeCollapse(item.id);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
|
||||||
const baseLevelRadius = 150;
|
const baseLevelRadius = 150;
|
||||||
|
|
||||||
const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI, parentRadius = 0) => {
|
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 nodeId = item.id;
|
||||||
const items = item.items || [];
|
const items = item.items || [];
|
||||||
|
|
@ -58,7 +58,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
|
||||||
data: {
|
data: {
|
||||||
...item,
|
...item,
|
||||||
label: item.title,
|
label: item.title,
|
||||||
style: getNodeStyle(item, isLeaf),
|
style: getNodeStyle(item, isLeaf), // Переносим стили в data
|
||||||
hasChildren: items.length > 0,
|
hasChildren: items.length > 0,
|
||||||
collapsed: collapsedNodes[nodeId]
|
collapsed: collapsedNodes[nodeId]
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
|
||||||
|
|
||||||
const centerNode = {
|
const centerNode = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
type: 'customNode',
|
type: 'customNode', // Добавляем тип узла
|
||||||
position: nodePositions[data.id] || { x: centerX, y: centerY },
|
position: nodePositions[data.id] || { x: centerX, y: centerY },
|
||||||
style: getCenterNodeStyle(data),
|
style: getCenterNodeStyle(data),
|
||||||
data: { label: data.title, hasChildren: data.items.length > 0, collapsed: collapsedNodes[data.id] }
|
data: { label: data.title, hasChildren: data.items.length > 0, collapsed: collapsedNodes[data.id] }
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ const NodeWrapper = memo(({ id, data, selected }) => {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden', // Чтобы текст не выходил за границы
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis', // Добавляем многоточие если текст не помещается
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap', // Запрещаем перенос строк
|
||||||
padding: '0 8px',
|
padding: '0 8px', // Горизонтальный padding для текста
|
||||||
boxSizing: 'border-box'
|
boxSizing: 'border-box' // Учитываем padding в общей ширине
|
||||||
}}
|
}}
|
||||||
title={data.label}
|
title={data.label} // Простой tooltip при наведении
|
||||||
>
|
>
|
||||||
{/* Хендл для входящих соединений */}
|
{/* Хендл для входящих соединений */}
|
||||||
<Handle
|
<Handle
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export const useFlowChart = (initialData) => {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
const [nodePositions, setNodePositions] = useState({});
|
const [nodePositions, setNodePositions] = useState({});
|
||||||
const [collapsedNodes, setCollapsedNodes] = useState({});
|
const [collapsedNodes, setCollapsedNodes] = useState({}); // Добавили
|
||||||
|
|
||||||
const toggleNodeCollapse = useCallback((nodeId) => {
|
const toggleNodeCollapse = useCallback((nodeId) => {
|
||||||
setCollapsedNodes((prev) => ({
|
setCollapsedNodes((prev) => ({
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
export const useNodeHandlers = (debouncedSetNodePositions) => {
|
export const useNodeHandlers = (debouncedSetNodePositions) => {
|
||||||
const onNodeDrag = useCallback((event, node) => {
|
const onNodeDrag = useCallback((event, node) => {
|
||||||
|
// Фиксируем позицию сразу при перемещении
|
||||||
node.position = {
|
node.position = {
|
||||||
x: Math.round(node.position.x),
|
x: Math.round(node.position.x),
|
||||||
y: Math.round(node.position.y)
|
y: Math.round(node.position.y)
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,48 @@
|
||||||
const StatusManager = () => {
|
const StatusManager = () => {
|
||||||
const getRandomStatus = () => {
|
const getRandomStatus = () => {
|
||||||
const statuses = [
|
const statuses = [
|
||||||
...Array(90).fill("green"),
|
...Array(90).fill("green"), // 90% шанс
|
||||||
...Array(6).fill("yellow"),
|
...Array(6).fill("yellow"), // 6% шанс
|
||||||
...Array(3).fill("orange"),
|
...Array(3).fill("orange"), // 3% шанс
|
||||||
...Array(1).fill("red"),
|
...Array(1).fill("red"), // 1% шанс
|
||||||
];
|
];
|
||||||
return statuses[Math.floor(Math.random() * statuses.length)];
|
return statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusWeight = (status) => {
|
const getStatusWeight = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "green": return 1;
|
case "green": return 1; // 100% здоровья
|
||||||
case "yellow": return 0.75;
|
case "yellow": return 0.75;
|
||||||
case "orange": return 0.5;
|
case "orange": return 0.5;
|
||||||
case "red": return 0.25;
|
case "red": return 0.25; // 25% здоровья
|
||||||
default: return 1;
|
default: return 1; // По умолчанию "green"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStatuses = (data) => {
|
const updateStatuses = (data) => {
|
||||||
if (!data.items || data.items.length === 0) {
|
if (!data.items || data.items.length === 0) {
|
||||||
|
// Если это элемент нижнего уровня, генерируем случайный статус
|
||||||
data.status = getRandomStatus();
|
data.status = getRandomStatus();
|
||||||
return getStatusWeight(data.status);
|
return getStatusWeight(data.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Рекурсивно обновляем статусы для всех дочерних элементов
|
||||||
let childStatusWeights = data.items.map((child) => updateStatuses(child));
|
let childStatusWeights = data.items.map((child) => updateStatuses(child));
|
||||||
|
|
||||||
|
// Проверяем, есть ли дочерние элементы (избегаем деления на 0)
|
||||||
if (childStatusWeights.length === 0) {
|
if (childStatusWeights.length === 0) {
|
||||||
data.status = "green";
|
data.status = "green";
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вычисляем среднее арифметическое значение весов статусов
|
||||||
const averageStatusWeight =
|
const averageStatusWeight =
|
||||||
childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length;
|
childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length;
|
||||||
|
|
||||||
|
// Определяем статус текущего элемента
|
||||||
data.status = getStatusFromWeight(averageStatusWeight);
|
data.status = getStatusFromWeight(averageStatusWeight);
|
||||||
|
|
||||||
return Math.max(0, averageStatusWeight);
|
return Math.max(0, averageStatusWeight); // Гарантия, что не будет отрицательных значений
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusFromWeight = (weight) => {
|
const getStatusFromWeight = (weight) => {
|
||||||
|
|
@ -64,13 +69,16 @@ const StatusManager = () => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Создаем два независимых менеджера статусов
|
||||||
export const statusManager1 = StatusManager();
|
export const statusManager1 = StatusManager();
|
||||||
export const statusManager2 = StatusManager();
|
export const statusManager2 = StatusManager();
|
||||||
|
|
||||||
|
// Функция для расчета процентов здоровья системы
|
||||||
export const calculateStatusPercentage = (averageStatusValue) => {
|
export const calculateStatusPercentage = (averageStatusValue) => {
|
||||||
return Math.max(0, Math.min(100, averageStatusValue * 100));
|
return Math.max(0, Math.min(100, averageStatusValue * 100));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Экспортируем getStatusColor отдельно
|
||||||
export const getStatusColor = (status) => {
|
export const getStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "green":
|
case "green":
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,16 @@
|
||||||
"id": "media_server_1",
|
"id": "media_server_1",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "device$18",
|
"id": "18",
|
||||||
"title": "Graviton S2082I (device$18)",
|
"title": "Graviton S2082I (device$18)",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "module$11",
|
"id": "4",
|
||||||
"title": "OS Linux (module$11) АО",
|
"title": "OS Linux (module$4) АО",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "zvks_cpu1min",
|
"id": "190",
|
||||||
"title": "Загрузка процессора за 1 минуту",
|
"title": "Загрузка процессора за 1 минуту"
|
||||||
"metric": "zvks_cpu1min",
|
|
||||||
"description": "Загрузка процессора за 1 минуту"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "191",
|
"id": "191",
|
||||||
|
|
@ -401,139 +399,183 @@
|
||||||
"id": "media_server_2",
|
"id": "media_server_2",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "device$19",
|
"id": "182",
|
||||||
"title": "Graviton S2082I (device$19)",
|
"title": "Graviton S2082I (device$18)",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "module$13",
|
"id": "42",
|
||||||
"title": "OS Linux (module$13) АО",
|
"title": "OS Linux (module$4) АО",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "zvks_cpu1min",
|
"id": "1902",
|
||||||
"title": "Загрузка процессора за 1 минуту",
|
"title": "Загрузка процессора за 1 минуту"
|
||||||
"metric": "zvks_cpu1min",
|
|
||||||
"description": "Загрузка процессора за 1 минуту"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "372",
|
"id": "1912",
|
||||||
"title": "Загрузка процессора за 5 минут"
|
"title": "Загрузка процессора за 5 минут"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "373",
|
"id": "1922",
|
||||||
"title": "Загрузка процессора за 15 минут"
|
"title": "Загрузка процессора за 15 минут"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "378",
|
"id": "1972",
|
||||||
"title": "Общий объем SWAP-файла"
|
"title": "Общий объем SWAP-файла"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "379",
|
"id": "1982",
|
||||||
"title": "Используемый объем SWAP-файла"
|
"title": "Используемый объем SWAP-файла"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "380",
|
"id": "1992",
|
||||||
"title": "Общий объем физической оперативной памяти"
|
"title": "Общий объем физической оперативной памяти"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "381",
|
"id": "2002",
|
||||||
"title": "Доступный объем физической оперативной памяти"
|
"title": "Доступный объем физической оперативной памяти"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "382",
|
"id": "2012",
|
||||||
"title": "Свободный объем физической и виртуальной оперативной памяти"
|
"title": "Свободный объем физической и виртуальной оперативной памяти"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "383",
|
"id": "2022",
|
||||||
"title": "Буферизованный объем оперативной памяти"
|
"title": "Буферизованный объем оперативной памяти"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "384",
|
"id": "2032",
|
||||||
"title": "Кэшированый объем оперативной памяти"
|
"title": "Кэшированый объем оперативной памяти"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "375",
|
"id": "2742",
|
||||||
|
"title": "Используемый объем SWAP-файла"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2752",
|
||||||
"title": "Время затраченное процессором на процессы с пониженным приоритетом"
|
"title": "Время затраченное процессором на процессы с пониженным приоритетом"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "376",
|
"id": "2762",
|
||||||
"title": "Время затраченное процессором на процессы ядра ОС"
|
"title": "Время затраченное процессором на процессы ядра ОС"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "377",
|
"id": "2772",
|
||||||
"title": "Время простоя процессора"
|
"title": "Время простоя процессора"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "385",
|
"id": "2782",
|
||||||
"title": "Общая емкость жестких дисков"
|
"title": "Общая емкость жестких дисков"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "386",
|
"id": "2792",
|
||||||
"title": "Доступная емкость жестких дисков"
|
"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",
|
"id": "2802",
|
||||||
"title": "Сетевой адаптер №1 (port$261) Eth_1",
|
"title": "Сетевой адаптер №1 (port$261) Eth_1",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "388",
|
"id": "2072",
|
||||||
"title": "Скорость порта Eth_1"
|
"title": "Скорость порта Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "390",
|
"id": "2092",
|
||||||
"title": "Административное состояние порта Eth_1"
|
"title": "Административное состояние порта Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "391",
|
"id": "2102",
|
||||||
"title": "Оперативное состояние порта Eth_1"
|
"title": "Оперативное состояние порта Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "392",
|
"id": "2112",
|
||||||
"title": "Общее количество отправленных октетов Eth_1"
|
"title": "Общее количество отправленных октетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "393",
|
"id": "2122",
|
||||||
"title": "Количество входящих Multicast пакетов Eth_1"
|
"title": "Количество входящих Multicast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "394",
|
"id": "2132",
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_1"
|
"title": "Количество иcходящих Multiicast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "395",
|
"id": "2142",
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_1"
|
"title": "Количество входящих Broadcast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "396",
|
"id": "2152",
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_1"
|
"title": "Количество иcходящих Broadcast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "397",
|
"id": "2162",
|
||||||
"title": "Количество входящих Unicast пакетов Eth_1"
|
"title": "Количество входящих Unicast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "398",
|
"id": "2172",
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_1"
|
"title": "Количество иcходящих Unicast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "399",
|
"id": "2182",
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_1"
|
"title": "Количество входящих пакетов помеченные как отброшенные Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "400",
|
"id": "2192",
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_1"
|
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "401",
|
"id": "2202",
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_1"
|
"title": "Количество входящих пакетов с ошибкой Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "402",
|
"id": "2212",
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_1"
|
"title": "Количество исходящих пакетов с ошибкой Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "403",
|
"id": "2222",
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_1"
|
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_1"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -543,63 +585,63 @@
|
||||||
"title": "Сетевой адаптер №2 (port$262) Eth_2",
|
"title": "Сетевой адаптер №2 (port$262) Eth_2",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "405",
|
"id": "2242",
|
||||||
"title": "Скорость порта Eth_2"
|
"title": "Скорость порта Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "407",
|
"id": "2262",
|
||||||
"title": "Административное состояние порта Eth_2"
|
"title": "Административное состояние порта Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "408",
|
"id": "2272",
|
||||||
"title": "Оперативное состояние порта Eth_2"
|
"title": "Оперативное состояние порта Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "409",
|
"id": "2282",
|
||||||
"title": "Общее количество отправленных октетов Eth_2"
|
"title": "Общее количество отправленных октетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "410",
|
"id": "2292",
|
||||||
"title": "Количество входящих Multicast пакетов Eth_2"
|
"title": "Количество входящих Multicast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "411",
|
"id": "2302",
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_2"
|
"title": "Количество иcходящих Multiicast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "412",
|
"id": "2312",
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_2"
|
"title": "Количество входящих Broadcast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "413",
|
"id": "2322",
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_2"
|
"title": "Количество иcходящих Broadcast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "414",
|
"id": "2332",
|
||||||
"title": "Количество входящих Unicast пакетов Eth_2"
|
"title": "Количество входящих Unicast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "415",
|
"id": "2342",
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_2"
|
"title": "Количество иcходящих Unicast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "416",
|
"id": "2352",
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_2"
|
"title": "Количество входящих пакетов помеченные как отброшенные Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "417",
|
"id": "2362",
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_2"
|
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "418",
|
"id": "2372",
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_2"
|
"title": "Количество входящих пакетов с ошибкой Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "419",
|
"id": "2382",
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_2"
|
"title": "Количество исходящих пакетов с ошибкой Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "420",
|
"id": "2392",
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_2"
|
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_2"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -609,63 +651,63 @@
|
||||||
"title": "Сетевой адаптер №3 (port$263) Eth_3",
|
"title": "Сетевой адаптер №3 (port$263) Eth_3",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "422",
|
"id": "2412",
|
||||||
"title": "Скорость порта Eth_3"
|
"title": "Скорость порта Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "424",
|
"id": "2432",
|
||||||
"title": "Административное состояние порта Eth_3"
|
"title": "Административное состояние порта Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "425",
|
"id": "2442",
|
||||||
"title": "Оперативное состояние порта Eth_3"
|
"title": "Оперативное состояние порта Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "426",
|
"id": "2452",
|
||||||
"title": "Общее количество отправленных октетов Eth_3"
|
"title": "Общее количество отправленных октетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "427",
|
"id": "2462",
|
||||||
"title": "Количество входящих Multicast пакетов Eth_3"
|
"title": "Количество входящих Multicast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "428",
|
"id": "2472",
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_3"
|
"title": "Количество иcходящих Multiicast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "429",
|
"id": "2482",
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_3"
|
"title": "Количество входящих Broadcast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "430",
|
"id": "2492",
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_3"
|
"title": "Количество иcходящих Broadcast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "431",
|
"id": "2502",
|
||||||
"title": "Количество входящих Unicast пакетов Eth_3"
|
"title": "Количество входящих Unicast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "432",
|
"id": "2512",
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_3"
|
"title": "Количество иcходящих Unicast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "433",
|
"id": "2522",
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_3"
|
"title": "Количество входящих пакетов помеченные как отброшенные Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "434",
|
"id": "2532",
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_3"
|
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "435",
|
"id": "2542",
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_3"
|
"title": "Количество входящих пакетов с ошибкой Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "436",
|
"id": "2552",
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_3"
|
"title": "Количество исходящих пакетов с ошибкой Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "437",
|
"id": "2562",
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_3"
|
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_3"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -675,63 +717,63 @@
|
||||||
"title": "Сетевой адаптер №4 (port$264) Eth_4",
|
"title": "Сетевой адаптер №4 (port$264) Eth_4",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "439",
|
"id": "2582",
|
||||||
"title": "Скорость порта Eth_4"
|
"title": "Скорость порта Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "441",
|
"id": "2602",
|
||||||
"title": "Административное состояние порта Eth_4"
|
"title": "Административное состояние порта Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "442",
|
"id": "2612",
|
||||||
"title": "Оперативное состояние порта Eth_4"
|
"title": "Оперативное состояние порта Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "443",
|
"id": "2622",
|
||||||
"title": "Общее количество отправленных октетов Eth_4"
|
"title": "Общее количество отправленных октетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "444",
|
"id": "2632",
|
||||||
"title": "Количество входящих Multicast пакетов Eth_4"
|
"title": "Количество входящих Multicast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "445",
|
"id": "2642",
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_4"
|
"title": "Количество иcходящих Multiicast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "446",
|
"id": "2652",
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_4"
|
"title": "Количество входящих Broadcast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "447",
|
"id": "2662",
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_4"
|
"title": "Количество иcходящих Broadcast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "448",
|
"id": "2672",
|
||||||
"title": "Количество входящих Unicast пакетов Eth_4"
|
"title": "Количество входящих Unicast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "449",
|
"id": "2682",
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_4"
|
"title": "Количество иcходящих Unicast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "450",
|
"id": "2692",
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_4"
|
"title": "Количество входящих пакетов помеченные как отброшенные Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "451",
|
"id": "2702",
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_4"
|
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "452",
|
"id": "2712",
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_4"
|
"title": "Количество входящих пакетов с ошибкой Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "453",
|
"id": "2722",
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_4"
|
"title": "Количество исходящих пакетов с ошибкой Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "454",
|
"id": "2732",
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_4"
|
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_4"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -847,15 +889,15 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "373000",
|
"id": "373",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "38300",
|
"id": "383",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "39300",
|
"id": "393",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -1239,11 +1281,11 @@
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "38400",
|
"id": "384",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "39400",
|
"id": "394",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -1629,7 +1671,7 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "37900",
|
"id": "379",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1637,7 +1679,7 @@
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "39900",
|
"id": "399",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -2405,15 +2447,15 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "37800",
|
"id": "378",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "38800",
|
"id": "388",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "39800",
|
"id": "398",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -2799,15 +2841,15 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "37500",
|
"id": "375",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "38500",
|
"id": "385",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "39500",
|
"id": "395",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -3187,15 +3229,15 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "37600",
|
"id": "376",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "38600",
|
"id": "386",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "39600",
|
"id": "396",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -3575,7 +3617,7 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "37700",
|
"id": "377",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -3583,7 +3625,7 @@
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "39700",
|
"id": "397",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,90 @@
|
||||||
import React, { lazy, Suspense } from "react";
|
import React, { lazy, Suspense } from "react";
|
||||||
import Skeleton from "@mui/material/Skeleton";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
|
|
||||||
const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart"));
|
const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart'));
|
||||||
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
|
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
|
||||||
|
|
||||||
// Компонент Skeleton для графика
|
// Функция для генерации названия метрики на основе id
|
||||||
const ChartSkeleton = () => (
|
const getMetricName = (id) => {
|
||||||
<Box sx={{ width: "100%" }}>
|
return `zvks_apiforsnmp_measure_${id}`;
|
||||||
<Skeleton variant="text" width="60%" height={30} />
|
|
||||||
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Компонент Skeleton для контейнера
|
|
||||||
const ContainerSkeleton = () => (
|
|
||||||
<Box sx={{ width: "100%" }}>
|
|
||||||
<Skeleton variant="text" width="40%" height={40} />
|
|
||||||
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<ChartSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Утилита для извлечения контекста из пути
|
|
||||||
const 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 tabContent = (data, cache = {}) => {
|
const getAllChildIds = (node) => {
|
||||||
const tabContentMap = { ...cache };
|
let ids = [];
|
||||||
|
if (node.id) {
|
||||||
if (!data || !data.items || data.items.length === 0) {
|
ids.push(node.id); // Добавляем id текущего узла
|
||||||
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 processNode = (node, parentContext = {}) => {
|
const tabContent = (data) => {
|
||||||
// Получаем полный контекст из всей цепочки родителей
|
const tabContent = {};
|
||||||
const pathContext = parseContextFromPath(node);
|
|
||||||
const currentContext = { ...parentContext, ...pathContext };
|
|
||||||
|
|
||||||
// Генерируем уникальный ключ на основе пути
|
// Функция для рекурсивного обхода и сбора данных
|
||||||
const path = [];
|
const generateContent = (nodes) => {
|
||||||
let current = node;
|
nodes.forEach((node) => {
|
||||||
while (current) {
|
// Если у узла есть вложенные элементы, рекурсивно обрабатываем их
|
||||||
path.unshift(current.id);
|
if (node.items && node.items.length > 0) {
|
||||||
current = current.parent;
|
// Создаем контент для родителя
|
||||||
}
|
const childrenContent = generateContent(node.items);
|
||||||
const pathId = path.join('_');
|
|
||||||
|
|
||||||
if (Array.isArray(node.items) && node.items.length > 0) {
|
const content = (
|
||||||
const children = node.items
|
<div>
|
||||||
.map((child) => processNode(child, currentContext))
|
<h2>{node.title}</h2>
|
||||||
.filter(Boolean);
|
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} />
|
||||||
|
<p>Контент для {node.title}.</p>
|
||||||
|
{childrenContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const content = (
|
// Сохраняем контент для текущего id
|
||||||
<div key={`${pathId}-container`}>
|
tabContent[node.id] = {
|
||||||
<h2>{node.title}</h2>
|
title: node.title,
|
||||||
<Suspense fallback={<ContainerSkeleton />}>
|
content: content,
|
||||||
<LazyChartBatchRenderer charts={children.map((c) => c.content)} />
|
};
|
||||||
</Suspense>
|
} else {
|
||||||
</div>
|
// Если у узла нет вложенных элементов, это самый нижний уровень
|
||||||
);
|
const metricName = getMetricName(node.id);
|
||||||
|
const content = (
|
||||||
|
<div key={node.id}>
|
||||||
|
<h3>{node.title}</h3> {/* Используем title узла */}
|
||||||
|
<Suspense fallback={<div>Загрузка графика...</div>}>
|
||||||
|
<PrometheusChart metricName={metricName} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
tabContentMap[pathId] = {
|
// Сохраняем контент для текущего id
|
||||||
title: node.title,
|
tabContent[node.id] = {
|
||||||
content,
|
title: node.title,
|
||||||
context: currentContext,
|
content: content,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { content, context: currentContext };
|
// Возвращаем контент для всех потомков
|
||||||
}
|
return (
|
||||||
|
<div>
|
||||||
if (node.metric) {
|
{nodes.map((node) => (
|
||||||
const chartKey = `${node.metric}-${currentContext.device || "all"}-${currentContext.module || "all"}-${pathId}`;
|
<div key={node.id}>{tabContent[node.id].content}</div>
|
||||||
|
))}
|
||||||
const content = (
|
|
||||||
<div key={chartKey}>
|
|
||||||
<h3>{node.title}</h3>
|
|
||||||
{currentContext.device && <p>Устройство: {currentContext.device}</p>}
|
|
||||||
{currentContext.module && <p>Модуль: {currentContext.module}</p>}
|
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
|
||||||
<PrometheusChart
|
|
||||||
metricInfo={{
|
|
||||||
name: node.metric,
|
|
||||||
filters: {
|
|
||||||
...(currentContext.device && { device: currentContext.device }),
|
|
||||||
...(currentContext.source_id && { source_id: currentContext.source_id }),
|
|
||||||
},
|
|
||||||
title: node.title,
|
|
||||||
description: node.description,
|
|
||||||
context: currentContext,
|
|
||||||
}}
|
|
||||||
key={chartKey}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
tabContentMap[pathId] = {
|
|
||||||
title: node.title,
|
|
||||||
content,
|
|
||||||
context: currentContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { content, context: currentContext };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Узел без метрики и без вложенных — просто заголовок
|
|
||||||
const content = (
|
|
||||||
<div key={pathId}>
|
|
||||||
<h3>{node.title}</h3>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
tabContentMap[pathId] = {
|
|
||||||
title: node.title,
|
|
||||||
content,
|
|
||||||
context: currentContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { content, context: currentContext };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
// Начинаем обработку с корневого уровня
|
||||||
processNode(data);
|
if (data.items && data.items.length > 0) {
|
||||||
} catch (error) {
|
generateContent(data.items);
|
||||||
console.error("Ошибка обработки данных:", error);
|
} else {
|
||||||
|
console.warn("Данные отсутствуют или массив items пуст");
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabContentMap;
|
return tabContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default tabContent;
|
export default tabContent;
|
||||||
|
|
@ -3,7 +3,6 @@ import Modal from "./Modal";
|
||||||
import "../../Style/LoginModal.css";
|
import "../../Style/LoginModal.css";
|
||||||
import Logo from '../../assets/images/logo.svg?react';
|
import Logo from '../../assets/images/logo.svg?react';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const LoginModal = ({ onLogin, onClose }) => {
|
const LoginModal = ({ onLogin, onClose }) => {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
|
|
@ -17,27 +16,26 @@ const LoginModal = ({ onLogin, onClose }) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post(
|
// Отправляем данные на бэкенд
|
||||||
`${import.meta.env.VITE_BACK_URL}/api/auth/login`,
|
const response = await fetch(`${import.meta.env.VITE_BACK_URL}/api/auth/login`, {
|
||||||
{ login: username, password },
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
withCredentials: true,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
},
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({ login: username, password }),
|
||||||
},
|
});
|
||||||
}
|
|
||||||
);
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
localStorage.setItem('access_token', data.access_token);
|
onLogin(); // Успешная авторизация
|
||||||
onLogin(data.user);
|
onClose(); // Закрыть модальное окно
|
||||||
onClose();
|
|
||||||
} else {
|
} else {
|
||||||
setError(data.message || "Неверный логин или пароль");
|
setError(data.message || "Неверный логин или пароль");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при отправке запроса:', err);
|
console.error('Ошибка при отправке запроса:', err);
|
||||||
setError(err.response?.data?.message || "Ошибка при подключении к серверу");
|
setError("Ошибка при подключении к серверу");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Tabs, Tab, Box, styled, Typography } from "@mui/material";
|
import { Tabs, Tab, Box, styled } from "@mui/material";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
|
||||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
padding: theme.spacing(1, 2),
|
|
||||||
textTransform: 'none',
|
|
||||||
'&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
fontWeight: theme.typography.fontWeightMedium,
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
|
|
@ -16,43 +14,9 @@ const StyledTab = styled(Tab)(({ theme }) => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const TabLabel = ({ title, onClose }) => {
|
|
||||||
return (
|
|
||||||
<Box sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
minWidth: 0 // Для корректного обрезания длинного текста
|
|
||||||
}}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
noWrap
|
|
||||||
sx={{
|
|
||||||
maxWidth: 120,
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<CloseIcon
|
|
||||||
fontSize="small"
|
|
||||||
sx={{
|
|
||||||
ml: 1,
|
|
||||||
cursor: "pointer",
|
|
||||||
flexShrink: 0,
|
|
||||||
'&:hover': {
|
|
||||||
color: 'error.main'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||||
const handleMouseDown = (e, id) => {
|
const handleMouseDown = (e, id) => {
|
||||||
if (e.button === 1) { // Средняя кнопка мыши
|
if (e.button === 1) { // Middle mouse button
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCloseTab(id);
|
onCloseTab(id);
|
||||||
}
|
}
|
||||||
|
|
@ -62,12 +26,6 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||||
onTabClick(newValue);
|
onTabClick(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Статические вкладки (сохраняем оригинальные id)
|
|
||||||
const staticTabs = [
|
|
||||||
{ id: "Главная", title: "Главная" },
|
|
||||||
{ id: "Визуализация", title: "Визуализация" }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
borderBottom: 1,
|
borderBottom: 1,
|
||||||
|
|
@ -81,31 +39,42 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
variant="scrollable"
|
variant="scrollable"
|
||||||
scrollButtons="auto"
|
scrollButtons="auto"
|
||||||
allowScrollButtonsMobile
|
|
||||||
aria-label="tabs"
|
aria-label="tabs"
|
||||||
>
|
>
|
||||||
{/* Статические вкладки */}
|
{/* Статические вкладки */}
|
||||||
{staticTabs.map(tab => (
|
<StyledTab
|
||||||
<StyledTab
|
label="Главная"
|
||||||
key={`static_${tab.id}`} // Добавляем префикс для уникальности
|
value="Главная"
|
||||||
label={tab.title}
|
onMouseDown={(e) => handleMouseDown(e, "Главная")}
|
||||||
value={tab.id} // Используем id как value
|
/>
|
||||||
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
<StyledTab
|
||||||
/>
|
label="Визуализация"
|
||||||
))}
|
value="Визуализация"
|
||||||
|
onMouseDown={(e) => handleMouseDown(e, "Визуализация")}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Динамические вкладки */}
|
{/* Динамические вкладки */}
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<StyledTab
|
<StyledTab
|
||||||
key={`dynamic_${tab.id}`} // Добавляем префикс для уникальности
|
key={tab.id}
|
||||||
label={
|
label={
|
||||||
<TabLabel
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
title={tab.title}
|
<span>{tab.title}</span>
|
||||||
onClose={(e) => {
|
<CloseIcon
|
||||||
e.stopPropagation();
|
fontSize="small"
|
||||||
onCloseTab(tab.id);
|
sx={{
|
||||||
}}
|
ml: 1,
|
||||||
/>
|
cursor: "pointer",
|
||||||
|
'&:hover': {
|
||||||
|
color: 'error.main'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCloseTab(tab.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
value={tab.id}
|
value={tab.id}
|
||||||
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,7 @@ const TreeTable = ({ data }) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="ul" sx={{
|
<Box component="ul" sx={{
|
||||||
pl: 2,
|
pl: 2,
|
||||||
maxHeight: 400,
|
maxHeight: 200,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
listStyle: 'none'
|
listStyle: 'none'
|
||||||
}}>
|
}}>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,167 +1,29 @@
|
||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Skeleton from '@mui/material/Skeleton';
|
|
||||||
|
|
||||||
const LazyChartBatchRenderer = ({ charts }) => {
|
const LazyChartBatchRenderer = ({ charts, batchSize = 3, delay = 150 }) => {
|
||||||
const [visibleIndices, setVisibleIndices] = useState(new Set());
|
const [visibleCharts, setVisibleCharts] = useState([]);
|
||||||
const placeholderRefs = useRef([]);
|
|
||||||
const observerRef = useRef(null);
|
|
||||||
const cleanupTimeoutRef = useRef(null);
|
|
||||||
|
|
||||||
const ChartSkeleton = () => (
|
|
||||||
<Box sx={{
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
position: 'relative',
|
|
||||||
height: '400px',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
<Box sx={{ position: 'absolute', right: '20px', top: '20px' }}>
|
|
||||||
<Skeleton variant="circular" width={16} height={16} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
|
||||||
<Skeleton variant="text" width="40%" height={30} />
|
|
||||||
<Skeleton variant="text" width="30%" height={30} />
|
|
||||||
</Box>
|
|
||||||
<Skeleton
|
|
||||||
variant="rectangular"
|
|
||||||
width="100%"
|
|
||||||
height="300px"
|
|
||||||
sx={{
|
|
||||||
transform: 'none',
|
|
||||||
animation: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, gap: 2 }}>
|
|
||||||
{[1, 2, 3, 4].map((_, i) => (
|
|
||||||
<Skeleton
|
|
||||||
key={i}
|
|
||||||
variant="rounded"
|
|
||||||
width={80}
|
|
||||||
height={36}
|
|
||||||
sx={{
|
|
||||||
transform: 'none',
|
|
||||||
animation: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
observerRef.current = new IntersectionObserver(
|
let index = 0;
|
||||||
(entries) => {
|
const timer = setInterval(() => {
|
||||||
entries.forEach(entry => {
|
setVisibleCharts((prev) => [
|
||||||
if (entry.isIntersecting) {
|
...prev,
|
||||||
updateVisibleIndices();
|
...charts.slice(index, index + batchSize),
|
||||||
}
|
]);
|
||||||
});
|
index += batchSize;
|
||||||
},
|
if (index >= charts.length) clearInterval(timer);
|
||||||
{
|
}, delay);
|
||||||
root: null,
|
|
||||||
rootMargin: '500px 0px',
|
|
||||||
threshold: 0.01
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
placeholderRefs.current.forEach(ref => {
|
return () => clearInterval(timer);
|
||||||
if (ref) observerRef.current.observe(ref);
|
}, [charts]);
|
||||||
});
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div>
|
<>
|
||||||
{charts.map((chart, index) => (
|
{visibleCharts.map((chart, idx) => (
|
||||||
<div
|
<div key={idx}>{chart}</div>
|
||||||
key={index}
|
|
||||||
ref={(el) => (placeholderRefs.current[index] = el)}
|
|
||||||
data-index={index}
|
|
||||||
style={{
|
|
||||||
minHeight: '400px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
transition: 'opacity 0.3s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{shouldShowChart(index) ? chart : <ChartSkeleton />}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(LazyChartBatchRenderer);
|
export default LazyChartBatchRenderer;
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,12 @@
|
||||||
import SystemStatusChart from "../../Charts/SystemStatusChart";
|
import SystemStatusChart from "../../Charts/SystemStatusChart";
|
||||||
import TreeTable from "../UI/TreeTable";
|
import TreeTable from "../UI/TreeTable";
|
||||||
import FlowChart from "../TreeChart/FlowChart";
|
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 === "Главная") {
|
if (activeTab === "Главная") {
|
||||||
const statusCounts = treeData1 ? countStatuses(treeData1) : { green: 0, yellow: 0, orange: 0, red: 0 };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</h2>
|
<h2>Общий мониторинг состояния системы</h2>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
|
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
|
||||||
<label>Надежность системы</label>
|
<label>Надежность системы</label>
|
||||||
|
|
@ -38,32 +17,6 @@ const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, h
|
||||||
<SystemStatusChart data={statusHistories.history2} />
|
<SystemStatusChart data={statusHistories.history2} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Контейнер для индикаторов статусов */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
marginTop: '20px',
|
|
||||||
gap: '10px'
|
|
||||||
}}>
|
|
||||||
{Object.entries(statusCounts).map(([status, count]) => (
|
|
||||||
<div key={status} style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '30px',
|
|
||||||
backgroundColor: getStatusColor(status),
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
color: 'white',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
borderRadius: '5px',
|
|
||||||
boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
|
|
||||||
}}>
|
|
||||||
{count}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>Статус компонентов системы</label>
|
<label>Статус компонентов системы</label>
|
||||||
<TreeTable data={treeData1} />
|
<TreeTable data={treeData1} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -71,7 +24,7 @@ const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, h
|
||||||
} else if (activeTab === "Визуализация") {
|
} else if (activeTab === "Визуализация") {
|
||||||
return <FlowChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
|
return <FlowChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
|
||||||
} else {
|
} else {
|
||||||
const tabData = tabs.find(t => t.id === activeTab);
|
const tabData = tabContent[activeTab];
|
||||||
return tabData ? tabData.content : <p>Нет данных</p>;
|
return tabData ? tabData.content : <p>Нет данных</p>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,43 +4,23 @@ const useTabs = (initialTab) => {
|
||||||
const [tabs, setTabs] = useState([]);
|
const [tabs, setTabs] = useState([]);
|
||||||
const [activeTab, setActiveTab] = useState(initialTab);
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
|
|
||||||
const handleOpenTab = useCallback((newTab) => {
|
const handleOpenTab = useCallback((id, title) => {
|
||||||
setTabs((prevTabs) => {
|
setTabs((prevTabs) =>
|
||||||
const exists = prevTabs.some((tab) => tab.id === newTab.id);
|
prevTabs.some((tab) => tab.id === id)
|
||||||
if (!exists) {
|
? prevTabs
|
||||||
return [...prevTabs, newTab];
|
: [...prevTabs, { id, title }]
|
||||||
}
|
);
|
||||||
return prevTabs;
|
setActiveTab(id);
|
||||||
});
|
|
||||||
setActiveTab(newTab.id);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCloseTab = useCallback((id) => {
|
const handleCloseTab = useCallback((id) => {
|
||||||
setTabs((prevTabs) => {
|
setTabs((prevTabs) => prevTabs.filter((tab) => tab.id !== id));
|
||||||
const newTabs = prevTabs.filter((tab) => tab.id !== id);
|
if (activeTab === id) {
|
||||||
if (activeTab === id) {
|
setActiveTab(tabs.length > 1 ? tabs[tabs.length - 2].id : initialTab);
|
||||||
setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : initialTab);
|
}
|
||||||
}
|
}, [activeTab, tabs, initialTab]);
|
||||||
return newTabs;
|
|
||||||
});
|
|
||||||
}, [activeTab, initialTab]);
|
|
||||||
|
|
||||||
const updateTabContent = useCallback((id, content) => {
|
return { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab };
|
||||||
setTabs(prevTabs =>
|
|
||||||
prevTabs.map(tab =>
|
|
||||||
tab.id === id ? { ...tab, content } : tab
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tabs,
|
|
||||||
activeTab,
|
|
||||||
handleOpenTab,
|
|
||||||
handleCloseTab,
|
|
||||||
setActiveTab,
|
|
||||||
updateTabContent
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useTabs;
|
export default useTabs;
|
||||||
|
|
@ -76,7 +76,7 @@ export const lightTheme = createTheme({
|
||||||
|
|
||||||
// Фоновые цвета
|
// Фоновые цвета
|
||||||
background: {
|
background: {
|
||||||
default: "#FFFFFF", // Основной фон приложения
|
default: "#6CACE4", // Основной фон приложения
|
||||||
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
|
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -139,7 +139,7 @@ export const darkTheme = createTheme({
|
||||||
|
|
||||||
// Фоновые цвета
|
// Фоновые цвета
|
||||||
background: {
|
background: {
|
||||||
default: "#2d2d2d", // Основной фон приложения
|
default: "#1E1E1E", // Основной фон приложения
|
||||||
paper: "#2d2d2d", // Фон "бумажных" поверхностей
|
paper: "#2d2d2d", // Фон "бумажных" поверхностей
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<svg width="43" height="43" viewBox="0 0 43 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M22.4391 0.0295059V0H21.5049H21.4951H20.5609V0.0295059C9.76424 0.48193 1.02264 8.95014 0.0884977 19.6116C0.0294994 20.2312 0 20.8607 0 21.5C0 22.1295 0.0294994 22.7589 0.0884977 23.3884C1.04231 34.3646 10.2756 43 21.4951 43H22.4391V39.2331H21.4951C12.37 39.2331 4.8182 32.3484 3.87423 23.3884H6.43083H14.6513C14.4349 22.7097 14.3169 21.9819 14.3169 21.2246C14.3169 20.6738 14.3858 20.1329 14.5038 19.6215H11.4752C12.37 14.8808 16.5884 11.3008 21.5049 11.3008C24.9367 11.3008 28.1226 13.0416 29.9909 15.8545H34.2584C32.0656 10.8484 27.0016 7.53385 21.5049 7.53385C14.5038 7.53385 8.58427 12.7761 7.65996 19.6215H6.2145H3.87423C4.8182 10.6615 12.37 3.77676 21.4951 3.77676H21.5049C30.63 3.77676 38.1818 10.6615 39.1258 19.6215H28.4962C28.6142 20.1427 28.6831 20.6738 28.6831 21.2246C28.6831 21.9819 28.5651 22.7097 28.3487 23.3884H28.919H31.5248H35.34H37.3067H43V21.5C43 9.95334 33.8552 0.511436 22.4391 0.0295059Z" fill="#428AC9"/>
|
|
||||||
<path d="M22.7045 32.25C22.3112 32.2992 21.9081 32.3287 21.5049 32.3287C17.2472 32.3287 13.5205 29.6436 12.016 25.8472H8.06311C9.70523 31.7681 15.1528 36.0956 21.5049 36.0956C21.9081 36.0956 22.3112 36.0759 22.7045 36.0366V32.25Z" fill="#428AC9"/>
|
|
||||||
<path d="M25.2611 24.3817C23.383 26.457 20.1873 26.6242 18.1125 24.7457C16.0377 22.8769 15.8706 19.6706 17.7388 17.5954C19.617 15.5201 22.8127 15.3529 24.8875 17.2315C26.9623 19.1002 27.1294 22.3065 25.2611 24.3817Z" fill="url(#paint0_radial_2_3)"/>
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="paint0_radial_2_3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(19.8648 18.1752) scale(7.12571 7.12734)">
|
|
||||||
<stop stop-color="#4A96D2"/>
|
|
||||||
<stop offset="1" stop-color="#1F2466"/>
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
|
@ -2,6 +2,8 @@ import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
//import './Style/light-theme.css'; // Подключаем светлую тему по умолчанию
|
||||||
|
//import './Style/dark-theme.css'; // Подключаем темную тему
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue