Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
4fe280d904 |
|
|
@ -1,7 +0,0 @@
|
||||||
node_modules
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
dist
|
|
||||||
npm-debug.log
|
|
||||||
|
|
@ -31,10 +31,4 @@ node_modules
|
||||||
.env.local
|
.env.local
|
||||||
.env.development
|
.env.development
|
||||||
.env.production
|
.env.production
|
||||||
.env.test
|
.env.test
|
||||||
|
|
||||||
# Local configs
|
|
||||||
vite.config.js
|
|
||||||
vite.config.local.js
|
|
||||||
.env.local
|
|
||||||
*.local.*
|
|
||||||
10
Dockerfile
10
Dockerfile
|
|
@ -2,14 +2,10 @@ FROM node:22.13.0
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json vite.config.js eslint.config.js ./
|
||||||
RUN npm install --verbose
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
COPY vite.config.js eslint.config.js ./
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
|
||||||
|
|
||||||
EXPOSE 5173
|
|
||||||
|
|
||||||
ENTRYPOINT ["npm", "run", "dev"]
|
ENTRYPOINT ["npm", "run", "dev"]
|
||||||
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5173",
|
"dev": "vite --host 0.0.0.0 --port 3333",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"chartjs-chart-box-and-violin-plot": "^4.0.0",
|
"chartjs-chart-box-and-violin-plot": "^4.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"esbuild": "^0.25.8",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-chartjs-2": "^5.0.0",
|
"react-chartjs-2": "^5.0.0",
|
||||||
"react-datepicker": "^8.1.0",
|
"react-datepicker": "^8.1.0",
|
||||||
|
|
@ -31,10 +30,7 @@
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"vite-plugin-svgr": "^4.3.0",
|
"vite-plugin-svgr": "^4.3.0"
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@dnd-kit/core": "^6.3.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|
@ -45,7 +41,8 @@
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"express": "^5.1.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"vite": "^7.1.0"
|
"vite": "^6.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
const devServer = 'http://192.168.2.39';
|
||||||
|
const config = [
|
||||||
|
['/api', `${devServer}:3000`],
|
||||||
|
['/ai_api', `${devServer}:5134`,
|
||||||
|
{
|
||||||
|
pathRewrite: {
|
||||||
|
'^/ai_api':''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"name": "trust-module",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 5173",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@mui/icons-material": "^6.4.8",
|
||||||
|
"@mui/material": "^6.4.7",
|
||||||
|
"antd": "^5.24.7",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"chart.js": "^4.0.0",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"chartjs-chart-box-and-violin-plot": "^4.0.0",
|
||||||
|
"d3": "^7.9.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-chartjs-2": "^5.0.0",
|
||||||
|
"react-datepicker": "^8.1.0",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-scripts": "^5.0.1",
|
||||||
|
"react-virtualized-auto-sizer": "1.0.26",
|
||||||
|
"react-window": "1.8.11",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
|
"recharts": "^2.15.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"vite-plugin-svgr": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react": "^7.37.2",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
//const express = require('express')
|
||||||
|
const express = require('express')
|
||||||
|
const yargs = require('yargs/yargs')
|
||||||
|
const { hideBin } = require('yargs/helpers')
|
||||||
|
|
||||||
|
const argv = yargs(hideBin(process.argv))
|
||||||
|
.option('config', {
|
||||||
|
alias: 'c',
|
||||||
|
description: 'Configuration file name (without .config.js extension)',
|
||||||
|
type: 'string',
|
||||||
|
demandOption: true
|
||||||
|
})
|
||||||
|
.help()
|
||||||
|
.argv
|
||||||
|
const configName = argv.config;
|
||||||
|
const config = require(`./${argv.config}.config.js`)
|
||||||
|
const { createProxyMiddleware } = require('http-proxy-middleware')
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
config.forEach(el => {
|
||||||
|
|
||||||
|
const [route, target, addOptions] = el;
|
||||||
|
let options = {
|
||||||
|
target,
|
||||||
|
changeOrigin: true,
|
||||||
|
timeout: 10000,
|
||||||
|
proxyTimeout: 30000,
|
||||||
|
...addOptions || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (configName === '127.0.0.1' && route === '/api') {
|
||||||
|
options.pathRewrite = { '^/api': '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('route', route, 'target', target);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
route,
|
||||||
|
createProxyMiddleware(options),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/',
|
||||||
|
createProxyMiddleware({
|
||||||
|
target: 'http://localhost:3333',
|
||||||
|
changeOrigin: true,
|
||||||
|
logLevel: 'debug',
|
||||||
|
timeout: 10000,
|
||||||
|
proxyTimeout: 30000,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.listen(4000)
|
||||||
|
console.log('proxy started on 4000')
|
||||||
47
src/App.jsx
47
src/App.jsx
|
|
@ -3,7 +3,7 @@ import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress, Typography }
|
||||||
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';
|
||||||
import { checkAuth } from "./Components/UI/auth";
|
import { checkAuth } from "./Components/UI/auth";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
|
@ -110,41 +110,22 @@ function App() {
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('access_token');
|
await axios.post(`${import.meta.env.VITE_BACK_URL}/api/auth/logout`, null, {
|
||||||
|
withCredentials: true,
|
||||||
if (!token) {
|
});
|
||||||
// Если нет токена - просто очищаем данные
|
|
||||||
cleanup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await axios.post('/api/auth/logout', {}, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
setShowLoginModal(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error);
|
console.error('Logout failed:', error);
|
||||||
cleanup();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
user: null,
|
|
||||||
});
|
|
||||||
setShowLoginModal(true);
|
|
||||||
}
|
|
||||||
// Полноэкранный лоадер во время проверки авторизации
|
// Полноэкранный лоадер во время проверки авторизации
|
||||||
if (authState.isLoading) {
|
if (authState.isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -182,7 +163,7 @@ function App() {
|
||||||
zIndex: 1200,
|
zIndex: 1200,
|
||||||
'& svg': { width: 400, height: 'auto' }
|
'& svg': { width: 400, height: 'auto' }
|
||||||
}}>
|
}}>
|
||||||
<Logo />
|
{/* <Logo /> */}
|
||||||
</Box>
|
</Box>
|
||||||
<LoginModal
|
<LoginModal
|
||||||
open={showLoginModal}
|
open={showLoginModal}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { BarChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Bar, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
const BarChartComponent = ({ chartData, metricName, metricType, colors }) => {
|
||||||
|
// Преобразуем данные для отображения
|
||||||
|
const data = Object.keys(chartData).map(instance => {
|
||||||
|
const instanceData = chartData[instance].reduce((acc, point) => {
|
||||||
|
if (point.value !== null) {
|
||||||
|
acc[point.quantile] = point.value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return { instance, ...instanceData };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получаем все уникальные квантили
|
||||||
|
const allQuantiles = [...new Set(
|
||||||
|
Object.values(chartData).flat().map(point => point.quantile)
|
||||||
|
)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>{metricName} ({metricType})</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="instance" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
{allQuantiles.map((quantile, index) => (
|
||||||
|
<Bar
|
||||||
|
key={quantile}
|
||||||
|
dataKey={quantile}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
name={`Quantile ${quantile}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BarChartComponent;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const ConnectionStatusIndicator = ({ connectionStatus }) => {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '10px',
|
||||||
|
right: '10px',
|
||||||
|
padding: '5px 10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: connectionStatus === 'connected' ? '#4CAF50' :
|
||||||
|
connectionStatus === 'error' ? '#F44336' : '#FFC107',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{connectionStatus === 'connected' ? 'Online' :
|
||||||
|
connectionStatus === 'error' ? 'Connection Error' : 'Offline'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const CounterComponent = ({ value, metricName }) => {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px', border: '1px solid #ccc', borderRadius: '8px', margin: '10px' }}>
|
||||||
|
<h2>{metricName}</h2>
|
||||||
|
<p style={{ fontSize: '48px', fontWeight: 'bold', color: '#3e95cd' }}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CounterComponent;
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const CurrentRangeDisplay = ({ useCustomRange, startDate, endDate, selectedRange }) => {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
margin: '10px 0',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#f0f7ff',
|
||||||
|
borderRadius: '4px',
|
||||||
|
borderLeft: '3px solid #4a6baf'
|
||||||
|
}}>
|
||||||
|
Текущий диапазон: {useCustomRange
|
||||||
|
? `${startDate.toLocaleString()} - ${endDate.toLocaleString()}`
|
||||||
|
: selectedRange.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer, ReferenceArea } from 'recharts';
|
||||||
|
import { Skeleton } from '@mui/material';
|
||||||
|
import { HOUR, DAY } from './constants';
|
||||||
|
|
||||||
|
const TIME_FORMATS = {
|
||||||
|
LONG: 'dd.MM HH:mm', // Для диапазона > 24 часов
|
||||||
|
MEDIUM: 'HH:mm', // Для диапазона > 1 часа
|
||||||
|
SHORT: 'HH:mm:ss' // Для коротких диапазонов
|
||||||
|
};
|
||||||
|
|
||||||
|
const LineChartComponent = ({
|
||||||
|
chartData,
|
||||||
|
metricName,
|
||||||
|
colors,
|
||||||
|
onRangeSelect,
|
||||||
|
filteredData
|
||||||
|
}) => {
|
||||||
|
const [selectionArea, setSelectionArea] = useState(null);
|
||||||
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
|
const chartRef = useRef(null);
|
||||||
|
|
||||||
|
|
||||||
|
const allTimestamps = Object.values(chartData)
|
||||||
|
.flat()
|
||||||
|
.map(point => point.timestamp)
|
||||||
|
.filter((timestamp, index, self) => self.indexOf(timestamp) === index)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const data = allTimestamps.map(timestamp => {
|
||||||
|
const point = { timestamp };
|
||||||
|
|
||||||
|
const firstPoint = Object.values(chartData)
|
||||||
|
.flat()
|
||||||
|
.find(p => p.timestamp === timestamp);
|
||||||
|
|
||||||
|
if (firstPoint) {
|
||||||
|
point.time = firstPoint.time;
|
||||||
|
point.fullTime = firstPoint.fullTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(chartData).forEach(key => {
|
||||||
|
const instanceData = chartData[key].find(p => p.timestamp === timestamp);
|
||||||
|
point[key] = instanceData ? instanceData.value : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayData = filteredData || data;
|
||||||
|
|
||||||
|
const instanceKeys = displayData.length
|
||||||
|
? Object.keys(displayData[0]).filter(k => !['timestamp', 'time', 'fullTime'].includes(k))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const getTimeFormat = () => {
|
||||||
|
if (!data.length) return TIME_FORMATS.SHORT;
|
||||||
|
|
||||||
|
const range = data[data.length - 1].timestamp - data[0].timestamp;
|
||||||
|
|
||||||
|
if (range > DAY) return TIME_FORMATS.LONG;
|
||||||
|
if (range > HOUR) return TIME_FORMATS.MEDIUM;
|
||||||
|
return TIME_FORMATS.SHORT;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectStart = (e) => {
|
||||||
|
if (isSelecting) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener('selectstart', handleSelectStart);
|
||||||
|
return () => document.removeEventListener('selectstart', handleSelectStart);
|
||||||
|
}, [isSelecting]);
|
||||||
|
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
if (!e) return;
|
||||||
|
|
||||||
|
const activeIndex = e.activeTooltipIndex;
|
||||||
|
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
|
||||||
|
|
||||||
|
setIsSelecting(true);
|
||||||
|
setSelectionArea({
|
||||||
|
start: data[activeIndex].timestamp,
|
||||||
|
end: null,
|
||||||
|
startIndex: activeIndex,
|
||||||
|
endIndex: null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!isSelecting || !selectionArea?.start || !e) return;
|
||||||
|
|
||||||
|
const activeIndex = e.activeTooltipIndex;
|
||||||
|
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
|
||||||
|
|
||||||
|
setSelectionArea(prev => ({
|
||||||
|
...prev,
|
||||||
|
end: data[activeIndex].timestamp,
|
||||||
|
endIndex: activeIndex
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (!isSelecting || !selectionArea?.start || !selectionArea?.end) {
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectionArea(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = Math.min(selectionArea.startIndex, selectionArea.endIndex);
|
||||||
|
const endIndex = Math.max(selectionArea.startIndex, selectionArea.endIndex);
|
||||||
|
|
||||||
|
const normalizedStart = startIndex / (data.length - 1);
|
||||||
|
const normalizedEnd = endIndex / (data.length - 1);
|
||||||
|
|
||||||
|
onRangeSelect({
|
||||||
|
startIndex: normalizedStart,
|
||||||
|
endIndex: normalizedEnd
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectionArea(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const currentPoint = data.find(point => point.timestamp === label);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: '10px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: '0 2px 5px rgba(0,0,0,0.1)'
|
||||||
|
}}>
|
||||||
|
<p style={{ fontWeight: 'bold', marginBottom: '5px' }}>
|
||||||
|
{currentPoint?.fullTime || new Date(label).toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
{payload.map((item, index) => (
|
||||||
|
<p key={index} style={{ color: item.color }}>
|
||||||
|
{`Значение: ${item.value}`}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
return (
|
||||||
|
<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 (
|
||||||
|
<div style={{ position: 'relative', height: '400px' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={displayData}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
ref={chartRef}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
height={75}
|
||||||
|
tick={{ fontSize: 12, angle: -45, textAnchor: 'end' }}
|
||||||
|
interval={Math.max(1, Math.floor(data.length / 10))}
|
||||||
|
tickFormatter={(timestamp) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const format = getTimeFormat();
|
||||||
|
|
||||||
|
if (format === 'dd.MM HH:mm') {
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} else if (format === 'HH:mm') {
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YAxis tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{instanceKeys.map((instance, index) => (
|
||||||
|
<Line
|
||||||
|
key={instance}
|
||||||
|
type=""
|
||||||
|
dataKey={instance}
|
||||||
|
name={instance}
|
||||||
|
stroke={colors[index % colors.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{selectionArea?.start && selectionArea?.end && (
|
||||||
|
<ReferenceArea
|
||||||
|
x1={selectionArea.start}
|
||||||
|
x2={selectionArea.end}
|
||||||
|
strokeOpacity={0.3}
|
||||||
|
fill="#4a6baf"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(LineChartComponent);
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ScatterChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Scatter, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
const ScatterChartComponent = ({ chartData, metricName, metricType, colors }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>{metricName} ({metricType})</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<ScatterChart>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" />
|
||||||
|
<YAxis dataKey="value" />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
{Object.keys(chartData).map((instance, index) => (
|
||||||
|
<Scatter
|
||||||
|
key={instance}
|
||||||
|
data={chartData[instance]}
|
||||||
|
name={instance}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScatterChartComponent;
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import React from 'react';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
import { TIME_RANGES } from './constants';
|
||||||
|
|
||||||
|
export const TimeRangeSelector = ({
|
||||||
|
selectedRange,
|
||||||
|
handleRangeChange,
|
||||||
|
startDate,
|
||||||
|
setStartDate,
|
||||||
|
endDate,
|
||||||
|
setEndDate,
|
||||||
|
useCustomRange,
|
||||||
|
handleCustomRangeChange,
|
||||||
|
handleResetZoom
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '15px',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '15px'
|
||||||
|
}}>
|
||||||
|
{/* Стандартные диапазоны */}
|
||||||
|
<div style={{ flex: '1 1 200px', display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ flex: '1' }}>
|
||||||
|
<label htmlFor="time-range" style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '5px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#555'
|
||||||
|
}}>Стандартные диапазоны:</label>
|
||||||
|
<select
|
||||||
|
id="time-range"
|
||||||
|
value={selectedRange.value}
|
||||||
|
onChange={handleRangeChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
color: "#333",
|
||||||
|
backgroundColor: '#f9f9f9'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TIME_RANGES.map(range => (
|
||||||
|
<option key={range.value} value={range.value}>{range.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleResetZoom}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
color: '#333',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
height: '36px',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => e.target.style.backgroundColor = '#e0e0e0'}
|
||||||
|
onMouseOut={(e) => e.target.style.backgroundColor = '#f0f0f0'}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кастомный диапазон */}
|
||||||
|
<div style={{ flex: '1 1 300px' }}>
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#555'
|
||||||
|
}}>
|
||||||
|
Или укажите свой диапазон:
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<DatePicker
|
||||||
|
selected={startDate}
|
||||||
|
onChange={(date) => setStartDate(date)}
|
||||||
|
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={(date) => setEndDate(date)}
|
||||||
|
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={handleCustomRangeChange}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#4a6baf',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
alignSelf: 'flex-end'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => e.target.style.backgroundColor = '#3a5a9f'}
|
||||||
|
onMouseOut={(e) => e.target.style.backgroundColor = '#4a6baf'}
|
||||||
|
>
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
export const TIME_RANGES = [
|
||||||
|
{ label: '1 минута', value: 60, interval: 3000 },
|
||||||
|
{ label: '5 минут', value: 300, interval: 15000 },
|
||||||
|
{ label: '30 минут', value: 1800, interval: 90000 },
|
||||||
|
{ label: '1 час', value: 3600, interval: 180000 },
|
||||||
|
{ label: '3 часа', value: 10800, interval: 540000 },
|
||||||
|
{ label: '6 часов', value: 21600, interval: 1080000 },
|
||||||
|
{ label: '12 часов', value: 43200, interval: 2160000 },
|
||||||
|
{ label: '24 часа', value: 86400, interval: 4320000 },
|
||||||
|
{ label: '2 дня', value: 172800, interval: 8640000 },
|
||||||
|
{ label: '7 дней', value: 604800, interval: 30240000 },
|
||||||
|
{ label: '30 дней', value: 2592000, interval: 129600000 },
|
||||||
|
{ label: '90 дней', value: 7776000, interval: 388800000 },
|
||||||
|
{ label: '6 месяцев', value: 15552000, interval: 777600000 },
|
||||||
|
{ label: '9 месяцев', value: 23328000, interval: 1166400000 },
|
||||||
|
{ label: '1 год', value: 31536000, interval: 1576800000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COLORS = ['#3e95cd', '#8e5ea2', '#3cba9f', '#e8c3b9', '#c45850'];
|
||||||
|
export const MAX_POINTS = 20;
|
||||||
|
|
||||||
|
|
||||||
|
// Для работы с временными интервалами (setTimeout и т.д.)
|
||||||
|
export const MS = 1;
|
||||||
|
export const SECOND_MS = 1000 * MS;
|
||||||
|
export const MINUTE_MS = 60 * SECOND_MS;
|
||||||
|
export const HOUR_MS = 60 * MINUTE_MS;
|
||||||
|
export const DAY_MS = 24 * HOUR_MS;
|
||||||
|
|
||||||
|
// Для работы с Unix-временем и API (Prometheus и т.д.)
|
||||||
|
export const SECOND = 1;
|
||||||
|
export const MINUTE = 60 * SECOND;
|
||||||
|
export const HOUR = 60 * MINUTE;
|
||||||
|
export const DAY = 24 * HOUR;
|
||||||
|
export const WEEK = 7 * DAY;
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
|
||||||
ResponsiveContainer, ReferenceLine
|
|
||||||
} from 'recharts';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
const lineColors = {
|
|
||||||
'18': '#8884d8',
|
|
||||||
'19': '#82ca9d',
|
|
||||||
'default': '#ff8042'
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatXAxis = (tickItem) => {
|
|
||||||
return format(new Date(tickItem), 'HH:mm:ss');
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTooltip = (value, name, props) => {
|
|
||||||
return [`${value.toFixed(2)}`, `Устройство ${name}`];
|
|
||||||
};
|
|
||||||
|
|
||||||
const LineChartComponent = ({
|
|
||||||
data = [],
|
|
||||||
multipleLines = true, // По умолчанию включаем множественные линии
|
|
||||||
lineKey = 'device', // Ключ для разделения линий
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
height = 400,
|
|
||||||
ranges = []
|
|
||||||
}) => {
|
|
||||||
if (!data || data.length === 0) return <div>Нет данных для отображения</div>;
|
|
||||||
|
|
||||||
// Создаем массив уникальных устройств
|
|
||||||
const devices = [...new Set(data.map(item => item.device))];
|
|
||||||
|
|
||||||
// Группируем данные по timestamp для правильного отображения
|
|
||||||
const timestamps = [...new Set(data.map(item => item.timestamp))].sort();
|
|
||||||
|
|
||||||
const chartData = timestamps.map(timestamp => {
|
|
||||||
const point = { timestamp };
|
|
||||||
|
|
||||||
// Для каждого устройства находим значение в этот timestamp
|
|
||||||
devices.forEach(device => {
|
|
||||||
const deviceData = data.find(item =>
|
|
||||||
item.timestamp === timestamp && item.device === device
|
|
||||||
);
|
|
||||||
point[`device_${device}`] = deviceData ? deviceData.value : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return point;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ width: '100%', height: `${height}px` }}>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
{description && <p>{description}</p>}
|
|
||||||
<ResponsiveContainer width="100%" height="90%">
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="timestamp"
|
|
||||||
tickFormatter={formatXAxis}
|
|
||||||
/>
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip
|
|
||||||
formatter={formatTooltip}
|
|
||||||
labelFormatter={(label) => format(new Date(label), 'yyyy-MM-dd HH:mm:ss')}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
|
|
||||||
{devices.map(device => (
|
|
||||||
<Line
|
|
||||||
key={`line-${device}`}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={`device_${device}`}
|
|
||||||
name={`Устройство ${device}`}
|
|
||||||
stroke={lineColors[device] || lineColors.default}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 6 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
connectNulls={true}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Добавляем диапазоны если они есть */}
|
|
||||||
{ranges.map((range, idx) => (
|
|
||||||
<ReferenceLine
|
|
||||||
key={`range-${idx}`}
|
|
||||||
y={range.value}
|
|
||||||
stroke={range.color}
|
|
||||||
label={range.label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LineChartComponent;
|
|
||||||
|
|
@ -0,0 +1,499 @@
|
||||||
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { webSocketManager } from './WebSocketManager';
|
||||||
|
import LineChartComponent from './Components/LineChartComponent';
|
||||||
|
import { TimeRangeSelector } from './Components/TimeRangeSelector';
|
||||||
|
import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator';
|
||||||
|
import { CurrentRangeDisplay } from './Components/CurrentRangeDisplay';
|
||||||
|
import { TIME_RANGES, COLORS, SECOND, MINUTE, HOUR, DAY } from './Components/constants';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
|
||||||
|
// Компонент Skeleton для графика
|
||||||
|
const ChartSkeleton = () => (
|
||||||
|
<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 [chartData, setChartData] = useState(null);
|
||||||
|
const [selectedRange, setSelectedRange] = useState(TIME_RANGES[0]);
|
||||||
|
const [startDate, setStartDate] = useState(new Date());
|
||||||
|
const [endDate, setEndDate] = useState(new Date());
|
||||||
|
const [useCustomRange, setUseCustomRange] = useState(false);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState('disconnected');
|
||||||
|
const [selectedGraphRange, setSelectedGraphRange] = useState(null);
|
||||||
|
const [filteredData, setFilteredData] = useState(null);
|
||||||
|
const [isSelectingRange, setIsSelectingRange] = useState(false);
|
||||||
|
const [lastCustomRange, setLastCustomRange] = useState(null);
|
||||||
|
const intervalRef = useRef(null);
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
|
||||||
|
const formatTime = useCallback((timestamp, rangeSeconds) => {
|
||||||
|
const ts = typeof timestamp === 'number' ? timestamp : Date.now();
|
||||||
|
const date = new Date(ts);
|
||||||
|
|
||||||
|
// Определяем формат в зависимости от диапазона
|
||||||
|
const showFullDate = rangeSeconds > 86400; // больше суток
|
||||||
|
|
||||||
|
const timeOptions = {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateOptions = showFullDate ? {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
...timeOptions
|
||||||
|
} : timeOptions;
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: date.toLocaleString('ru-RU', dateOptions),
|
||||||
|
fullDisplay: date.toLocaleString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
}),
|
||||||
|
timestamp: ts
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const calculateStep = useCallback((start, end) => {
|
||||||
|
const range = end - start;
|
||||||
|
if (range <= MINUTE) return 1; // 1 мин
|
||||||
|
if (range <= MINUTE * 5) return 5; // 5 мин
|
||||||
|
if (range <= HOUR / 2) return 15; // 30 мин
|
||||||
|
if (range <= HOUR) return 30; // 1 час
|
||||||
|
if (range <= HOUR * 3) return 60; // 3 часа
|
||||||
|
if (range <= HOUR * 6) return 120; // 6 часов
|
||||||
|
if (range <= DAY / 2) return 300; // 12 часов
|
||||||
|
if (range <= DAY) return 600; // 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(() => {
|
||||||
|
if (isSelectingRange) return;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const start = now - selectedRange.value;
|
||||||
|
const end = now;
|
||||||
|
const step = calculateStep(start, end);
|
||||||
|
|
||||||
|
webSocketManager.getMetricsRange(metricName, start, end, step)
|
||||||
|
.then(data => {
|
||||||
|
processMetricsData({ metric: metricName, data });
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching metrics:', error);
|
||||||
|
});
|
||||||
|
}, [metricName, selectedRange.value, isSelectingRange, calculateStep, processMetricsData]);
|
||||||
|
|
||||||
|
const fetchCustomRangeData = useCallback(async () => {
|
||||||
|
// Добавляем проверку на валидность дат
|
||||||
|
if (!startDate || !endDate || startDate >= endDate) {
|
||||||
|
console.error('Invalid date range');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.floor(startDate.getTime() / 1000);
|
||||||
|
const end = Math.ceil(endDate.getTime() / 1000); // Используем Math.ceil для конечной даты
|
||||||
|
const rangeSeconds = end - start;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics`, {
|
||||||
|
params: {
|
||||||
|
metric: metricName,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
step: calculateStep(start, end)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.length) {
|
||||||
|
// Добавляем нормализацию timestamp
|
||||||
|
const processedData = response.data.map(item => ({
|
||||||
|
...item,
|
||||||
|
timestamp: item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000,
|
||||||
|
value: parseFloat(item.value)
|
||||||
|
}));
|
||||||
|
|
||||||
|
processMetricsData({
|
||||||
|
metric: metricName,
|
||||||
|
data: processedData
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении кастомных данных:', error);
|
||||||
|
}
|
||||||
|
}, [metricName, startDate, endDate, calculateStep, processMetricsData]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleRangeChange = useCallback(async (event) => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedValue = event.target.value;
|
||||||
|
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
|
||||||
|
|
||||||
|
// Полный сброс состояния перед загрузкой новых данных
|
||||||
|
setChartData(null);
|
||||||
|
setSelectedRange(range);
|
||||||
|
setUseCustomRange(false);
|
||||||
|
setSelectedGraphRange(null);
|
||||||
|
setFilteredData(null);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
setEndDate(now);
|
||||||
|
setStartDate(new Date(now.getTime() - range.value * 1000));
|
||||||
|
|
||||||
|
// Ждем завершения обновления состояния перед загрузкой
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleCustomRangeChange = useCallback(() => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUseCustomRange(true);
|
||||||
|
setChartData(null);
|
||||||
|
setSelectedGraphRange(null);
|
||||||
|
setFilteredData(null);
|
||||||
|
fetchCustomRangeData();
|
||||||
|
}, [fetchCustomRangeData]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const interpolateData = useCallback((data, targetPointCount) => {
|
||||||
|
if (!data || data.length < 2) return data;
|
||||||
|
if (data.length >= targetPointCount) return data;
|
||||||
|
|
||||||
|
const interpolated = [];
|
||||||
|
const step = (data.length - 1) / (targetPointCount - 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < targetPointCount; i++) {
|
||||||
|
const index = i * step;
|
||||||
|
const lowerIndex = Math.floor(index);
|
||||||
|
const upperIndex = Math.ceil(index);
|
||||||
|
|
||||||
|
if (lowerIndex === upperIndex) {
|
||||||
|
interpolated.push(data[lowerIndex]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fraction = index - lowerIndex;
|
||||||
|
const interpolatedPoint = {};
|
||||||
|
|
||||||
|
Object.keys(data[lowerIndex]).forEach(key => {
|
||||||
|
if (key === 'timestamp') {
|
||||||
|
interpolatedPoint[key] = data[lowerIndex][key] +
|
||||||
|
fraction * (data[upperIndex][key] - data[lowerIndex][key]);
|
||||||
|
|
||||||
|
// Добавляем отображаемое время
|
||||||
|
const { display, fullDisplay } = formatTime(interpolatedPoint[key],
|
||||||
|
(endDate - startDate) / 1000);
|
||||||
|
interpolatedPoint.time = display;
|
||||||
|
interpolatedPoint.fullTime = fullDisplay;
|
||||||
|
} else if (typeof data[lowerIndex][key] === 'number') {
|
||||||
|
interpolatedPoint[key] = data[lowerIndex][key] +
|
||||||
|
fraction * (data[upperIndex][key] - data[lowerIndex][key]);
|
||||||
|
} else {
|
||||||
|
interpolatedPoint[key] = data[lowerIndex][key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interpolated.push(interpolatedPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return interpolated;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRangeSelect = useCallback((range) => {
|
||||||
|
setLastCustomRange(range);
|
||||||
|
if (!range || !chartData) return;
|
||||||
|
|
||||||
|
setIsSelectingRange(true);
|
||||||
|
setSelectedGraphRange(range);
|
||||||
|
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем все точки и сортируем по времени
|
||||||
|
const allPoints = Object.values(chartData).flat();
|
||||||
|
const sortedPoints = allPoints.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
// Вычисляем абсолютные индексы
|
||||||
|
const startIndex = Math.floor(range.startIndex * (sortedPoints.length - 1));
|
||||||
|
const endIndex = Math.floor(range.endIndex * (sortedPoints.length - 1));
|
||||||
|
|
||||||
|
// Фильтруем точки по выбранному диапазону
|
||||||
|
const filtered = sortedPoints.slice(startIndex, endIndex + 1);
|
||||||
|
|
||||||
|
// Применяем интерполяцию только если точек меньше 100
|
||||||
|
const interpolated = filtered.length < 100 ?
|
||||||
|
interpolateData(filtered, Math.min(100, filtered.length * 3)) :
|
||||||
|
filtered;
|
||||||
|
|
||||||
|
setFilteredData(interpolated);
|
||||||
|
setIsSelectingRange(false);
|
||||||
|
}, [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(() => {
|
||||||
|
// Обработчик данных с сервера
|
||||||
|
const handleMetricsData = (data) => {
|
||||||
|
if (!useCustomRange) {
|
||||||
|
processMetricsData({ metric: metricName, data });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Подписываемся на обновления метрики
|
||||||
|
const unsubscribe = webSocketManager.subscribe(metricName, handleMetricsData);
|
||||||
|
|
||||||
|
// Подписываемся на изменения статуса соединения
|
||||||
|
const unsubscribeStatus = webSocketManager.onConnectionStatusChange(setConnectionStatus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Отписываемся при размонтировании компонента
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeStatus();
|
||||||
|
|
||||||
|
// Очищаем интервал обновления
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [metricName, useCustomRange, processMetricsData]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useCustomRange && !isSelectingRange) {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
fetchCustomRangeData();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [useCustomRange, isSelectingRange, startDate, endDate, fetchCustomRangeData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useCustomRange || isSelectingRange) return;
|
||||||
|
|
||||||
|
const fetchDataWrapper = () => {
|
||||||
|
try {
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in interval fetch:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fetchDataWrapper();
|
||||||
|
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchData, selectedRange.interval, useCustomRange, isSelectingRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedGraphRange || !chartData) {
|
||||||
|
setFilteredData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPoints = Object.values(chartData).flat();
|
||||||
|
const sortedPoints = allPoints.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
const startIndex = Math.floor(selectedGraphRange.startIndex * (sortedPoints.length - 1));
|
||||||
|
const endIndex = Math.floor(selectedGraphRange.endIndex * (sortedPoints.length - 1));
|
||||||
|
|
||||||
|
const filtered = sortedPoints.slice(startIndex, endIndex + 1);
|
||||||
|
const interpolated = filtered.length > 100 ?
|
||||||
|
interpolateData(filtered, 100) :
|
||||||
|
filtered;
|
||||||
|
|
||||||
|
setFilteredData(interpolated);
|
||||||
|
}, [selectedGraphRange, chartData, interpolateData]);
|
||||||
|
|
||||||
|
if (chartData === null) {
|
||||||
|
return <ChartSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(chartData).length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
No data available
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<ConnectionStatusIndicator connectionStatus={connectionStatus} />
|
||||||
|
|
||||||
|
<TimeRangeSelector
|
||||||
|
selectedRange={selectedRange}
|
||||||
|
handleRangeChange={handleRangeChange}
|
||||||
|
startDate={startDate}
|
||||||
|
setStartDate={setStartDate}
|
||||||
|
endDate={endDate}
|
||||||
|
setEndDate={setEndDate}
|
||||||
|
useCustomRange={useCustomRange}
|
||||||
|
handleCustomRangeChange={handleCustomRangeChange}
|
||||||
|
handleResetZoom={handleResetZoom}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CurrentRangeDisplay
|
||||||
|
useCustomRange={useCustomRange}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
selectedRange={selectedRange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LineChartComponent
|
||||||
|
chartData={chartData}
|
||||||
|
metricName={metricName}
|
||||||
|
colors={COLORS}
|
||||||
|
onRangeSelect={handleRangeSelect}
|
||||||
|
filteredData={filteredData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(PrometheusChart);
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import LineChartComponent from './LineChartComponent';
|
|
||||||
import DateRangeSelector from '../Charts2/Components/DateRangeSelector';
|
|
||||||
import metricsService from '../Charts2/Components/metricsService';
|
|
||||||
import { Button, Radio, message, Tag } from 'antd';
|
|
||||||
import moment from 'moment';
|
|
||||||
import StatusLogTable from '../Charts2/Components/StatusLogTable';
|
|
||||||
import { Box, IconButton, Tooltip as MuiTooltip } from '@mui/material';
|
|
||||||
import { ListAlt } from '@mui/icons-material';
|
|
||||||
|
|
||||||
const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
|
||||||
const {
|
|
||||||
name: metricName,
|
|
||||||
filters = {},
|
|
||||||
title = metricName,
|
|
||||||
description,
|
|
||||||
context = {},
|
|
||||||
ranges = []
|
|
||||||
} = metricInfo || {};
|
|
||||||
|
|
||||||
const { device, source_id } = 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 [showLogs, setShowLogs] = useState(false);
|
|
||||||
const [statusLogs, setStatusLogs] = useState([]);
|
|
||||||
const MAX_POINTS = 50;
|
|
||||||
const TIME_WINDOW_MS = 3600 * 1000;
|
|
||||||
|
|
||||||
|
|
||||||
// Эта функция может больше не понадобиться, так как
|
|
||||||
// сервис сам генерирует ключи, но оставьте для совместимости
|
|
||||||
const getSubscriptionKey = () => {
|
|
||||||
const filterParts = [];
|
|
||||||
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
|
|
||||||
if (source_id) filterParts.push(`source_id=${encodeURIComponent(source_id)}`);
|
|
||||||
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusFromRanges = (value, ranges) => {
|
|
||||||
if (!ranges || ranges.length === 0) return 1;
|
|
||||||
for (const r of ranges) {
|
|
||||||
if (value >= r.min && value <= r.max) {
|
|
||||||
return r.status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMetricData = (dataArray) => {
|
|
||||||
if (!Array.isArray(dataArray)) {
|
|
||||||
console.error('Expected array in formatMetricData, got:', typeof dataArray);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataArray.map(item => {
|
|
||||||
if (item.timestamp === undefined || item.value === undefined) {
|
|
||||||
console.warn('Invalid metric item:', item);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
timestamp: Number(item.timestamp),
|
|
||||||
value: parseFloat(item.value),
|
|
||||||
status: getStatusFromRanges(parseFloat(item.value), ranges),
|
|
||||||
name: item.__name__ || metricName,
|
|
||||||
device: item.device?.trim() || null,
|
|
||||||
source_id: item.source_id || null,
|
|
||||||
description: item.description || description
|
|
||||||
};
|
|
||||||
}).filter(Boolean)
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateStep = (startTime, endTime, maxPoints = 10000) => {
|
|
||||||
const durationSeconds = (endTime.getTime() - startTime.getTime()) / 1000;
|
|
||||||
return Math.max(Math.ceil(durationSeconds / maxPoints), 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const downsampleData = (data, maxPoints = MAX_POINTS) => {
|
|
||||||
if (data.length <= maxPoints) return [...data];
|
|
||||||
|
|
||||||
const sortedData = [...data].sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
const step = Math.max(1, Math.floor(sortedData.length / maxPoints));
|
|
||||||
|
|
||||||
const result = [];
|
|
||||||
for (let i = 0; i < sortedData.length; i += step) {
|
|
||||||
if (result.length >= maxPoints) break;
|
|
||||||
result.push(sortedData[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
const lastOriginalPoint = sortedData[sortedData.length - 1];
|
|
||||||
if (result[result.length - 1].timestamp !== lastOriginalPoint.timestamp) {
|
|
||||||
result[result.length - 1] = lastOriginalPoint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (chartData.length > 0) {
|
|
||||||
const newLogs = chartData.reduce((acc, point, index) => {
|
|
||||||
if (index === 0 || point.status !== chartData[index - 1].status) {
|
|
||||||
return [...acc, point];
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
setStatusLogs(newLogs);
|
|
||||||
}
|
|
||||||
}, [chartData]);
|
|
||||||
|
|
||||||
|
|
||||||
const fetchHistoricalData = async (start, end) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extendedFilters = {
|
|
||||||
...filters,
|
|
||||||
...(device && { device: device.toString() }),
|
|
||||||
...(source_id && { source_id: source_id.toString() })
|
|
||||||
};
|
|
||||||
|
|
||||||
const step = calculateStep(start, end);
|
|
||||||
|
|
||||||
// Используем новый метод для исторических данных
|
|
||||||
const data = await metricsService.fetchMetricsRange(
|
|
||||||
metricName,
|
|
||||||
start.getTime(), // Теперь передаем timestamp в миллисекундах
|
|
||||||
end.getTime(),
|
|
||||||
step,
|
|
||||||
extendedFilters
|
|
||||||
);
|
|
||||||
|
|
||||||
const formattedData = formatMetricData(data)
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
const limitedData = formattedData.length > MAX_POINTS
|
|
||||||
? downsampleData(formattedData, MAX_POINTS)
|
|
||||||
: formattedData;
|
|
||||||
|
|
||||||
if (limitedData.length > 0) {
|
|
||||||
setMetricMeta({
|
|
||||||
type: data[0]?.type,
|
|
||||||
description: data[0]?.description || description,
|
|
||||||
instance: data[0]?.instance,
|
|
||||||
job: data[0]?.job
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setChartData(limitedData);
|
|
||||||
} 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() - TIME_WINDOW_MS);
|
|
||||||
|
|
||||||
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
|
||||||
|
|
||||||
// Изменяем параметры подписки
|
|
||||||
return metricsService.subscribeToMetric(
|
|
||||||
metricName, // Теперь передаем просто имя метрики
|
|
||||||
{ ...filters, device, source_id }, // Фильры отдельным параметром
|
|
||||||
(update) => { // Колбэк получает объект с данными
|
|
||||||
console.log('Received WS update:', update);
|
|
||||||
|
|
||||||
if (!update || !Array.isArray(update.data)) {
|
|
||||||
console.error('Invalid update format:', update);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setChartData(prev => {
|
|
||||||
const now = Date.now();
|
|
||||||
const cutoffTime = now - TIME_WINDOW_MS;
|
|
||||||
|
|
||||||
const formattedNew = formatMetricData(update.data)
|
|
||||||
.filter(point => point.timestamp >= cutoffTime);
|
|
||||||
|
|
||||||
const filteredPrev = prev.filter(point =>
|
|
||||||
point.timestamp >= cutoffTime
|
|
||||||
);
|
|
||||||
|
|
||||||
const merged = [...filteredPrev, ...formattedNew]
|
|
||||||
.filter((v, i, a) =>
|
|
||||||
a.findIndex(t => t.timestamp === v.timestamp) === i
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
return merged.length > MAX_POINTS
|
|
||||||
? merged.slice(-MAX_POINTS)
|
|
||||||
: merged;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
5000 // Интервал обновления (можно настроить)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const stopRealtimeUpdates = () => {
|
|
||||||
setIsLiveUpdating(false);
|
|
||||||
// Теперь отписываемся по метрике и фильтрам
|
|
||||||
metricsService.unsubscribeFromMetric(
|
|
||||||
metricName,
|
|
||||||
{ ...filters, device, source_id }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomRangeApply = () => {
|
|
||||||
if (startDate && endDate) {
|
|
||||||
fetchHistoricalData(startDate, endDate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('Metric changed:', { metricName, device, source_id, filters });
|
|
||||||
|
|
||||||
let unsubscribe;
|
|
||||||
|
|
||||||
const init = async () => {
|
|
||||||
if (mode === 'realtime') {
|
|
||||||
unsubscribe = startRealtimeUpdates();
|
|
||||||
} else {
|
|
||||||
await fetchHistoricalData(startDate, endDate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (unsubscribe) {
|
|
||||||
unsubscribe(); // Вызываем функцию отписки
|
|
||||||
}
|
|
||||||
if (mode === 'realtime') {
|
|
||||||
stopRealtimeUpdates(); // Дополнительная очистка
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров
|
|
||||||
|
|
||||||
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>}
|
|
||||||
{source_id && <Tag color="purple">Модуль: {source_id.split('$')[1]}</Tag>}
|
|
||||||
|
|
||||||
<Box position="relative">
|
|
||||||
<MuiTooltip title={showLogs ? "Скрыть логи" : "Показать логи"}>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setShowLogs(!showLogs)}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
zIndex: 1000,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
boxShadow: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListAlt />
|
|
||||||
</IconButton>
|
|
||||||
</MuiTooltip>
|
|
||||||
|
|
||||||
{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,
|
|
||||||
source_id
|
|
||||||
}}
|
|
||||||
ranges={ranges}
|
|
||||||
/>
|
|
||||||
{showLogs && (
|
|
||||||
<StatusLogTable logs={statusLogs} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SystemChart;
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
const SystemStatusChart = ({ data }) => {
|
||||||
|
// Обрезаем массив, оставляя только последние 20 точек
|
||||||
|
const trimmedData = data.slice(-20);
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="custom-tooltip" style={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: '10px',
|
||||||
|
border: '1px solid #ccc'
|
||||||
|
}}>
|
||||||
|
<p>{`Время: ${label}`}</p>
|
||||||
|
<p>{`Значение: ${payload[0].value}`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart
|
||||||
|
data={trimmedData} // Используем обрезанный массив
|
||||||
|
margin={{
|
||||||
|
top: 5, right: 30, left: 20, bottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" />
|
||||||
|
<YAxis domain={[0, 100]} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Line type="monotone" dataKey="status" stroke="#8884d8" activeDot={{ r: 8 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemStatusChart;
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
class WebSocketManager {
|
||||||
|
constructor() {
|
||||||
|
this.socket = null;
|
||||||
|
this.subscribers = new Map();
|
||||||
|
this.connectionStatus = 'disconnected';
|
||||||
|
this.connectionCallbacks = new Set();
|
||||||
|
this.connecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.socket?.connected || this.connecting) {
|
||||||
|
return this.socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connecting = true;
|
||||||
|
|
||||||
|
this.socket = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: Infinity,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
this.connectionStatus = 'connected';
|
||||||
|
this.connecting = false;
|
||||||
|
this.notifyConnectionStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('disconnect', (reason) => {
|
||||||
|
this.connectionStatus = 'disconnected';
|
||||||
|
this.connecting = false;
|
||||||
|
this.notifyConnectionStatus();
|
||||||
|
if (reason === 'io server disconnect') this.socket.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect_error', (error) => {
|
||||||
|
this.connectionStatus = 'error';
|
||||||
|
this.notifyConnectionStatus();
|
||||||
|
setTimeout(() => this.socket.connect(), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('metrics-data', (response) => {
|
||||||
|
const callbacks = this.subscribers.get(response.metric);
|
||||||
|
if (callbacks) {
|
||||||
|
callbacks.forEach(callback => callback(response.data));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(metricName, callback) {
|
||||||
|
if (!this.socket?.connected) {
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.subscribers.has(metricName)) {
|
||||||
|
this.subscribers.set(metricName, new Set());
|
||||||
|
this.socket.emit('subscribe-metric', {
|
||||||
|
metric: metricName,
|
||||||
|
isSubscription: true // Флаг для подписки
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscribers.get(metricName).add(callback);
|
||||||
|
|
||||||
|
return () => this.unsubscribe(metricName, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(metricName, callback) {
|
||||||
|
const callbacks = this.subscribers.get(metricName);
|
||||||
|
if (callbacks) {
|
||||||
|
callbacks.delete(callback);
|
||||||
|
if (callbacks.size === 0) {
|
||||||
|
this.subscribers.delete(metricName);
|
||||||
|
this.socket.emit('unsubscribe-metric', { metric: metricName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetricsRange(metricName, start, end, step) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error('Timeout while waiting for metrics data'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
// Временный обработчик для разового запроса
|
||||||
|
const tempHandler = (data) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.socket.off(`metrics-range-${metricName}`, tempHandler);
|
||||||
|
resolve(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket.on(`metrics-range-${metricName}`, tempHandler);
|
||||||
|
this.socket.emit('get-metrics', {
|
||||||
|
metric: metricName,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
step,
|
||||||
|
isRangeQuery: true // Флаг для разового запроса
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onConnectionStatusChange(callback) {
|
||||||
|
this.connectionCallbacks.add(callback);
|
||||||
|
callback(this.connectionStatus);
|
||||||
|
return () => this.connectionCallbacks.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyConnectionStatus() {
|
||||||
|
this.connectionCallbacks.forEach(callback => callback(this.connectionStatus));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const webSocketManager = new WebSocketManager();
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
|
|
@ -66,39 +66,12 @@ const StatusIndicator = ({ cx, cy, payload }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusBadge = ({ status }) => {
|
const CustomTooltip = ({ active, payload, label }) => {
|
||||||
const statusColor = getStatusColor(status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '4px 8px',
|
|
||||||
background: `${statusColor}20`,
|
|
||||||
borderLeft: `4px solid ${statusColor}`,
|
|
||||||
borderRadius: '4px',
|
|
||||||
marginTop: '4px'
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
backgroundColor: statusColor,
|
|
||||||
borderRadius: '50%',
|
|
||||||
marginRight: 8
|
|
||||||
}} />
|
|
||||||
<div>
|
|
||||||
<strong>{getStatusText(status)}</strong>
|
|
||||||
<div style={{ fontSize: '0.8em', color: '#666' }}>
|
|
||||||
{getStatusDescription(status)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label, multipleLines }) => {
|
|
||||||
if (!active || !payload || !payload.length) return null;
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
|
const status = payload[0].payload.status;
|
||||||
|
const statusColor = getStatusColor(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
|
|
@ -108,24 +81,31 @@ const CustomTooltip = ({ active, payload, label, multipleLines }) => {
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||||
}}>
|
}}>
|
||||||
<p><strong>{new Date(label).toLocaleString()}</strong></p>
|
<p><strong>{new Date(label).toLocaleString()}</strong></p>
|
||||||
|
<p style={{ color: payload[0].color }}>
|
||||||
{multipleLines ? (
|
Значение: <strong>{payload[0].value.toFixed(2)}</strong>
|
||||||
payload.map((item, index) => (
|
</p>
|
||||||
<div key={index} style={{ marginBottom: '8px' }}>
|
<div style={{
|
||||||
<p style={{ color: item.color }}>
|
display: 'flex',
|
||||||
{item.name}: <strong>{item.value.toFixed(2)}</strong>
|
alignItems: 'center',
|
||||||
</p>
|
padding: '4px 8px',
|
||||||
<StatusBadge status={item.payload.status} />
|
background: `${statusColor}20`,
|
||||||
|
borderLeft: `4px solid ${statusColor}`,
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
backgroundColor: statusColor,
|
||||||
|
borderRadius: '50%',
|
||||||
|
marginRight: 8
|
||||||
|
}} />
|
||||||
|
<div>
|
||||||
|
<strong>{getStatusText(status)}</strong>
|
||||||
|
<div style={{ fontSize: '0.8em', color: '#666' }}>
|
||||||
|
{getStatusDescription(status)}
|
||||||
</div>
|
</div>
|
||||||
))
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<p style={{ color: payload[0].color }}>
|
|
||||||
Значение: <strong>{payload[0].value.toFixed(2)}</strong>
|
|
||||||
</p>
|
|
||||||
<StatusBadge status={payload[0].payload.status} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -137,43 +117,9 @@ const LineChartComponent = ({
|
||||||
metaInfo,
|
metaInfo,
|
||||||
dataKey = 'value',
|
dataKey = 'value',
|
||||||
height = 400,
|
height = 400,
|
||||||
ranges = [],
|
ranges = [],
|
||||||
statusBoundaries = [],
|
statusBoundaries = []
|
||||||
multipleLines = false,
|
|
||||||
lineKey = 'device'
|
|
||||||
}) => {
|
}) => {
|
||||||
// Группировка данных для нескольких линий
|
|
||||||
const groupedData = useMemo(() => {
|
|
||||||
if (!multipleLines || !data || data.length === 0) return null;
|
|
||||||
|
|
||||||
return data.reduce((groups, item) => {
|
|
||||||
const key = item[lineKey] || 'default';
|
|
||||||
if (!groups[key]) {
|
|
||||||
groups[key] = {
|
|
||||||
data: [],
|
|
||||||
color: getLineColor(key),
|
|
||||||
name: `${title} (${key})`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
groups[key].data.push(item);
|
|
||||||
return groups;
|
|
||||||
}, {});
|
|
||||||
}, [data, multipleLines, lineKey, title]);
|
|
||||||
|
|
||||||
// Функции для цветов линий
|
|
||||||
const getLineColor = (key) => {
|
|
||||||
const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff8042', '#0088FE'];
|
|
||||||
const index = Math.abs(hashCode(key)) % colors.length;
|
|
||||||
return colors[index];
|
|
||||||
};
|
|
||||||
|
|
||||||
const hashCode = (str) => {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusAreas = () => {
|
const getStatusAreas = () => {
|
||||||
if (!data || data.length === 0) return null;
|
if (!data || data.length === 0) return null;
|
||||||
|
|
@ -195,21 +141,22 @@ const LineChartComponent = ({
|
||||||
|
|
||||||
return areas.map((area, i) => (
|
return areas.map((area, i) => (
|
||||||
<ReferenceArea
|
<ReferenceArea
|
||||||
key={`area-${i}`}
|
key={`area-${i}`}
|
||||||
x1={area.start}
|
x1={area.start}
|
||||||
x2={area.end}
|
x2={area.end}
|
||||||
fill={getStatusColor(area.status)}
|
fill={getStatusColor(area.status)}
|
||||||
fillOpacity={0.12}
|
fillOpacity={0.12}
|
||||||
stroke={getStatusColor(area.status)}
|
stroke={getStatusColor(area.status)}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
strokeOpacity={0.5}
|
strokeOpacity={0.5}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderRangeLines = () => {
|
const renderRangeLines = () => {
|
||||||
if (!ranges || ranges.length === 0) return null;
|
if (!ranges || ranges.length === 0) return null;
|
||||||
|
|
||||||
// Собираем только уникальные граничные значения, исключая дубликаты на стыках диапазонов
|
// Собираем только уникальные граничные значения, исключая дубликаты на стыках диапазонов
|
||||||
const boundaryValues = [];
|
const boundaryValues = [];
|
||||||
ranges.forEach((range, index) => {
|
ranges.forEach((range, index) => {
|
||||||
|
|
@ -217,28 +164,28 @@ const LineChartComponent = ({
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
boundaryValues.push(range.min);
|
boundaryValues.push(range.min);
|
||||||
boundaryValues.push(range.max);
|
boundaryValues.push(range.max);
|
||||||
}
|
}
|
||||||
// Для остальных добавляем только max (min будет совпадать с max предыдущего)
|
// Для остальных добавляем только max (min будет совпадать с max предыдущего)
|
||||||
else {
|
else {
|
||||||
boundaryValues.push(range.max);
|
boundaryValues.push(range.max);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return boundaryValues.map((value, index) => {
|
return boundaryValues.map((value, index) => {
|
||||||
// Находим диапазон, к которому принадлежит эта граница
|
// Находим диапазон, к которому принадлежит эта граница
|
||||||
const range = ranges.find(r => r.min === value || r.max === value);
|
const range = ranges.find(r => r.min === value || r.max === value);
|
||||||
const status = range ? range.status : 1;
|
const status = range ? range.status : 1;
|
||||||
|
|
||||||
const lineStyle = {
|
const lineStyle = {
|
||||||
1: { strokeWidth: 1, strokeDasharray: "none", opacity: 0.7 },
|
1: { strokeWidth: 1, strokeDasharray: "none", opacity: 0.7 },
|
||||||
2: { strokeWidth: 2, strokeDasharray: "none", opacity: 0.9 },
|
2: { strokeWidth: 2, strokeDasharray: "none", opacity: 0.9 },
|
||||||
3: { strokeWidth: 2, strokeDasharray: "none", opacity: 1 },
|
3: { strokeWidth: 2, strokeDasharray: "none", opacity: 1 },
|
||||||
4: { strokeWidth: 2, strokeDasharray: "none", opacity: 1 }
|
4: { strokeWidth: 2, strokeDasharray: "none", opacity: 1 }
|
||||||
}[status] || { strokeWidth: 1, strokeDasharray: "3 3", opacity: 0.7 };
|
}[status] || { strokeWidth: 1, strokeDasharray: "3 3", opacity: 0.7 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
key={`line-${value}`}
|
key={`line-${value}`} // Используем значение как ключ для стабильности
|
||||||
y={value}
|
y={value}
|
||||||
stroke={rangeColors[status] || '#888'}
|
stroke={rangeColors[status] || '#888'}
|
||||||
strokeWidth={lineStyle.strokeWidth}
|
strokeWidth={lineStyle.strokeWidth}
|
||||||
|
|
@ -285,9 +232,10 @@ const LineChartComponent = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', height: `${height}px` }}>
|
<div style={{ width: '100%', height: `${height}px` }}>
|
||||||
{/* Заголовок и описание */}
|
|
||||||
{title && <h3>{title}</h3>}
|
{title && <h3>{title}</h3>}
|
||||||
{description && <p style={{ marginTop: -10, color: '#666' }}>{description}</p>}
|
{description && (
|
||||||
|
<p style={{ marginTop: -10, color: '#666' }}>{description}</p>
|
||||||
|
)}
|
||||||
{metaInfo && (
|
{metaInfo && (
|
||||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: '#888', marginBottom: 10 }}>
|
||||||
{metaInfo}
|
{metaInfo}
|
||||||
|
|
@ -339,56 +287,35 @@ const LineChartComponent = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* График */}
|
|
||||||
<ResponsiveContainer width="100%" height="75%">
|
<ResponsiveContainer width="100%" height="75%">
|
||||||
<LineChart
|
<LineChart
|
||||||
data={multipleLines ? null : data}
|
data={data}
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(ts) => new Date(ts).toLocaleTimeString()}
|
tickFormatter={(ts) => new Date(ts).toLocaleTimeString()}
|
||||||
/>
|
/>
|
||||||
<YAxis />
|
<YAxis />
|
||||||
|
{renderRangeLines()}
|
||||||
|
{renderStatusBoundaries()}
|
||||||
|
{getStatusAreas()}
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey={dataKey}
|
||||||
|
stroke="#8884d8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={<StatusIndicator />}
|
||||||
|
activeDot={{ r: 8 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
name={title}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
|
||||||
{renderRangeLines()}
|
|
||||||
{renderStatusBoundaries()}
|
|
||||||
{getStatusAreas()}
|
|
||||||
|
|
||||||
<Tooltip content={<CustomTooltip multipleLines={multipleLines} />} />
|
|
||||||
<Legend />
|
|
||||||
|
|
||||||
{multipleLines && groupedData ? (
|
|
||||||
Object.entries(groupedData).map(([key, group]) => (
|
|
||||||
<Line
|
|
||||||
key={key}
|
|
||||||
data={group.data}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={dataKey}
|
|
||||||
stroke={group.color}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={<StatusIndicator />}
|
|
||||||
activeDot={{ r: 8 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
animationDuration={300}
|
|
||||||
name={group.name}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey={dataKey}
|
|
||||||
stroke="#8884d8"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={<StatusIndicator />}
|
|
||||||
activeDot={{ r: 8 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
animationDuration={300}
|
|
||||||
name={title}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
||||||
{/* Легенда статусов */}
|
{/* Легенда статусов */}
|
||||||
|
|
|
||||||
|
|
@ -1,380 +1,158 @@
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
class MetricsService {
|
class MetricsService {
|
||||||
constructor() {
|
constructor(baseUrl) {
|
||||||
this.baseUrl = '/metrics-ws';
|
this.baseUrl = baseUrl || window.location.origin;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.subscriptions = new Map(); // Хранит подписки на real-time данные
|
this.subscriptions = new Map();
|
||||||
this.pendingRequests = new Map(); // Для разовых запросов
|
this.pendingRequests = new Map();
|
||||||
this.reconnectAttempts = 0;
|
window.addEventListener('beforeunload', this.cleanupAll.bind(this));
|
||||||
this.maxReconnectAttempts = 5;
|
window.addEventListener('pagehide', this.cleanupAll.bind(this));
|
||||||
this.reconnectDelay = 5000;
|
|
||||||
this.connectionCallbacks = new Set(); // Колбэки для событий подключения
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => this.cleanupAll());
|
window.addEventListener('beforeunload', () => {
|
||||||
window.addEventListener('pagehide', () => this.cleanupAll());
|
this.cleanupAll();
|
||||||
}
|
});
|
||||||
|
|
||||||
// Новый метод для отслеживания состояния подключения
|
|
||||||
onConnectionChange(callback) {
|
|
||||||
this.connectionCallbacks.add(callback);
|
|
||||||
return () => this.connectionCallbacks.delete(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Уведомление всех подписчиков о изменении состояния
|
|
||||||
notifyConnectionChange(connected) {
|
|
||||||
this.connectionCallbacks.forEach(cb => cb(connected));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleServerMessage(msg) {
|
|
||||||
try {
|
|
||||||
if (!msg || typeof msg !== 'object') {
|
|
||||||
console.error('Invalid message format', msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { event, data, requestId } = msg;
|
|
||||||
|
|
||||||
switch (event) {
|
|
||||||
case 'connected':
|
|
||||||
console.log('Server connection confirmed:', data);
|
|
||||||
this.notifyConnectionChange(true);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'realtime-data':
|
|
||||||
this.handleRealtimeData(data, requestId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'historical-data':
|
|
||||||
this.handleHistoricalData(data, requestId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'current-data':
|
|
||||||
this.handleCurrentData(data, requestId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'error':
|
|
||||||
this.handleError(data, requestId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.warn('Unknown message type:', event);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing message:', error, msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleRealtimeData(data, requestId) {
|
|
||||||
const { metric, filters, data: metricsData, type } = data;
|
|
||||||
const metricKey = this.getMetricKey(metric, filters);
|
|
||||||
|
|
||||||
if (requestId && this.pendingRequests.has(requestId)) {
|
|
||||||
// Это ответ на разовый запрос
|
|
||||||
const { resolve } = this.pendingRequests.get(requestId);
|
|
||||||
resolve(metricsData);
|
|
||||||
this.pendingRequests.delete(requestId);
|
|
||||||
} else {
|
|
||||||
// Это обновление по подписке
|
|
||||||
const callbacks = this.subscriptions.get(metricKey) || [];
|
|
||||||
callbacks.forEach(cb => cb({
|
|
||||||
data: metricsData,
|
|
||||||
type: type || 'update',
|
|
||||||
metric,
|
|
||||||
filters,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHistoricalData(data, requestId) {
|
|
||||||
if (requestId && this.pendingRequests.has(requestId)) {
|
|
||||||
const { resolve } = this.pendingRequests.get(requestId);
|
|
||||||
resolve(data.data || data);
|
|
||||||
this.pendingRequests.delete(requestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCurrentData(data, requestId) {
|
|
||||||
if (requestId && this.pendingRequests.has(requestId)) {
|
|
||||||
const { resolve } = this.pendingRequests.get(requestId);
|
|
||||||
resolve(data.data || data);
|
|
||||||
this.pendingRequests.delete(requestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleError(data, requestId) {
|
|
||||||
if (requestId && this.pendingRequests.has(requestId)) {
|
|
||||||
const { reject } = this.pendingRequests.get(requestId);
|
|
||||||
reject(new Error(data.error || 'Unknown error'));
|
|
||||||
this.pendingRequests.delete(requestId);
|
|
||||||
} else {
|
|
||||||
console.error('Server error:', data.error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectWebSocket() {
|
connectWebSocket() {
|
||||||
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
|
if (this.socket) {
|
||||||
|
console.log('WebSocket already exists');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Connecting WebSocket...');
|
console.log('Connecting WebSocket...');
|
||||||
this.socket = new WebSocket(this.baseUrl);
|
this.socket = io(`${this.baseUrl.replace('http', 'ws')}/api/metrics-ws`, {
|
||||||
this.notifyConnectionChange(false);
|
transports: ['websocket'],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
this.socket.addEventListener('open', () => {
|
this.socket.on('connect', () => {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
this.reconnectAttempts = 0;
|
// Восстанавливаем подписки при переподключении
|
||||||
this.notifyConnectionChange(true);
|
this.subscriptions.forEach((_, metricKey) => {
|
||||||
|
const [metric, query] = metricKey.split('?');
|
||||||
// Переподписываемся на все активные подписки
|
const filters = this.parseFiltersFromKey(metricKey);
|
||||||
this.resubscribeAll();
|
this.socket.emit('subscribe-metric', { metric, filters });
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.addEventListener('close', (event) => {
|
|
||||||
console.log('WebSocket disconnected', event.code, event.reason);
|
|
||||||
this.socket = null;
|
|
||||||
this.notifyConnectionChange(false);
|
|
||||||
this.scheduleReconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.addEventListener('error', (err) => {
|
|
||||||
console.error('WebSocket error:', err);
|
|
||||||
this.notifyConnectionChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.addEventListener('message', (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
this.handleServerMessage(msg);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing WS message:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переподписка на все активные подписки после переподключения
|
|
||||||
resubscribeAll() {
|
|
||||||
this.subscriptions.forEach((_, metricKey) => {
|
|
||||||
const { metric, filters } = this.parseMetricKey(metricKey);
|
|
||||||
this.sendMessage('subscribe-realtime', {
|
|
||||||
metric,
|
|
||||||
filters,
|
|
||||||
interval: 10000 // Дефолтный интервал
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
scheduleReconnect() {
|
this.socket.on('disconnect', () => {
|
||||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
console.log('WebSocket disconnected');
|
||||||
console.warn('Max reconnect attempts reached');
|
this.socket = null;
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
this.reconnectAttempts++;
|
this.socket.on('metrics-data', ({ metric, data, requestId }) => {
|
||||||
const delay = this.reconnectDelay * this.reconnectAttempts;
|
console.log('Incoming metric update:', metric);
|
||||||
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
|
if (requestId && this.pendingRequests.has(requestId)) {
|
||||||
|
const { resolve } = this.pendingRequests.get(requestId);
|
||||||
setTimeout(() => {
|
resolve(data);
|
||||||
this.connectWebSocket();
|
this.pendingRequests.delete(requestId);
|
||||||
}, delay);
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
sendMessage(event, data, requestId) {
|
|
||||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
||||||
if (this.socket && this.socket.readyState === WebSocket.CONNECTING) {
|
|
||||||
// Ждем открытия соединения
|
|
||||||
const waitForOpen = () => {
|
|
||||||
if (this.socket.readyState === WebSocket.OPEN) {
|
|
||||||
this.doSendMessage(event, data, requestId);
|
|
||||||
} else if (this.socket.readyState === WebSocket.CONNECTING) {
|
|
||||||
setTimeout(waitForOpen, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
waitForOpen();
|
|
||||||
} else {
|
|
||||||
console.warn('WebSocket not connected, cannot send:', event);
|
|
||||||
this.connectWebSocket();
|
|
||||||
// Сохраняем сообщение для отправки после подключения
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
||||||
this.doSendMessage(event, data, requestId);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.doSendMessage(event, data, requestId);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
doSendMessage(event, data, requestId) {
|
async fetchMetricsRange(metric, start, end, step = 15, filters = {}) {
|
||||||
const message = requestId ? { event, data, requestId } : { event, data };
|
return new Promise((resolve, reject) => {
|
||||||
this.socket.send(JSON.stringify(message));
|
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 = {}) {
|
||||||
|
|
||||||
// Подписка на real-time данные
|
|
||||||
subscribeToMetric(metric, filters = {}, callback, interval = 10000) {
|
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
|
|
||||||
const metricKey = this.getMetricKey(metric, filters);
|
|
||||||
|
|
||||||
if (!this.subscriptions.has(metricKey)) {
|
if (!this.subscriptions.has(metricKey)) {
|
||||||
this.subscriptions.set(metricKey, []);
|
this.subscriptions.set(metricKey, []);
|
||||||
|
const [metric] = metricKey.split('?');
|
||||||
this.sendMessage('subscribe-realtime', {
|
this.socket.emit('subscribe-metric', {
|
||||||
metric,
|
metric,
|
||||||
filters,
|
interval,
|
||||||
interval
|
filters
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const callbacks = this.subscriptions.get(metricKey);
|
const callbacks = this.subscriptions.get(metricKey);
|
||||||
callbacks.push(callback);
|
callbacks.push(callback);
|
||||||
|
|
||||||
// Возвращаем функцию для отписки
|
return () => {
|
||||||
return () => this.unsubscribeFromMetric(metric, filters, callback);
|
this.unsubscribeFromMetric(metricKey, callback);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отписка от real-time данных
|
unsubscribeFromMetric(metricKey, callback) {
|
||||||
unsubscribeFromMetric(metric, filters = {}, callback) {
|
|
||||||
const metricKey = this.getMetricKey(metric, filters);
|
|
||||||
const callbacks = this.subscriptions.get(metricKey) || [];
|
const callbacks = this.subscriptions.get(metricKey) || [];
|
||||||
const filtered = callbacks.filter(cb => cb !== callback);
|
const filtered = callbacks.filter(cb => cb !== callback);
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
this.subscriptions.delete(metricKey);
|
this.subscriptions.delete(metricKey);
|
||||||
this.sendMessage('unsubscribe-realtime', { metric, filters });
|
if (this.socket && this.socket.connected) {
|
||||||
|
const [metric] = metricKey.split('?');
|
||||||
|
this.socket.emit('unsubscribe-metric', { metric });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.subscriptions.set(metricKey, filtered);
|
this.subscriptions.set(metricKey, filtered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запрос исторических данных (разовый)
|
parseFiltersFromKey(metricKey) {
|
||||||
async fetchMetricsRange(metric, start, end, step = 60, filters = {}) {
|
const parts = metricKey.split('?');
|
||||||
return new Promise((resolve, reject) => {
|
if (parts.length < 2) return {};
|
||||||
this.connectWebSocket();
|
|
||||||
const requestId = `historical-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
return parts[1].split('&').reduce((acc, pair) => {
|
||||||
reject(new Error('Historical data request timeout'));
|
const [key, value] = pair.split('=');
|
||||||
this.pendingRequests.delete(requestId);
|
if (key && value) acc[key] = value;
|
||||||
}, 30000); // 30 секунд таймаут для historical данных
|
return acc;
|
||||||
|
}, {});
|
||||||
this.pendingRequests.set(requestId, {
|
|
||||||
resolve: (data) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(data);
|
|
||||||
},
|
|
||||||
reject: (err) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sendMessage('get-historical', {
|
|
||||||
metric,
|
|
||||||
start: Math.floor(start / 1000) * 1000, // Ensure milliseconds
|
|
||||||
end: Math.floor(end / 1000) * 1000,
|
|
||||||
step,
|
|
||||||
filters
|
|
||||||
}, requestId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запрос текущих данных (разовый)
|
|
||||||
async fetchCurrentMetrics(metric, filters = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.connectWebSocket();
|
|
||||||
const requestId = `current-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new Error('Current data request timeout'));
|
|
||||||
this.pendingRequests.delete(requestId);
|
|
||||||
}, 10000); // 10 секунд таймаут
|
|
||||||
|
|
||||||
this.pendingRequests.set(requestId, {
|
|
||||||
resolve: (data) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(data);
|
|
||||||
},
|
|
||||||
reject: (err) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sendMessage('get-current', {
|
|
||||||
metric,
|
|
||||||
filters
|
|
||||||
}, requestId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отписка от всех подписок
|
|
||||||
unsubscribeAll() {
|
|
||||||
this.sendMessage('unsubscribe-all', {});
|
|
||||||
this.subscriptions.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ============
|
|
||||||
|
|
||||||
getMetricKey(metric, filters) {
|
|
||||||
const sortedKeys = Object.keys(filters).sort();
|
|
||||||
const filterString = sortedKeys
|
|
||||||
.map(key => `${key}=${encodeURIComponent(filters[key])}`)
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
return filterString ? `${metric}?${filterString}` : metric;
|
|
||||||
}
|
|
||||||
|
|
||||||
parseMetricKey(metricKey) {
|
|
||||||
const [metric, query] = metricKey.split('?');
|
|
||||||
const filters = {};
|
|
||||||
|
|
||||||
if (query) {
|
|
||||||
query.split('&').forEach(pair => {
|
|
||||||
const [key, value] = pair.split('=');
|
|
||||||
if (key && value) {
|
|
||||||
filters[decodeURIComponent(key)] = decodeURIComponent(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { metric, filters };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupAll() {
|
cleanupAll() {
|
||||||
this.unsubscribeAll();
|
if (this.socket && this.socket.connected) {
|
||||||
|
this.socket.emit('unsubscribe-all');
|
||||||
|
}
|
||||||
|
this.subscriptions.clear();
|
||||||
this.disconnectWebSocket();
|
this.disconnectWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectWebSocket() {
|
disconnectWebSocket() {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.close(1000, 'Client disconnected');
|
this.socket.disconnect();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
this.notifyConnectionChange(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка состояния подключения
|
|
||||||
isConnected() {
|
|
||||||
return this.socket?.readyState === WebSocket.OPEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получение текущего состояния
|
|
||||||
getConnectionState() {
|
|
||||||
return this.socket ? this.socket.readyState : WebSocket.CLOSED;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем глобальный экземпляр
|
// Создаем экземпляр сервиса
|
||||||
const metricsService = new MetricsService();
|
const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL);
|
||||||
|
|
||||||
// Экспорт для использования в модульной системе
|
export default metricsService;
|
||||||
export default metricsService;
|
|
||||||
|
|
||||||
// Глобальный экспорт для прямого использования в браузере
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.MetricsService = metricsService;
|
|
||||||
}
|
|
||||||
|
|
@ -30,12 +30,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
const [isLiveUpdating, setIsLiveUpdating] = useState(false);
|
const [isLiveUpdating, setIsLiveUpdating] = useState(false);
|
||||||
const [showLogs, setShowLogs] = useState(false);
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
const [statusLogs, setStatusLogs] = useState([]);
|
const [statusLogs, setStatusLogs] = useState([]);
|
||||||
const MAX_POINTS = 50;
|
|
||||||
const TIME_WINDOW_MS = 3600 * 1000;
|
|
||||||
|
|
||||||
|
|
||||||
// Эта функция может больше не понадобиться, так как
|
|
||||||
// сервис сам генерирует ключи, но оставьте для совместимости
|
|
||||||
const getSubscriptionKey = () => {
|
const getSubscriptionKey = () => {
|
||||||
const filterParts = [];
|
const filterParts = [];
|
||||||
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
|
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
|
||||||
|
|
@ -43,70 +38,35 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
|
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusFromRanges = (value, ranges) => {
|
|
||||||
if (!ranges || ranges.length === 0) return 1;
|
|
||||||
for (const r of ranges) {
|
|
||||||
if (value >= r.min && value <= r.max) {
|
|
||||||
return r.status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMetricData = (dataArray) => {
|
const formatMetricData = (dataArray) => {
|
||||||
if (!Array.isArray(dataArray)) {
|
return dataArray
|
||||||
console.error('Expected array in formatMetricData, got:', typeof dataArray);
|
.map(item => ({
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataArray.map(item => {
|
|
||||||
if (item.timestamp === undefined || item.value === undefined) {
|
|
||||||
console.warn('Invalid metric item:', item);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
...item,
|
||||||
timestamp: Number(item.timestamp),
|
timestamp: item.timestamp,
|
||||||
value: parseFloat(item.value),
|
value: parseFloat(item.value),
|
||||||
status: getStatusFromRanges(parseFloat(item.value), ranges),
|
|
||||||
name: item.__name__ || metricName,
|
name: item.__name__ || metricName,
|
||||||
|
status: parseInt(item.status) || 0,
|
||||||
device: item.device?.trim() || null,
|
device: item.device?.trim() || null,
|
||||||
source_id: item.source_id || null,
|
source_id: item.source_id || null,
|
||||||
description: item.description || description
|
description: item.description || description
|
||||||
};
|
}))
|
||||||
}).filter(Boolean)
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downsampleData = (data, maxPoints = 500) => {
|
||||||
|
if (data.length <= maxPoints) return data;
|
||||||
|
|
||||||
|
const ratio = Math.ceil(data.length / maxPoints);
|
||||||
|
return data.filter((_, index) => index % ratio === 0);
|
||||||
|
};
|
||||||
|
|
||||||
const calculateStep = (startTime, endTime, maxPoints = 10000) => {
|
const calculateStep = (startTime, endTime, maxPoints = 10000) => {
|
||||||
const durationSeconds = (endTime.getTime() - startTime.getTime()) / 1000;
|
const seconds = (endTime.getTime() - startTime.getTime()) / 1000;
|
||||||
return Math.max(Math.ceil(durationSeconds / maxPoints), 1);
|
return Math.max(Math.ceil(seconds / maxPoints), 1); // в секундах
|
||||||
};
|
|
||||||
|
|
||||||
const downsampleData = (data, maxPoints = MAX_POINTS) => {
|
|
||||||
if (data.length <= maxPoints) return [...data];
|
|
||||||
|
|
||||||
const sortedData = [...data].sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
const step = Math.max(1, Math.floor(sortedData.length / maxPoints));
|
|
||||||
|
|
||||||
const result = [];
|
|
||||||
for (let i = 0; i < sortedData.length; i += step) {
|
|
||||||
if (result.length >= maxPoints) break;
|
|
||||||
result.push(sortedData[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
const lastOriginalPoint = sortedData[sortedData.length - 1];
|
|
||||||
if (result[result.length - 1].timestamp !== lastOriginalPoint.timestamp) {
|
|
||||||
result[result.length - 1] = lastOriginalPoint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Обновляем логи при изменении данных
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chartData.length > 0) {
|
if (chartData.length > 0) {
|
||||||
const newLogs = chartData.reduce((acc, point, index) => {
|
const newLogs = chartData.reduce((acc, point, index) => {
|
||||||
|
|
@ -132,24 +92,17 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const step = calculateStep(start, end);
|
const step = calculateStep(start, end);
|
||||||
|
|
||||||
// Используем новый метод для исторических данных
|
|
||||||
const data = await metricsService.fetchMetricsRange(
|
const data = await metricsService.fetchMetricsRange(
|
||||||
metricName,
|
metricName,
|
||||||
start.getTime(), // Теперь передаем timestamp в миллисекундах
|
Math.floor(start.getTime() / 1000),
|
||||||
end.getTime(),
|
Math.floor(end.getTime() / 1000),
|
||||||
step,
|
step,
|
||||||
extendedFilters
|
extendedFilters
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedData = formatMetricData(data)
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
const limitedData = formattedData.length > MAX_POINTS
|
const formattedData = downsampleData(formatMetricData(data), 100); //КОЛИЧЕСТВО ТОЧЕК НА ГРАФИКЕ
|
||||||
? downsampleData(formattedData, MAX_POINTS)
|
if (formattedData.length > 0) {
|
||||||
: formattedData;
|
|
||||||
|
|
||||||
if (limitedData.length > 0) {
|
|
||||||
setMetricMeta({
|
setMetricMeta({
|
||||||
type: data[0]?.type,
|
type: data[0]?.type,
|
||||||
description: data[0]?.description || description,
|
description: data[0]?.description || description,
|
||||||
|
|
@ -158,7 +111,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setChartData(limitedData);
|
setChartData(formattedData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error loading historical data for ${metricName}:`, err);
|
console.error(`Error loading historical data for ${metricName}:`, err);
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -173,56 +126,32 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
const start = new Date(end.getTime() - TIME_WINDOW_MS);
|
const start = new Date(end.getTime() - 3600 * 1000);
|
||||||
|
|
||||||
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
||||||
|
|
||||||
// Изменяем параметры подписки
|
|
||||||
return metricsService.subscribeToMetric(
|
return metricsService.subscribeToMetric(
|
||||||
metricName, // Теперь передаем просто имя метрики
|
getSubscriptionKey(),
|
||||||
{ ...filters, device, source_id }, // Фильры отдельным параметром
|
(newData) => {
|
||||||
(update) => { // Колбэк получает объект с данными
|
const formattedData = formatMetricData(newData);
|
||||||
console.log('Received WS update:', update);
|
|
||||||
|
|
||||||
if (!update || !Array.isArray(update.data)) {
|
|
||||||
console.error('Invalid update format:', update);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setChartData(prev => {
|
setChartData(prev => {
|
||||||
const now = Date.now();
|
const newChartData = [...prev, ...formattedData]
|
||||||
const cutoffTime = now - TIME_WINDOW_MS;
|
.filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i)
|
||||||
|
.slice(-200);
|
||||||
const formattedNew = formatMetricData(update.data)
|
return newChartData;
|
||||||
.filter(point => point.timestamp >= cutoffTime);
|
|
||||||
|
|
||||||
const filteredPrev = prev.filter(point =>
|
|
||||||
point.timestamp >= cutoffTime
|
|
||||||
);
|
|
||||||
|
|
||||||
const merged = [...filteredPrev, ...formattedNew]
|
|
||||||
.filter((v, i, a) =>
|
|
||||||
a.findIndex(t => t.timestamp === v.timestamp) === i
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
return merged.length > MAX_POINTS
|
|
||||||
? merged.slice(-MAX_POINTS)
|
|
||||||
: merged;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
5000 // Интервал обновления (можно настроить)
|
5000,
|
||||||
|
{
|
||||||
|
...filters,
|
||||||
|
...(device && { device }),
|
||||||
|
...(source_id && { source_id })
|
||||||
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const stopRealtimeUpdates = () => {
|
const stopRealtimeUpdates = () => {
|
||||||
setIsLiveUpdating(false);
|
setIsLiveUpdating(false);
|
||||||
// Теперь отписываемся по метрике и фильтрам
|
metricsService.unsubscribeFromMetric(getSubscriptionKey());
|
||||||
metricsService.unsubscribeFromMetric(
|
|
||||||
metricName,
|
|
||||||
{ ...filters, device, source_id }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomRangeApply = () => {
|
const handleCustomRangeApply = () => {
|
||||||
|
|
@ -232,29 +161,20 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Metric changed:', { metricName, device, source_id, filters });
|
console.log('Current metric context:', { device, source_id, metricName });
|
||||||
|
|
||||||
let unsubscribe;
|
let unsubscribe;
|
||||||
|
if (mode === 'realtime') {
|
||||||
const init = async () => {
|
unsubscribe = startRealtimeUpdates();
|
||||||
if (mode === 'realtime') {
|
} else {
|
||||||
unsubscribe = startRealtimeUpdates();
|
stopRealtimeUpdates();
|
||||||
} else {
|
fetchHistoricalData(startDate, endDate);
|
||||||
await fetchHistoricalData(startDate, endDate);
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unsubscribe) {
|
if (unsubscribe) unsubscribe();
|
||||||
unsubscribe(); // Вызываем функцию отписки
|
stopRealtimeUpdates();
|
||||||
}
|
|
||||||
if (mode === 'realtime') {
|
|
||||||
stopRealtimeUpdates(); // Дополнительная очистка
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров
|
}, [mode, metricName, device, source_id]);
|
||||||
|
|
||||||
const metaInfo = [
|
const metaInfo = [
|
||||||
metricMeta.instance && `Instance: ${metricMeta.instance}`,
|
metricMeta.instance && `Instance: ${metricMeta.instance}`,
|
||||||
|
|
@ -347,4 +267,4 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PrometheusChart;
|
export default PrometheusChart;
|
||||||
|
|
@ -141,13 +141,13 @@ const Dashboard = ({ isDarkMode, setIsDarkMode, user, onLogout }) => {
|
||||||
top: 12,
|
top: 12,
|
||||||
right: 20,
|
right: 20,
|
||||||
zIndex: (theme) => theme.zIndex.tooltip + 10,
|
zIndex: (theme) => theme.zIndex.tooltip + 10,
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto', //ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}//ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
|
}}
|
||||||
>
|
>
|
||||||
<AIAnalysisButton />
|
<AIAnalysisButton />
|
||||||
<ProfileMenu user={user} onLogout={onLogout} />
|
<ProfileMenu user={user} onLogout={onLogout} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,472 +0,0 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
TextField, Box, Typography, IconButton, Divider,
|
|
||||||
CircularProgress, Alert, Collapse, Tooltip, Button,
|
|
||||||
Card, CardContent, Chip, Dialog, DialogTitle,
|
|
||||||
DialogContent, DialogActions, Snackbar, Table,
|
|
||||||
TableBody, TableCell, TableContainer, TableHead,
|
|
||||||
TableRow, Paper, Badge
|
|
||||||
} from '@mui/material';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const FormulaItem = React.memo(({ formula, onEdit }) => {
|
|
||||||
const getMetricStatusColor = (found) => {
|
|
||||||
return found ? 'success' : 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatValue = (value) => {
|
|
||||||
if (value === undefined) return 'N/A';
|
|
||||||
return value.toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card sx={{ mb: 2, border: '1px solid', borderColor: 'divider' }}>
|
|
||||||
<CardContent>
|
|
||||||
{/* Заголовок с ID и статусом метрик */}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" color="primary">
|
|
||||||
{formula.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
ID: {formula.id}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Badge
|
|
||||||
badgeContent={formula.metadata?.missingMetrics}
|
|
||||||
color="error"
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
label={`${formula.metadata?.foundMetrics || 0}/${formula.metadata?.totalMetrics || 0} метрик`}
|
|
||||||
color={formula.metadata?.missingMetrics === 0 ? "success" : "warning"}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
startIcon={<EditIcon />}
|
|
||||||
onClick={() => onEdit(formula)}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
Редактировать
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Описание */}
|
|
||||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
||||||
{formula.description}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Метрики */}
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
Метрики в формуле:
|
|
||||||
{formula.metadata?.missingMetrics > 0 && (
|
|
||||||
<WarningIcon color="warning" fontSize="small" />
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ mb: 2 }}>
|
|
||||||
<Table size="small">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Метрика</TableCell>
|
|
||||||
<TableCell>Описание</TableCell>
|
|
||||||
<TableCell align="right">Значение</TableCell>
|
|
||||||
<TableCell>Статус</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{formula.enrichedMetrics?.map((metric, index) => (
|
|
||||||
<TableRow key={index}>
|
|
||||||
<TableCell>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" fontWeight="bold">
|
|
||||||
{metric.originalName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{metric.prometheusName}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="body2">
|
|
||||||
{metric.description}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={metric.found ? 'text.primary' : 'text.disabled'}
|
|
||||||
>
|
|
||||||
{formatValue(metric.currentValue)}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
icon={metric.found ? <CheckCircleIcon /> : <WarningIcon />}
|
|
||||||
label={metric.found ? 'Найдена' : 'Не найдена'}
|
|
||||||
color={getMetricStatusColor(metric.found)}
|
|
||||||
size="small"
|
|
||||||
variant={metric.found ? "filled" : "outlined"}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Формула */}
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Формула с описанием метрик:
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
backgroundColor: 'primary.light',
|
|
||||||
color: 'primary.contrastText',
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1,
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
fontFamily: 'monospace'
|
|
||||||
}}>
|
|
||||||
{formula.humanReadableFormula}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Веса */}
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Веса (warr):
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
{formula.values?.warr?.map((weight, index) => (
|
|
||||||
<Chip
|
|
||||||
key={index}
|
|
||||||
label={`warr[${index + 1}]: ${weight}`}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const EditFormulaDialog = ({ open, formula, onClose, onSave }) => {
|
|
||||||
const [editedFormula, setEditedFormula] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (formula) {
|
|
||||||
setEditedFormula(formula.formula || '');
|
|
||||||
}
|
|
||||||
}, [formula]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (formula && editedFormula.trim()) {
|
|
||||||
onSave(formula.id, editedFormula.trim());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
|
||||||
<DialogTitle>
|
|
||||||
Редактирование формулы: {formula?.name}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
||||||
{formula?.description}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2, mb: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Доступные переменные:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
<Chip label="statusarr[]" size="small" />
|
|
||||||
<Chip label="warr[]" size="small" />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Формула"
|
|
||||||
value={editedFormula}
|
|
||||||
onChange={(e) => setEditedFormula(e.target.value)}
|
|
||||||
multiline
|
|
||||||
rows={6}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
placeholder="Введите формулу..."
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose}>Отмена</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<SaveIcon />}
|
|
||||||
disabled={!editedFormula.trim()}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FormulaEditor = () => {
|
|
||||||
const [formulas, setFormulas] = useState([]);
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [editingFormula, setEditingFormula] = useState(null);
|
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
|
||||||
const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' });
|
|
||||||
|
|
||||||
const showSnackbar = (message, severity = 'success') => {
|
|
||||||
setSnackbar({ open: true, message, severity });
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadFormulas = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await axios.get('http://192.168.2.39:3000/api/enriched-formulas');
|
|
||||||
|
|
||||||
if (Array.isArray(response.data)) {
|
|
||||||
setFormulas(response.data);
|
|
||||||
showSnackbar(`Загружено ${response.data.length} формул`);
|
|
||||||
} else {
|
|
||||||
throw new Error('Некорректный формат данных');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при загрузке формул:', err);
|
|
||||||
const errorMessage = axios.isAxiosError(err)
|
|
||||||
? `Ошибка сервера: ${err.response?.status} - ${err.response?.data?.message || err.message}`
|
|
||||||
: `Ошибка загрузки: ${err.message}`;
|
|
||||||
setError(errorMessage);
|
|
||||||
showSnackbar(errorMessage, 'error');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEditFormula = (formula) => {
|
|
||||||
setEditingFormula(formula);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveFormula = async (formulaId, newFormula) => {
|
|
||||||
try {
|
|
||||||
setSaveLoading(true);
|
|
||||||
|
|
||||||
await axios.post(`http://192.168.2.39:3000/api/formula/${formulaId}/update`, {
|
|
||||||
formula: newFormula
|
|
||||||
});
|
|
||||||
|
|
||||||
setFormulas(prev => prev.map(formula =>
|
|
||||||
formula.id === formulaId
|
|
||||||
? { ...formula, formula: newFormula }
|
|
||||||
: formula
|
|
||||||
));
|
|
||||||
|
|
||||||
setEditingFormula(null);
|
|
||||||
showSnackbar('Формула успешно обновлена!');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при сохранении формулы:', err);
|
|
||||||
showSnackbar('Ошибка при сохранении формулы', 'error');
|
|
||||||
} finally {
|
|
||||||
setSaveLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = useCallback(() => {
|
|
||||||
setRefreshing(true);
|
|
||||||
loadFormulas();
|
|
||||||
}, [loadFormulas]);
|
|
||||||
|
|
||||||
const filteredFormulas = formulas.filter(formula =>
|
|
||||||
formula.id.toLowerCase().includes(filter.toLowerCase()) ||
|
|
||||||
formula.name.toLowerCase().includes(filter.toLowerCase()) ||
|
|
||||||
formula.description.toLowerCase().includes(filter.toLowerCase()) ||
|
|
||||||
formula.formula.toLowerCase().includes(filter.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.totalMetrics || 0), 0);
|
|
||||||
const foundMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.foundMetrics || 0), 0);
|
|
||||||
const missingMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.missingMetrics || 0), 0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadFormulas();
|
|
||||||
}, [loadFormulas]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ position: 'relative', p: 2 }}>
|
|
||||||
{/* Загрузка */}
|
|
||||||
{(loading || refreshing) && (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ошибки */}
|
|
||||||
<Collapse in={!!error}>
|
|
||||||
<Alert
|
|
||||||
severity="error"
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
action={
|
|
||||||
<Button color="inherit" size="small" onClick={refreshData}>
|
|
||||||
Повторить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
{/* Панель управления */}
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h4" color="primary" fontWeight="bold">
|
|
||||||
Редактор формул с метриками
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={refreshData}
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
disabled={refreshing}
|
|
||||||
>
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Статистика */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
|
||||||
<Chip
|
|
||||||
label={`Формулы: ${formulas.length}`}
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
label={`Метрики: ${foundMetrics}/${totalMetrics}`}
|
|
||||||
color={missingMetrics === 0 ? "success" : "warning"}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
{missingMetrics > 0 && (
|
|
||||||
<Chip
|
|
||||||
label={`Отсутствуют: ${missingMetrics}`}
|
|
||||||
color="error"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Поиск */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1, mb: 2 }}>
|
|
||||||
<TextField
|
|
||||||
label="Поиск по формулам"
|
|
||||||
fullWidth
|
|
||||||
value={filter}
|
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
|
||||||
variant="outlined"
|
|
||||||
placeholder="Введите ID, название или описание..."
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<SearchIcon sx={{ color: 'action.active', mb: 0.5 }} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 3 }} />
|
|
||||||
|
|
||||||
{/* Список формул */}
|
|
||||||
<Box sx={{ maxHeight: '70vh', overflowY: 'auto', pr: 1 }}>
|
|
||||||
{filteredFormulas.map((formula) => (
|
|
||||||
<FormulaItem
|
|
||||||
key={formula.id}
|
|
||||||
formula={formula}
|
|
||||||
onEdit={handleEditFormula}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{filteredFormulas.length === 0 && !loading && (
|
|
||||||
<Typography
|
|
||||||
color="text.secondary"
|
|
||||||
textAlign="center"
|
|
||||||
py={3}
|
|
||||||
variant="h6"
|
|
||||||
>
|
|
||||||
{filter ? 'Формулы не найдены' : 'Нет загруженных формул'}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Статус бар */}
|
|
||||||
<Box sx={{
|
|
||||||
position: 'sticky',
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'background.paper',
|
|
||||||
p: 1,
|
|
||||||
borderTop: 1,
|
|
||||||
borderColor: 'divider',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Всего формул: {formulas.length} • Отфильтровано: {filteredFormulas.length}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color={missingMetrics === 0 ? "success.main" : "warning.main"}>
|
|
||||||
Метрики: {foundMetrics}/{totalMetrics} найдено
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Диалог редактирования */}
|
|
||||||
<EditFormulaDialog
|
|
||||||
open={!!editingFormula}
|
|
||||||
formula={editingFormula}
|
|
||||||
onClose={() => setEditingFormula(null)}
|
|
||||||
onSave={handleSaveFormula}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Уведомления */}
|
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
autoHideDuration={6000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
>
|
|
||||||
<Alert
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
severity={snackbar.severity}
|
|
||||||
>
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default React.memo(FormulaEditor);
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
// components/SettingsComponents/Licensing.jsx
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Divider,
|
|
||||||
Chip,
|
|
||||||
Button,
|
|
||||||
TextField,
|
|
||||||
InputAdornment,
|
|
||||||
IconButton,
|
|
||||||
Alert,
|
|
||||||
Stack,
|
|
||||||
Grid
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
|
||||||
CheckCircle as CheckCircleIcon,
|
|
||||||
Cancel as CancelIcon,
|
|
||||||
VpnKey as VpnKeyIcon,
|
|
||||||
Refresh as RefreshIcon,
|
|
||||||
Api as ApiIcon,
|
|
||||||
Devices as DevicesIcon,
|
|
||||||
Storage as StorageIcon,
|
|
||||||
Security as SecurityIcon,
|
|
||||||
ContentCopy as ContentCopyIcon
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
import { styled } from '@mui/material/styles';
|
|
||||||
|
|
||||||
const StyledPaper = styled(Paper)(({ theme }) => ({
|
|
||||||
padding: theme.spacing(3),
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
backgroundColor: theme.palette.background.default,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const LicenseKeyBox = styled(Box)(({ theme }) => ({
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '1.1rem',
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginTop: theme.spacing(2),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const Licensing = ({ onSave }) => {
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
const [licenseKey, setLicenseKey] = useState('ABCDE-FGHIJ-KLMNO-PQRST-UVWXY');
|
|
||||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
|
||||||
|
|
||||||
// Текущий состав лицензии (заглушка)
|
|
||||||
const licenseFeatures = [
|
|
||||||
{ name: 'Модуль API', active: true, icon: ApiIcon, description: 'Полный доступ к API' },
|
|
||||||
{ name: 'Подключение устройств', active: true, icon: DevicesIcon, value: '', description: '' },
|
|
||||||
{ name: 'Модуль контроля параметров устойчивого функционирования компонентов, доверенного ПАК', active: true, icon: StorageIcon, value: '', description: '' },
|
|
||||||
//{ name: 'Расширенная безопасность', active: false, icon: SecurityIcon, description: '' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Уведомляем родительский компонент об изменениях
|
|
||||||
useEffect(() => {
|
|
||||||
if (onSave) {
|
|
||||||
onSave({
|
|
||||||
hasChanges,
|
|
||||||
saveChanges: handleSave
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [hasChanges]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
// Здесь будет логика сохранения
|
|
||||||
console.log('Сохранение лицензионных настроек');
|
|
||||||
setHasChanges(false);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefreshLicense = () => {
|
|
||||||
// Заглушка для обновления лицензии
|
|
||||||
const newKey = generateLicenseKey();
|
|
||||||
setLicenseKey(newKey);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateLicenseKey = () => {
|
|
||||||
// Заглушка для генерации ключа
|
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
||||||
const segments = [];
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
let segment = '';
|
|
||||||
for (let j = 0; j < 5; j++) {
|
|
||||||
segment += chars[Math.floor(Math.random() * chars.length)];
|
|
||||||
}
|
|
||||||
segments.push(segment);
|
|
||||||
}
|
|
||||||
return segments.join('-');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyKey = () => {
|
|
||||||
navigator.clipboard.writeText(licenseKey);
|
|
||||||
setShowCopySuccess(true);
|
|
||||||
setTimeout(() => setShowCopySuccess(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{/* Текущий состав лицензии */}
|
|
||||||
<StyledPaper elevation={0}>
|
|
||||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<VpnKeyIcon color="primary" />
|
|
||||||
Текущий состав лицензии
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<List>
|
|
||||||
{licenseFeatures.map((feature, index) => {
|
|
||||||
const IconComponent = feature.icon;
|
|
||||||
return (
|
|
||||||
<React.Fragment key={feature.name}>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemIcon>
|
|
||||||
<IconComponent color={feature.active ? "primary" : "disabled"} />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="body1">{feature.name}</Typography>
|
|
||||||
{feature.value && (
|
|
||||||
<Chip
|
|
||||||
label={feature.value}
|
|
||||||
size="small"
|
|
||||||
color={feature.active ? "success" : "default"}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
secondary={feature.description}
|
|
||||||
/>
|
|
||||||
<ListItemIcon>
|
|
||||||
{feature.active ? (
|
|
||||||
<CheckCircleIcon color="success" />
|
|
||||||
) : (
|
|
||||||
<CancelIcon color="error" />
|
|
||||||
)}
|
|
||||||
</ListItemIcon>
|
|
||||||
</ListItem>
|
|
||||||
{index < licenseFeatures.length - 1 && <Divider variant="inset" component="li" />}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</List>
|
|
||||||
</StyledPaper>
|
|
||||||
|
|
||||||
{/* Идентификатор лицензии */}
|
|
||||||
<StyledPaper elevation={0}>
|
|
||||||
<Grid container spacing={2} alignItems="center">
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Идентификатор лицензии
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="textSecondary" paragraph>
|
|
||||||
Этот ключ используется для активации и обновления лицензии
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<LicenseKeyBox>
|
|
||||||
<Typography variant="body1" sx={{ fontFamily: 'monospace' }}>
|
|
||||||
{licenseKey}
|
|
||||||
</Typography>
|
|
||||||
<Box>
|
|
||||||
<IconButton onClick={handleCopyKey} size="small" title="Копировать">
|
|
||||||
<ContentCopyIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</LicenseKeyBox>
|
|
||||||
{showCopySuccess && (
|
|
||||||
<Alert severity="success" sx={{ mt: 1 }}>Ключ скопирован в буфер обмена</Alert>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
//onClick={handleRefreshLicense}
|
|
||||||
//startIcon={<RefreshIcon />}
|
|
||||||
>
|
|
||||||
Обновить лицензию
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</StyledPaper>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Licensing;
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
ListItemSecondaryAction,
|
|
||||||
IconButton,
|
|
||||||
TextField,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Collapse,
|
|
||||||
CircularProgress
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
|
||||||
Edit as EditIcon,
|
|
||||||
Delete as DeleteIcon,
|
|
||||||
ExpandMore as ExpandMoreIcon,
|
|
||||||
ExpandLess as ExpandLessIcon
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const MenuItemComponent = ({ item, level = 0, onEdit, onDelete }) => {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const hasChildren = item.items && item.items.length > 0;
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (hasChildren) {
|
|
||||||
setExpanded(!expanded);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ListItem
|
|
||||||
sx={{
|
|
||||||
pl: level * 4,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="body1">{item.title}</Typography>
|
|
||||||
{item.isDynamic && (
|
|
||||||
<Chip
|
|
||||||
label="Динамический"
|
|
||||||
size="small"
|
|
||||||
color="info"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
secondary={item.id}
|
|
||||||
/>
|
|
||||||
<ListItemSecondaryAction>
|
|
||||||
{/* */}
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
aria-label="edit"
|
|
||||||
onClick={() => onEdit(item)}
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
aria-label="delete"
|
|
||||||
onClick={() => onDelete(item)}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
</>
|
|
||||||
{hasChildren && (
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
aria-label="expand"
|
|
||||||
onClick={handleToggle}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</ListItemSecondaryAction>
|
|
||||||
</ListItem>
|
|
||||||
{hasChildren && (
|
|
||||||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
|
||||||
<List component="div" disablePadding>
|
|
||||||
{item.items.map((child) => (
|
|
||||||
<MenuItemComponent
|
|
||||||
key={child.id}
|
|
||||||
item={child}
|
|
||||||
level={level + 1}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EditDialog = ({ open, item, onClose, onSave }) => {
|
|
||||||
const [title, setTitle] = useState(item?.title || '');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTitle(item?.title || '');
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onSave(item.id, { title });
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
|
||||||
<DialogTitle>Редактировать элемент меню</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
label="Название"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose}>Отмена</Button>
|
|
||||||
<Button onClick={handleSave} variant="contained">
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MenuEditor = ({ onSave }) => {
|
|
||||||
const [menuData, setMenuData] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [selectedItem, setSelectedItem] = useState(null);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMenuData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchMenuData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await axios.get('/api/menu/full');
|
|
||||||
setMenuData(response.data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Ошибка загрузки меню');
|
|
||||||
console.error('Error fetching menu:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (item) => {
|
|
||||||
setSelectedItem(item);
|
|
||||||
setEditDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (item) => {
|
|
||||||
setSelectedItem(item);
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditSave = async (id, updates) => {
|
|
||||||
try {
|
|
||||||
await axios.put(`/api/menu/${id}`, updates);
|
|
||||||
setHasChanges(true);
|
|
||||||
fetchMenuData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error updating menu item:', err);
|
|
||||||
alert('Ошибка при сохранении изменений');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
try {
|
|
||||||
await axios.delete(`/api/menu/items/${selectedItem.id}`);
|
|
||||||
setHasChanges(true);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
fetchMenuData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error deleting menu item:', err);
|
|
||||||
alert('Ошибка при удалении элемента');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (hasChanges) {
|
|
||||||
onSave({
|
|
||||||
hasChanges: true, saveChanges: async () => {
|
|
||||||
// Принудительно обновляем кэш
|
|
||||||
try {
|
|
||||||
await axios.post('/api/menu/invalidate-cache');
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error invalidating cache:', err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setHasChanges(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 3 }}>
|
|
||||||
<Typography color="error">{error}</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Редактирование меню
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" paragraph>
|
|
||||||
Вы можете редактировать названия и удалять элементы меню. Динамические элементы (помечены синим) нельзя редактировать.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<List>
|
|
||||||
{menuData.items.map((item) => (
|
|
||||||
<MenuItemComponent
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<EditDialog
|
|
||||||
open={editDialogOpen}
|
|
||||||
item={selectedItem}
|
|
||||||
onClose={() => setEditDialogOpen(false)}
|
|
||||||
onSave={handleEditSave}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onClose={() => setDeleteDialogOpen(false)}
|
|
||||||
>
|
|
||||||
<DialogTitle>Подтверждение удаления</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Typography>
|
|
||||||
Вы уверены, что хотите удалить элемент "{selectedItem?.title}"?
|
|
||||||
</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setDeleteDialogOpen(false)}>Отмена</Button>
|
|
||||||
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Применить изменения
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuEditor;
|
|
||||||
|
|
@ -115,7 +115,7 @@ const MetricRangeEditor = ({ onSave }) => {
|
||||||
const loadRanges = useCallback(async () => {
|
const loadRanges = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await axios.get(`/api/ranges/list`);
|
const res = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/ranges/list`);
|
||||||
setRanges(
|
setRanges(
|
||||||
Object.entries(res.data).map(([name, r]) => ({
|
Object.entries(res.data).map(([name, r]) => ({
|
||||||
name,
|
name,
|
||||||
|
|
@ -184,7 +184,7 @@ const MetricRangeEditor = ({ onSave }) => {
|
||||||
const saveChanges = useCallback(async () => {
|
const saveChanges = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await axios.post(`/api/ranges/update`, ranges);
|
await axios.post(`${import.meta.env.VITE_BACK_URL}/api/ranges/update`, ranges);
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setTimeout(() => setSuccess(false), 3000);
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
|
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Paper,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
TextField,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
IconButton,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
CircularProgress,
|
|
||||||
Alert,
|
|
||||||
Snackbar,
|
|
||||||
Divider,
|
|
||||||
Tooltip
|
|
||||||
} from '@mui/material';
|
|
||||||
import { Add, Delete } from '@mui/icons-material';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const UserManagement = () => {
|
|
||||||
const [users, setUsers] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [success, setSuccess] = useState('');
|
|
||||||
const [openDialog, setOpenDialog] = useState(false);
|
|
||||||
const [newUser, setNewUser] = useState({
|
|
||||||
login: '',
|
|
||||||
password: '',
|
|
||||||
role: 'user'
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchUsers();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/api/auth/users', {
|
|
||||||
withCredentials: true
|
|
||||||
});
|
|
||||||
setUsers(response.data);
|
|
||||||
setError('');
|
|
||||||
} catch (err) {
|
|
||||||
setError('Не удалось загрузить пользователей');
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setNewUser(prev => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRoleChange = (e) => {
|
|
||||||
setNewUser(prev => ({ ...prev, role: e.target.value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await axios.post('/api/auth/users', newUser, {
|
|
||||||
withCredentials: true
|
|
||||||
});
|
|
||||||
setOpenDialog(false);
|
|
||||||
setNewUser({
|
|
||||||
login: '',
|
|
||||||
password: '',
|
|
||||||
role: 'user'
|
|
||||||
});
|
|
||||||
setSuccess('Пользователь успешно создан');
|
|
||||||
fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.response?.data?.message || 'Не удалось создать пользователя');
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
try {
|
|
||||||
await axios.delete(`/api/auth/users/${id}`, {
|
|
||||||
withCredentials: true
|
|
||||||
});
|
|
||||||
setSuccess('Пользователь успешно удален');
|
|
||||||
fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.response?.data?.message || 'Не удалось удалить пользователя');
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
{loading && (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>
|
|
||||||
{success}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Управление пользователями
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<Add />}
|
|
||||||
onClick={() => setOpenDialog(true)}
|
|
||||||
>
|
|
||||||
Добавить пользователя
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
|
|
||||||
{!loading && (
|
|
||||||
<TableContainer component={Paper} sx={{ maxHeight: '60vh', overflow: 'auto' }}>
|
|
||||||
<Table stickyHeader>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
|
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Логин</TableCell>
|
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Роль</TableCell>
|
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Действия</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<TableRow key={user.id} hover>
|
|
||||||
<TableCell>{user.id}</TableCell>
|
|
||||||
<TableCell>{user.login}</TableCell>
|
|
||||||
<TableCell>{user.role === 'admin' ? 'Администратор' : 'Пользователь'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Tooltip title={user.role === 'admin' ? 'Нельзя удалить администратора' : 'Удалить пользователя'}>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => handleDelete(user.id)}
|
|
||||||
color="error"
|
|
||||||
disabled={user.role === 'admin'}
|
|
||||||
>
|
|
||||||
<Delete />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog open={openDialog} onClose={() => setOpenDialog(false)}>
|
|
||||||
<DialogTitle>Добавить нового пользователя</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2, minWidth: 400 }}>
|
|
||||||
<TextField
|
|
||||||
label="Логин"
|
|
||||||
name="login"
|
|
||||||
value={newUser.login}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
fullWidth
|
|
||||||
variant="standard"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Пароль"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
value={newUser.password}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
fullWidth
|
|
||||||
variant="standard"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormControl fullWidth variant="standard">
|
|
||||||
<InputLabel>Роль</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={newUser.role}
|
|
||||||
onChange={handleRoleChange}
|
|
||||||
label="Роль"
|
|
||||||
>
|
|
||||||
<MenuItem value="user">Пользователь</MenuItem>
|
|
||||||
<MenuItem value="admin">Администратор</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setOpenDialog(false)}>Отмена</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
variant="contained"
|
|
||||||
disabled={!newUser.login || !newUser.password}
|
|
||||||
>
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserManagement;
|
|
||||||
|
|
@ -20,10 +20,6 @@ import {
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
import MetricRangeEditor from './SettingsComponents/MetricRangeEditor';
|
import MetricRangeEditor from './SettingsComponents/MetricRangeEditor';
|
||||||
import UserManagement from './SettingsComponents/UserManagement';
|
|
||||||
import MenuEditor from './SettingsComponents/MenuEditor';
|
|
||||||
import FormulaEditor from './SettingsComponents/FormulaEditor';
|
|
||||||
import Licensing from './SettingsComponents/Licensing';
|
|
||||||
|
|
||||||
const Transition = React.forwardRef(function Transition(props, ref) {
|
const Transition = React.forwardRef(function Transition(props, ref) {
|
||||||
return <Slide direction="up" ref={ref} {...props} />;
|
return <Slide direction="up" ref={ref} {...props} />;
|
||||||
|
|
@ -67,18 +63,6 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
hasChanges: false,
|
hasChanges: false,
|
||||||
save: () => { }
|
save: () => { }
|
||||||
});
|
});
|
||||||
const [menuEditorState, setMenuEditorState] = useState({
|
|
||||||
hasChanges: false,
|
|
||||||
save: () => Promise.resolve(true)
|
|
||||||
});
|
|
||||||
const [formulaEditorState, setFormulaEditorState] = useState({
|
|
||||||
hasChanges: false,
|
|
||||||
save: () => Promise.resolve(true)
|
|
||||||
});
|
|
||||||
const [licensingState, setLicensingState] = useState({
|
|
||||||
hasChanges: false,
|
|
||||||
save: () => Promise.resolve(true)
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleTabChange = (event, newValue) => {
|
const handleTabChange = (event, newValue) => {
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
|
|
@ -88,30 +72,12 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMenuEditorChange = ({ hasChanges, saveChanges }) => {
|
|
||||||
setMenuEditorState({ hasChanges, save: saveChanges });
|
|
||||||
setHasChanges(hasChanges);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
if (tabValue === 0 && menuEditorState.hasChanges) {
|
|
||||||
success = await menuEditorState.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabValue === 1 && metricEditorState.hasChanges) {
|
if (tabValue === 1 && metricEditorState.hasChanges) {
|
||||||
success = success && await metricEditorState.save();
|
success = await metricEditorState.save();
|
||||||
}
|
|
||||||
|
|
||||||
if (tabValue === 3 && formulaEditorState.hasChanges) {
|
|
||||||
success = success && await formulaEditorState.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabValue === 4 && licensingState.hasChanges) {
|
|
||||||
success = success && await licensingState.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|
@ -131,16 +97,6 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
setHasChanges(hasChanges);
|
setHasChanges(hasChanges);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormulaEditorChange = ({ hasChanges, saveChanges }) => {
|
|
||||||
setFormulaEditorState({ hasChanges, save: saveChanges });
|
|
||||||
setHasChanges(hasChanges);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLicensingChange = ({ hasChanges, saveChanges }) => {
|
|
||||||
setLicensingState({ hasChanges, save: saveChanges });
|
|
||||||
setHasChanges(hasChanges);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
setShowConfirmClose(true);
|
setShowConfirmClose(true);
|
||||||
|
|
@ -156,6 +112,7 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Пример обработчика изменений
|
||||||
const handleSettingChange = () => {
|
const handleSettingChange = () => {
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
};
|
};
|
||||||
|
|
@ -190,38 +147,23 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs">
|
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs">
|
||||||
<Tab label="Меню" id="settings-tab-0" aria-controls="settings-tabpanel-0" />
|
<Tab label="Меню" id="settings-tab-0" aria-controls="settings-tabpanel-0" />
|
||||||
<Tab label="Границы метрик" id="settings-tab-1" aria-controls="settings-tabpanel-1" />
|
<Tab label="Границы метрик" id="settings-tab-1" aria-controls="settings-tabpanel-1" />
|
||||||
<Tab label="Управление пользователями" id="settings-tab-2" aria-controls="settings-tabpanel-2" />
|
{/* Добавляйте новые вкладки здесь */}
|
||||||
<Tab label="Настройка формул" id="settings-tab-3" aria-controls="settings-tabpanel-3" />
|
|
||||||
<Tab label="Лицензирование" id="settings-tab-4" aria-controls="settings-tabpanel-4" />
|
|
||||||
{/* Добавить новые вкладки здесь */}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<TabPanel value={tabValue} index={0}>
|
<TabPanel value={tabValue} index={0}>
|
||||||
<MenuEditor onSave={handleMenuEditorChange} />
|
<Typography variant="h6">Настройки меню</Typography>
|
||||||
|
{/* Добавьте содержимое для вкладки меню */}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={1}>
|
<TabPanel value={tabValue} index={1}>
|
||||||
<MetricRangeEditor onSave={handleMetricEditorChange} />
|
<MetricRangeEditor onSave={handleMetricEditorChange} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={2}>
|
|
||||||
<UserManagement />
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={3}>
|
|
||||||
<FormulaEditor onSave={handleFormulaEditorChange} />
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={4}>
|
|
||||||
<Licensing onSave={handleLicensingChange} />
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
{/* Добавляйте новые TabPanel для новых вкладок */}
|
{/* Добавляйте новые TabPanel для новых вкладок */}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleClose}>Закрыть</Button>
|
<Button onClick={handleClose}>Закрыть</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect } from "react";
|
// SidebarMenu.jsx
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
|
|
@ -6,30 +7,14 @@ import {
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Box,
|
Box,
|
||||||
alpha
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
||||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
||||||
import useSidebarResize from "../hooks/useSidebarResize";
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
import ChevronLeft from '@mui/icons-material/ChevronLeft';
|
||||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
import ChevronRight from '@mui/icons-material/ChevronRight';
|
||||||
import LogoFull from "../../assets/images/logo.svg?react";
|
import LogoFull from '../../assets/images/logo.svg?react';
|
||||||
import LogoSmall from "../../assets/images/system_monitor_icon.svg?react";
|
import LogoSmall from '../../assets/images/system_monitor_icon.svg?react';
|
||||||
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
DragOverlay,
|
|
||||||
MeasuringStrategy
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
|
|
||||||
import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem";
|
|
||||||
|
|
||||||
const SidebarMenu = ({
|
const SidebarMenu = ({
|
||||||
data,
|
data,
|
||||||
|
|
@ -37,385 +22,107 @@ const SidebarMenu = ({
|
||||||
setIsDarkMode,
|
setIsDarkMode,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
forceRefreshMenu,
|
forceRefreshMenu,
|
||||||
user,
|
user
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const { sidebarWidth, startResizing } = useSidebarResize(320); // Увеличил минимальную ширину
|
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
||||||
const [menuItems, setMenuItems] = useState(data.items || []);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [activeItem, setActiveItem] = useState(null);
|
|
||||||
const [hoveredItem, setHoveredItem] = useState(null);
|
|
||||||
const [dropIndicator, setDropIndicator] = useState({ show: false, position: null, targetId: null });
|
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 4,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cached = localStorage.getItem("menuTree");
|
|
||||||
if (cached) {
|
|
||||||
try {
|
|
||||||
setMenuItems(JSON.parse(cached));
|
|
||||||
} catch {
|
|
||||||
setMenuItems(data.items || []);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setMenuItems(data.items || []);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const handleToggleCollapse = () => {
|
const handleToggleCollapse = () => {
|
||||||
setCollapsed(!collapsed);
|
setCollapsed(!collapsed);
|
||||||
setHoveredItem(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функции для работы с деревом (остаются без изменений)
|
const SidebarResizer = styled('div')(({ theme }) => ({
|
||||||
const findItemInTree = (items, id) => {
|
width: '4px',
|
||||||
for (const item of items) {
|
cursor: 'ew-resize',
|
||||||
if (item.id === id) return item;
|
backgroundColor: 'transparent',
|
||||||
if (item.items) {
|
'&:hover': {
|
||||||
const found = findItemInTree(item.items, id);
|
backgroundColor: theme.palette.action.hover,
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeItemFromTree = (items, id) => {
|
|
||||||
return items.filter(item => {
|
|
||||||
if (item.id === id) return false;
|
|
||||||
if (item.items) {
|
|
||||||
item.items = removeItemFromTree(item.items, id);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addItemToFolder = (items, folderId, newItem) => {
|
|
||||||
return items.map(item => {
|
|
||||||
if (item.id === folderId) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
items: [...(item.items || []), newItem]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (item.items) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
items: addItemToFolder(item.items, folderId, newItem)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const findParent = (items, childId, parent = null) => {
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.id === childId) return parent;
|
|
||||||
if (item.items) {
|
|
||||||
const found = findParent(item.items, childId, item);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addItemAtSameLevel = (items, parentId, newItem, afterId = null) => {
|
|
||||||
return items.map(item => {
|
|
||||||
if (item.id === parentId) {
|
|
||||||
const children = item.items || [];
|
|
||||||
const insertIndex = afterId ? children.findIndex(i => i.id === afterId) + 1 : children.length;
|
|
||||||
|
|
||||||
const newChildren = [
|
|
||||||
...children.slice(0, insertIndex),
|
|
||||||
newItem,
|
|
||||||
...children.slice(insertIndex)
|
|
||||||
];
|
|
||||||
|
|
||||||
return { ...item, items: newChildren };
|
|
||||||
}
|
|
||||||
if (item.items) {
|
|
||||||
return { ...item, items: addItemAtSameLevel(item.items, parentId, newItem, afterId) };
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragStart = (event) => {
|
|
||||||
const { active } = event;
|
|
||||||
const item = findItemInTree(menuItems, active.id);
|
|
||||||
setActiveItem(item);
|
|
||||||
setDropIndicator({ show: false, position: null, targetId: null });
|
|
||||||
};
|
|
||||||
const handleDragEnd = (event) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveItem(null);
|
|
||||||
setHoveredItem(null);
|
|
||||||
setDropIndicator({ show: false, position: null, targetId: null });
|
|
||||||
|
|
||||||
if (!over) return;
|
|
||||||
if (active.id === over.id) return;
|
|
||||||
|
|
||||||
const draggedItem = findItemInTree(menuItems, active.id);
|
|
||||||
if (!draggedItem) return;
|
|
||||||
|
|
||||||
const overItem = findItemInTree(menuItems, over.id);
|
|
||||||
|
|
||||||
// Проверяем, не пытаемся ли переместить элемент в его же потомка
|
|
||||||
if (isDescendant(draggedItem, overItem)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let newTree;
|
|
||||||
|
|
||||||
if (dropIndicator.position === 'inside' && overItem && Array.isArray(overItem.items)) {
|
|
||||||
// Вставка внутрь папки
|
|
||||||
newTree = removeItemFromTree([...menuItems], active.id);
|
|
||||||
newTree = addItemToFolder(newTree, over.id, draggedItem);
|
|
||||||
} else {
|
|
||||||
// Вставка на том же уровне
|
|
||||||
const overParent = findParent(menuItems, over.id);
|
|
||||||
if (!overParent) return;
|
|
||||||
|
|
||||||
newTree = removeItemFromTree([...menuItems], active.id);
|
|
||||||
|
|
||||||
// Определяем позицию для вставки
|
|
||||||
let insertAfterId = null;
|
|
||||||
if (dropIndicator.position === 'below') {
|
|
||||||
insertAfterId = over.id;
|
|
||||||
} else if (dropIndicator.position === 'above') {
|
|
||||||
const siblings = overParent.items || [];
|
|
||||||
const overIndex = siblings.findIndex(item => item.id === over.id);
|
|
||||||
if (overIndex > 0) {
|
|
||||||
insertAfterId = siblings[overIndex - 1].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, insertAfterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMenuItems(newTree);
|
|
||||||
localStorage.setItem("menuTree", JSON.stringify(newTree));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (event) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (!over) {
|
|
||||||
setDropIndicator({ show: false, position: null, targetId: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overItem = findItemInTree(menuItems, over.id);
|
|
||||||
const activeItem = findItemInTree(menuItems, active.id);
|
|
||||||
|
|
||||||
if (!overItem || !activeItem || active.id === over.id) {
|
|
||||||
setDropIndicator({ show: false, position: null, targetId: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, можно ли перемещать элемент
|
|
||||||
if (isDescendant(activeItem, overItem)) {
|
|
||||||
setDropIndicator({ show: false, position: null, targetId: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overRect = over.rect.current;
|
|
||||||
if (!overRect) return;
|
|
||||||
|
|
||||||
const relativeY = event.delta.y;
|
|
||||||
const isOverFolder = overItem && Array.isArray(overItem.items);
|
|
||||||
const isTopHalf = relativeY < overRect.height * 0.4;
|
|
||||||
const isBottomHalf = relativeY > overRect.height * 0.6;
|
|
||||||
|
|
||||||
if (isOverFolder && !isTopHalf && !isBottomHalf) {
|
|
||||||
// Показываем индикатор для вставки в папку
|
|
||||||
setDropIndicator({
|
|
||||||
show: true,
|
|
||||||
position: 'inside',
|
|
||||||
targetId: over.id
|
|
||||||
});
|
|
||||||
setHoveredItem(over.id);
|
|
||||||
} else if (isTopHalf) {
|
|
||||||
// Показываем индикатор для вставки выше
|
|
||||||
setDropIndicator({
|
|
||||||
show: true,
|
|
||||||
position: 'above',
|
|
||||||
targetId: over.id
|
|
||||||
});
|
|
||||||
setHoveredItem(null);
|
|
||||||
} else if (isBottomHalf) {
|
|
||||||
// Показываем индикатор для вставки ниже
|
|
||||||
setDropIndicator({
|
|
||||||
show: true,
|
|
||||||
position: 'below',
|
|
||||||
targetId: over.id
|
|
||||||
});
|
|
||||||
setHoveredItem(null);
|
|
||||||
} else {
|
|
||||||
setDropIndicator({ show: false, position: null, targetId: null });
|
|
||||||
setHoveredItem(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDescendant = (parent, child) => {
|
|
||||||
if (!parent || !child || !parent.items) return false;
|
|
||||||
|
|
||||||
const checkChildren = (items, targetId) => {
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.id === targetId) return true;
|
|
||||||
if (item.items && checkChildren(item.items, targetId)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return checkChildren(parent.items, child.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidebarResizer = styled("div")(({ theme }) => ({
|
|
||||||
width: "3px",
|
|
||||||
cursor: "col-resize",
|
|
||||||
backgroundColor: alpha(theme.palette.primary.main, 0.3),
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: theme.palette.primary.main,
|
|
||||||
},
|
},
|
||||||
height: "100%",
|
height: '100%',
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
transition: "background-color 0.2s ease",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const DropIndicator = ({ position, targetId }) => {
|
|
||||||
if (!targetId) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: '2px',
|
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
zIndex: 1001,
|
|
||||||
...(position === 'above' && { top: 0 }),
|
|
||||||
...(position === 'below' && { bottom: 0 }),
|
|
||||||
'&::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-3px',
|
|
||||||
left: '10%',
|
|
||||||
width: '80%',
|
|
||||||
height: '8px',
|
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: 'relative',
|
||||||
width: collapsed ? 72 : sidebarWidth,
|
width: collapsed ? 64 : sidebarWidth,
|
||||||
transition: "width 0.2s ease",
|
transition: 'width 0.3s ease',
|
||||||
height: "100vh",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
sx={{
|
sx={{
|
||||||
width: collapsed ? 72 : sidebarWidth,
|
width: collapsed ? 64 : sidebarWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
"& .MuiDrawer-paper": {
|
'& .MuiDrawer-paper': {
|
||||||
width: collapsed ? 72 : sidebarWidth,
|
width: collapsed ? 64 : sidebarWidth,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: "background.paper",
|
backgroundColor: 'custom.sidebar',
|
||||||
color: "text.primary",
|
color: 'custom.sidebarText',
|
||||||
transition: "width 0.2s ease, background-color 0.2s ease",
|
transition: 'width 0.3s ease',
|
||||||
overflowX: "hidden",
|
overflowX: 'hidden',
|
||||||
borderRight: "1px solid",
|
borderRight: 'none'
|
||||||
borderColor: "divider",
|
|
||||||
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.08)",
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Заголовок с логотипом */}
|
{/* Заголовок с логотипом */}
|
||||||
<Box
|
<Box sx={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
display: "flex",
|
alignItems: 'center',
|
||||||
alignItems: "center",
|
justifyContent: 'center', // Центрируем содержимое
|
||||||
justifyContent: "center",
|
p: 1,
|
||||||
p: 2,
|
borderBottom: '1px solid',
|
||||||
borderBottom: "1px solid",
|
borderColor: 'divider',
|
||||||
borderColor: "divider",
|
backgroundColor: 'custom.sidebar',
|
||||||
backgroundColor: "background.paper",
|
height: 80, // Фиксированная высота
|
||||||
height: 80,
|
position: 'relative' // Для позиционирования кнопки
|
||||||
position: "relative",
|
}}>
|
||||||
transition: "all 0.2s ease",
|
{/* Логотип (занимает все пространство) */}
|
||||||
minHeight: 80,
|
<Box sx={{
|
||||||
}}
|
display: 'flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
<Box
|
justifyContent: 'center',
|
||||||
sx={{
|
width: '100%',
|
||||||
display: "flex",
|
height: '100%',
|
||||||
alignItems: "center",
|
'& svg': {
|
||||||
justifyContent: "center",
|
width: '100%',
|
||||||
width: "100%",
|
height: '100%',
|
||||||
height: "100%",
|
padding: collapsed ? '8px' : '12px',
|
||||||
transition: "all 0.2s ease",
|
objectFit: 'contain'
|
||||||
"& svg": {
|
}
|
||||||
width: "auto",
|
}}>
|
||||||
height: "40px", // Фиксированная высота для лого
|
|
||||||
objectFit: "contain",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<LogoSmall style={{
|
<LogoSmall style={{
|
||||||
color: "inherit",
|
color: 'inherit' // Наследует цвет темы
|
||||||
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
|
||||||
width: "32px",
|
|
||||||
height: "32px"
|
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
<LogoFull style={{
|
<LogoFull style={{
|
||||||
color: "inherit",
|
color: 'inherit' // Наследует цвет темы
|
||||||
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
|
||||||
maxWidth: "180px",
|
|
||||||
height: "40px"
|
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Tooltip
|
{/* Кнопка сворачивания (абсолютное позиционирование) */}
|
||||||
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
|
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
||||||
placement="right"
|
|
||||||
>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleToggleCollapse}
|
onClick={handleToggleCollapse}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
color: "text.secondary",
|
color: 'custom.sidebarText',
|
||||||
"&:hover": {
|
'&:hover': { backgroundColor: 'custom.sidebarHover' },
|
||||||
backgroundColor: "action.hover",
|
position: 'absolute',
|
||||||
color: "text.primary"
|
right: 8,
|
||||||
},
|
top: '50%',
|
||||||
position: "absolute",
|
transform: 'translateY(-50%)'
|
||||||
right: 12,
|
|
||||||
top: "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||||
|
|
@ -424,97 +131,18 @@ const SidebarMenu = ({
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Основное содержимое меню */}
|
{/* Основное содержимое меню */}
|
||||||
<Box
|
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
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}
|
||||||
position: "relative",
|
level={0}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
measuring={{
|
|
||||||
droppable: {
|
|
||||||
strategy: MeasuringStrategy.Always
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SortableContext items={menuItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
<List
|
|
||||||
sx={{
|
|
||||||
overflowY: "auto",
|
|
||||||
flex: "1 1 auto",
|
|
||||||
py: 1,
|
|
||||||
px: 1,
|
|
||||||
position: 'relative',
|
|
||||||
'&::-webkit-scrollbar': {
|
|
||||||
width: '6px',
|
|
||||||
},
|
|
||||||
'&::-webkit-scrollbar-track': {
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
'&::-webkit-scrollbar-thumb': {
|
|
||||||
background: 'text.disabled',
|
|
||||||
borderRadius: '3px',
|
|
||||||
},
|
|
||||||
'&::-webkit-scrollbar-thumb:hover': {
|
|
||||||
background: 'text.secondary',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<Box key={item.id} position="relative">
|
|
||||||
{dropIndicator.show && dropIndicator.targetId === item.id &&
|
|
||||||
dropIndicator.position !== 'inside' && (
|
|
||||||
<DropIndicator
|
|
||||||
position={dropIndicator.position}
|
|
||||||
targetId={dropIndicator.targetId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SortableMenuItem
|
|
||||||
item={item}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onSelectItem={onSelectItem}
|
|
||||||
isHovered={hoveredItem === item.id}
|
|
||||||
showDropIndicator={dropIndicator.show && dropIndicator.targetId === item.id && dropIndicator.position === 'inside'}
|
|
||||||
sidebarWidth={sidebarWidth}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</SortableContext>
|
|
||||||
|
|
||||||
<DragOverlay>
|
onSelectItem={onSelectItem}
|
||||||
{activeItem ? (
|
/>
|
||||||
<Box
|
)}
|
||||||
sx={{
|
</List>
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
color: 'white',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
|
||||||
maxWidth: 250,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
||||||
transform: 'rotate(5deg)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{activeItem.title}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
|
|
@ -524,11 +152,8 @@ const SidebarMenu = ({
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<Tooltip title="Изменить ширину" placement="top">
|
<SidebarResizer onMouseDown={startResizing} />
|
||||||
<SidebarResizer onMouseDown={startResizing} />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,121 @@
|
||||||
// // MenuItem.jsx
|
// MenuItem.jsx
|
||||||
// import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
// import {
|
import {
|
||||||
// ListItem,
|
ListItem,
|
||||||
// ListItemIcon,
|
ListItemIcon,
|
||||||
// ListItemText,
|
ListItemText,
|
||||||
// Collapse,
|
Collapse,
|
||||||
// List,
|
List,
|
||||||
// styled,
|
styled,
|
||||||
// Menu,
|
Menu,
|
||||||
// MenuItem as MuiMenuItem
|
MenuItem as MuiMenuItem
|
||||||
// } from "@mui/material";
|
} from "@mui/material";
|
||||||
// import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
||||||
// import StatusIndicator from "./StatusIndicator";
|
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',
|
position: 'relative',
|
||||||
// '&:hover': {
|
'&:hover': {
|
||||||
// backgroundColor: theme.palette.action.hover,
|
backgroundColor: theme.palette.action.hover,
|
||||||
// },
|
},
|
||||||
// '&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
// backgroundColor: theme.palette.custom.sidebarHover,
|
backgroundColor: theme.palette.custom.sidebarHover,
|
||||||
// },
|
},
|
||||||
// }));
|
}));
|
||||||
|
|
||||||
// const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
// const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
// const [contextMenu, setContextMenu] = useState(null);
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
// const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
||||||
|
|
||||||
// const handleContextMenu = (e) => {
|
const handleContextMenu = (e) => {
|
||||||
// e.preventDefault();
|
e.preventDefault();
|
||||||
// setContextMenu(
|
setContextMenu(
|
||||||
// contextMenu === null
|
contextMenu === null
|
||||||
// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
||||||
// : null
|
: null
|
||||||
// );
|
);
|
||||||
// };
|
};
|
||||||
|
|
||||||
// const handleCloseContextMenu = () => {
|
const handleCloseContextMenu = () => {
|
||||||
// setContextMenu(null);
|
setContextMenu(null);
|
||||||
// };
|
};
|
||||||
|
|
||||||
// const handleToggle = (e) => {
|
const handleToggle = (e) => {
|
||||||
// e.stopPropagation();
|
e.stopPropagation();
|
||||||
// setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
// };
|
};
|
||||||
|
|
||||||
// const handleClick = () => {
|
const handleClick = () => {
|
||||||
// if (onSelectItem) {
|
if (onSelectItem) {
|
||||||
// onSelectItem(item);
|
onSelectItem(item);
|
||||||
// }
|
}
|
||||||
// };
|
};
|
||||||
|
|
||||||
// return (
|
return (
|
||||||
// <>
|
<>
|
||||||
// <StyledListItem
|
<StyledListItem
|
||||||
// component="div"
|
component="div"
|
||||||
// onClick={hasChildren ? handleToggle : handleClick}
|
onClick={hasChildren ? handleToggle : handleClick}
|
||||||
// onContextMenu={handleContextMenu}
|
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} />}
|
{!collapsed && <StatusIndicator status={item.status} />}
|
||||||
|
|
||||||
// <ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
|
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
|
||||||
// {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
||||||
// </ListItemIcon>
|
</ListItemIcon>
|
||||||
|
|
||||||
// {!collapsed && (
|
{!collapsed && (
|
||||||
// <>
|
<>
|
||||||
// <ListItemText
|
<ListItemText
|
||||||
// primary={item.title}
|
primary={item.title}
|
||||||
// primaryTypographyProps={{
|
primaryTypographyProps={{
|
||||||
// color: 'custom.sidebarText'
|
color: 'custom.sidebarText'
|
||||||
// }}
|
}}
|
||||||
// />
|
/>
|
||||||
// {hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
||||||
// </>
|
</>
|
||||||
// )}
|
)}
|
||||||
// </StyledListItem>
|
</StyledListItem>
|
||||||
|
|
||||||
// <Menu
|
<Menu
|
||||||
// open={contextMenu !== null}
|
open={contextMenu !== null}
|
||||||
// onClose={handleCloseContextMenu}
|
onClose={handleCloseContextMenu}
|
||||||
// anchorReference="anchorPosition"
|
anchorReference="anchorPosition"
|
||||||
// anchorPosition={
|
anchorPosition={
|
||||||
// contextMenu !== null
|
contextMenu !== null
|
||||||
// ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||||
// : undefined
|
: undefined
|
||||||
// }
|
}
|
||||||
// >
|
>
|
||||||
|
|
||||||
// </Menu>
|
</Menu>
|
||||||
|
|
||||||
// {hasChildren && !collapsed && (
|
{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={child.id ?? index}
|
||||||
// item={child}
|
item={child}
|
||||||
// onSelectItem={onSelectItem}
|
onSelectItem={onSelectItem}
|
||||||
// onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
// level={level + 1}
|
level={level + 1}
|
||||||
// collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
// />
|
/>
|
||||||
// ))}
|
))}
|
||||||
// </List>
|
</List>
|
||||||
// </Collapse>
|
</Collapse>
|
||||||
// )}
|
)}
|
||||||
// </>
|
</>
|
||||||
// );
|
);
|
||||||
// };
|
};
|
||||||
|
|
||||||
// export default MenuItem;
|
export default MenuItem;
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,20 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Brightness4, Brightness7, Settings, Help } from "@mui/icons-material";
|
import { Brightness4, Brightness7 } from "@mui/icons-material";
|
||||||
import {
|
import { IconButton, Tooltip } from "@mui/material";
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
alpha
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
import {
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
styled,
|
styled,
|
||||||
Switch,
|
Switch,
|
||||||
|
Box,
|
||||||
|
Button
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import SettingsModal from "../SettingsModal";
|
import SettingsModal from "../SettingsModal";
|
||||||
import { RoleBasedRender } from "../../UI/RoleBasedRender";
|
import { RoleBasedRender } from "../../UI/RoleBasedRender";
|
||||||
|
|
||||||
const FooterList = styled(List)(({ theme }) => ({
|
const FooterList = styled(List)(({ theme }) => ({
|
||||||
backgroundColor: 'background.paper',
|
backgroundColor: theme.palette.custom.sidebar,
|
||||||
padding: theme.spacing(1, 0),
|
padding: theme.spacing(1, 0),
|
||||||
borderTop: `1px solid ${theme.palette.divider}`,
|
borderTop: `1px solid ${theme.palette.divider}`,
|
||||||
marginTop: 'auto'
|
marginTop: 'auto'
|
||||||
|
|
@ -26,15 +22,12 @@ const FooterList = styled(List)(({ theme }) => ({
|
||||||
|
|
||||||
const FooterListItem = styled(ListItem)(({ theme }) => ({
|
const FooterListItem = styled(ListItem)(({ theme }) => ({
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: alpha(theme.palette.action.hover, 0.4),
|
backgroundColor: theme.palette.custom.sidebarHover,
|
||||||
},
|
},
|
||||||
padding: theme.spacing(1, 2),
|
padding: theme.spacing(1, 2),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center'
|
||||||
borderRadius: '8px',
|
|
||||||
margin: '0 8px 4px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SidebarFooter = ({
|
const SidebarFooter = ({
|
||||||
|
|
@ -53,93 +46,72 @@ const SidebarFooter = ({
|
||||||
const handleSettingsClose = () => {
|
const handleSettingsClose = () => {
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
};
|
};
|
||||||
|
console.log('SidebarFooter user with role:', {
|
||||||
|
...user,
|
||||||
|
hasRole: 'role' in user,
|
||||||
|
roleValue: user?.role
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FooterList>
|
<FooterList>
|
||||||
{!collapsed ? (
|
{!collapsed && (
|
||||||
<>
|
<FooterListItem button>
|
||||||
<FooterListItem>
|
<ListItemText
|
||||||
<Button
|
primary="Помощь"
|
||||||
onClick={handleSettingsOpen}
|
primaryTypographyProps={{
|
||||||
startIcon={<Settings />}
|
color: 'custom.sidebarText',
|
||||||
sx={{
|
variant: 'body2'
|
||||||
color: 'text.secondary',
|
}}
|
||||||
textTransform: 'none',
|
/>
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
'&:hover': {
|
|
||||||
color: 'text.primary',
|
|
||||||
backgroundColor: 'transparent'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Настройки
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Tooltip title="Переключить тему">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&:hover': {
|
|
||||||
color: 'text.primary',
|
|
||||||
backgroundColor: alpha('#000000', 0.1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Switch
|
|
||||||
checked={isDarkMode}
|
|
||||||
onChange={() => setIsDarkMode(!isDarkMode)}
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</FooterListItem>
|
|
||||||
|
|
||||||
<FooterListItem button>
|
|
||||||
<Button
|
|
||||||
startIcon={<Help />}
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
textTransform: 'none',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
'&:hover': {
|
|
||||||
color: 'text.primary',
|
|
||||||
backgroundColor: 'transparent'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Помощь
|
|
||||||
</Button>
|
|
||||||
</FooterListItem>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<FooterListItem sx={{ justifyContent: 'center' }}>
|
|
||||||
<Tooltip title="Настройки" placement="right">
|
|
||||||
<IconButton
|
|
||||||
onClick={handleSettingsOpen}
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&:hover': {
|
|
||||||
color: 'text.primary',
|
|
||||||
backgroundColor: alpha('#000000', 0.1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</FooterListItem>
|
</FooterListItem>
|
||||||
)}
|
)}
|
||||||
|
<FooterListItem>
|
||||||
|
{/* кнопка настроек */}
|
||||||
|
<RoleBasedRender user={user} allowedRoles={['admin']}>
|
||||||
|
{!collapsed && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSettingsOpen}
|
||||||
|
sx={{
|
||||||
|
color: 'custom.sidebarText',
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
marginRight: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary="Настройки"
|
||||||
|
primaryTypographyProps={{
|
||||||
|
color: 'custom.sidebarText',
|
||||||
|
variant: 'body2'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</RoleBasedRender>
|
||||||
|
|
||||||
|
<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>
|
||||||
</FooterList>
|
</FooterList>
|
||||||
|
|
||||||
|
{/* Используем RoleBasedRender для модального окна */}
|
||||||
<RoleBasedRender user={user} allowedRoles={['admin']}>
|
<RoleBasedRender user={user} allowedRoles={['admin']}>
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
ListItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Collapse,
|
|
||||||
List,
|
|
||||||
IconButton,
|
|
||||||
Box,
|
|
||||||
alpha,
|
|
||||||
Typography,
|
|
||||||
Tooltip
|
|
||||||
} from "@mui/material";
|
|
||||||
import { ChevronRight, DragIndicator, Folder, FolderOpen } from "@mui/icons-material";
|
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
|
|
||||||
const SortableMenuItem = ({
|
|
||||||
item,
|
|
||||||
collapsed,
|
|
||||||
onSelectItem,
|
|
||||||
level = 0,
|
|
||||||
isHovered = false,
|
|
||||||
showDropIndicator = false,
|
|
||||||
sidebarWidth = 300
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [isLocalHovered, setIsLocalHovered] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
isOver
|
|
||||||
} = useSortable({
|
|
||||||
id: item.id,
|
|
||||||
data: {
|
|
||||||
type: 'menu-item',
|
|
||||||
item,
|
|
||||||
level
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition: transition || 'all 0.2s ease',
|
|
||||||
opacity: isDragging ? 0.6 : 1,
|
|
||||||
zIndex: isDragging ? 1000 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
|
||||||
const isFolder = hasChildren;
|
|
||||||
const isHighlighted = isHovered || isOver;
|
|
||||||
|
|
||||||
// Рассчитываем максимальную ширину текста в зависимости от уровня вложенности
|
|
||||||
const calculateMaxTextWidth = () => {
|
|
||||||
const baseWidth = sidebarWidth - 40; // Отступы и иконки
|
|
||||||
const levelOffset = level * 24; // Отступ для каждого уровня
|
|
||||||
return baseWidth - levelOffset - 60; // Оставляем место для иконок и отступов
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (hasChildren) {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
} else {
|
|
||||||
onSelectItem?.(item);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
setIsLocalHovered(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setIsLocalHovered(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBackgroundColor = (theme) => {
|
|
||||||
if (isDragging) return alpha(theme.palette.primary.main, 0.1);
|
|
||||||
if (isHighlighted) return alpha(theme.palette.primary.main, 0.08);
|
|
||||||
if (isLocalHovered) return alpha(theme.palette.action.hover, 0.4);
|
|
||||||
return 'transparent';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
'&::before': isHighlighted ? {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 4,
|
|
||||||
bottom: 4,
|
|
||||||
width: 3,
|
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
borderRadius: '0 2px 2px 0',
|
|
||||||
} : {},
|
|
||||||
...(showDropIndicator && {
|
|
||||||
backgroundColor: (theme) => alpha(theme.palette.primary.main, 0.1),
|
|
||||||
border: (theme) => `2px dashed ${theme.palette.primary.main}`,
|
|
||||||
borderRadius: '8px',
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItem
|
|
||||||
button
|
|
||||||
sx={{
|
|
||||||
pl: collapsed ? 1 : Math.max(0.1, 0.1 + level * 0.1),
|
|
||||||
pr: 0.5,
|
|
||||||
py: 0.25,
|
|
||||||
minHeight: 32,
|
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
|
||||||
backgroundColor: (theme) => getBackgroundColor(theme),
|
|
||||||
borderRadius: '6px',
|
|
||||||
margin: '1px 4px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
|
|
||||||
{!collapsed && (
|
|
||||||
<IconButton
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
cursor: isDragging ? "grabbing" : "grab",
|
|
||||||
mr: 1,
|
|
||||||
opacity: isLocalHovered || isDragging ? 1 : 0.4,
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&:hover': {
|
|
||||||
color: 'text.primary',
|
|
||||||
backgroundColor: 'transparent'
|
|
||||||
},
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DragIndicator fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!collapsed && (
|
|
||||||
<>
|
|
||||||
<Tooltip title={item.title} placement="right" enterDelay={400} arrow>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
fontWeight: isFolder ? 600 : 400,
|
|
||||||
color: isFolder ? 'text.primary' : 'text.secondary',
|
|
||||||
maxWidth: calculateMaxTextWidth(),
|
|
||||||
display: "-webkit-box",
|
|
||||||
WebkitLineClamp: 2, // максимум 2 строки
|
|
||||||
WebkitBoxOrient: "vertical",
|
|
||||||
overflow: "hidden",
|
|
||||||
lineHeight: 1.2,
|
|
||||||
fontSize: "0.85rem", // компактнее текст
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
sx={{ mr: 0.5, flex: '1 1 auto', minWidth: 0 }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
{hasChildren && (
|
|
||||||
<ChevronRight
|
|
||||||
sx={{
|
|
||||||
fontSize: 18,
|
|
||||||
color: 'text.disabled',
|
|
||||||
transform: isOpen ? 'rotate(90deg)' : 'none',
|
|
||||||
transition: 'transform 0.2s ease',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
{hasChildren && !collapsed && (
|
|
||||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
|
||||||
<List
|
|
||||||
disablePadding
|
|
||||||
sx={{
|
|
||||||
pl: 1.5,
|
|
||||||
borderLeft: (theme) => `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
|
||||||
marginLeft: 2,
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.items.map((child) => (
|
|
||||||
<Box key={child.id} position="relative">
|
|
||||||
<SortableMenuItem
|
|
||||||
item={child}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onSelectItem={onSelectItem}
|
|
||||||
level={level + 1}
|
|
||||||
isHovered={isHovered}
|
|
||||||
showDropIndicator={showDropIndicator}
|
|
||||||
sidebarWidth={sidebarWidth}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SortableMenuItem;
|
|
||||||
|
|
@ -44,7 +44,7 @@ const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect, user }) =
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const headers = lastModified ? { 'If-Modified-Since': lastModified } : {};
|
const headers = lastModified ? { 'If-Modified-Since': lastModified } : {};
|
||||||
|
|
||||||
const response = await axios.get(`/api/menu/full`, {
|
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`, {
|
||||||
headers,
|
headers,
|
||||||
validateStatus: status => status === 200 || status === 304
|
validateStatus: status => status === 200 || status === 304
|
||||||
});
|
});
|
||||||
|
|
@ -78,13 +78,13 @@ const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect, user }) =
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
setBackgroundLoading(true);
|
setBackgroundLoading(true);
|
||||||
const response = await axios.get(`/api/menu/check-updates`, {
|
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/check-updates`, {
|
||||||
headers: { 'If-Modified-Since': lastModified }
|
headers: { 'If-Modified-Since': lastModified }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.hasUpdates) {
|
if (response.data.hasUpdates) {
|
||||||
// Если есть обновления, загружаем их в фоне
|
// Если есть обновления, загружаем их в фоне
|
||||||
const updateResponse = await axios.get(`/api/menu/full`);
|
const updateResponse = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`);
|
||||||
setMenuData(updateResponse.data);
|
setMenuData(updateResponse.data);
|
||||||
setLastModified(updateResponse.headers['last-modified']);
|
setLastModified(updateResponse.headers['last-modified']);
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect, user }) =
|
||||||
const handleSaveChanges = async (updatedItem) => {
|
const handleSaveChanges = async (updatedItem) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(
|
const response = await axios.put(
|
||||||
`/api/menu/${updatedItem.id}`,
|
`${import.meta.env.VITE_BACK_URL}/api/menu/${updatedItem.id}`,
|
||||||
updatedItem,
|
updatedItem,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,11 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import { Button, CircularProgress, Alert, Box } from '@mui/material';
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogActions,
|
|
||||||
Typography,
|
|
||||||
IconButton
|
|
||||||
} from '@mui/material';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const AIAnalysisButton = ({ onAnalysisComplete }) => {
|
const AIAnalysisButton = ({ onAnalysisComplete }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [openModal, setOpenModal] = useState(false);
|
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -28,38 +14,34 @@ const AIAnalysisButton = ({ onAnalysisComplete }) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Получаем данные из ClickHouse
|
// 1. Получаем данные из ClickHouse
|
||||||
console.log('Запрашиваем данные из ClickHouse...');
|
|
||||||
const metricsResponse = await axios.get('/api/clickhouse');
|
const metricsResponse = await axios.get('/api/clickhouse');
|
||||||
console.log('Получены данные из ClickHouse:', metricsResponse.data);
|
|
||||||
|
const sendData = metricsResponse.data.slice(0, 100);
|
||||||
|
|
||||||
// 2. Отправляем в AI API
|
// 2. Отправляем в AI API
|
||||||
console.log('Отправляем данные в AI API:', metricsResponse.data);
|
|
||||||
const aiResponse = await axios.post(
|
const aiResponse = await axios.post(
|
||||||
'/ai-api/api/metrics/rest',
|
'/ai_api/api/metrics/rest',
|
||||||
metricsResponse.data,
|
sendData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
console.log('Ответ от AI API:', aiResponse.data);
|
|
||||||
|
|
||||||
setResult(aiResponse.data);
|
setResult(aiResponse.data);
|
||||||
setOpenModal(true);
|
|
||||||
if (onAnalysisComplete) {
|
if (onAnalysisComplete) {
|
||||||
onAnalysisComplete(aiResponse.data);
|
onAnalysisComplete(aiResponse.data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Детали ошибки:", err.response?.data);
|
console.error("Детали ошибки 422:", err.response?.data);
|
||||||
setError(err.response?.data?.message || JSON.stringify(err.response?.data)) || "Ошибка при анализе данных";
|
setError(err.response?.data?.message || JSON.stringify(err.response?.data) || "Ошибка валидации данных");
|
||||||
|
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setOpenModal(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -87,63 +69,11 @@ const AIAnalysisButton = ({ onAnalysisComplete }) => {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{result && !loading && (
|
||||||
|
<Alert severity="success" sx={{ mt: 1 }}>
|
||||||
{/* Модальное окно с результатом */}
|
Анализ завершен! Результат в консоли.
|
||||||
<Dialog
|
</Alert>
|
||||||
open={openModal}
|
)}
|
||||||
onClose={handleCloseModal}
|
|
||||||
fullWidth={true}
|
|
||||||
maxWidth="lg"
|
|
||||||
scroll="paper"
|
|
||||||
>
|
|
||||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
Результат AI-анализа
|
|
||||||
<IconButton
|
|
||||||
aria-label="close"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
sx={{
|
|
||||||
color: (theme) => theme.palette.grey[500],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
{result ? (
|
|
||||||
<>
|
|
||||||
<Typography variant="h6" gutterBottom>Данные анализа:</Typography>
|
|
||||||
<Box
|
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: '#f5f5f5',
|
|
||||||
borderRadius: 1,
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: '60vh',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordWrap: 'break-word'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JSON.stringify(result, null, 2)}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<DialogContentText>Нет данных для отображения</DialogContentText>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleCloseModal}>Закрыть</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(JSON.stringify(result, null, 2));
|
|
||||||
alert('Результат скопирован в буфер обмена');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Копировать
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ const LoginModal = ({ onLogin, onClose }) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post(
|
const { data } = await axios.post(
|
||||||
//`${import.meta.env.VITE_BACK_URL}/api/auth/login`,
|
// `${import.meta.env.VITE_BACK_URL}/api/auth/login`,
|
||||||
'/api/auth/login',
|
`/api/auth/login`,
|
||||||
{ login: username, password },
|
{ login: username, password },
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const RoleBasedRender = ({ user, allowedRoles, children }) => {
|
export const RoleBasedRender = ({ user, allowedRoles, children }) => {
|
||||||
// console.log('RoleBasedRender check:', {
|
console.log('RoleBasedRender check:', {
|
||||||
// user,
|
user,
|
||||||
// hasRole: user?.role,
|
hasRole: user?.role,
|
||||||
// allowedRoles,
|
allowedRoles,
|
||||||
// hasAccess: user && allowedRoles.includes(user.role)
|
hasAccess: user && allowedRoles.includes(user.role)
|
||||||
// });
|
});
|
||||||
|
|
||||||
if (!user || !allowedRoles.includes(user.role)) {
|
if (!user || !allowedRoles.includes(user.role)) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import axios from "axios"
|
import axios from 'axios'
|
||||||
|
|
||||||
export const checkAuth = async () => {
|
export const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(
|
const { data } = await axios.get(
|
||||||
//`${import.meta.env.VITE_BACK_URL}/api/auth/check`,
|
`${import.meta.env.VITE_BACK_URL}/api/auth/check`,
|
||||||
'/api/auth/check',
|
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,12 @@ const MetricsAnalyzer = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 1. Сначала загружаем метрики
|
// 1. Сначала загружаем метрики
|
||||||
const metricsResponse = await axios.get(`/api/metrics/all-values`);
|
const metricsResponse = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics/all-values`);
|
||||||
setMetrics(metricsResponse.data);
|
setMetrics(metricsResponse.data);
|
||||||
|
|
||||||
// 2. Преобразуем и отправляем на анализ
|
// 2. Преобразуем и отправляем на анализ
|
||||||
const requestData = transformMetricsForAnalysis(metricsResponse.data);
|
const requestData = transformMetricsForAnalysis(metricsResponse.data);
|
||||||
const analysisResponse = await axios.get(`:5134/api/metrics/rest`, {
|
const analysisResponse = await axios.get(`${import.meta.env.VITE_BACK_URL}:5134/api/metrics/rest`, {
|
||||||
data: requestData,
|
data: requestData,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
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";
|
import { getStatusColor } from "../TreeChart/dataUtils";
|
||||||
import SystemChart from "../../Charts/SystemChart";
|
import MetricsAnalyzer from "./MetricsAnalyzer"; // Импортируем новый компонент
|
||||||
|
|
||||||
const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, handleOpenTab }) => {
|
const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, handleOpenTab }) => {
|
||||||
const countStatuses = (data) => {
|
const countStatuses = (data) => {
|
||||||
|
|
@ -16,48 +17,24 @@ const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, h
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data) countRecursive(data);
|
countRecursive(data);
|
||||||
return counts;
|
return counts;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activeTab === "Главная") {
|
if (activeTab === "Главная") {
|
||||||
const statusCounts = countStatuses(treeData1);
|
const statusCounts = treeData1 ? countStatuses(treeData1) : { green: 0, yellow: 0, orange: 0, red: 0 };
|
||||||
|
|
||||||
// Конфигурация для метрики серверов (с несколькими линиями)
|
|
||||||
const serverMetric = {
|
|
||||||
name: "zvks_server_li",
|
|
||||||
title: "Надежность системы",
|
|
||||||
description: "Уровень надежности системы",
|
|
||||||
multipleLines: true,
|
|
||||||
lineKey: "device",
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
// Конфигурация для метрики приложений (одна линия)
|
|
||||||
const appMetric = {
|
|
||||||
name: "zvks_application_li",
|
|
||||||
title: "Функциональность системы",
|
|
||||||
description: "Уровень функциональности системы",
|
|
||||||
multipleLines: false
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</h2>
|
<h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</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>
|
||||||
<SystemChart
|
<SystemStatusChart data={statusHistories.history1} />
|
||||||
metricInfo={serverMetric}
|
|
||||||
chartHeight={580}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'inline-block', width: '48%' }}>
|
<div style={{ display: 'inline-block', width: '48%' }}>
|
||||||
<label>Функциональность приложений</label>
|
<label>Функциональность системы</label>
|
||||||
<SystemChart
|
<SystemStatusChart data={statusHistories.history2} />
|
||||||
metricInfo={appMetric}
|
|
||||||
chartHeight={580}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -88,6 +65,9 @@ const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, h
|
||||||
|
|
||||||
<label>Статус компонентов системы</label>
|
<label>Статус компонентов системы</label>
|
||||||
<TreeTable data={treeData1} />
|
<TreeTable data={treeData1} />
|
||||||
|
|
||||||
|
{/* Добавляем кнопку анализа
|
||||||
|
<MetricsAnalyzer />*/}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (activeTab === "Визуализация") {
|
} else if (activeTab === "Визуализация") {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
import React from 'react'
|
||||||
import { StrictMode } from 'react'
|
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'
|
||||||
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import svgr from 'vite-plugin-svgr'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), svgr()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
allowedHosts: ['dev.msf.enode', 'demo-msf.kis-npo.ru'],
|
||||||
|
// proxy: {
|
||||||
|
// '/api': {
|
||||||
|
// target: 'http://192.168.2.39:3000',
|
||||||
|
// changeOrigin: true,
|
||||||
|
// rewrite: (path) => path.replace(/^\/api/, '') //
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue