Compare commits

..

37 Commits
proxy ... main

Author SHA1 Message Date
deployer3000 e5e4d75637 Merge pull request 'rc' (#66) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/66
2026-03-23 15:14:38 +03:00
Vladislav Drozdov 3a142185a2 Merge pull request 'redisign' (#65) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/65
2026-03-23 15:14:13 +03:00
DmitriyA 0b600095ce Merge branch 'main' of http://git.enode/deployer3000/trust-module-frontend into redisign 2026-03-23 07:58:24 -04:00
DmitriyA 33a88d2a1a add licensing component 2026-03-23 07:55:22 -04:00
deployer3000 daf9cab8ac Merge pull request 'rc' (#64) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/64
2025-12-02 12:47:58 +03:00
Vladislav Drozdov 7400e77fa0 Merge pull request 'redisign' (#63) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build started... Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/63
2025-12-02 12:38:51 +03:00
deployer3000 86baaa29ff Удалить vite.config.js
test-org/trust-module-frontend/pipeline/pr-rc Build queued... Details
2025-12-02 12:38:01 +03:00
DmitriyA 14d2f3eb68 version update 2025-12-02 04:34:56 -05:00
DmitriyA 558cf8eaba added formula 2025-10-21 09:14:20 -04:00
DmitriyA 585692c838 Merge branch 'rc' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-09-01 09:46:56 -04:00
deployer3000 3cef08f65b Merge pull request 'rc' (#62) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/62
2025-09-01 16:46:38 +03:00
Vladislav Drozdov 04738fae91 Merge pull request 'edit vite.config, added env' (#60) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/60
2025-09-01 16:45:01 +03:00
DmitriyA 555c28d942 Merge branch 'redisign' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-09-01 09:44:36 -04:00
DmitriyA 97295a6748 Merge branch 'rc' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-09-01 09:44:21 -04:00
deployer3000 179baad012 Merge branch 'rc' into redisign 2025-09-01 16:43:56 +03:00
DmitriyA 75fa0ebfe3 edit vite.config, added env 2025-09-01 09:42:34 -04:00
deployer3000 f271fb5acf Merge pull request 'rc' (#55) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/55
2025-09-01 16:26:30 +03:00
Vladislav Drozdov c411142840 Merge pull request 'redisign' (#59) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/59
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-09-01 16:23:55 +03:00
DmitriyA 06249fce3a sidebar redesign 2025-09-01 09:20:23 -04:00
DmitriyA 933ceb2547 added drag-and-drop 2025-09-01 08:16:59 -04:00
DmitriyA 34f2010cae added menu editor 2025-08-28 09:20:42 -04:00
SovietSpiderCat 205ddc71e0 added complex variables 2025-08-22 09:57:16 +03:00
SovietSpiderCat 421d95565c fixed WS 2025-08-20 00:17:20 +03:00
Vladislav Drozdov 46cd1fa0fa Merge pull request 'redisign' (#58) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/58
2025-08-14 15:58:55 +03:00
SovietSpiderCat 911bfb88d1 change endpoint for proxy 2025-08-14 14:03:34 +03:00
SovietSpiderCat 1bcb15f655 rework ws 2025-08-14 13:51:41 +03:00
Vladislav Drozdov f8d822ace7 Merge pull request 'test env' (#57) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/57
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-11 14:38:54 +03:00
SovietSpiderCat fd5a202d74 test env
test-org/trust-module-frontend/pipeline/pr-rc Build started... Details
2025-08-11 14:37:29 +03:00
Vladislav Drozdov b1a760336d Merge pull request 'redisign' (#56) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/56
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-08 16:07:09 +03:00
SovietSpiderCat 00866d9d57 added UserManagement
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-08-08 15:54:58 +03:00
Vladislav Drozdov c208813daa Merge pull request 'redisign' (#54) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/54
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-07 14:56:50 +03:00
SovietSpiderCat f55fb2df56 Merge branch 'redisign' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-08-07 04:45:21 +03:00
SovietSpiderCat be3bf3b21e fixed a bug with logout 2025-08-07 04:44:54 +03:00
DmitriyA 4fb1975428 added AI analyzer
test-org/trust-module-frontend/pipeline/pr-main Build started... Details
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-08-05 08:45:58 -04:00
SovietSpiderCat 08fde58a30 added proxy 2025-08-01 11:49:10 +03:00
SovietSpiderCat 6f0b15427a Merge branch 'redisign' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-07-31 11:15:43 +03:00
DmitriyA d7c40ee04b added real data on the main page 2025-07-30 18:35:19 -04:00
49 changed files with 4417 additions and 4826 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
.git
.gitignore
Dockerfile
.dockerignore
dist
npm-debug.log

6
.gitignore vendored
View File

@ -32,3 +32,9 @@ node_modules
.env.development
.env.production
.env.test
# Local configs
vite.config.js
vite.config.local.js
.env.local
*.local.*

View File

@ -2,10 +2,14 @@ FROM node:22.13.0
WORKDIR /app
COPY package.json package-lock.json vite.config.js eslint.config.js ./
RUN npm install
COPY package.json package-lock.json ./
RUN npm install --verbose
COPY vite.config.js eslint.config.js ./
COPY . .
ENV HOST=0.0.0.0
EXPOSE 5173
ENTRYPOINT ["npm", "run", "dev"]

3949
package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 3333",
"dev": "vite --port 5173",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
@ -20,6 +20,7 @@
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-chart-box-and-violin-plot": "^4.0.0",
"d3": "^7.9.0",
"esbuild": "^0.25.8",
"react": "^18.3.1",
"react-chartjs-2": "^5.0.0",
"react-datepicker": "^8.1.0",
@ -30,7 +31,10 @@
"reactflow": "^11.11.4",
"recharts": "^2.15.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": {
"@eslint/js": "^9.17.0",
@ -41,8 +45,7 @@
"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"
"vite": "^7.1.0"
}
}

View File

@ -1,13 +0,0 @@
const devServer = 'http://192.168.2.39';
const config = [
['/api', `${devServer}:3000`],
['/ai_api', `${devServer}:5134`,
{
pathRewrite: {
'^/ai_api':''
}
}
],
];
module.exports = config;

View File

@ -1,47 +0,0 @@
{
"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"
}
}

View File

@ -1,56 +0,0 @@
//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')

View File

@ -3,7 +3,7 @@ import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress, Typography }
import Dashboard from "./Components/Layout/Dashboard";
import LoginModal from "./Components/UI/LoginModal";
import { lightTheme, darkTheme } from "./Style/theme";
// import Logo from './assets/images/logo.svg';
import Logo from './assets/images/logo.svg?react';
import { checkAuth } from "./Components/UI/auth";
import axios from "axios";
@ -110,10 +110,32 @@ function App() {
const handleLogout = async () => {
try {
await axios.post(`${import.meta.env.VITE_BACK_URL}/api/auth/logout`, null, {
withCredentials: true,
});
const token = localStorage.getItem('access_token');
if (!token) {
// Если нет токена - просто очищаем данные
cleanup();
return;
}
try {
await axios.post('/api/auth/logout', {}, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
} finally {
cleanup();
}
} catch (error) {
console.error('Logout error:', error);
cleanup();
}
};
function cleanup() {
localStorage.removeItem('access_token');
localStorage.removeItem('user');
setAuthState({
@ -122,10 +144,7 @@ function App() {
user: null,
});
setShowLoginModal(true);
} catch (error) {
console.error('Logout failed:', error);
}
};
// Полноэкранный лоадер во время проверки авторизации
if (authState.isLoading) {
return (
@ -163,7 +182,7 @@ function App() {
zIndex: 1200,
'& svg': { width: 400, height: 'auto' }
}}>
{/* <Logo /> */}
<Logo />
</Box>
<LoginModal
open={showLoginModal}

View File

@ -1,45 +0,0 @@
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;

View File

@ -1,26 +0,0 @@
const ChartSkeleton = () => (
<Box sx={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
position: 'relative'
}}>
<Box sx={{ position: 'absolute', right: '20px', top: '20px' }}>
<Skeleton variant="circular" width={16} height={16} />
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Skeleton variant="text" width="40%" height={30} />
<Skeleton variant="text" width="30%" height={30} />
</Box>
<Skeleton variant="rectangular" width="100%" height={300} />
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, gap: 2 }}>
{[1, 2, 3, 4].map((_, i) => (
<Skeleton key={i} variant="rounded" width={80} height={36} />
))}
</Box>
</Box>
);

View File

@ -1,20 +0,0 @@
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>
);
};

View File

@ -1,12 +0,0 @@
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;

View File

@ -1,17 +0,0 @@
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>
);
};

View File

@ -1,239 +0,0 @@
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);

View File

@ -1,29 +0,0 @@
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;

View File

@ -1,151 +0,0 @@
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>
);
};

View File

@ -1,35 +0,0 @@
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;

View File

@ -0,0 +1,101 @@
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;

View File

@ -1,499 +0,0 @@
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);

350
src/Charts/SystemChart.jsx Normal file
View File

@ -0,0 +1,350 @@
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;

View File

@ -1,42 +0,0 @@
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;

View File

@ -1,121 +0,0 @@
import { io } from 'socket.io-client';
class WebSocketManager {
constructor() {
this.socket = null;
this.subscribers = new Map();
this.connectionStatus = 'disconnected';
this.connectionCallbacks = new Set();
this.connecting = false;
}
connect() {
if (this.socket?.connected || this.connecting) {
return this.socket;
}
this.connecting = true;
this.socket = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
this.socket.on('connect', () => {
this.connectionStatus = 'connected';
this.connecting = false;
this.notifyConnectionStatus();
});
this.socket.on('disconnect', (reason) => {
this.connectionStatus = 'disconnected';
this.connecting = false;
this.notifyConnectionStatus();
if (reason === 'io server disconnect') this.socket.connect();
});
this.socket.on('connect_error', (error) => {
this.connectionStatus = 'error';
this.notifyConnectionStatus();
setTimeout(() => this.socket.connect(), 1000);
});
this.socket.on('metrics-data', (response) => {
const callbacks = this.subscribers.get(response.metric);
if (callbacks) {
callbacks.forEach(callback => callback(response.data));
}
});
return this.socket;
}
subscribe(metricName, callback) {
if (!this.socket?.connected) {
this.connect();
}
if (!this.subscribers.has(metricName)) {
this.subscribers.set(metricName, new Set());
this.socket.emit('subscribe-metric', {
metric: metricName,
isSubscription: true // Флаг для подписки
});
}
this.subscribers.get(metricName).add(callback);
return () => this.unsubscribe(metricName, callback);
}
unsubscribe(metricName, callback) {
const callbacks = this.subscribers.get(metricName);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.subscribers.delete(metricName);
this.socket.emit('unsubscribe-metric', { metric: metricName });
}
}
}
getMetricsRange(metricName, start, end, step) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Timeout while waiting for metrics data'));
}, 10000);
// Временный обработчик для разового запроса
const tempHandler = (data) => {
clearTimeout(timer);
this.socket.off(`metrics-range-${metricName}`, tempHandler);
resolve(data);
};
this.socket.on(`metrics-range-${metricName}`, tempHandler);
this.socket.emit('get-metrics', {
metric: metricName,
start,
end,
step,
isRangeQuery: true // Флаг для разового запроса
});
});
}
onConnectionStatusChange(callback) {
this.connectionCallbacks.add(callback);
callback(this.connectionStatus);
return () => this.connectionCallbacks.delete(callback);
}
notifyConnectionStatus() {
this.connectionCallbacks.forEach(callback => callback(this.connectionStatus));
}
}
export const webSocketManager = new WebSocketManager();

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import {
LineChart,
Line,
@ -66,31 +66,18 @@ const StatusIndicator = ({ cx, cy, payload }) => {
);
};
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const status = payload[0].payload.status;
const StatusBadge = ({ status }) => {
const statusColor = getStatusColor(status);
return (
<div style={{
background: '#fff',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<p><strong>{new Date(label).toLocaleString()}</strong></p>
<p style={{ color: payload[0].color }}>
Значение: <strong>{payload[0].value.toFixed(2)}</strong>
</p>
<div style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
background: `${statusColor}20`,
borderLeft: `4px solid ${statusColor}`,
borderRadius: '4px'
borderRadius: '4px',
marginTop: '4px'
}}>
<span style={{
width: 12,
@ -106,6 +93,39 @@ const CustomTooltip = ({ active, payload, label }) => {
</div>
</div>
</div>
);
};
const CustomTooltip = ({ active, payload, label, multipleLines }) => {
if (!active || !payload || !payload.length) return null;
return (
<div style={{
background: '#fff',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<p><strong>{new Date(label).toLocaleString()}</strong></p>
{multipleLines ? (
payload.map((item, index) => (
<div key={index} style={{ marginBottom: '8px' }}>
<p style={{ color: item.color }}>
{item.name}: <strong>{item.value.toFixed(2)}</strong>
</p>
<StatusBadge status={item.payload.status} />
</div>
))
) : (
<>
<p style={{ color: payload[0].color }}>
Значение: <strong>{payload[0].value.toFixed(2)}</strong>
</p>
<StatusBadge status={payload[0].payload.status} />
</>
)}
</div>
);
};
@ -118,8 +138,42 @@ const LineChartComponent = ({
dataKey = 'value',
height = 400,
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 = () => {
if (!data || data.length === 0) return null;
@ -149,8 +203,7 @@ const LineChartComponent = ({
stroke={getStatusColor(area.status)}
strokeWidth={1}
strokeOpacity={0.5}
/>
/>
));
};
@ -185,7 +238,7 @@ const LineChartComponent = ({
return (
<ReferenceLine
key={`line-${value}`} // Используем значение как ключ для стабильности
key={`line-${value}`}
y={value}
stroke={rangeColors[status] || '#888'}
strokeWidth={lineStyle.strokeWidth}
@ -232,10 +285,9 @@ const LineChartComponent = ({
return (
<div style={{ width: '100%', height: `${height}px` }}>
{/* Заголовок и описание */}
{title && <h3>{title}</h3>}
{description && (
<p style={{ marginTop: -10, color: '#666' }}>{description}</p>
)}
{description && <p style={{ marginTop: -10, color: '#666' }}>{description}</p>}
{metaInfo && (
<div style={{ fontSize: 12, color: '#888', marginBottom: 10 }}>
{metaInfo}
@ -287,22 +339,43 @@ const LineChartComponent = ({
</div>
)}
{/* График */}
<ResponsiveContainer width="100%" height="75%">
<LineChart
data={data}
data={multipleLines ? null : data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(ts) => new Date(ts).toLocaleTimeString()}
/>
<YAxis />
{renderRangeLines()}
{renderStatusBoundaries()}
{getStatusAreas()}
<Tooltip content={<CustomTooltip />} />
<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}
@ -311,11 +384,11 @@ const LineChartComponent = ({
dot={<StatusIndicator />}
activeDot={{ r: 8 }}
isAnimationActive={false}
animationDuration={300}
name={title}
/>
</LineChart>
)}
</LineChart>
</ResponsiveContainer>
{/* Легенда статусов */}

View File

@ -1,158 +1,380 @@
import { io } from 'socket.io-client';
class MetricsService {
constructor(baseUrl) {
this.baseUrl = baseUrl || window.location.origin;
constructor() {
this.baseUrl = '/metrics-ws';
this.socket = null;
this.subscriptions = new Map();
this.pendingRequests = new Map();
window.addEventListener('beforeunload', this.cleanupAll.bind(this));
window.addEventListener('pagehide', this.cleanupAll.bind(this));
this.subscriptions = new Map(); // Хранит подписки на real-time данные
this.pendingRequests = new Map(); // Для разовых запросов
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 5000;
this.connectionCallbacks = new Set(); // Колбэки для событий подключения
window.addEventListener('beforeunload', () => {
this.cleanupAll();
});
window.addEventListener('beforeunload', () => this.cleanupAll());
window.addEventListener('pagehide', () => 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() {
if (this.socket) {
console.log('WebSocket already exists');
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
return;
}
console.log('Connecting WebSocket...');
this.socket = io(`${this.baseUrl.replace('http', 'ws')}/api/metrics-ws`, {
transports: ['websocket'],
withCredentials: true,
});
this.socket = new WebSocket(this.baseUrl);
this.notifyConnectionChange(false);
this.socket.on('connect', () => {
this.socket.addEventListener('open', () => {
console.log('WebSocket connected');
// Восстанавливаем подписки при переподключении
this.subscriptions.forEach((_, metricKey) => {
const [metric, query] = metricKey.split('?');
const filters = this.parseFiltersFromKey(metricKey);
this.socket.emit('subscribe-metric', { metric, filters });
});
this.reconnectAttempts = 0;
this.notifyConnectionChange(true);
// Переподписываемся на все активные подписки
this.resubscribeAll();
});
this.socket.on('disconnect', () => {
console.log('WebSocket disconnected');
this.socket.addEventListener('close', (event) => {
console.log('WebSocket disconnected', event.code, event.reason);
this.socket = null;
this.notifyConnectionChange(false);
this.scheduleReconnect();
});
this.socket.on('metrics-data', ({ metric, data, requestId }) => {
console.log('Incoming metric update:', metric);
if (requestId && this.pendingRequests.has(requestId)) {
const { resolve } = this.pendingRequests.get(requestId);
resolve(data);
this.pendingRequests.delete(requestId);
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() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.warn('Max reconnect attempts reached');
return;
}
const callbacks = this.subscriptions.get(metric) || [];
callbacks.forEach(cb => cb(data));
});
this.socket.on('metrics-error', ({ error, requestId }) => {
if (requestId && this.pendingRequests.has(requestId)) {
const { reject } = this.pendingRequests.get(requestId);
reject(new Error(error));
this.pendingRequests.delete(requestId);
}
});
}
async fetchMetricsRange(metric, start, end, step = 15, filters = {}) {
return new Promise((resolve, reject) => {
this.connectWebSocket();
const requestId = `range-${Date.now()}`;
this.pendingRequests.set(requestId, { resolve, reject });
this.socket.emit('get-metrics', {
metric,
start,
end,
step,
filters,
isRangeQuery: true,
requestId
});
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
reject(new Error('Request timeout'));
this.pendingRequests.delete(requestId);
}
}, 30000);
});
this.connectWebSocket();
}, delay);
}
subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) {
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);
}
doSendMessage(event, data, requestId) {
const message = requestId ? { event, data, requestId } : { event, data };
this.socket.send(JSON.stringify(message));
}
// ============ ПУБЛИЧНЫЕ МЕТОДЫ ============
// Подписка на real-time данные
subscribeToMetric(metric, filters = {}, callback, interval = 10000) {
this.connectWebSocket();
const metricKey = this.getMetricKey(metric, filters);
if (!this.subscriptions.has(metricKey)) {
this.subscriptions.set(metricKey, []);
const [metric] = metricKey.split('?');
this.socket.emit('subscribe-metric', {
this.sendMessage('subscribe-realtime', {
metric,
interval,
filters
filters,
interval
});
}
const callbacks = this.subscriptions.get(metricKey);
callbacks.push(callback);
return () => {
this.unsubscribeFromMetric(metricKey, callback);
};
// Возвращаем функцию для отписки
return () => this.unsubscribeFromMetric(metric, filters, callback);
}
unsubscribeFromMetric(metricKey, callback) {
// Отписка от real-time данных
unsubscribeFromMetric(metric, filters = {}, callback) {
const metricKey = this.getMetricKey(metric, filters);
const callbacks = this.subscriptions.get(metricKey) || [];
const filtered = callbacks.filter(cb => cb !== callback);
if (filtered.length === 0) {
this.subscriptions.delete(metricKey);
if (this.socket && this.socket.connected) {
const [metric] = metricKey.split('?');
this.socket.emit('unsubscribe-metric', { metric });
}
this.sendMessage('unsubscribe-realtime', { metric, filters });
} else {
this.subscriptions.set(metricKey, filtered);
}
}
parseFiltersFromKey(metricKey) {
const parts = metricKey.split('?');
if (parts.length < 2) return {};
// Запрос исторических данных (разовый)
async fetchMetricsRange(metric, start, end, step = 60, filters = {}) {
return new Promise((resolve, reject) => {
this.connectWebSocket();
const requestId = `historical-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return parts[1].split('&').reduce((acc, pair) => {
const timeout = setTimeout(() => {
reject(new Error('Historical data request timeout'));
this.pendingRequests.delete(requestId);
}, 30000); // 30 секунд таймаут для historical данных
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) acc[key] = value;
return acc;
}, {});
if (key && value) {
filters[decodeURIComponent(key)] = decodeURIComponent(value);
}
});
}
return { metric, filters };
}
cleanupAll() {
if (this.socket && this.socket.connected) {
this.socket.emit('unsubscribe-all');
}
this.subscriptions.clear();
this.unsubscribeAll();
this.disconnectWebSocket();
}
disconnectWebSocket() {
if (this.socket) {
this.socket.disconnect();
this.socket.close(1000, 'Client disconnected');
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(import.meta.env.VITE_BACK_URL);
// Создаем глобальный экземпляр
const metricsService = new MetricsService();
// Экспорт для использования в модульной системе
export default metricsService;
// Глобальный экспорт для прямого использования в браузере
if (typeof window !== 'undefined') {
window.MetricsService = metricsService;
}

View File

@ -30,7 +30,12 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
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)}`);
@ -38,35 +43,70 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
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) => {
return dataArray
.map(item => ({
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: item.timestamp,
timestamp: Number(item.timestamp),
value: parseFloat(item.value),
status: getStatusFromRanges(parseFloat(item.value), ranges),
name: item.__name__ || metricName,
status: parseInt(item.status) || 0,
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 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 seconds = (endTime.getTime() - startTime.getTime()) / 1000;
return Math.max(Math.ceil(seconds / maxPoints), 1); // в секундах
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) => {
@ -92,17 +132,24 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
};
const step = calculateStep(start, end);
// Используем новый метод для исторических данных
const data = await metricsService.fetchMetricsRange(
metricName,
Math.floor(start.getTime() / 1000),
Math.floor(end.getTime() / 1000),
start.getTime(), // Теперь передаем timestamp в миллисекундах
end.getTime(),
step,
extendedFilters
);
const formattedData = formatMetricData(data)
.sort((a, b) => a.timestamp - b.timestamp);
const formattedData = downsampleData(formatMetricData(data), 100); //КОЛИЧЕСТВО ТОЧЕК НА ГРАФИКЕ
if (formattedData.length > 0) {
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,
@ -111,7 +158,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
});
}
setChartData(formattedData);
setChartData(limitedData);
} catch (err) {
console.error(`Error loading historical data for ${metricName}:`, err);
setError(err.message);
@ -126,32 +173,56 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
setIsLoading(true);
const end = new Date();
const start = new Date(end.getTime() - 3600 * 1000);
const start = new Date(end.getTime() - TIME_WINDOW_MS);
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
// Изменяем параметры подписки
return metricsService.subscribeToMetric(
getSubscriptionKey(),
(newData) => {
const formattedData = formatMetricData(newData);
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 newChartData = [...prev, ...formattedData]
.filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i)
.slice(-200);
return newChartData;
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,
{
...filters,
...(device && { device }),
...(source_id && { source_id })
}
5000 // Интервал обновления (можно настроить)
);
};
const stopRealtimeUpdates = () => {
setIsLiveUpdating(false);
metricsService.unsubscribeFromMetric(getSubscriptionKey());
// Теперь отписываемся по метрике и фильтрам
metricsService.unsubscribeFromMetric(
metricName,
{ ...filters, device, source_id }
);
};
const handleCustomRangeApply = () => {
@ -161,20 +232,29 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
};
useEffect(() => {
console.log('Current metric context:', { device, source_id, metricName });
console.log('Metric changed:', { metricName, device, source_id, filters });
let unsubscribe;
const init = async () => {
if (mode === 'realtime') {
unsubscribe = startRealtimeUpdates();
} else {
stopRealtimeUpdates();
fetchHistoricalData(startDate, endDate);
await fetchHistoricalData(startDate, endDate);
}
};
init();
return () => {
if (unsubscribe) unsubscribe();
stopRealtimeUpdates();
if (unsubscribe) {
unsubscribe(); // Вызываем функцию отписки
}
if (mode === 'realtime') {
stopRealtimeUpdates(); // Дополнительная очистка
}
};
}, [mode, metricName, device, source_id]);
}, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров
const metaInfo = [
metricMeta.instance && `Instance: ${metricMeta.instance}`,

View File

@ -141,11 +141,11 @@ const Dashboard = ({ isDarkMode, setIsDarkMode, user, onLogout }) => {
top: 12,
right: 20,
zIndex: (theme) => theme.zIndex.tooltip + 10,
pointerEvents: 'auto', //ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
pointerEvents: 'auto',
display: 'flex',
gap: 1,
alignItems: 'center'
}}
}}//ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
>
<AIAnalysisButton />
<ProfileMenu user={user} onLogout={onLogout} />

View File

@ -0,0 +1,472 @@
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);

View File

@ -0,0 +1,206 @@
// 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;

View File

@ -0,0 +1,303 @@
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;

View File

@ -115,7 +115,7 @@ const MetricRangeEditor = ({ onSave }) => {
const loadRanges = useCallback(async () => {
try {
setLoading(true);
const res = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/ranges/list`);
const res = await axios.get(`/api/ranges/list`);
setRanges(
Object.entries(res.data).map(([name, r]) => ({
name,
@ -184,7 +184,7 @@ const MetricRangeEditor = ({ onSave }) => {
const saveChanges = useCallback(async () => {
try {
setLoading(true);
await axios.post(`${import.meta.env.VITE_BACK_URL}/api/ranges/update`, ranges);
await axios.post(`/api/ranges/update`, ranges);
setHasChanges(false);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);

View File

@ -0,0 +1,227 @@
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;

View File

@ -20,6 +20,10 @@ import {
import CloseIcon from '@mui/icons-material/Close';
import SaveIcon from '@mui/icons-material/Save';
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) {
return <Slide direction="up" ref={ref} {...props} />;
@ -63,6 +67,18 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
hasChanges: false,
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) => {
if (hasChanges) {
@ -72,12 +88,30 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
}
};
const handleMenuEditorChange = ({ hasChanges, saveChanges }) => {
setMenuEditorState({ hasChanges, save: saveChanges });
setHasChanges(hasChanges);
};
const handleSave = async () => {
setIsSaving(true);
try {
let success = true;
if (tabValue === 0 && menuEditorState.hasChanges) {
success = await menuEditorState.save();
}
if (tabValue === 1 && metricEditorState.hasChanges) {
success = await metricEditorState.save();
success = 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) {
@ -97,6 +131,16 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
setHasChanges(hasChanges);
};
const handleFormulaEditorChange = ({ hasChanges, saveChanges }) => {
setFormulaEditorState({ hasChanges, save: saveChanges });
setHasChanges(hasChanges);
};
const handleLicensingChange = ({ hasChanges, saveChanges }) => {
setLicensingState({ hasChanges, save: saveChanges });
setHasChanges(hasChanges);
};
const handleClose = () => {
if (hasChanges) {
setShowConfirmClose(true);
@ -112,7 +156,6 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
}
};
// Пример обработчика изменений
const handleSettingChange = () => {
setHasChanges(true);
};
@ -147,23 +190,38 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
<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-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>
</Box>
<DialogContent dividers>
<TabPanel value={tabValue} index={0}>
<Typography variant="h6">Настройки меню</Typography>
{/* Добавьте содержимое для вкладки меню */}
<MenuEditor onSave={handleMenuEditorChange} />
</TabPanel>
<TabPanel value={tabValue} index={1}>
<MetricRangeEditor onSave={handleMetricEditorChange} />
</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 для новых вкладок */}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Закрыть</Button>
<Button

View File

@ -1,5 +1,4 @@
// SidebarMenu.jsx
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import {
Drawer,
List,
@ -7,14 +6,30 @@ import {
IconButton,
Tooltip,
Box,
alpha
} from "@mui/material";
import MenuItem from "./SidebarMenuComponents/MenuItem";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
import useSidebarResize from "../hooks/useSidebarResize";
import ChevronLeft from '@mui/icons-material/ChevronLeft';
import ChevronRight from '@mui/icons-material/ChevronRight';
import LogoFull from '../../assets/images/logo.svg?react';
import LogoSmall from '../../assets/images/system_monitor_icon.svg?react';
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import LogoFull from "../../assets/images/logo.svg?react";
import LogoSmall from "../../assets/images/system_monitor_icon.svg?react";
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 = ({
data,
@ -22,107 +37,385 @@ const SidebarMenu = ({
setIsDarkMode,
onSelectItem,
forceRefreshMenu,
user
user,
}) => {
const [collapsed, setCollapsed] = useState(false);
const { sidebarWidth, startResizing } = useSidebarResize(290);
const [hovered, setHovered] = useState(false);
const { sidebarWidth, startResizing } = useSidebarResize(320); // Увеличил минимальную ширину
const [menuItems, setMenuItems] = useState(data.items || []);
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 = () => {
setCollapsed(!collapsed);
setHoveredItem(null);
};
const SidebarResizer = styled('div')(({ theme }) => ({
width: '4px',
cursor: 'ew-resize',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: theme.palette.action.hover,
// Функции для работы с деревом (остаются без изменений)
const findItemInTree = (items, id) => {
for (const item of items) {
if (item.id === id) return item;
if (item.items) {
const found = findItemInTree(item.items, id);
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%',
position: 'absolute',
height: "100%",
position: "absolute",
top: 0,
right: 0,
zIndex: 1000,
transition: "background-color 0.2s ease",
}));
const DropIndicator = ({ position, targetId }) => {
if (!targetId) return null;
return (
<Box
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{
position: 'relative',
width: collapsed ? 64 : sidebarWidth,
transition: 'width 0.3s ease',
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 (
<Box
sx={{
position: "relative",
width: collapsed ? 72 : sidebarWidth,
transition: "width 0.2s ease",
height: "100vh",
}}
>
<Drawer
variant="permanent"
sx={{
width: collapsed ? 64 : sidebarWidth,
width: collapsed ? 72 : sidebarWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: collapsed ? 64 : sidebarWidth,
"& .MuiDrawer-paper": {
width: collapsed ? 72 : sidebarWidth,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
backgroundColor: 'custom.sidebar',
color: 'custom.sidebarText',
transition: 'width 0.3s ease',
overflowX: 'hidden',
borderRight: 'none'
backgroundColor: "background.paper",
color: "text.primary",
transition: "width 0.2s ease, background-color 0.2s ease",
overflowX: "hidden",
borderRight: "1px solid",
borderColor: "divider",
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.08)",
},
}}
>
{/* Заголовок с логотипом */}
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center', // Центрируем содержимое
p: 1,
borderBottom: '1px solid',
borderColor: 'divider',
backgroundColor: 'custom.sidebar',
height: 80, // Фиксированная высота
position: 'relative' // Для позиционирования кнопки
}}>
{/* Логотип (занимает все пространство) */}
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
'& svg': {
width: '100%',
height: '100%',
padding: collapsed ? '8px' : '12px',
objectFit: 'contain'
}
}}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
borderBottom: "1px solid",
borderColor: "divider",
backgroundColor: "background.paper",
height: 80,
position: "relative",
transition: "all 0.2s ease",
minHeight: 80,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
transition: "all 0.2s ease",
"& svg": {
width: "auto",
height: "40px", // Фиксированная высота для лого
objectFit: "contain",
transition: "all 0.2s ease",
},
}}
>
{collapsed ? (
<LogoSmall style={{
color: 'inherit' // Наследует цвет темы
color: "inherit",
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
width: "32px",
height: "32px"
}} />
) : (
<LogoFull style={{
color: 'inherit' // Наследует цвет темы
color: "inherit",
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
maxWidth: "180px",
height: "40px"
}} />
)}
</Box>
{/* Кнопка сворачивания (абсолютное позиционирование) */}
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
<Tooltip
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
placement="right"
>
<IconButton
onClick={handleToggleCollapse}
size="small"
sx={{
color: 'custom.sidebarText',
'&:hover': { backgroundColor: 'custom.sidebarHover' },
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)'
color: "text.secondary",
"&:hover": {
backgroundColor: "action.hover",
color: "text.primary"
},
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
transition: "all 0.2s ease",
width: 32,
height: 32,
}}
>
{collapsed ? <ChevronRight /> : <ChevronLeft />}
@ -131,18 +424,97 @@ const SidebarMenu = ({
</Box>
{/* Основное содержимое меню */}
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
{data && (
<MenuItem
item={data}
collapsed={collapsed}
level={0}
onSelectItem={onSelectItem}
<Box
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
position: "relative",
}}
>
<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>
{activeItem ? (
<Box
sx={{
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
collapsed={collapsed}
@ -152,8 +524,11 @@ const SidebarMenu = ({
user={user}
/>
</Box>
{!collapsed && (
<Tooltip title="Изменить ширину" placement="top">
<SidebarResizer onMouseDown={startResizing} />
</Tooltip>
)}
</Drawer>
</Box>

View File

@ -1,121 +1,121 @@
// MenuItem.jsx
import React, { useState } from "react";
import {
ListItem,
ListItemIcon,
ListItemText,
Collapse,
List,
styled,
Menu,
MenuItem as MuiMenuItem
} from "@mui/material";
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
import StatusIndicator from "./StatusIndicator";
// // MenuItem.jsx
// import React, { useState } from "react";
// import {
// ListItem,
// ListItemIcon,
// ListItemText,
// Collapse,
// List,
// styled,
// Menu,
// MenuItem as MuiMenuItem
// } from "@mui/material";
// import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
// import StatusIndicator from "./StatusIndicator";
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
cursor: "pointer",
paddingLeft: theme.spacing(2 + level * 2),
position: 'relative',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
'&.Mui-selected': {
backgroundColor: theme.palette.custom.sidebarHover,
},
}));
// const StyledListItem = styled(ListItem)(({ theme, level }) => ({
// cursor: "pointer",
// paddingLeft: theme.spacing(2 + level * 2),
// position: 'relative',
// '&:hover': {
// backgroundColor: theme.palette.action.hover,
// },
// '&.Mui-selected': {
// backgroundColor: theme.palette.custom.sidebarHover,
// },
// }));
const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
const [isOpen, setIsOpen] = useState(false);
const [contextMenu, setContextMenu] = useState(null);
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
// const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
// const [isOpen, setIsOpen] = useState(false);
// const [contextMenu, setContextMenu] = useState(null);
// const hasChildren = Array.isArray(item.items) && item.items.length > 0;
const handleContextMenu = (e) => {
e.preventDefault();
setContextMenu(
contextMenu === null
? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
: null
);
};
// const handleContextMenu = (e) => {
// e.preventDefault();
// setContextMenu(
// contextMenu === null
// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
// : null
// );
// };
const handleCloseContextMenu = () => {
setContextMenu(null);
};
// const handleCloseContextMenu = () => {
// setContextMenu(null);
// };
const handleToggle = (e) => {
e.stopPropagation();
setIsOpen(!isOpen);
};
// const handleToggle = (e) => {
// e.stopPropagation();
// setIsOpen(!isOpen);
// };
const handleClick = () => {
if (onSelectItem) {
onSelectItem(item);
}
};
// const handleClick = () => {
// if (onSelectItem) {
// onSelectItem(item);
// }
// };
return (
<>
<StyledListItem
component="div"
onClick={hasChildren ? handleToggle : handleClick}
onContextMenu={handleContextMenu}
level={level}
sx={{
pl: collapsed ? 2 : 2 + level * 2,
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
{!collapsed && <StatusIndicator status={item.status} />}
// return (
// <>
// <StyledListItem
// component="div"
// onClick={hasChildren ? handleToggle : handleClick}
// onContextMenu={handleContextMenu}
// level={level}
// sx={{
// pl: collapsed ? 2 : 2 + level * 2,
// justifyContent: collapsed ? 'center' : 'flex-start',
// }}
// >
// {!collapsed && <StatusIndicator status={item.status} />}
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</ListItemIcon>
// <ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
// {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
// </ListItemIcon>
{!collapsed && (
<>
<ListItemText
primary={item.title}
primaryTypographyProps={{
color: 'custom.sidebarText'
}}
/>
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
</>
)}
</StyledListItem>
// {!collapsed && (
// <>
// <ListItemText
// primary={item.title}
// primaryTypographyProps={{
// color: 'custom.sidebarText'
// }}
// />
// {hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
// </>
// )}
// </StyledListItem>
<Menu
open={contextMenu !== null}
onClose={handleCloseContextMenu}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
>
// <Menu
// open={contextMenu !== null}
// onClose={handleCloseContextMenu}
// anchorReference="anchorPosition"
// anchorPosition={
// contextMenu !== null
// ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
// : undefined
// }
// >
</Menu>
// </Menu>
{hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.items.map((child, index) => (
<MenuItem
key={child.id ?? index}
item={child}
onSelectItem={onSelectItem}
onEdit={onEdit}
level={level + 1}
collapsed={collapsed}
/>
))}
</List>
</Collapse>
)}
</>
);
};
// {hasChildren && !collapsed && (
// <Collapse in={isOpen} timeout="auto" unmountOnExit>
// <List component="div" disablePadding>
// {item.items.map((child, index) => (
// <MenuItem
// key={child.id ?? index}
// item={child}
// onSelectItem={onSelectItem}
// onEdit={onEdit}
// level={level + 1}
// collapsed={collapsed}
// />
// ))}
// </List>
// </Collapse>
// )}
// </>
// );
// };
export default MenuItem;
// export default MenuItem;

View File

@ -1,20 +1,24 @@
import React, { useState } from "react";
import { Brightness4, Brightness7 } from "@mui/icons-material";
import { IconButton, Tooltip } from "@mui/material";
import { Brightness4, Brightness7, Settings, Help } from "@mui/icons-material";
import {
IconButton,
Tooltip,
Box,
Button,
alpha
} from "@mui/material";
import {
List,
ListItem,
ListItemText,
styled,
Switch,
Box,
Button
} from "@mui/material";
import SettingsModal from "../SettingsModal";
import { RoleBasedRender } from "../../UI/RoleBasedRender";
const FooterList = styled(List)(({ theme }) => ({
backgroundColor: theme.palette.custom.sidebar,
backgroundColor: 'background.paper',
padding: theme.spacing(1, 0),
borderTop: `1px solid ${theme.palette.divider}`,
marginTop: 'auto'
@ -22,12 +26,15 @@ const FooterList = styled(List)(({ theme }) => ({
const FooterListItem = styled(ListItem)(({ theme }) => ({
'&:hover': {
backgroundColor: theme.palette.custom.sidebarHover,
backgroundColor: alpha(theme.palette.action.hover, 0.4),
},
padding: theme.spacing(1, 2),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
borderRadius: '8px',
margin: '0 8px 4px',
transition: 'all 0.2s ease',
}));
const SidebarFooter = ({
@ -46,72 +53,93 @@ const SidebarFooter = ({
const handleSettingsClose = () => {
setSettingsOpen(false);
};
console.log('SidebarFooter user with role:', {
...user,
hasRole: 'role' in user,
roleValue: user?.role
});
return (
<>
<FooterList>
{!collapsed && (
<FooterListItem button>
<ListItemText
primary="Помощь"
primaryTypographyProps={{
color: 'custom.sidebarText',
variant: 'body2'
}}
/>
</FooterListItem>
)}
{!collapsed ? (
<>
<FooterListItem>
{/* кнопка настроек */}
<RoleBasedRender user={user} allowedRoles={['admin']}>
{!collapsed && (
<Button
onClick={handleSettingsOpen}
startIcon={<Settings />}
sx={{
color: 'custom.sidebarText',
color: 'text.secondary',
textTransform: 'none',
minWidth: 0,
padding: 0,
marginRight: 'auto'
fontSize: '0.875rem',
fontWeight: 500,
'&:hover': {
color: 'text.primary',
backgroundColor: 'transparent'
}
}}
>
<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' }}
sx={{
color: 'text.secondary',
'&:hover': {
color: 'text.primary',
backgroundColor: alpha('#000000', 0.1)
}
}}
>
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
</IconButton>
</Tooltip>
{!collapsed && (
<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>
)}
</FooterList>
{/* Используем RoleBasedRender для модального окна */}
<RoleBasedRender user={user} allowedRoles={['admin']}>
<SettingsModal
open={settingsOpen}

View File

@ -0,0 +1,222 @@
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;

View File

@ -44,7 +44,7 @@ const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect, user }) =
setLoading(true);
const headers = lastModified ? { 'If-Modified-Since': lastModified } : {};
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`, {
const response = await axios.get(`/api/menu/full`, {
headers,
validateStatus: status => status === 200 || status === 304
});
@ -78,13 +78,13 @@ const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect, user }) =
const checkForUpdates = async () => {
try {
setBackgroundLoading(true);
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/check-updates`, {
const response = await axios.get(`/api/menu/check-updates`, {
headers: { 'If-Modified-Since': lastModified }
});
if (response.data.hasUpdates) {
// Если есть обновления, загружаем их в фоне
const updateResponse = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`);
const updateResponse = await axios.get(`/api/menu/full`);
setMenuData(updateResponse.data);
setLastModified(updateResponse.headers['last-modified']);
@ -108,7 +108,7 @@ const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect, user }) =
const handleSaveChanges = async (updatedItem) => {
try {
const response = await axios.put(
`${import.meta.env.VITE_BACK_URL}/api/menu/${updatedItem.id}`,
`/api/menu/${updatedItem.id}`,
updatedItem,
{
headers: {

View File

@ -1,11 +1,25 @@
import React, { useState } from 'react';
import { Button, CircularProgress, Alert, Box } from '@mui/material';
import {
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';
const AIAnalysisButton = ({ onAnalysisComplete }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [result, setResult] = useState(null);
const [openModal, setOpenModal] = useState(false);
const handleAnalyze = async () => {
setLoading(true);
@ -14,34 +28,38 @@ const AIAnalysisButton = ({ onAnalysisComplete }) => {
try {
// 1. Получаем данные из ClickHouse
console.log('Запрашиваем данные из ClickHouse...');
const metricsResponse = await axios.get('/api/clickhouse');
const sendData = metricsResponse.data.slice(0, 100);
console.log('Получены данные из ClickHouse:', metricsResponse.data);
// 2. Отправляем в AI API
console.log('Отправляем данные в AI API:', metricsResponse.data);
const aiResponse = await axios.post(
'/ai_api/api/metrics/rest',
sendData,
'/ai-api/api/metrics/rest',
metricsResponse.data,
{
headers: {
'Content-Type': 'application/json',
},
}
);
console.log('Ответ от AI API:', aiResponse.data);
setResult(aiResponse.data);
setOpenModal(true);
if (onAnalysisComplete) {
onAnalysisComplete(aiResponse.data);
}
} catch (err) {
console.error("Детали ошибки 422:", err.response?.data);
setError(err.response?.data?.message || JSON.stringify(err.response?.data) || "Ошибка валидации данных");
console.error("Детали ошибки:", err.response?.data);
setError(err.response?.data?.message || JSON.stringify(err.response?.data)) || "Ошибка при анализе данных";
} finally {
setLoading(false);
}
};
const handleCloseModal = () => {
setOpenModal(false);
};
return (
@ -69,11 +87,63 @@ const AIAnalysisButton = ({ onAnalysisComplete }) => {
</Alert>
)}
{result && !loading && (
<Alert severity="success" sx={{ mt: 1 }}>
Анализ завершен! Результат в консоли.
</Alert>
{/* Модальное окно с результатом */}
<Dialog
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>
);
};

View File

@ -24,8 +24,8 @@ const LoginModal = ({ onLogin, onClose }) => {
e.preventDefault();
try {
const { data } = await axios.post(
// `${import.meta.env.VITE_BACK_URL}/api/auth/login`,
`/api/auth/login`,
//`${import.meta.env.VITE_BACK_URL}/api/auth/login`,
'/api/auth/login',
{ login: username, password },
{
withCredentials: true,

View File

@ -1,12 +1,12 @@
import React from 'react';
export const RoleBasedRender = ({ user, allowedRoles, children }) => {
console.log('RoleBasedRender check:', {
user,
hasRole: user?.role,
allowedRoles,
hasAccess: user && allowedRoles.includes(user.role)
});
// console.log('RoleBasedRender check:', {
// user,
// hasRole: user?.role,
// allowedRoles,
// hasAccess: user && allowedRoles.includes(user.role)
// });
if (!user || !allowedRoles.includes(user.role)) {
return null;

View File

@ -1,9 +1,10 @@
import axios from 'axios'
import axios from "axios"
export const checkAuth = async () => {
try {
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,
headers: {

View File

@ -44,12 +44,12 @@ const MetricsAnalyzer = () => {
setError(null);
// 1. Сначала загружаем метрики
const metricsResponse = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics/all-values`);
const metricsResponse = await axios.get(`/api/metrics/all-values`);
setMetrics(metricsResponse.data);
// 2. Преобразуем и отправляем на анализ
const requestData = transformMetricsForAnalysis(metricsResponse.data);
const analysisResponse = await axios.get(`${import.meta.env.VITE_BACK_URL}:5134/api/metrics/rest`, {
const analysisResponse = await axios.get(`:5134/api/metrics/rest`, {
data: requestData,
headers: {
'Content-Type': 'application/json',

View File

@ -1,8 +1,7 @@
import SystemStatusChart from "../../Charts/SystemStatusChart";
import TreeTable from "../UI/TreeTable";
import FlowChart from "../TreeChart/FlowChart";
import { getStatusColor } from "../TreeChart/dataUtils";
import MetricsAnalyzer from "./MetricsAnalyzer"; // Импортируем новый компонент
import SystemChart from "../../Charts/SystemChart";
const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, handleOpenTab }) => {
const countStatuses = (data) => {
@ -17,24 +16,48 @@ const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, h
}
};
countRecursive(data);
if (data) countRecursive(data);
return counts;
};
if (activeTab === "Главная") {
const statusCounts = treeData1 ? countStatuses(treeData1) : { green: 0, yellow: 0, orange: 0, red: 0 };
const statusCounts = countStatuses(treeData1);
// Конфигурация для метрики серверов (с несколькими линиями)
const serverMetric = {
name: "zvks_server_li",
title: "Надежность системы",
description: "Уровень надежности системы",
multipleLines: true,
lineKey: "device",
};
// Конфигурация для метрики приложений (одна линия)
const appMetric = {
name: "zvks_application_li",
title: "Функциональность системы",
description: "Уровень функциональности системы",
multipleLines: false
};
return (
<div>
<h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</h2>
<div>
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
<label>Надежность системы</label>
<SystemStatusChart data={statusHistories.history1} />
<label>Надежность серверов</label>
<SystemChart
metricInfo={serverMetric}
chartHeight={580}
/>
</div>
<div style={{ display: 'inline-block', width: '48%' }}>
<label>Функциональность системы</label>
<SystemStatusChart data={statusHistories.history2} />
<label>Функциональность приложений</label>
<SystemChart
metricInfo={appMetric}
chartHeight={580}
/>
</div>
</div>
@ -65,9 +88,6 @@ const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, h
<label>Статус компонентов системы</label>
<TreeTable data={treeData1} />
{/* Добавляем кнопку анализа
<MetricsAnalyzer />*/}
</div>
);
} else if (activeTab === "Визуализация") {

View File

@ -1,9 +1,9 @@
import React from 'react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />

View File

@ -1,19 +0,0 @@
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/, '') //
// }
// },
}
});