Compare commits
106 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
e5e4d75637 | |
|
|
3a142185a2 | |
|
|
0b600095ce | |
|
|
33a88d2a1a | |
|
|
daf9cab8ac | |
|
|
7400e77fa0 | |
|
|
86baaa29ff | |
|
|
14d2f3eb68 | |
|
|
558cf8eaba | |
|
|
585692c838 | |
|
|
3cef08f65b | |
|
|
04738fae91 | |
|
|
555c28d942 | |
|
|
97295a6748 | |
|
|
179baad012 | |
|
|
75fa0ebfe3 | |
|
|
f271fb5acf | |
|
|
c411142840 | |
|
|
06249fce3a | |
|
|
933ceb2547 | |
|
|
34f2010cae | |
|
|
205ddc71e0 | |
|
|
421d95565c | |
|
|
46cd1fa0fa | |
|
|
911bfb88d1 | |
|
|
1bcb15f655 | |
|
|
f8d822ace7 | |
|
|
fd5a202d74 | |
|
|
b1a760336d | |
|
|
00866d9d57 | |
|
|
c208813daa | |
|
|
f55fb2df56 | |
|
|
be3bf3b21e | |
|
|
4fb1975428 | |
|
|
08fde58a30 | |
|
|
6f0b15427a | |
|
|
d7c40ee04b | |
|
|
8fcace10b1 | |
|
|
140b058f41 | |
|
|
cb030a01d2 | |
|
|
dabdda4afe | |
|
|
6a73bd8104 | |
|
|
87a79f98d7 | |
|
|
15c20f1352 | |
|
|
ef5df6971d | |
|
|
61c623b93d | |
|
|
26276e0360 | |
|
|
c99b6add47 | |
|
|
b681add6fc | |
|
|
36a75ee93c | |
|
|
9f8d0072c2 | |
|
|
5e9e40aad2 | |
|
|
405bda3df9 | |
|
|
328018edfa | |
|
|
69a5e4ade1 | |
|
|
a931fd3ea4 | |
|
|
5bf3124fd4 | |
|
|
f87274d41a | |
|
|
b5b758ffa0 | |
|
|
12e1ff08f5 | |
|
|
350f375015 | |
|
|
fa32e75b5a | |
|
|
09a6082917 | |
|
|
cb7c22929a | |
|
|
c55806d180 | |
|
|
d70c7673b4 | |
|
|
8223cc4a27 | |
|
|
4088dacba4 | |
|
|
08e2c24a63 | |
|
|
efd8532ac3 | |
|
|
069cea21b0 | |
|
|
2b79159d35 | |
|
|
4dfd972615 | |
|
|
b9a2be4860 | |
|
|
d5aa312104 | |
|
|
bbbcd932ad | |
|
|
6fd5d1aed2 | |
|
|
40d8046617 | |
|
|
e47161acd1 | |
|
|
fd53b187d5 | |
|
|
b4d653f3a6 | |
|
|
54cf5504a4 | |
|
|
f38c8825fe | |
|
|
c7ebbcaf5c | |
|
|
d83f05e2b5 | |
|
|
dd0aa2d706 | |
|
|
bcdbd0f0fc | |
|
|
08eaa274b2 | |
|
|
2c9813fbb9 | |
|
|
c1b0f10e62 | |
|
|
4eed2f364c | |
|
|
9ee15160e5 | |
|
|
46484efdea | |
|
|
f69443d051 | |
|
|
88b7b8af77 | |
|
|
b7f6a6c386 | |
|
|
f5b92715ef | |
|
|
2713142c7d | |
|
|
3c60e9d144 | |
|
|
a8ca18edc9 | |
|
|
b7e5941500 | |
|
|
3d63dbe94c | |
|
|
ff10808dd8 | |
|
|
92d1236587 | |
|
|
47159bb698 | |
|
|
c4f95448c1 |
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
|
@ -32,3 +32,9 @@ node_modules
|
||||||
.env.development
|
.env.development
|
||||||
.env.production
|
.env.production
|
||||||
.env.test
|
.env.test
|
||||||
|
|
||||||
|
# Local configs
|
||||||
|
vite.config.js
|
||||||
|
vite.config.local.js
|
||||||
|
.env.local
|
||||||
|
*.local.*
|
||||||
10
Dockerfile
10
Dockerfile
|
|
@ -2,10 +2,14 @@ FROM node:22.13.0
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json vite.config.js eslint.config.js ./
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm install --verbose
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
|
COPY vite.config.js eslint.config.js ./
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
ENTRYPOINT ["npm", "run", "dev"]
|
ENTRYPOINT ["npm", "run", "dev"]
|
||||||
|
|
@ -30,7 +30,23 @@ pipeline {
|
||||||
stage ('Initialize variables') {
|
stage ('Initialize variables') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
env.IMAGE_TAG = sh(script: "git describe --tags --abbrev=0", returnStdout: true).trim()
|
def hasTags = sh(script: "git tag -l | wc -l", returnStdout: true).trim().toInteger() > 0
|
||||||
|
echo "${hasTags}"
|
||||||
|
|
||||||
|
def lastVersion = "0.0.0"
|
||||||
|
|
||||||
|
if (hasTags) {
|
||||||
|
lastVersion = sh(script: "git describe --tags --abbrev=0", returnStdout: true).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Last version: ${lastVersion}"
|
||||||
|
|
||||||
|
def (major, minor, patch) = lastVersion.tokenize('.')
|
||||||
|
def newVersion = "${major}.${minor}.${patch.toInteger() + 1}"
|
||||||
|
echo "New version: ${newVersion}"
|
||||||
|
|
||||||
|
env.IMAGE_TAG = newVersion
|
||||||
|
env.NEW_VERSION = newVersion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -71,17 +87,29 @@ pipeline {
|
||||||
echo "Attempting to merge PR ${env.CHANGE_ID} into master..."
|
echo "Attempting to merge PR ${env.CHANGE_ID} into master..."
|
||||||
withCredentials([usernamePassword(credentialsId: 'gitea_creds', usernameVariable: 'GITEA_USER', passwordVariable: 'GITEA_PASS')]) {
|
withCredentials([usernamePassword(credentialsId: 'gitea_creds', usernameVariable: 'GITEA_USER', passwordVariable: 'GITEA_PASS')]) {
|
||||||
def prId = env.CHANGE_ID
|
def prId = env.CHANGE_ID
|
||||||
|
|
||||||
sh """
|
sh """
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-u "${GITEA_USER}:${GITEA_PASS}" \
|
-u "${GITEA_USER}:${GITEA_PASS}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"do":"merge"}' \
|
-d '{"do":"merge"}' \
|
||||||
http://git.entcor/api/v1/repos/deployer3000/trust-module-frontend/pulls/${prId}/merge
|
http://git.entcor/api/v1/repos/deployer3000/${env.IMAGE_NAME}/pulls/${prId}/merge
|
||||||
"""
|
"""
|
||||||
echo "PR ${prId} merged successfully into master!"
|
def commitHash = sh(script: "git rev-parse HEAD~1", returnStdout: true).trim() // необходим для корректного отображения статусов
|
||||||
def context = "test-org/trust-module-frontend/pipeline/pr-${env.CHANGE_TARGET}"
|
echo "PR ${prId} merged successfully into main!"
|
||||||
def commitHash = sh(script: "git rev-parse HEAD~1", returnStdout: true).trim()
|
sleep(time: 15, unit: 'SECONDS')
|
||||||
notify(context, GITEA_USER, GITEA_PASS, env.GITEA_REPOSITORY_URL, "trust-module-frontend", commitHash, "success")
|
sh "git checkout main && git pull origin main"
|
||||||
|
|
||||||
|
sh """
|
||||||
|
curl -v -X POST -u "${GITEA_USER}:${GITEA_PASS}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tag_name": "${env.NEW_VERSION}", "name": "Release ${env.NEW_VERSION}", "target_commitish": "main"}' \
|
||||||
|
"${env.GITEA_REPOSITORY_URL}deployer3000/${env.IMAGE_NAME}/releases"
|
||||||
|
"""
|
||||||
|
echo "New release succeeded!"
|
||||||
|
|
||||||
|
def context = "test-org/${env.IMAGE_NAME}/pipeline/pr-${env.CHANGE_TARGET}"
|
||||||
|
notify(context, GITEA_USER, GITEA_PASS, env.GITEA_REPOSITORY_URL, env.IMAGE_NAME, commitHash, "success")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
|
|
@ -10,24 +10,31 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
|
||||||
"recharts": "^2.15.1",
|
|
||||||
"d3": "^7.9.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"chart.js": "^4.0.0",
|
|
||||||
"chartjs-chart-box-and-violin-plot": "^4.0.0",
|
|
||||||
"react-chartjs-2": "^5.0.0",
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"react-datepicker": "^8.1.0",
|
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mui/material": "^6.4.7",
|
|
||||||
"@mui/icons-material": "^6.4.8",
|
"@mui/icons-material": "^6.4.8",
|
||||||
"reactflow": "^11.11.4",
|
"@mui/material": "^6.4.7",
|
||||||
"vite-plugin-svgr": "^4.3.0",
|
"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",
|
||||||
|
"esbuild": "^0.25.8",
|
||||||
|
"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-scripts": "^5.0.1",
|
||||||
"socket.io-client": "^4.8.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",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@dnd-kit/core": "^6.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|
@ -39,6 +46,6 @@
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"vite": "^6.0.5"
|
"vite": "^7.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
204
src/App.jsx
204
src/App.jsx
|
|
@ -1,48 +1,191 @@
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
import { ThemeProvider, CssBaseline, Switch, Box } from "@mui/material";
|
import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress, Typography } from "@mui/material";
|
||||||
import Dashboard from "./Components/Layout/Dashboard";
|
import Dashboard from "./Components/Layout/Dashboard";
|
||||||
import LoginModal from "./Components/UI/LoginModal";
|
import LoginModal from "./Components/UI/LoginModal";
|
||||||
import { lightTheme, darkTheme } from "./Style/theme";
|
import { lightTheme, darkTheme } from "./Style/theme";
|
||||||
import Logo from './assets/images/logo.svg?react'; // Импорт как компонента
|
import Logo from './assets/images/logo.svg?react';
|
||||||
|
import { checkAuth } from "./Components/UI/auth";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [authState, setAuthState] = useState({
|
||||||
const [showLoginModal, setShowLoginModal] = useState(true);
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
user: null
|
||||||
|
});
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
const [isDarkMode, setIsDarkMode] = useState(
|
const [isDarkMode, setIsDarkMode] = useState(
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
);
|
);
|
||||||
|
|
||||||
const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]);
|
const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]);
|
||||||
|
|
||||||
const handleLogin = () => {
|
useEffect(() => {
|
||||||
setIsAuthenticated(true);
|
const verifyAuth = async () => {
|
||||||
|
try {
|
||||||
|
const savedToken = localStorage.getItem('access_token');
|
||||||
|
|
||||||
|
// Если есть токен, но нет пользователя - делаем запрос к серверу
|
||||||
|
if (savedToken && !localStorage.getItem('user')) {
|
||||||
|
const authStatus = await checkAuth();
|
||||||
|
handleAuthResponse(authStatus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если есть сохраненный пользователь
|
||||||
|
const savedUser = JSON.parse(localStorage.getItem('user'));
|
||||||
|
if (savedUser && savedToken) {
|
||||||
|
// Если у сохраненного пользователя нет роли - запрашиваем свежие данные
|
||||||
|
if (!savedUser.role) {
|
||||||
|
const authStatus = await checkAuth();
|
||||||
|
handleAuthResponse(authStatus);
|
||||||
|
} else {
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
user: savedUser
|
||||||
|
});
|
||||||
|
setShowLoginModal(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стандартная проверка авторизации
|
||||||
|
const authStatus = await checkAuth();
|
||||||
|
handleAuthResponse(authStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth verification error:', error);
|
||||||
|
handleAuthFailure();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleAuthResponse = (authStatus) => {
|
||||||
|
if (authStatus.isAuthenticated && authStatus.user?.role) {
|
||||||
|
const userToSave = {
|
||||||
|
id: authStatus.user.id,
|
||||||
|
login: authStatus.user.login,
|
||||||
|
role: authStatus.user.role
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Saving user:', userToSave);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userToSave));
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
user: userToSave
|
||||||
|
});
|
||||||
|
setShowLoginModal(false);
|
||||||
|
} else {
|
||||||
|
handleAuthFailure();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleAuthFailure = () => {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
user: null
|
||||||
|
});
|
||||||
|
setShowLoginModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
verifyAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = (userData) => {
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
user: {
|
||||||
|
id: userData.id,
|
||||||
|
login: userData.login,
|
||||||
|
role: userData.role
|
||||||
|
}
|
||||||
|
});
|
||||||
setShowLoginModal(false);
|
setShowLoginModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
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({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
setShowLoginModal(true);
|
||||||
|
}
|
||||||
|
// Полноэкранный лоадер во время проверки авторизации
|
||||||
|
if (authState.isLoading) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Box sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 9999,
|
||||||
|
bgcolor: 'background.default'
|
||||||
|
}}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
Проверка авторизации...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
{!isAuthenticated && showLoginModal ? (
|
{!authState.isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
{/* Логотип */}
|
<Box sx={{
|
||||||
<Box
|
position: "fixed",
|
||||||
component="div"
|
top: 24,
|
||||||
sx={{
|
left: "50%",
|
||||||
position: "fixed",
|
transform: "translateX(-50%)",
|
||||||
top: 24,
|
zIndex: 1200,
|
||||||
left: "50%", // Сдвигаем начало логотипа в центр
|
'& svg': { width: 400, height: 'auto' }
|
||||||
transform: "translateX(-50%)", // Смещаем назад на половину ширины логотипа
|
}}>
|
||||||
zIndex: 1200,
|
|
||||||
'& svg': {
|
|
||||||
width: 400,
|
|
||||||
height: 'auto'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Logo />
|
<Logo />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<LoginModal
|
<LoginModal
|
||||||
|
open={showLoginModal}
|
||||||
onLogin={handleLogin}
|
onLogin={handleLogin}
|
||||||
onClose={() => setShowLoginModal(false)}
|
onClose={() => setShowLoginModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -52,13 +195,14 @@ function App() {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
bgcolor: "background.default",
|
bgcolor: "background.default"
|
||||||
color: "text.primary"
|
|
||||||
}}>
|
}}>
|
||||||
<Dashboard />
|
<Dashboard
|
||||||
<Box sx={{ position: "absolute", top: 10, right: 10 }}>
|
user={authState.user}
|
||||||
<Switch checked={isDarkMode} onChange={() => setIsDarkMode((prev) => !prev)} />
|
onLogout={handleLogout}
|
||||||
</Box>
|
isDarkMode={isDarkMode}
|
||||||
|
setIsDarkMode={setIsDarkMode}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer, ReferenceArea } from 'recharts';
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Нормализуем индексы к диапазону [0, 1] для родительского компонента
|
|
||||||
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 <div style={{ padding: '20px', textAlign: 'center' }}>Нет данных для отображения</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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="monotone"
|
|
||||||
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);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,494 +0,0 @@
|
||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
||||||
import { io } from 'socket.io-client';
|
|
||||||
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';
|
|
||||||
|
|
||||||
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 socketRef = 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 fetchData = useCallback(() => {
|
|
||||||
|
|
||||||
if (isSelectingRange) return;
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const start = now - selectedRange.value;
|
|
||||||
const end = now;
|
|
||||||
const step = calculateStep(start, end);
|
|
||||||
|
|
||||||
if (socketRef.current?.connected) {
|
|
||||||
socketRef.current.emit('get-metrics', {
|
|
||||||
metric: metricName,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
step,
|
|
||||||
_t: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [metricName, selectedRange.value, isSelectingRange]);
|
|
||||||
|
|
||||||
const processMetricsData = useCallback((response) => {
|
|
||||||
console.log('Processing metrics data:', response);
|
|
||||||
if (response.metric !== metricName) return;
|
|
||||||
|
|
||||||
const dataArray = Array.isArray(response.data) ? response.data : [response.data];
|
|
||||||
if (!dataArray.length) return;
|
|
||||||
|
|
||||||
setChartData(prev => {
|
|
||||||
const newData = { ...(prev || {}) };
|
|
||||||
const rangeSeconds = useCustomRange
|
|
||||||
? (endDate.getTime() - startDate.getTime()) / 1000
|
|
||||||
: selectedRange.value;
|
|
||||||
|
|
||||||
dataArray.forEach(item => {
|
|
||||||
const instance = item.instance || 'default';
|
|
||||||
if (!newData[instance]) newData[instance] = [];
|
|
||||||
|
|
||||||
// Унифицированная конвертация timestamp
|
|
||||||
let timestamp;
|
|
||||||
if (typeof item.timestamp === 'number') {
|
|
||||||
// Определяем, в секундах или миллисекундах пришел timestamp
|
|
||||||
timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000;
|
|
||||||
} else {
|
|
||||||
timestamp = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = parseFloat(item.value);
|
|
||||||
const formattedTime = formatTime(timestamp, rangeSeconds);
|
|
||||||
|
|
||||||
newData[instance].push({
|
|
||||||
time: formattedTime.display,
|
|
||||||
fullTime: formattedTime.fullDisplay,
|
|
||||||
value: value,
|
|
||||||
timestamp: timestamp
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Сортируем и ограничиваем данные
|
|
||||||
Object.keys(newData).forEach(instance => {
|
|
||||||
newData[instance] = newData[instance]
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp)
|
|
||||||
.slice(-1000);
|
|
||||||
});
|
|
||||||
return newData;
|
|
||||||
});
|
|
||||||
}, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]);
|
|
||||||
|
|
||||||
const setupWebSocket = useCallback(() => {
|
|
||||||
if (socketRef.current) {
|
|
||||||
// Если соединение уже существует, возвращаем его
|
|
||||||
if (socketRef.current.connected) return socketRef.current;
|
|
||||||
// Если соединение в процессе переподключения, тоже возвращаем
|
|
||||||
if (socketRef.current.reconnecting) return socketRef.current;
|
|
||||||
}
|
|
||||||
//VITE_BACK_WS_URL
|
|
||||||
const socket = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
|
|
||||||
transports: ['websocket'],
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionAttempts: Infinity,
|
|
||||||
reconnectionDelay: 1000,
|
|
||||||
reconnectionDelayMax: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
socketRef.current = socket;
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
setConnectionStatus('connected');
|
|
||||||
fetchData();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
|
||||||
console.log('WebSocket disconnected:', reason);
|
|
||||||
setConnectionStatus('disconnected');
|
|
||||||
if (reason === 'io server disconnect') socket.connect();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect_error', (error) => {
|
|
||||||
console.error('WebSocket connection error:', error);
|
|
||||||
setConnectionStatus('error');
|
|
||||||
setTimeout(() => socket.connect(), 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('metrics-data', (response) => {
|
|
||||||
console.log('Received raw metrics data:', response);
|
|
||||||
processMetricsData(response);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('metrics-error', (error) => {
|
|
||||||
console.error('Metrics error:', error);
|
|
||||||
setConnectionStatus('error');
|
|
||||||
});
|
|
||||||
|
|
||||||
return socket;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchCustomRangeData = useCallback(async () => {
|
|
||||||
const start = Math.floor(startDate.getTime() / 1000);
|
|
||||||
const end = Math.floor(endDate.getTime() / 1000);
|
|
||||||
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) {
|
|
||||||
// Преобразуем данные перед передачей в processMetricsData
|
|
||||||
const processedData = response.data.map(item => ({
|
|
||||||
...item,
|
|
||||||
timestamp: item.timestamp, // оставляем в секундах - processMetricsData конвертирует
|
|
||||||
value: item.value.toString()
|
|
||||||
}));
|
|
||||||
|
|
||||||
processMetricsData({
|
|
||||||
metric: metricName,
|
|
||||||
data: processedData
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при получении кастомных данных:', error);
|
|
||||||
}
|
|
||||||
}, [metricName, startDate, endDate, calculateStep, processMetricsData]);
|
|
||||||
|
|
||||||
|
|
||||||
const handleRangeChange = useCallback((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));
|
|
||||||
|
|
||||||
setSelectedRange(range);
|
|
||||||
setUseCustomRange(false);
|
|
||||||
setChartData(null);
|
|
||||||
setSelectedGraphRange(null);
|
|
||||||
setFilteredData(null);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
setEndDate(now);
|
|
||||||
setStartDate(new Date(now.getTime() - range.value * 1000));
|
|
||||||
|
|
||||||
// Переподключение сокета
|
|
||||||
if (!socketRef.current?.connected) {
|
|
||||||
socketRef.current?.connect();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCustomRangeChange = useCallback(() => {
|
|
||||||
// Отключаем WebSocket соединение
|
|
||||||
if (socketRef.current?.connected) {
|
|
||||||
socketRef.current.disconnect();
|
|
||||||
setConnectionStatus('disconnected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Очищаем интервал обновления
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUseCustomRange(true);
|
|
||||||
setChartData(null);
|
|
||||||
setSelectedGraphRange(null);
|
|
||||||
setFilteredData(null);
|
|
||||||
fetchCustomRangeData();
|
|
||||||
}, [fetchCustomRangeData]);
|
|
||||||
|
|
||||||
const handleResetZoom = useCallback(() => {
|
|
||||||
setSelectedGraphRange(null);
|
|
||||||
setFilteredData(null);
|
|
||||||
setIsSelectingRange(false);
|
|
||||||
|
|
||||||
if (useCustomRange) {
|
|
||||||
fetchCustomRangeData();
|
|
||||||
} else {
|
|
||||||
if (!socketRef.current?.connected) {
|
|
||||||
socketRef.current?.connect();
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastCustomRange) {
|
|
||||||
handleRangeSelect(lastCustomRange);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [fetchData, fetchCustomRangeData, useCustomRange]);
|
|
||||||
|
|
||||||
const interpolateData = useCallback((data, targetPointCount) => {
|
|
||||||
if (!data || data.length < 2) return data;
|
|
||||||
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 (socketRef.current?.connected) {
|
|
||||||
socketRef.current.disconnect();
|
|
||||||
}
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = setupWebSocket();
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
socket.disconnect();
|
|
||||||
};
|
|
||||||
}, [setupWebSocket]);
|
|
||||||
|
|
||||||
// Обновим useEffect для кастомного диапазона
|
|
||||||
useEffect(() => {
|
|
||||||
if (useCustomRange && !isSelectingRange) {
|
|
||||||
// Очищаем предыдущий таймер
|
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем новый таймер с задержкой 500 мс
|
|
||||||
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 <div style={{ padding: '20px', textAlign: 'center' }}>Loading data...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(chartData).length === 0) {
|
|
||||||
return <div style={{ padding: '20px', textAlign: 'center' }}>No data available</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import React from 'react';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
|
||||||
|
const DateRangeSelector = ({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange,
|
||||||
|
onEndDateChange,
|
||||||
|
onApply
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 10,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
padding: '15px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#555'
|
||||||
|
}}>
|
||||||
|
Укажите диапазон дат:
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'flex-end'
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<DatePicker
|
||||||
|
selected={startDate}
|
||||||
|
onChange={onStartDateChange}
|
||||||
|
showTimeSelect
|
||||||
|
timeFormat="HH:mm"
|
||||||
|
timeIntervals={15}
|
||||||
|
dateFormat="yyyy-MM-dd HH:mm"
|
||||||
|
placeholderText="Начальная дата"
|
||||||
|
customInput={
|
||||||
|
<input style={{
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
color: "#555",
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid #ddd'
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<DatePicker
|
||||||
|
selected={endDate}
|
||||||
|
onChange={onEndDateChange}
|
||||||
|
showTimeSelect
|
||||||
|
timeFormat="HH:mm"
|
||||||
|
timeIntervals={15}
|
||||||
|
dateFormat="yyyy-MM-dd HH:mm"
|
||||||
|
placeholderText="Конечная дата"
|
||||||
|
customInput={
|
||||||
|
<input style={{
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
color: "#555",
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid #ddd'
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onApply}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#4a6baf',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
height: '36px'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => e.target.style.backgroundColor = '#3a5a9f'}
|
||||||
|
onMouseOut={(e) => e.target.style.backgroundColor = '#4a6baf'}
|
||||||
|
>
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateRangeSelector;
|
||||||
|
|
@ -0,0 +1,425 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
ReferenceArea,
|
||||||
|
ReferenceLine
|
||||||
|
} from 'recharts';
|
||||||
|
import { Tag } from 'antd';
|
||||||
|
|
||||||
|
// Цвета для граничных значений
|
||||||
|
const rangeColors = {
|
||||||
|
1: '#4CAF50', // зеленый (норма)
|
||||||
|
2: '#FFC107', // желтый (предупреждение)
|
||||||
|
3: '#FF9800', // оранжевый (опасно)
|
||||||
|
4: '#F44336' // красный (критично)
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 0: return '#757575'; // серый (нет связи)
|
||||||
|
case 1: return rangeColors[1]; // зеленый
|
||||||
|
case 2: return rangeColors[2]; // желтый
|
||||||
|
case 3: return rangeColors[3]; // оранжевый
|
||||||
|
case 4: return rangeColors[4]; // красный
|
||||||
|
default: return '#BDBDBD'; // серый по умолчанию
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
return {
|
||||||
|
0: 'Нет соединения',
|
||||||
|
1: 'Норма',
|
||||||
|
2: 'Отклонение',
|
||||||
|
3: 'Критично',
|
||||||
|
4: 'Авария'
|
||||||
|
}[status] || 'Неизвестно';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusDescription = (status) => {
|
||||||
|
return {
|
||||||
|
0: 'Устройство не отвечает',
|
||||||
|
1: 'Параметры в норме',
|
||||||
|
2: 'Обнаружены отклонения от нормы',
|
||||||
|
3: 'Критическое состояние системы',
|
||||||
|
4: 'Авария'
|
||||||
|
}[status] || 'Статус неизвестен';
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusIndicator = ({ cx, cy, payload }) => {
|
||||||
|
const status = payload?.status ?? 0;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={6}
|
||||||
|
fill={getStatusColor(status)}
|
||||||
|
stroke="#fff"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBadge = ({ status }) => {
|
||||||
|
const statusColor = getStatusColor(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: `${statusColor}20`,
|
||||||
|
borderLeft: `4px solid ${statusColor}`,
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginTop: '4px'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
backgroundColor: statusColor,
|
||||||
|
borderRadius: '50%',
|
||||||
|
marginRight: 8
|
||||||
|
}} />
|
||||||
|
<div>
|
||||||
|
<strong>{getStatusText(status)}</strong>
|
||||||
|
<div style={{ fontSize: '0.8em', color: '#666' }}>
|
||||||
|
{getStatusDescription(status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label, multipleLines }) => {
|
||||||
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LineChartComponent = ({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
metaInfo,
|
||||||
|
dataKey = 'value',
|
||||||
|
height = 400,
|
||||||
|
ranges = [],
|
||||||
|
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;
|
||||||
|
|
||||||
|
const areas = [];
|
||||||
|
let currentStatus = data[0].status;
|
||||||
|
let start = data[0].timestamp;
|
||||||
|
|
||||||
|
for (let i = 1; i < data.length; i++) {
|
||||||
|
const current = data[i];
|
||||||
|
if (current.status !== currentStatus) {
|
||||||
|
areas.push({ status: currentStatus, start, end: current.timestamp });
|
||||||
|
currentStatus = current.status;
|
||||||
|
start = current.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
areas.push({ status: currentStatus, start, end: data[data.length - 1].timestamp });
|
||||||
|
|
||||||
|
return areas.map((area, i) => (
|
||||||
|
<ReferenceArea
|
||||||
|
key={`area-${i}`}
|
||||||
|
x1={area.start}
|
||||||
|
x2={area.end}
|
||||||
|
fill={getStatusColor(area.status)}
|
||||||
|
fillOpacity={0.12}
|
||||||
|
stroke={getStatusColor(area.status)}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRangeLines = () => {
|
||||||
|
if (!ranges || ranges.length === 0) return null;
|
||||||
|
|
||||||
|
// Собираем только уникальные граничные значения, исключая дубликаты на стыках диапазонов
|
||||||
|
const boundaryValues = [];
|
||||||
|
ranges.forEach((range, index) => {
|
||||||
|
// Для первого диапазона добавляем и min и max
|
||||||
|
if (index === 0) {
|
||||||
|
boundaryValues.push(range.min);
|
||||||
|
boundaryValues.push(range.max);
|
||||||
|
}
|
||||||
|
// Для остальных добавляем только max (min будет совпадать с max предыдущего)
|
||||||
|
else {
|
||||||
|
boundaryValues.push(range.max);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return boundaryValues.map((value, index) => {
|
||||||
|
// Находим диапазон, к которому принадлежит эта граница
|
||||||
|
const range = ranges.find(r => r.min === value || r.max === value);
|
||||||
|
const status = range ? range.status : 1;
|
||||||
|
|
||||||
|
const lineStyle = {
|
||||||
|
1: { strokeWidth: 1, strokeDasharray: "none", opacity: 0.7 },
|
||||||
|
2: { strokeWidth: 2, strokeDasharray: "none", opacity: 0.9 },
|
||||||
|
3: { strokeWidth: 2, strokeDasharray: "none", opacity: 1 },
|
||||||
|
4: { strokeWidth: 2, strokeDasharray: "none", opacity: 1 }
|
||||||
|
}[status] || { strokeWidth: 1, strokeDasharray: "3 3", opacity: 0.7 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReferenceLine
|
||||||
|
key={`line-${value}`}
|
||||||
|
y={value}
|
||||||
|
stroke={rangeColors[status] || '#888'}
|
||||||
|
strokeWidth={lineStyle.strokeWidth}
|
||||||
|
strokeDasharray={lineStyle.strokeDasharray}
|
||||||
|
strokeOpacity={lineStyle.opacity}
|
||||||
|
ifOverflow="extendDomain"
|
||||||
|
label={{
|
||||||
|
value: value.toFixed(1),
|
||||||
|
position: 'right',
|
||||||
|
fill: rangeColors[status] || '#888',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
padding: [4, 6],
|
||||||
|
borderRadius: 4,
|
||||||
|
stroke: 'none',
|
||||||
|
boxShadow: '0 0 2px rgba(0,0,0,0.1)',
|
||||||
|
textAnchor: 'start'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatusBoundaries = () => {
|
||||||
|
if (!statusBoundaries || statusBoundaries.length === 0) return null;
|
||||||
|
|
||||||
|
return statusBoundaries.map((boundary, index) => (
|
||||||
|
<ReferenceLine
|
||||||
|
key={`boundary-${index}`}
|
||||||
|
x={boundary.timestamp}
|
||||||
|
stroke={getStatusColor(boundary.status)}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 3"
|
||||||
|
label={{
|
||||||
|
value: boundary.label || `Граница ${index + 1}`,
|
||||||
|
position: 'top',
|
||||||
|
fill: getStatusColor(boundary.status),
|
||||||
|
fontSize: 12
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: `${height}px` }}>
|
||||||
|
{/* Заголовок и описание */}
|
||||||
|
{title && <h3>{title}</h3>}
|
||||||
|
{description && <p style={{ marginTop: -10, color: '#666' }}>{description}</p>}
|
||||||
|
{metaInfo && (
|
||||||
|
<div style={{ fontSize: 12, color: '#888', marginBottom: 10 }}>
|
||||||
|
{metaInfo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Легенда граничных значений */}
|
||||||
|
{ranges.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<span style={{ marginRight: 8, fontWeight: 'bold' }}>Диапазоны:</span>
|
||||||
|
{ranges
|
||||||
|
.sort((a, b) => a.min - b.min)
|
||||||
|
.map((range, index) => (
|
||||||
|
<Tag
|
||||||
|
key={`range-tag-${index}`}
|
||||||
|
color={rangeColors[range.status] || 'default'}
|
||||||
|
style={{
|
||||||
|
marginRight: 5,
|
||||||
|
marginBottom: 5,
|
||||||
|
border: `1px solid ${rangeColors[range.status]}`,
|
||||||
|
background: `${rangeColors[range.status]}20`,
|
||||||
|
color: '#000000'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{range.min.toFixed(0)}-{range.max.toFixed(0)} (Ур. {range.status})
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Легенда границ статусов */}
|
||||||
|
{statusBoundaries.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<span style={{ marginRight: 8, fontWeight: 'bold' }}>Границы статусов:</span>
|
||||||
|
{statusBoundaries.map((boundary, index) => (
|
||||||
|
<Tag
|
||||||
|
key={`boundary-tag-${index}`}
|
||||||
|
color={getStatusColor(boundary.status)}
|
||||||
|
style={{
|
||||||
|
marginRight: 5,
|
||||||
|
marginBottom: 5,
|
||||||
|
border: `1px solid ${getStatusColor(boundary.status)}`,
|
||||||
|
background: `${getStatusColor(boundary.status)}20`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{boundary.label || `Граница ${index + 1}`} ({new Date(boundary.timestamp).toLocaleString()})
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* График */}
|
||||||
|
<ResponsiveContainer width="100%" height="75%">
|
||||||
|
<LineChart
|
||||||
|
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 multipleLines={multipleLines} />} />
|
||||||
|
<Legend />
|
||||||
|
|
||||||
|
{multipleLines && groupedData ? (
|
||||||
|
Object.entries(groupedData).map(([key, group]) => (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
data={group.data}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={dataKey}
|
||||||
|
stroke={group.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={<StatusIndicator />}
|
||||||
|
activeDot={{ r: 8 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
animationDuration={300}
|
||||||
|
name={group.name}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey={dataKey}
|
||||||
|
stroke="#8884d8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={<StatusIndicator />}
|
||||||
|
activeDot={{ r: 8 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
animationDuration={300}
|
||||||
|
name={title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Легенда статусов */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 20,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 20,
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ status: 1, label: '1 - Норма' },
|
||||||
|
{ status: 2, label: '2 - Отклонение' },
|
||||||
|
{ status: 3, label: '3 - Критично' },
|
||||||
|
{ status: 4, label: '4 - Авария' },
|
||||||
|
{ status: 0, label: '0 - Нет связи' }
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.status} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
backgroundColor: getStatusColor(item.status),
|
||||||
|
marginRight: 8,
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}></div>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LineChartComponent;
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { statusConfig } from '../../Components/Layout/SettingsComponents/statusConfig';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Chip,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
const StatusLogTable = ({ logs }) => {
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper} sx={{ mt: 2, maxHeight: 400 }}>
|
||||||
|
<Table stickyHeader size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Время</TableCell>
|
||||||
|
<TableCell>Устройство</TableCell>
|
||||||
|
<TableCell>Модуль</TableCell>
|
||||||
|
<TableCell>Статус</TableCell>
|
||||||
|
<TableCell>Значение</TableCell>
|
||||||
|
<TableCell>Описание</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{logs.map((log, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{log.device}</TableCell>
|
||||||
|
<TableCell>{log.source_id?.split('$')[1]}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={statusConfig.getStatusText(log.status)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: statusConfig.getStatusColor(log.status),
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
border: 'none'
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{parseFloat(log.value).toFixed(2)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{log.description || statusConfig.getStatusDescription(log.status)}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusLogTable;
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
class MetricsService {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = '/metrics-ws';
|
||||||
|
this.socket = null;
|
||||||
|
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('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 && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Connecting WebSocket...');
|
||||||
|
this.socket = new WebSocket(this.baseUrl);
|
||||||
|
this.notifyConnectionChange(false);
|
||||||
|
|
||||||
|
this.socket.addEventListener('open', () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.notifyConnectionChange(true);
|
||||||
|
|
||||||
|
// Переподписываемся на все активные подписки
|
||||||
|
this.resubscribeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.addEventListener('close', (event) => {
|
||||||
|
console.log('WebSocket disconnected', event.code, event.reason);
|
||||||
|
this.socket = null;
|
||||||
|
this.notifyConnectionChange(false);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.addEventListener('error', (err) => {
|
||||||
|
console.error('WebSocket error:', err);
|
||||||
|
this.notifyConnectionChange(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.addEventListener('message', (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
this.handleServerMessage(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing WS message:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переподписка на все активные подписки после переподключения
|
||||||
|
resubscribeAll() {
|
||||||
|
this.subscriptions.forEach((_, metricKey) => {
|
||||||
|
const { metric, filters } = this.parseMetricKey(metricKey);
|
||||||
|
this.sendMessage('subscribe-realtime', {
|
||||||
|
metric,
|
||||||
|
filters,
|
||||||
|
interval: 10000 // Дефолтный интервал
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.warn('Max reconnect attempts reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
const delay = this.reconnectDelay * this.reconnectAttempts;
|
||||||
|
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connectWebSocket();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, []);
|
||||||
|
|
||||||
|
this.sendMessage('subscribe-realtime', {
|
||||||
|
metric,
|
||||||
|
filters,
|
||||||
|
interval
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbacks = this.subscriptions.get(metricKey);
|
||||||
|
callbacks.push(callback);
|
||||||
|
|
||||||
|
// Возвращаем функцию для отписки
|
||||||
|
return () => this.unsubscribeFromMetric(metric, filters, 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);
|
||||||
|
this.sendMessage('unsubscribe-realtime', { metric, filters });
|
||||||
|
} else {
|
||||||
|
this.subscriptions.set(metricKey, filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос исторических данных (разовый)
|
||||||
|
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)}`;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
filters[decodeURIComponent(key)] = decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metric, filters };
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupAll() {
|
||||||
|
this.unsubscribeAll();
|
||||||
|
this.disconnectWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectWebSocket() {
|
||||||
|
if (this.socket) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Экспорт для использования в модульной системе
|
||||||
|
export default metricsService;
|
||||||
|
|
||||||
|
// Глобальный экспорт для прямого использования в браузере
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.MetricsService = metricsService;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import LineChartComponent from './Components/LineChartComponent';
|
||||||
|
import DateRangeSelector from './Components/DateRangeSelector';
|
||||||
|
import metricsService from './Components/metricsService';
|
||||||
|
import { Button, Radio, message, Tag } from 'antd';
|
||||||
|
import moment from 'moment';
|
||||||
|
import StatusLogTable from './Components/StatusLogTable';
|
||||||
|
import { Box, IconButton, Tooltip as MuiTooltip } from '@mui/material';
|
||||||
|
import { ListAlt } from '@mui/icons-material';
|
||||||
|
|
||||||
|
const PrometheusChart = ({ 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 PrometheusChart;
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Box, styled } from "@mui/material";
|
import { Box, styled } from "@mui/material";
|
||||||
import SidebarMenu from "./SidebarMenu";
|
|
||||||
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
||||||
import generateTabContent from "../TreeChart/tabContent";
|
import generateTabContent from "../TreeChart/tabContent";
|
||||||
import CustomTabs from "../UI/MUItabs";
|
import CustomTabs from "../UI/MUItabs";
|
||||||
|
|
@ -8,8 +7,11 @@ import useTabs from "../hooks/useTabs";
|
||||||
import useSidebarResize from "../hooks/useSidebarResize";
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
import TabContent from "../hooks/TabContent";
|
import TabContent from "../hooks/TabContent";
|
||||||
import menuData from "../TreeChart/menuData.json";
|
import menuData from "../TreeChart/menuData.json";
|
||||||
|
import SidebarMenuWrapper from "./SidebarMenuWrapper";
|
||||||
|
import MetricTabContent from "./MetricTabContent";
|
||||||
|
import ProfileMenu from "../UI/ProfileMenu";
|
||||||
|
import AIAnalysisButton from "../UI/AIAnalysisButton";
|
||||||
|
|
||||||
// Создаем стилизованные компоненты
|
|
||||||
const DashboardContainer = styled(Box)(({ theme }) => ({
|
const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
|
|
@ -19,46 +21,25 @@ const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SidebarWrapper = styled(Box)(({ theme }) => ({
|
|
||||||
position: 'relative',
|
|
||||||
backgroundColor: theme.palette.custom.sidebar,
|
|
||||||
color: theme.palette.custom.sidebarText,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const SidebarResizer = styled(Box)(({ theme }) => ({
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: '4px',
|
|
||||||
cursor: 'col-resize',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: theme.palette.primary.main,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const MainContent = styled(Box)(({ theme }) => ({
|
const MainContent = styled(Box)(({ theme }) => ({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
padding: theme.spacing(2.5), // 20px
|
padding: theme.spacing(2.5),
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
backgroundColor: theme.palette.background.default,
|
backgroundColor: theme.palette.background.default,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const Content = styled(Box)(({ theme }) => ({
|
const Content = styled(Box)(({ theme }) => ({
|
||||||
backgroundColor: theme.palette.custom.modalBackground,
|
backgroundColor: theme.palette.custom.modalBackground,
|
||||||
padding: theme.spacing(2.5),
|
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
boxShadow: theme.shadows[2],
|
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
color: theme.palette.custom.modalText,
|
color: theme.palette.custom.modalText,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = ({ isDarkMode, setIsDarkMode, user, onLogout }) => {
|
||||||
const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная");
|
const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная");
|
||||||
const { sidebarWidth, startResizing } = useSidebarResize(250);
|
|
||||||
const [tabContent, setTabContent] = useState({});
|
const [tabContent, setTabContent] = useState({});
|
||||||
const [treeData1, setTreeData1] = useState(menuData);
|
const [treeData1, setTreeData1] = useState(menuData);
|
||||||
const [treeData2, setTreeData2] = useState(menuData);
|
const [treeData2, setTreeData2] = useState(menuData);
|
||||||
|
|
@ -100,40 +81,122 @@ const Dashboard = () => {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [treeData1, treeData2]);
|
}, [treeData1, treeData2]);
|
||||||
|
|
||||||
|
const handleMenuSelect = (item) => {
|
||||||
|
const tabId = `tab_${item.id}`;
|
||||||
|
const tabTitle = item.title || 'Новая вкладка';
|
||||||
|
|
||||||
|
const tabContent = item.metric
|
||||||
|
? <MetricTabContent
|
||||||
|
metricInfo={{
|
||||||
|
name: item.metric,
|
||||||
|
filters: item.filters,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
ranges: item.ranges,
|
||||||
|
context: {
|
||||||
|
device: item.filters?.device,
|
||||||
|
source_id: item.filters?.source_id,
|
||||||
|
parent: item
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
: <div style={{ padding: 20 }}>Контент для <strong>{item.title}</strong></div>;
|
||||||
|
|
||||||
|
const existingTab = tabs.find(tab => tab.id === tabId);
|
||||||
|
|
||||||
|
if (!existingTab) {
|
||||||
|
const newTab = {
|
||||||
|
id: tabId,
|
||||||
|
title: tabTitle,
|
||||||
|
content: tabContent,
|
||||||
|
type: item.metric ? 'metric' : 'menuItem',
|
||||||
|
metric: item.metric,
|
||||||
|
filters: item.filters,
|
||||||
|
ranges: item.ranges
|
||||||
|
};
|
||||||
|
handleOpenTab(newTab);
|
||||||
|
} else {
|
||||||
|
setActiveTab(tabId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Вспомогательная функция для получения всех дочерних элементов
|
||||||
|
const getAllChildren = (node) => {
|
||||||
|
let children = [];
|
||||||
|
if (node.items && node.items.length > 0) {
|
||||||
|
node.items.forEach((child) => {
|
||||||
|
children.push(child);
|
||||||
|
children = children.concat(getAllChildren(child));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardContainer>
|
<DashboardContainer>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 12,
|
||||||
|
right: 20,
|
||||||
|
zIndex: (theme) => theme.zIndex.tooltip + 10,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
alignItems: 'center'
|
||||||
|
}}//ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
|
||||||
|
>
|
||||||
|
<AIAnalysisButton />
|
||||||
|
<ProfileMenu user={user} onLogout={onLogout} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Сайдбар */}
|
{/* Сайдбар */}
|
||||||
<SidebarWrapper sx={{ width: sidebarWidth }}>
|
<SidebarMenuWrapper
|
||||||
<SidebarMenu
|
isDarkMode={isDarkMode}
|
||||||
data={treeData1}
|
setIsDarkMode={setIsDarkMode}
|
||||||
onOpenTab={handleOpenTab}
|
onMenuSelect={handleMenuSelect}
|
||||||
sidebarWidth={sidebarWidth}
|
user={user}
|
||||||
startResizing={startResizing}
|
/>
|
||||||
/>
|
|
||||||
<SidebarResizer onMouseDown={startResizing} />
|
|
||||||
</SidebarWrapper>
|
|
||||||
|
|
||||||
{/* Основной контент */}
|
{/* Основной контент */}
|
||||||
<MainContent>
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
{/* Вкладки */}
|
{/* Вкладки */}
|
||||||
<CustomTabs
|
<Box sx={{
|
||||||
tabs={tabs}
|
borderBottom: 1,
|
||||||
activeTab={activeTab}
|
borderColor: 'divider',
|
||||||
onTabClick={setActiveTab}
|
backgroundColor: 'background.default',
|
||||||
onCloseTab={handleCloseTab}
|
zIndex: 1,
|
||||||
/>
|
transform: 'translateY(31px)'
|
||||||
|
}}>
|
||||||
{/* Контент вкладки */}
|
<CustomTabs
|
||||||
<Content>
|
tabs={tabs}
|
||||||
<TabContent
|
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
statusHistories={statusHistories}
|
onTabClick={setActiveTab}
|
||||||
treeData1={treeData1}
|
onCloseTab={handleCloseTab}
|
||||||
tabContent={tabContent}
|
|
||||||
handleOpenTab={handleOpenTab}
|
|
||||||
/>
|
/>
|
||||||
</Content>
|
</Box>
|
||||||
</MainContent>
|
|
||||||
|
{/* Остальной контент */}
|
||||||
|
<MainContent>
|
||||||
|
{/* Контент вкладки */}
|
||||||
|
<Content>
|
||||||
|
<TabContent
|
||||||
|
activeTab={activeTab}
|
||||||
|
statusHistories={statusHistories}
|
||||||
|
treeData1={treeData1}
|
||||||
|
tabContent={tabContent}
|
||||||
|
handleOpenTab={handleOpenTab}
|
||||||
|
tabs={tabs}
|
||||||
|
/>
|
||||||
|
</Content>
|
||||||
|
</MainContent>
|
||||||
|
</Box>
|
||||||
</DashboardContainer>
|
</DashboardContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import PrometheusChart from '../../Charts2/PrometheusChart';
|
||||||
|
import metricsService from '../../Charts2/Components/metricsService';
|
||||||
|
|
||||||
|
const MetricTabContent = ({ metricInfo }) => {
|
||||||
|
// Очистка подписок при закрытии вкладки
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (metricInfo?.name) {
|
||||||
|
metricsService.unsubscribeFromMetric(metricInfo.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [metricInfo?.name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<PrometheusChart
|
||||||
|
metricInfo={metricInfo}
|
||||||
|
chartHeight={600}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetricTabContent;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
TextField, Box, Typography, IconButton, Divider,
|
||||||
|
CircularProgress, Alert, Collapse, Tooltip, Button, Select, MenuItem
|
||||||
|
} from '@mui/material';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { statusConfig } from './statusConfig';
|
||||||
|
import { VariableSizeList as List } from 'react-window';
|
||||||
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
|
|
||||||
|
const MetricItem = React.memo(({ metric, index, updateRange, addRange, deleteRange }) => {
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
mb: 3,
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: 'background.paper'
|
||||||
|
}}>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
|
{metric.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{metric.ranges.map((r, j) => (
|
||||||
|
<Box
|
||||||
|
key={j}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'flex-end', // Изменено с 'center' на 'flex-end'
|
||||||
|
mt: 1,
|
||||||
|
'& > *': { flex: 1 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Минимум"
|
||||||
|
type="number"
|
||||||
|
value={r.min}
|
||||||
|
onChange={(e) => updateRange(index, j, 'min', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Максимум"
|
||||||
|
type="number"
|
||||||
|
value={r.max}
|
||||||
|
onChange={(e) => updateRange(index, j, 'max', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Статус"
|
||||||
|
value={r.status}
|
||||||
|
onChange={(e) => updateRange(index, j, 'status', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
variant="standard"
|
||||||
|
sx={{
|
||||||
|
// Добавляем вертикальное выравнивание для label
|
||||||
|
'& .MuiInputLabel-root': {
|
||||||
|
transform: 'translate(0, -20px) scale(0.75)'
|
||||||
|
},
|
||||||
|
// Корректируем положение выбранного значения
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
paddingBottom: '8px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusConfig.getAvailableStatuses().map(({ value, text }) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{text}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Tooltip title="Удалить диапазон">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => deleteRange(index, j)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
flex: 'none',
|
||||||
|
// Корректируем положение иконки
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => addRange(index)}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
Добавить диапазон
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const MetricRangeEditor = ({ onSave }) => {
|
||||||
|
const [ranges, setRanges] = useState([]);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [newMetricName, setNewMetricName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const loadRanges = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await axios.get(`/api/ranges/list`);
|
||||||
|
setRanges(
|
||||||
|
Object.entries(res.data).map(([name, r]) => ({
|
||||||
|
name,
|
||||||
|
ranges: Array.isArray(r) ? r : []
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка при получении ranges:', err);
|
||||||
|
setError('Не удалось загрузить данные');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRanges();
|
||||||
|
}, [loadRanges]);
|
||||||
|
|
||||||
|
const updateRange = useCallback((metricIndex, rangeIndex, field, value) => {
|
||||||
|
setRanges(prev => {
|
||||||
|
const newRanges = [...prev];
|
||||||
|
newRanges[metricIndex] = {
|
||||||
|
...newRanges[metricIndex],
|
||||||
|
ranges: [...newRanges[metricIndex].ranges]
|
||||||
|
};
|
||||||
|
newRanges[metricIndex].ranges[rangeIndex] = {
|
||||||
|
...newRanges[metricIndex].ranges[rangeIndex],
|
||||||
|
[field]: Number(value)
|
||||||
|
};
|
||||||
|
return newRanges;
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getItemSize = (index) => {
|
||||||
|
const baseHeight = 80;
|
||||||
|
const rangeCount = filtered[index].ranges.length;
|
||||||
|
return baseHeight + rangeCount * 56 + 40;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRange = useCallback((metricIndex) => {
|
||||||
|
setRanges(prev => {
|
||||||
|
const newRanges = [...prev];
|
||||||
|
newRanges[metricIndex] = {
|
||||||
|
...newRanges[metricIndex],
|
||||||
|
ranges: [...newRanges[metricIndex].ranges, { min: 0, max: 100, status: 1 }]
|
||||||
|
};
|
||||||
|
return newRanges;
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteRange = useCallback((metricIndex, rangeIndex) => {
|
||||||
|
setRanges(prev => {
|
||||||
|
const newRanges = [...prev];
|
||||||
|
newRanges[metricIndex] = {
|
||||||
|
...newRanges[metricIndex],
|
||||||
|
ranges: newRanges[metricIndex].ranges.filter((_, i) => i !== rangeIndex)
|
||||||
|
};
|
||||||
|
return newRanges;
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveChanges = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await axios.post(`/api/ranges/update`, ranges);
|
||||||
|
setHasChanges(false);
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
|
|
||||||
|
if (onSave) {
|
||||||
|
onSave({
|
||||||
|
hasChanges: false,
|
||||||
|
saveChanges: saveChanges
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка при сохранении:', err);
|
||||||
|
setError('Ошибка при сохранении');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [ranges, onSave]);
|
||||||
|
|
||||||
|
const addNewMetric = useCallback(() => {
|
||||||
|
if (!newMetricName.trim()) {
|
||||||
|
setError('Введите название метрики');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ranges.some(r => r.name === newMetricName)) {
|
||||||
|
setError('Метрика с таким именем уже существует');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRanges(prev => [...prev, {
|
||||||
|
name: newMetricName,
|
||||||
|
ranges: [{ min: 0, max: 100, status: 1 }]
|
||||||
|
}]);
|
||||||
|
setNewMetricName('');
|
||||||
|
setHasChanges(true);
|
||||||
|
setError(null);
|
||||||
|
}, [newMetricName, ranges]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return filter
|
||||||
|
? ranges.filter(r => r.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
: ranges;
|
||||||
|
}, [filter, ranges]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onSave) {
|
||||||
|
onSave({ hasChanges, saveChanges });
|
||||||
|
}
|
||||||
|
}, [hasChanges, onSave, saveChanges]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Collapse in={!!error}>
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse in={success}>
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(false)}>
|
||||||
|
Изменения успешно сохранены!
|
||||||
|
</Alert>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1, mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Поиск по метрике"
|
||||||
|
fullWidth
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<SearchIcon sx={{ color: 'action.active', mr: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'flex-end', // меняем с 'center' на 'flex-end'
|
||||||
|
mb: 3
|
||||||
|
}}>
|
||||||
|
<TextField
|
||||||
|
label="Новая метрика"
|
||||||
|
value={newMetricName}
|
||||||
|
onChange={(e) => setNewMetricName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<Tooltip title="Добавить метрику">
|
||||||
|
<IconButton
|
||||||
|
onClick={addNewMetric}
|
||||||
|
color="primary"
|
||||||
|
disabled={!newMetricName.trim()}
|
||||||
|
>
|
||||||
|
<AddIcon sx={{ color: 'action.active' }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
<Box sx={{ maxHeight: '60vh', overflowY: 'auto', pr: 1 }}>
|
||||||
|
{filtered.map((metric, index) => (
|
||||||
|
<MetricItem
|
||||||
|
key={metric.name}
|
||||||
|
metric={metric}
|
||||||
|
index={index}
|
||||||
|
updateRange={updateRange}
|
||||||
|
addRange={addRange}
|
||||||
|
deleteRange={deleteRange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<Typography color="text.secondary" textAlign="center" py={3}>
|
||||||
|
{filter ? 'Ничего не найдено' : 'Нет метрик для отображения'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(MetricRangeEditor);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
export const statusConfig = {
|
||||||
|
statusMap: {
|
||||||
|
'0': { text: 'Нет соединения', color: '#757575', description: 'Устройство не отвечает' },
|
||||||
|
'1': { text: 'Норма', color: '#4CAF50', description: 'Параметры в норме' },
|
||||||
|
'2': { text: 'Отклонение', color: '#FFC107', description: 'Обнаружены отклонения от нормы' },
|
||||||
|
'3': { text: 'Критично', color: '#FF9800', description: 'Критическое состояние системы' },
|
||||||
|
'4': { text: 'Авария', color: '#F44336', description: 'Аварийное состояние системы' }
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatusText(status) {
|
||||||
|
return this.statusMap[status]?.text || 'Неизвестно';
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatusColor(status) {
|
||||||
|
return this.statusMap[status]?.color || '#757575';
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatusDescription(status) {
|
||||||
|
return this.statusMap[status]?.description || 'Статус неизвестен';
|
||||||
|
},
|
||||||
|
|
||||||
|
getAvailableStatuses() {
|
||||||
|
return Object.entries(this.statusMap)
|
||||||
|
.filter(([key]) => key !== '0') // исключаем статус "Нет соединения"
|
||||||
|
.map(([value, config]) => ({ value, text: config.text }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
// components/SettingsModal.jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
styled,
|
||||||
|
CircularProgress,
|
||||||
|
Slide,
|
||||||
|
Snackbar,
|
||||||
|
Alert
|
||||||
|
} from '@mui/material';
|
||||||
|
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} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
const StyledDialog = styled(Dialog)(({ theme }) => ({
|
||||||
|
'& .MuiDialog-paper': {
|
||||||
|
minWidth: 600,
|
||||||
|
maxHeight: '80vh',
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TabPanel = (props) => {
|
||||||
|
const { children, value, index, ...other } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`settings-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`settings-tab-${index}`}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{value === index && (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography component="div">{children}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
|
const [tabValue, setTabValue] = useState(0);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [showConfirmClose, setShowConfirmClose] = useState(false);
|
||||||
|
const [metricEditorState, setMetricEditorState] = useState({
|
||||||
|
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) {
|
||||||
|
setShowConfirmClose(true);
|
||||||
|
} else {
|
||||||
|
setTabValue(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = 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) {
|
||||||
|
setShowSuccess(true);
|
||||||
|
setHasChanges(false);
|
||||||
|
if (onMenuUpdate) {
|
||||||
|
onMenuUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMetricEditorChange = ({ hasChanges, saveChanges }) => {
|
||||||
|
setMetricEditorState({ hasChanges, save: saveChanges });
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmClose = (shouldClose) => {
|
||||||
|
setShowConfirmClose(false);
|
||||||
|
if (shouldClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingChange = () => {
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
aria-labelledby="settings-dialog-title"
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
TransitionComponent={Transition}
|
||||||
|
>
|
||||||
|
<DialogTitle id="settings-dialog-title">
|
||||||
|
Настройки
|
||||||
|
<IconButton
|
||||||
|
aria-label="close"
|
||||||
|
onClick={handleClose}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
color: (theme) => theme.palette.grey[500],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<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}>
|
||||||
|
<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
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
disabled={isSaving || !hasChanges}
|
||||||
|
startIcon={isSaving ? <CircularProgress size={20} /> : <SaveIcon />}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</StyledDialog>
|
||||||
|
|
||||||
|
{/* Уведомление об успешном сохранении */}
|
||||||
|
<Snackbar
|
||||||
|
open={showSuccess}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
onClose={() => setShowSuccess(false)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => setShowSuccess(false)} severity="success" sx={{ width: '100%' }}>
|
||||||
|
Настройки успешно сохранены!
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
{/* Диалог подтверждения закрытия */}
|
||||||
|
<Dialog
|
||||||
|
open={showConfirmClose}
|
||||||
|
onClose={() => handleConfirmClose(false)}
|
||||||
|
aria-labelledby="alert-dialog-title"
|
||||||
|
aria-describedby="alert-dialog-description"
|
||||||
|
>
|
||||||
|
<DialogTitle id="alert-dialog-title">Есть несохраненные изменения</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>Вы уверены, что хотите закрыть без сохранения изменений?</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => handleConfirmClose(false)}>Отмена</Button>
|
||||||
|
<Button onClick={() => handleConfirmClose(true)} autoFocus color="error">
|
||||||
|
Закрыть без сохранения
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsModal;
|
||||||
|
|
@ -1,147 +1,534 @@
|
||||||
import React, { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
Typography,
|
|
||||||
styled,
|
styled,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Box
|
Box,
|
||||||
|
alpha
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Menu as MenuIcon
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
|
||||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
||||||
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
|
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||||
|
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||||
|
import LogoFull from "../../assets/images/logo.svg?react";
|
||||||
|
import LogoSmall from "../../assets/images/system_monitor_icon.svg?react";
|
||||||
|
|
||||||
const SidebarResizer = styled('div')(({ theme }) => ({
|
import {
|
||||||
width: "5px",
|
DndContext,
|
||||||
cursor: "ew-resize",
|
closestCenter,
|
||||||
backgroundColor: 'transparent',
|
PointerSensor,
|
||||||
height: "100%",
|
useSensor,
|
||||||
position: "absolute",
|
useSensors,
|
||||||
right: 0,
|
DragOverlay,
|
||||||
top: 0,
|
MeasuringStrategy
|
||||||
transition: 'background-color 0.2s',
|
} from "@dnd-kit/core";
|
||||||
'&:hover': {
|
import {
|
||||||
backgroundColor: theme.palette.primary.main,
|
SortableContext,
|
||||||
},
|
verticalListSortingStrategy,
|
||||||
zIndex: 2
|
} from "@dnd-kit/sortable";
|
||||||
}));
|
|
||||||
|
|
||||||
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
|
import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem";
|
||||||
|
|
||||||
|
const SidebarMenu = ({
|
||||||
|
data,
|
||||||
|
isDarkMode,
|
||||||
|
setIsDarkMode,
|
||||||
|
onSelectItem,
|
||||||
|
forceRefreshMenu,
|
||||||
|
user,
|
||||||
|
}) => {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
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 = () => {
|
const handleToggleCollapse = () => {
|
||||||
setCollapsed(!collapsed);
|
setCollapsed(!collapsed);
|
||||||
|
setHoveredItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectItem = (id, title, children) => {
|
// Функции для работы с деревом (остаются без изменений)
|
||||||
onOpenTab(id, title, children);
|
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",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DropIndicator = ({ position, targetId }) => {
|
||||||
|
if (!targetId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '2px',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
zIndex: 1001,
|
||||||
|
...(position === 'above' && { top: 0 }),
|
||||||
|
...(position === 'below' && { bottom: 0 }),
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-3px',
|
||||||
|
left: '10%',
|
||||||
|
width: '80%',
|
||||||
|
height: '8px',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawerWidth = collapsed ? 64 : sidebarWidth;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
width: drawerWidth,
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
transition: 'width 0.3s ease',
|
transition: "width 0.2s ease",
|
||||||
|
height: "100vh",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
sx={{
|
sx={{
|
||||||
width: drawerWidth,
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
'& .MuiDrawer-paper': {
|
"& .MuiDrawer-paper": {
|
||||||
width: drawerWidth,
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: 'custom.sidebar',
|
backgroundColor: "background.paper",
|
||||||
color: 'custom.sidebarText',
|
color: "text.primary",
|
||||||
transition: 'width 0.3s ease',
|
transition: "width 0.2s ease, background-color 0.2s ease",
|
||||||
overflowX: 'hidden',
|
overflowX: "hidden",
|
||||||
borderRight: 'none'
|
borderRight: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.08)",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Кнопка сворачивания/разворачивания */}
|
{/* Заголовок с логотипом */}
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
justifyContent: 'flex-end',
|
display: "flex",
|
||||||
p: 1,
|
alignItems: "center",
|
||||||
borderBottom: '1px solid',
|
justifyContent: "center",
|
||||||
borderColor: 'divider',
|
p: 2,
|
||||||
backgroundColor: 'custom.sidebar'
|
borderBottom: "1px solid",
|
||||||
}}>
|
borderColor: "divider",
|
||||||
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
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",
|
||||||
|
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px"
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<LogoFull style={{
|
||||||
|
color: "inherit",
|
||||||
|
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||||||
|
maxWidth: "180px",
|
||||||
|
height: "40px"
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleToggleCollapse}
|
onClick={handleToggleCollapse}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
color: 'custom.sidebarText',
|
color: "text.secondary",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: 'custom.sidebarHover',
|
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 ? (
|
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||||
hovered ? <ChevronRight /> : <MenuIcon />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Содержимое меню */}
|
{/* Основное содержимое меню */}
|
||||||
<Box sx={{
|
<Box
|
||||||
flexGrow: 1,
|
sx={{
|
||||||
display: 'flex',
|
flexGrow: 1,
|
||||||
flexDirection: 'column',
|
display: "flex",
|
||||||
overflow: 'hidden'
|
flexDirection: "column",
|
||||||
}}>
|
overflow: "hidden",
|
||||||
<List sx={{
|
position: "relative",
|
||||||
overflowY: 'auto',
|
}}
|
||||||
overflowX: 'hidden',
|
>
|
||||||
flex: '1 1 auto'
|
<DndContext
|
||||||
}}>
|
sensors={sensors}
|
||||||
{!collapsed && (
|
collisionDetection={closestCenter}
|
||||||
<Typography
|
onDragStart={handleDragStart}
|
||||||
variant="h6"
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
measuring={{
|
||||||
|
droppable: {
|
||||||
|
strategy: MeasuringStrategy.Always
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext items={menuItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<List
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
overflowY: "auto",
|
||||||
fontWeight: 'bold',
|
flex: "1 1 auto",
|
||||||
textAlign: 'center'
|
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) => (
|
||||||
</Typography>
|
<Box key={item.id} position="relative">
|
||||||
)}
|
{dropIndicator.show && dropIndicator.targetId === item.id &&
|
||||||
<MenuItem
|
dropIndicator.position !== 'inside' && (
|
||||||
item={data}
|
<DropIndicator
|
||||||
onSelectItem={handleSelectItem}
|
position={dropIndicator.position}
|
||||||
collapsed={collapsed}
|
targetId={dropIndicator.targetId}
|
||||||
/>
|
/>
|
||||||
</List>
|
)}
|
||||||
|
<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>
|
||||||
{!collapsed && (
|
{activeItem ? (
|
||||||
<SidebarFooter />
|
<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}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
setIsDarkMode={setIsDarkMode}
|
||||||
|
forceRefreshMenu={forceRefreshMenu}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Ресайзер */}
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<SidebarResizer onMouseDown={startResizing} />
|
<Tooltip title="Изменить ширину" placement="top">
|
||||||
|
<SidebarResizer onMouseDown={startResizing} />
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,121 @@
|
||||||
import React from "react";
|
// // MenuItem.jsx
|
||||||
import {
|
// import React, { useState } from "react";
|
||||||
ListItem,
|
// import {
|
||||||
ListItemIcon,
|
// ListItem,
|
||||||
ListItemText,
|
// ListItemIcon,
|
||||||
Collapse,
|
// ListItemText,
|
||||||
List,
|
// Collapse,
|
||||||
styled
|
// List,
|
||||||
} from "@mui/material";
|
// styled,
|
||||||
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
// 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 }) => ({
|
// const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||||
cursor: "pointer",
|
// cursor: "pointer",
|
||||||
paddingLeft: theme.spacing(2 + level * 2),
|
// paddingLeft: theme.spacing(2 + level * 2),
|
||||||
'&:hover': {
|
// position: 'relative',
|
||||||
backgroundColor: theme.palette.action.hover,
|
// '&:hover': {
|
||||||
},
|
// backgroundColor: theme.palette.action.hover,
|
||||||
'&.Mui-selected': {
|
// },
|
||||||
backgroundColor: theme.palette.custom.sidebarHover,
|
// '&.Mui-selected': {
|
||||||
},
|
// backgroundColor: theme.palette.custom.sidebarHover,
|
||||||
}));
|
// },
|
||||||
|
// }));
|
||||||
|
|
||||||
const IconWrapper = styled('div')(({ theme }) => ({
|
// const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
cursor: "pointer",
|
// const [isOpen, setIsOpen] = useState(false);
|
||||||
borderRadius: theme.shape.borderRadius,
|
// const [contextMenu, setContextMenu] = useState(null);
|
||||||
padding: theme.spacing(0.5),
|
// const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: theme.palette.action.selected,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
// const handleContextMenu = (e) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
// e.preventDefault();
|
||||||
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
// setContextMenu(
|
||||||
|
// contextMenu === null
|
||||||
|
// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
||||||
|
// : null
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleCloseContextMenu = () => {
|
||||||
|
// setContextMenu(null);
|
||||||
|
// };
|
||||||
|
|
||||||
const handleToggle = (e) => {
|
// const handleToggle = (e) => {
|
||||||
e.stopPropagation();
|
// e.stopPropagation();
|
||||||
setIsOpen(!isOpen);
|
// setIsOpen(!isOpen);
|
||||||
};
|
// };
|
||||||
|
|
||||||
const handleOpenTab = (e) => {
|
// const handleClick = () => {
|
||||||
e.stopPropagation();
|
// if (onSelectItem) {
|
||||||
const allChildren = getAllChildren(item);
|
// onSelectItem(item);
|
||||||
onSelectItem(item.id, item.title, allChildren);
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<>
|
// <>
|
||||||
<StyledListItem
|
// <StyledListItem
|
||||||
component="div"
|
// component="div"
|
||||||
onClick={hasChildren ? handleToggle : handleOpenTab}
|
// onClick={hasChildren ? handleToggle : handleClick}
|
||||||
level={level}
|
// onContextMenu={handleContextMenu}
|
||||||
sx={{
|
// level={level}
|
||||||
pl: collapsed ? 2 : 2 + level * 2, // Адаптируем отступы
|
// sx={{
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
// pl: collapsed ? 2 : 2 + level * 2,
|
||||||
}}
|
// justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
>
|
// }}
|
||||||
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
|
// >
|
||||||
<IconWrapper onClick={handleOpenTab}>
|
// {!collapsed && <StatusIndicator status={item.status} />}
|
||||||
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
|
||||||
</IconWrapper>
|
|
||||||
</ListItemIcon>
|
|
||||||
|
|
||||||
{!collapsed && ( // Показываем текст только в развернутом состоянии
|
// <ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
|
||||||
<>
|
// {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
||||||
<ListItemText
|
// </ListItemIcon>
|
||||||
primary={item.title}
|
|
||||||
primaryTypographyProps={{
|
|
||||||
color: 'custom.sidebarText'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</StyledListItem>
|
|
||||||
|
|
||||||
{hasChildren && !collapsed && ( // Показываем детей только в развернутом состоянии
|
// {!collapsed && (
|
||||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
// <>
|
||||||
<List component="div" disablePadding>
|
// <ListItemText
|
||||||
{item.items.map((child, index) => (
|
// primary={item.title}
|
||||||
<MenuItem
|
// primaryTypographyProps={{
|
||||||
key={index}
|
// color: 'custom.sidebarText'
|
||||||
item={child}
|
// }}
|
||||||
onSelectItem={onSelectItem}
|
// />
|
||||||
level={level + 1}
|
// {hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
||||||
collapsed={collapsed}
|
// </>
|
||||||
/>
|
// )}
|
||||||
))}
|
// </StyledListItem>
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Вспомогательная функция (остается без изменений)
|
// <Menu
|
||||||
const getAllChildren = (node) => {
|
// open={contextMenu !== null}
|
||||||
let children = [];
|
// onClose={handleCloseContextMenu}
|
||||||
if (node.items && node.items.length > 0) {
|
// anchorReference="anchorPosition"
|
||||||
node.items.forEach((child) => {
|
// anchorPosition={
|
||||||
children.push(child);
|
// contextMenu !== null
|
||||||
children = children.concat(getAllChildren(child));
|
// ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||||
});
|
// : undefined
|
||||||
}
|
// }
|
||||||
return children;
|
// >
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuItem;
|
// </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>
|
||||||
|
// )}
|
||||||
|
// </>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export default MenuItem;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { Brightness4, Brightness7, Settings, Help } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
alpha
|
||||||
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
styled
|
styled,
|
||||||
|
Switch,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import SettingsModal from "../SettingsModal";
|
||||||
|
import { RoleBasedRender } from "../../UI/RoleBasedRender";
|
||||||
|
|
||||||
const FooterList = styled(List)(({ theme }) => ({
|
const FooterList = styled(List)(({ theme }) => ({
|
||||||
backgroundColor: theme.palette.custom.sidebar,
|
backgroundColor: 'background.paper',
|
||||||
padding: theme.spacing(1, 0),
|
padding: theme.spacing(1, 0),
|
||||||
borderTop: `1px solid ${theme.palette.divider}`,
|
borderTop: `1px solid ${theme.palette.divider}`,
|
||||||
marginTop: 'auto'
|
marginTop: 'auto'
|
||||||
|
|
@ -15,33 +26,128 @@ const FooterList = styled(List)(({ theme }) => ({
|
||||||
|
|
||||||
const FooterListItem = styled(ListItem)(({ theme }) => ({
|
const FooterListItem = styled(ListItem)(({ theme }) => ({
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: theme.palette.custom.sidebarHover,
|
backgroundColor: alpha(theme.palette.action.hover, 0.4),
|
||||||
},
|
},
|
||||||
padding: theme.spacing(1, 2),
|
padding: theme.spacing(1, 2),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '0 8px 4px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SidebarFooter = () => {
|
const SidebarFooter = ({
|
||||||
|
collapsed,
|
||||||
|
isDarkMode,
|
||||||
|
setIsDarkMode,
|
||||||
|
forceRefreshMenu,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSettingsOpen = () => {
|
||||||
|
setSettingsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingsClose = () => {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FooterList>
|
<>
|
||||||
<FooterListItem button>
|
<FooterList>
|
||||||
<ListItemText
|
{!collapsed ? (
|
||||||
primary="Помощь"
|
<>
|
||||||
primaryTypographyProps={{
|
<FooterListItem>
|
||||||
color: 'custom.sidebarText',
|
<Button
|
||||||
variant: 'body2'
|
onClick={handleSettingsOpen}
|
||||||
}}
|
startIcon={<Settings />}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Настройки
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Tooltip title="Переключить тему">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: alpha('#000000', 0.1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Switch
|
||||||
|
checked={isDarkMode}
|
||||||
|
onChange={() => setIsDarkMode(!isDarkMode)}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</FooterListItem>
|
||||||
|
|
||||||
|
<FooterListItem button>
|
||||||
|
<Button
|
||||||
|
startIcon={<Help />}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Помощь
|
||||||
|
</Button>
|
||||||
|
</FooterListItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<FooterListItem sx={{ justifyContent: 'center' }}>
|
||||||
|
<Tooltip title="Настройки" placement="right">
|
||||||
|
<IconButton
|
||||||
|
onClick={handleSettingsOpen}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: alpha('#000000', 0.1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</FooterListItem>
|
||||||
|
)}
|
||||||
|
</FooterList>
|
||||||
|
|
||||||
|
<RoleBasedRender user={user} allowedRoles={['admin']}>
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={handleSettingsClose}
|
||||||
|
onMenuUpdate={forceRefreshMenu}
|
||||||
/>
|
/>
|
||||||
</FooterListItem>
|
</RoleBasedRender>
|
||||||
<FooterListItem button>
|
</>
|
||||||
<ListItemText
|
|
||||||
primary="Настройка"
|
|
||||||
primaryTypographyProps={{
|
|
||||||
color: 'custom.sidebarText',
|
|
||||||
variant: 'body2'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FooterListItem>
|
|
||||||
</FooterList>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from "react";
|
||||||
|
import { styled } from "@mui/material";
|
||||||
|
|
||||||
|
const StatusIndicator = styled('div')(({ theme, status }) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '4px',
|
||||||
|
backgroundColor: getStatusColor(status),
|
||||||
|
borderRadius: '0 2px 2px 0',
|
||||||
|
transition: 'background-color 0.3s ease'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'red': return '#F44336';
|
||||||
|
case 'orange': return '#FF9800';
|
||||||
|
case 'yellow': return '#cebd21';
|
||||||
|
case 'green': return '#4CAF50';
|
||||||
|
default: return 'transparent';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusIndicator;
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import SidebarMenu from './SidebarMenu';
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect, user }) => {
|
||||||
|
const [menuData, setMenuData] = useState(null);
|
||||||
|
const [lastModified, setLastModified] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [backgroundLoading, setBackgroundLoading] = useState(false);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
|
const forceRefreshMenu = () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
localStorage.removeItem('menuCache'); // Очищаем кэш
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загружаем меню из localStorage при инициализации
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCachedMenu = () => {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem('menuCache');
|
||||||
|
if (cached) {
|
||||||
|
const { data, timestamp } = JSON.parse(cached);
|
||||||
|
setMenuData(data);
|
||||||
|
setLastModified(timestamp);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load menu from cache', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCachedMenu();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Основная загрузка меню
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMenuData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const headers = lastModified ? { 'If-Modified-Since': lastModified } : {};
|
||||||
|
|
||||||
|
const response = await axios.get(`/api/menu/full`, {
|
||||||
|
headers,
|
||||||
|
validateStatus: status => status === 200 || status === 304
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const newLastModified = response.headers['last-modified'];
|
||||||
|
setMenuData(response.data);
|
||||||
|
setLastModified(newLastModified);
|
||||||
|
|
||||||
|
// Сохраняем в кэш
|
||||||
|
localStorage.setItem('menuCache', JSON.stringify({
|
||||||
|
data: response.data,
|
||||||
|
timestamp: newLastModified
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching menu data:', err);
|
||||||
|
setError(err.message || 'Failed to fetch menu data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMenuData();
|
||||||
|
}, [refreshTrigger]);
|
||||||
|
|
||||||
|
// Фоновая проверка обновлений
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastModified) return;
|
||||||
|
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
try {
|
||||||
|
setBackgroundLoading(true);
|
||||||
|
const response = await axios.get(`/api/menu/check-updates`, {
|
||||||
|
headers: { 'If-Modified-Since': lastModified }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.hasUpdates) {
|
||||||
|
// Если есть обновления, загружаем их в фоне
|
||||||
|
const updateResponse = await axios.get(`/api/menu/full`);
|
||||||
|
setMenuData(updateResponse.data);
|
||||||
|
setLastModified(updateResponse.headers['last-modified']);
|
||||||
|
|
||||||
|
localStorage.setItem('menuCache', JSON.stringify({
|
||||||
|
data: updateResponse.data,
|
||||||
|
timestamp: updateResponse.headers['last-modified']
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Background update check failed', err);
|
||||||
|
} finally {
|
||||||
|
setBackgroundLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Проверяем обновления каждые 5 минут
|
||||||
|
const interval = setInterval(checkForUpdates, 5 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [lastModified]);
|
||||||
|
|
||||||
|
const handleSaveChanges = async (updatedItem) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(
|
||||||
|
`/api/menu/${updatedItem.id}`,
|
||||||
|
updatedItem,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обновляем локальное состояние
|
||||||
|
const updateItemInTree = (items) => {
|
||||||
|
return items.map(item => {
|
||||||
|
if (item.id === updatedItem.id) {
|
||||||
|
return { ...item, ...updatedItem };
|
||||||
|
}
|
||||||
|
if (item.items) {
|
||||||
|
return { ...item, items: updateItemInTree(item.items) };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setMenuData(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: updateItemInTree(prev.items),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setEditModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating menu item:', err);
|
||||||
|
setError(err.response?.data?.message || err.message || 'Failed to update menu item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box p={2}>
|
||||||
|
<Typography color="error">Error loading menu: {error}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!menuData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu
|
||||||
|
data={menuData}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
setIsDarkMode={setIsDarkMode}
|
||||||
|
onEditItem={(item) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setEditModalOpen(true);
|
||||||
|
}}
|
||||||
|
onSelectItem={onMenuSelect}
|
||||||
|
editModalOpen={editModalOpen}
|
||||||
|
editingItem={editingItem}
|
||||||
|
onCloseEditModal={() => setEditModalOpen(false)}
|
||||||
|
onSaveChanges={handleSaveChanges}
|
||||||
|
forceRefreshMenu={forceRefreshMenu}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarMenuWrapper;
|
||||||
|
|
@ -45,12 +45,10 @@ const FlowChart = ({ data }) => {
|
||||||
const findAndCollapseLastLevelParents = (items) => {
|
const findAndCollapseLastLevelParents = (items) => {
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
if (item.items && item.items.length > 0) {
|
if (item.items && item.items.length > 0) {
|
||||||
// Проверяем, есть ли у детей свои дети
|
|
||||||
const hasGrandchildren = item.items.some(child =>
|
const hasGrandchildren = item.items.some(child =>
|
||||||
child.items && child.items.length > 0
|
child.items && child.items.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Если у детей нет своих детей - это родители последнего уровня
|
|
||||||
if (!hasGrandchildren) {
|
if (!hasGrandchildren) {
|
||||||
toggleNodeCollapse(item.id);
|
toggleNodeCollapse(item.id);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
|
||||||
const baseLevelRadius = 150;
|
const baseLevelRadius = 150;
|
||||||
|
|
||||||
const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI, parentRadius = 0) => {
|
const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI, parentRadius = 0) => {
|
||||||
if (!item || collapsedNodes[parentId]) return; // Пропускаем свёрнутые узлы
|
if (!item || collapsedNodes[parentId]) return;
|
||||||
|
|
||||||
const nodeId = item.id;
|
const nodeId = item.id;
|
||||||
const items = item.items || [];
|
const items = item.items || [];
|
||||||
|
|
@ -58,7 +58,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
|
||||||
data: {
|
data: {
|
||||||
...item,
|
...item,
|
||||||
label: item.title,
|
label: item.title,
|
||||||
style: getNodeStyle(item, isLeaf), // Переносим стили в data
|
style: getNodeStyle(item, isLeaf),
|
||||||
hasChildren: items.length > 0,
|
hasChildren: items.length > 0,
|
||||||
collapsed: collapsedNodes[nodeId]
|
collapsed: collapsedNodes[nodeId]
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
|
||||||
|
|
||||||
const centerNode = {
|
const centerNode = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
type: 'customNode', // Добавляем тип узла
|
type: 'customNode',
|
||||||
position: nodePositions[data.id] || { x: centerX, y: centerY },
|
position: nodePositions[data.id] || { x: centerX, y: centerY },
|
||||||
style: getCenterNodeStyle(data),
|
style: getCenterNodeStyle(data),
|
||||||
data: { label: data.title, hasChildren: data.items.length > 0, collapsed: collapsedNodes[data.id] }
|
data: { label: data.title, hasChildren: data.items.length > 0, collapsed: collapsedNodes[data.id] }
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ const NodeWrapper = memo(({ id, data, selected }) => {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden', // Чтобы текст не выходил за границы
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis', // Добавляем многоточие если текст не помещается
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap', // Запрещаем перенос строк
|
whiteSpace: 'nowrap',
|
||||||
padding: '0 8px', // Горизонтальный padding для текста
|
padding: '0 8px',
|
||||||
boxSizing: 'border-box' // Учитываем padding в общей ширине
|
boxSizing: 'border-box'
|
||||||
}}
|
}}
|
||||||
title={data.label} // Простой tooltip при наведении
|
title={data.label}
|
||||||
>
|
>
|
||||||
{/* Хендл для входящих соединений */}
|
{/* Хендл для входящих соединений */}
|
||||||
<Handle
|
<Handle
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export const useFlowChart = (initialData) => {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
const [nodePositions, setNodePositions] = useState({});
|
const [nodePositions, setNodePositions] = useState({});
|
||||||
const [collapsedNodes, setCollapsedNodes] = useState({}); // Добавили
|
const [collapsedNodes, setCollapsedNodes] = useState({});
|
||||||
|
|
||||||
const toggleNodeCollapse = useCallback((nodeId) => {
|
const toggleNodeCollapse = useCallback((nodeId) => {
|
||||||
setCollapsedNodes((prev) => ({
|
setCollapsedNodes((prev) => ({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
export const useNodeHandlers = (debouncedSetNodePositions) => {
|
export const useNodeHandlers = (debouncedSetNodePositions) => {
|
||||||
const onNodeDrag = useCallback((event, node) => {
|
const onNodeDrag = useCallback((event, node) => {
|
||||||
// Фиксируем позицию сразу при перемещении
|
|
||||||
node.position = {
|
node.position = {
|
||||||
x: Math.round(node.position.x),
|
x: Math.round(node.position.x),
|
||||||
y: Math.round(node.position.y)
|
y: Math.round(node.position.y)
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,43 @@
|
||||||
const StatusManager = () => {
|
const StatusManager = () => {
|
||||||
const getRandomStatus = () => {
|
const getRandomStatus = () => {
|
||||||
const statuses = [
|
const statuses = [
|
||||||
...Array(90).fill("green"), // 90% шанс
|
...Array(90).fill("green"),
|
||||||
...Array(6).fill("yellow"), // 6% шанс
|
...Array(6).fill("yellow"),
|
||||||
...Array(3).fill("orange"), // 3% шанс
|
...Array(3).fill("orange"),
|
||||||
...Array(1).fill("red"), // 1% шанс
|
...Array(1).fill("red"),
|
||||||
];
|
];
|
||||||
return statuses[Math.floor(Math.random() * statuses.length)];
|
return statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusWeight = (status) => {
|
const getStatusWeight = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "green": return 1; // 100% здоровья
|
case "green": return 1;
|
||||||
case "yellow": return 0.75;
|
case "yellow": return 0.75;
|
||||||
case "orange": return 0.5;
|
case "orange": return 0.5;
|
||||||
case "red": return 0.25; // 25% здоровья
|
case "red": return 0.25;
|
||||||
default: return 1; // По умолчанию "green"
|
default: return 1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStatuses = (data) => {
|
const updateStatuses = (data) => {
|
||||||
if (!data.items || data.items.length === 0) {
|
if (!data.items || data.items.length === 0) {
|
||||||
// Если это элемент нижнего уровня, генерируем случайный статус
|
|
||||||
data.status = getRandomStatus();
|
data.status = getRandomStatus();
|
||||||
return getStatusWeight(data.status);
|
return getStatusWeight(data.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рекурсивно обновляем статусы для всех дочерних элементов
|
|
||||||
let childStatusWeights = data.items.map((child) => updateStatuses(child));
|
let childStatusWeights = data.items.map((child) => updateStatuses(child));
|
||||||
|
|
||||||
// Проверяем, есть ли дочерние элементы (избегаем деления на 0)
|
|
||||||
if (childStatusWeights.length === 0) {
|
if (childStatusWeights.length === 0) {
|
||||||
data.status = "green";
|
data.status = "green";
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вычисляем среднее арифметическое значение весов статусов
|
|
||||||
const averageStatusWeight =
|
const averageStatusWeight =
|
||||||
childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length;
|
childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length;
|
||||||
|
|
||||||
// Определяем статус текущего элемента
|
|
||||||
data.status = getStatusFromWeight(averageStatusWeight);
|
data.status = getStatusFromWeight(averageStatusWeight);
|
||||||
|
|
||||||
return Math.max(0, averageStatusWeight); // Гарантия, что не будет отрицательных значений
|
return Math.max(0, averageStatusWeight);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusFromWeight = (weight) => {
|
const getStatusFromWeight = (weight) => {
|
||||||
|
|
@ -69,16 +64,13 @@ const StatusManager = () => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Создаем два независимых менеджера статусов
|
|
||||||
export const statusManager1 = StatusManager();
|
export const statusManager1 = StatusManager();
|
||||||
export const statusManager2 = StatusManager();
|
export const statusManager2 = StatusManager();
|
||||||
|
|
||||||
// Функция для расчета процентов здоровья системы
|
|
||||||
export const calculateStatusPercentage = (averageStatusValue) => {
|
export const calculateStatusPercentage = (averageStatusValue) => {
|
||||||
return Math.max(0, Math.min(100, averageStatusValue * 100));
|
return Math.max(0, Math.min(100, averageStatusValue * 100));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Экспортируем getStatusColor отдельно
|
|
||||||
export const getStatusColor = (status) => {
|
export const getStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "green":
|
case "green":
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,18 @@
|
||||||
"id": "media_server_1",
|
"id": "media_server_1",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "18",
|
"id": "device$18",
|
||||||
"title": "Graviton S2082I (device$18)",
|
"title": "Graviton S2082I (device$18)",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "4",
|
"id": "module$11",
|
||||||
"title": "OS Linux (module$4) АО",
|
"title": "OS Linux (module$11) АО",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "190",
|
"id": "zvks_cpu1min",
|
||||||
"title": "Загрузка процессора за 1 минуту"
|
"title": "Загрузка процессора за 1 минуту",
|
||||||
|
"metric": "zvks_cpu1min",
|
||||||
|
"description": "Загрузка процессора за 1 минуту"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "191",
|
"id": "191",
|
||||||
|
|
@ -399,118 +401,74 @@
|
||||||
"id": "media_server_2",
|
"id": "media_server_2",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "182",
|
"id": "device$19",
|
||||||
"title": "Graviton S2082I (device$18)",
|
"title": "Graviton S2082I (device$19)",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "42",
|
"id": "module$13",
|
||||||
"title": "OS Linux (module$4) АО",
|
"title": "OS Linux (module$13) АО",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "1902",
|
"id": "zvks_cpu1min",
|
||||||
"title": "Загрузка процессора за 1 минуту"
|
"title": "Загрузка процессора за 1 минуту",
|
||||||
},
|
"metric": "zvks_cpu1min",
|
||||||
{
|
"description": "Загрузка процессора за 1 минуту"
|
||||||
"id": "1912",
|
|
||||||
"title": "Загрузка процессора за 5 минут"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1922",
|
|
||||||
"title": "Загрузка процессора за 15 минут"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1972",
|
|
||||||
"title": "Общий объем SWAP-файла"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1982",
|
|
||||||
"title": "Используемый объем SWAP-файла"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1992",
|
|
||||||
"title": "Общий объем физической оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2002",
|
|
||||||
"title": "Доступный объем физической оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2012",
|
|
||||||
"title": "Свободный объем физической и виртуальной оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2022",
|
|
||||||
"title": "Буферизованный объем оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2032",
|
|
||||||
"title": "Кэшированый объем оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2742",
|
|
||||||
"title": "Используемый объем SWAP-файла"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2752",
|
|
||||||
"title": "Время затраченное процессором на процессы с пониженным приоритетом"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2762",
|
|
||||||
"title": "Время затраченное процессором на процессы ядра ОС"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2772",
|
|
||||||
"title": "Время простоя процессора"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2782",
|
|
||||||
"title": "Общая емкость жестких дисков"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2792",
|
|
||||||
"title": "Доступная емкость жестких дисков"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "52",
|
|
||||||
"title": "Vinteo (module$5) ПО",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "312",
|
|
||||||
"title": "Общее количество участников"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "322",
|
|
||||||
"title": "Ожидание соединения"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "332",
|
|
||||||
"title": "Зарегистрированные абоненты"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "342",
|
|
||||||
"title": "Количество пользоватей HLS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "352",
|
|
||||||
"title": "Общее количество P2P комнат"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "362",
|
|
||||||
"title": "Общее количество конференций"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "372",
|
"id": "372",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Загрузка процессора за 5 минут"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "373",
|
||||||
|
"title": "Загрузка процессора за 15 минут"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "378",
|
||||||
|
"title": "Общий объем SWAP-файла"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "379",
|
||||||
|
"title": "Используемый объем SWAP-файла"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "380",
|
||||||
|
"title": "Общий объем физической оперативной памяти"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "381",
|
||||||
|
"title": "Доступный объем физической оперативной памяти"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "382",
|
"id": "382",
|
||||||
"title": "Статус записи"
|
"title": "Свободный объем физической и виртуальной оперативной памяти"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "392",
|
"id": "383",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Буферизованный объем оперативной памяти"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "384",
|
||||||
|
"title": "Кэшированый объем оперативной памяти"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "375",
|
||||||
|
"title": "Время затраченное процессором на процессы с пониженным приоритетом"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "376",
|
||||||
|
"title": "Время затраченное процессором на процессы ядра ОС"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "377",
|
||||||
|
"title": "Время простоя процессора"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "385",
|
||||||
|
"title": "Общая емкость жестких дисков"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "386",
|
||||||
|
"title": "Доступная емкость жестких дисков"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -519,63 +477,63 @@
|
||||||
"title": "Сетевой адаптер №1 (port$261) Eth_1",
|
"title": "Сетевой адаптер №1 (port$261) Eth_1",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "2072",
|
"id": "388",
|
||||||
"title": "Скорость порта Eth_1"
|
"title": "Скорость порта Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2092",
|
"id": "390",
|
||||||
"title": "Административное состояние порта Eth_1"
|
"title": "Административное состояние порта Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2102",
|
"id": "391",
|
||||||
"title": "Оперативное состояние порта Eth_1"
|
"title": "Оперативное состояние порта Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2112",
|
"id": "392",
|
||||||
"title": "Общее количество отправленных октетов Eth_1"
|
"title": "Общее количество отправленных октетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2122",
|
"id": "393",
|
||||||
"title": "Количество входящих Multicast пакетов Eth_1"
|
"title": "Количество входящих Multicast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2132",
|
"id": "394",
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_1"
|
"title": "Количество иcходящих Multiicast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2142",
|
"id": "395",
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_1"
|
"title": "Количество входящих Broadcast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2152",
|
"id": "396",
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_1"
|
"title": "Количество иcходящих Broadcast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2162",
|
"id": "397",
|
||||||
"title": "Количество входящих Unicast пакетов Eth_1"
|
"title": "Количество входящих Unicast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2172",
|
"id": "398",
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_1"
|
"title": "Количество иcходящих Unicast пакетов Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2182",
|
"id": "399",
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_1"
|
"title": "Количество входящих пакетов помеченные как отброшенные Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2192",
|
"id": "400",
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_1"
|
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2202",
|
"id": "401",
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_1"
|
"title": "Количество входящих пакетов с ошибкой Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2212",
|
"id": "402",
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_1"
|
"title": "Количество исходящих пакетов с ошибкой Eth_1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2222",
|
"id": "403",
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_1"
|
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_1"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -585,63 +543,63 @@
|
||||||
"title": "Сетевой адаптер №2 (port$262) Eth_2",
|
"title": "Сетевой адаптер №2 (port$262) Eth_2",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "2242",
|
"id": "405",
|
||||||
"title": "Скорость порта Eth_2"
|
"title": "Скорость порта Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2262",
|
"id": "407",
|
||||||
"title": "Административное состояние порта Eth_2"
|
"title": "Административное состояние порта Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2272",
|
"id": "408",
|
||||||
"title": "Оперативное состояние порта Eth_2"
|
"title": "Оперативное состояние порта Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2282",
|
"id": "409",
|
||||||
"title": "Общее количество отправленных октетов Eth_2"
|
"title": "Общее количество отправленных октетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2292",
|
"id": "410",
|
||||||
"title": "Количество входящих Multicast пакетов Eth_2"
|
"title": "Количество входящих Multicast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2302",
|
"id": "411",
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_2"
|
"title": "Количество иcходящих Multiicast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2312",
|
"id": "412",
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_2"
|
"title": "Количество входящих Broadcast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2322",
|
"id": "413",
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_2"
|
"title": "Количество иcходящих Broadcast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2332",
|
"id": "414",
|
||||||
"title": "Количество входящих Unicast пакетов Eth_2"
|
"title": "Количество входящих Unicast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2342",
|
"id": "415",
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_2"
|
"title": "Количество иcходящих Unicast пакетов Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2352",
|
"id": "416",
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_2"
|
"title": "Количество входящих пакетов помеченные как отброшенные Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2362",
|
"id": "417",
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_2"
|
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2372",
|
"id": "418",
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_2"
|
"title": "Количество входящих пакетов с ошибкой Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2382",
|
"id": "419",
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_2"
|
"title": "Количество исходящих пакетов с ошибкой Eth_2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2392",
|
"id": "420",
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_2"
|
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_2"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -651,63 +609,63 @@
|
||||||
"title": "Сетевой адаптер №3 (port$263) Eth_3",
|
"title": "Сетевой адаптер №3 (port$263) Eth_3",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "2412",
|
"id": "422",
|
||||||
"title": "Скорость порта Eth_3"
|
"title": "Скорость порта Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2432",
|
"id": "424",
|
||||||
"title": "Административное состояние порта Eth_3"
|
"title": "Административное состояние порта Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2442",
|
"id": "425",
|
||||||
"title": "Оперативное состояние порта Eth_3"
|
"title": "Оперативное состояние порта Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2452",
|
"id": "426",
|
||||||
"title": "Общее количество отправленных октетов Eth_3"
|
"title": "Общее количество отправленных октетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2462",
|
"id": "427",
|
||||||
"title": "Количество входящих Multicast пакетов Eth_3"
|
"title": "Количество входящих Multicast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2472",
|
"id": "428",
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_3"
|
"title": "Количество иcходящих Multiicast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2482",
|
"id": "429",
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_3"
|
"title": "Количество входящих Broadcast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2492",
|
"id": "430",
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_3"
|
"title": "Количество иcходящих Broadcast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2502",
|
"id": "431",
|
||||||
"title": "Количество входящих Unicast пакетов Eth_3"
|
"title": "Количество входящих Unicast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2512",
|
"id": "432",
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_3"
|
"title": "Количество иcходящих Unicast пакетов Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2522",
|
"id": "433",
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_3"
|
"title": "Количество входящих пакетов помеченные как отброшенные Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2532",
|
"id": "434",
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_3"
|
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2542",
|
"id": "435",
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_3"
|
"title": "Количество входящих пакетов с ошибкой Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2552",
|
"id": "436",
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_3"
|
"title": "Количество исходящих пакетов с ошибкой Eth_3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2562",
|
"id": "437",
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_3"
|
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_3"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -717,63 +675,63 @@
|
||||||
"title": "Сетевой адаптер №4 (port$264) Eth_4",
|
"title": "Сетевой адаптер №4 (port$264) Eth_4",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "2582",
|
"id": "439",
|
||||||
"title": "Скорость порта Eth_4"
|
"title": "Скорость порта Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2602",
|
"id": "441",
|
||||||
"title": "Административное состояние порта Eth_4"
|
"title": "Административное состояние порта Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2612",
|
"id": "442",
|
||||||
"title": "Оперативное состояние порта Eth_4"
|
"title": "Оперативное состояние порта Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2622",
|
"id": "443",
|
||||||
"title": "Общее количество отправленных октетов Eth_4"
|
"title": "Общее количество отправленных октетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2632",
|
"id": "444",
|
||||||
"title": "Количество входящих Multicast пакетов Eth_4"
|
"title": "Количество входящих Multicast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2642",
|
"id": "445",
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_4"
|
"title": "Количество иcходящих Multiicast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2652",
|
"id": "446",
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_4"
|
"title": "Количество входящих Broadcast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2662",
|
"id": "447",
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_4"
|
"title": "Количество иcходящих Broadcast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2672",
|
"id": "448",
|
||||||
"title": "Количество входящих Unicast пакетов Eth_4"
|
"title": "Количество входящих Unicast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2682",
|
"id": "449",
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_4"
|
"title": "Количество иcходящих Unicast пакетов Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2692",
|
"id": "450",
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_4"
|
"title": "Количество входящих пакетов помеченные как отброшенные Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2702",
|
"id": "451",
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_4"
|
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2712",
|
"id": "452",
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_4"
|
"title": "Количество входящих пакетов с ошибкой Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2722",
|
"id": "453",
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_4"
|
"title": "Количество исходящих пакетов с ошибкой Eth_4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2732",
|
"id": "454",
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_4"
|
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_4"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -889,15 +847,15 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "373",
|
"id": "373000",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "383",
|
"id": "38300",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "393",
|
"id": "39300",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -1281,11 +1239,11 @@
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "384",
|
"id": "38400",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "394",
|
"id": "39400",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -1671,7 +1629,7 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "379",
|
"id": "37900",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1679,7 +1637,7 @@
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "399",
|
"id": "39900",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -2447,15 +2405,15 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "378",
|
"id": "37800",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "388",
|
"id": "38800",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "398",
|
"id": "39800",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -2841,15 +2799,15 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "375",
|
"id": "37500",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "385",
|
"id": "38500",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "395",
|
"id": "39500",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -3229,15 +3187,15 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "376",
|
"id": "37600",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "386",
|
"id": "38600",
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "396",
|
"id": "39600",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -3617,7 +3575,7 @@
|
||||||
"title": "Общее количество конференций"
|
"title": "Общее количество конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "377",
|
"id": "37700",
|
||||||
"title": "Общее количество активных конференций"
|
"title": "Общее количество активных конференций"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -3625,7 +3583,7 @@
|
||||||
"title": "Статус записи"
|
"title": "Статус записи"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "397",
|
"id": "39700",
|
||||||
"title": "Общее количество сохранённых записей"
|
"title": "Общее количество сохранённых записей"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,155 @@
|
||||||
import React, { lazy, Suspense } from "react";
|
import React, { lazy, Suspense } from "react";
|
||||||
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart'));
|
const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart"));
|
||||||
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
|
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
|
||||||
|
|
||||||
// Функция для генерации названия метрики на основе id
|
// Компонент Skeleton для графика
|
||||||
const getMetricName = (id) => {
|
const ChartSkeleton = () => (
|
||||||
return `zvks_apiforsnmp_measure_${id}`;
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Skeleton variant="text" width="60%" height={30} />
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Компонент Skeleton для контейнера
|
||||||
|
const ContainerSkeleton = () => (
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Skeleton variant="text" width="40%" height={40} />
|
||||||
|
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<ChartSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Утилита для извлечения контекста из пути
|
||||||
|
const parseContextFromPath = (node) => {
|
||||||
|
const context = {};
|
||||||
|
let current = node;
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
if (current.id.startsWith("device$")) {
|
||||||
|
context.device = current.id.split("$")[1];
|
||||||
|
context.deviceId = current.id;
|
||||||
|
}
|
||||||
|
if (current.id.startsWith("module$")) {
|
||||||
|
context.module = current.id;
|
||||||
|
context.source_id = current.id;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для рекурсивного сбора всех id потомков
|
// Основная функция построения контента вкладок
|
||||||
const getAllChildIds = (node) => {
|
const tabContent = (data, cache = {}) => {
|
||||||
let ids = [];
|
const tabContentMap = { ...cache };
|
||||||
if (node.id) {
|
|
||||||
ids.push(node.id); // Добавляем id текущего узла
|
if (!data || !data.items || data.items.length === 0) {
|
||||||
|
console.warn("Данные отсутствуют или массив items пуст", data);
|
||||||
|
return tabContentMap;
|
||||||
}
|
}
|
||||||
if (node.items && node.items.length > 0) {
|
|
||||||
node.items.forEach((child) => {
|
|
||||||
ids = ids.concat(getAllChildIds(child)); // Рекурсивно собираем id потомков
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabContent = (data) => {
|
const processNode = (node, parentContext = {}) => {
|
||||||
const tabContent = {};
|
// Получаем полный контекст из всей цепочки родителей
|
||||||
|
const pathContext = parseContextFromPath(node);
|
||||||
|
const currentContext = { ...parentContext, ...pathContext };
|
||||||
|
|
||||||
// Функция для рекурсивного обхода и сбора данных
|
// Генерируем уникальный ключ на основе пути
|
||||||
const generateContent = (nodes) => {
|
const path = [];
|
||||||
nodes.forEach((node) => {
|
let current = node;
|
||||||
// Если у узла есть вложенные элементы, рекурсивно обрабатываем их
|
while (current) {
|
||||||
if (node.items && node.items.length > 0) {
|
path.unshift(current.id);
|
||||||
// Создаем контент для родителя
|
current = current.parent;
|
||||||
const childrenContent = generateContent(node.items);
|
}
|
||||||
|
const pathId = path.join('_');
|
||||||
|
|
||||||
const content = (
|
if (Array.isArray(node.items) && node.items.length > 0) {
|
||||||
<div>
|
const children = node.items
|
||||||
<h2>{node.title}</h2>
|
.map((child) => processNode(child, currentContext))
|
||||||
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} />
|
.filter(Boolean);
|
||||||
<p>Контент для {node.title}.</p>
|
|
||||||
{childrenContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Сохраняем контент для текущего id
|
const content = (
|
||||||
tabContent[node.id] = {
|
<div key={`${pathId}-container`}>
|
||||||
title: node.title,
|
<h2>{node.title}</h2>
|
||||||
content: content,
|
<Suspense fallback={<ContainerSkeleton />}>
|
||||||
};
|
<LazyChartBatchRenderer charts={children.map((c) => c.content)} />
|
||||||
} else {
|
</Suspense>
|
||||||
// Если у узла нет вложенных элементов, это самый нижний уровень
|
</div>
|
||||||
const metricName = getMetricName(node.id);
|
);
|
||||||
const content = (
|
|
||||||
<div key={node.id}>
|
|
||||||
<h3>{node.title}</h3> {/* Используем title узла */}
|
|
||||||
<Suspense fallback={<div>Загрузка графика...</div>}>
|
|
||||||
<PrometheusChart metricName={metricName} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Сохраняем контент для текущего id
|
tabContentMap[pathId] = {
|
||||||
tabContent[node.id] = {
|
title: node.title,
|
||||||
title: node.title,
|
content,
|
||||||
content: content,
|
context: currentContext,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Возвращаем контент для всех потомков
|
return { content, context: currentContext };
|
||||||
return (
|
}
|
||||||
<div>
|
|
||||||
{nodes.map((node) => (
|
if (node.metric) {
|
||||||
<div key={node.id}>{tabContent[node.id].content}</div>
|
const chartKey = `${node.metric}-${currentContext.device || "all"}-${currentContext.module || "all"}-${pathId}`;
|
||||||
))}
|
|
||||||
|
const content = (
|
||||||
|
<div key={chartKey}>
|
||||||
|
<h3>{node.title}</h3>
|
||||||
|
{currentContext.device && <p>Устройство: {currentContext.device}</p>}
|
||||||
|
{currentContext.module && <p>Модуль: {currentContext.module}</p>}
|
||||||
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
|
<PrometheusChart
|
||||||
|
metricInfo={{
|
||||||
|
name: node.metric,
|
||||||
|
filters: {
|
||||||
|
...(currentContext.device && { device: currentContext.device }),
|
||||||
|
...(currentContext.source_id && { source_id: currentContext.source_id }),
|
||||||
|
},
|
||||||
|
title: node.title,
|
||||||
|
description: node.description,
|
||||||
|
context: currentContext,
|
||||||
|
}}
|
||||||
|
key={chartKey}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
tabContentMap[pathId] = {
|
||||||
|
title: node.title,
|
||||||
|
content,
|
||||||
|
context: currentContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { content, context: currentContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Узел без метрики и без вложенных — просто заголовок
|
||||||
|
const content = (
|
||||||
|
<div key={pathId}>
|
||||||
|
<h3>{node.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
tabContentMap[pathId] = {
|
||||||
|
title: node.title,
|
||||||
|
content,
|
||||||
|
context: currentContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { content, context: currentContext };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Начинаем обработку с корневого уровня
|
try {
|
||||||
if (data.items && data.items.length > 0) {
|
processNode(data);
|
||||||
generateContent(data.items);
|
} catch (error) {
|
||||||
} else {
|
console.error("Ошибка обработки данных:", error);
|
||||||
console.warn("Данные отсутствуют или массив items пуст");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabContent;
|
return tabContentMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default tabContent;
|
export default tabContent;
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
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);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Получаем данные из ClickHouse
|
||||||
|
console.log('Запрашиваем данные из ClickHouse...');
|
||||||
|
const metricsResponse = await axios.get('/api/clickhouse');
|
||||||
|
console.log('Получены данные из ClickHouse:', metricsResponse.data);
|
||||||
|
|
||||||
|
// 2. Отправляем в AI API
|
||||||
|
console.log('Отправляем данные в AI API:', metricsResponse.data);
|
||||||
|
const aiResponse = await axios.post(
|
||||||
|
'/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("Детали ошибки:", err.response?.data);
|
||||||
|
setError(err.response?.data?.message || JSON.stringify(err.response?.data)) || "Ошибка при анализе данных";
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setOpenModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||||
|
sx={{
|
||||||
|
minWidth: '180px',
|
||||||
|
backgroundColor: '#4caf50',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '#388e3c',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Отправка в AI...' : 'Проанализировать AI'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1 }}>
|
||||||
|
{error}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIAnalysisButton;
|
||||||
|
|
@ -1,41 +1,67 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import "../../Style/LoginModal.css";
|
import "../../Style/LoginModal.css";
|
||||||
import Logo from '../../assets/images/logo.svg?react';
|
import {
|
||||||
import TextField from '@mui/material/TextField';
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
InputAdornment
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
Visibility,
|
||||||
|
VisibilityOff
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const LoginModal = ({ onLogin, onClose }) => {
|
const LoginModal = ({ onLogin, onClose }) => {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const handleClickShowPassword = () => setShowPassword((show) => !show);
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем данные на бэкенд
|
const { data } = await axios.post(
|
||||||
const response = await fetch(`${import.meta.env.VITE_BACK_URL}/api/auth/login`, {
|
//`${import.meta.env.VITE_BACK_URL}/api/auth/login`,
|
||||||
method: 'POST',
|
'/api/auth/login',
|
||||||
headers: {
|
{ login: username, password },
|
||||||
'Content-Type': 'application/json',
|
{
|
||||||
},
|
withCredentials: true,
|
||||||
body: JSON.stringify({ login: username, password }),
|
headers: {
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const data = await response.json();
|
console.log('Login response:', data);
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
onLogin(); // Успешная авторизация
|
if (!data.user?.role) {
|
||||||
onClose(); // Закрыть модальное окно
|
console.error('Role missing in response:', data);
|
||||||
|
throw new Error('Роль пользователя не получена');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
id: data.user.id,
|
||||||
|
login: data.user.login,
|
||||||
|
role: data.user.role
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
|
||||||
|
console.log('User data saved:', userData);
|
||||||
|
|
||||||
|
onLogin(userData);
|
||||||
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError(data.message || "Неверный логин или пароль");
|
setError(data.message || 'Ошибка авторизации');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при отправке запроса:', err);
|
console.error('Login error:', err);
|
||||||
setError("Ошибка при подключении к серверу");
|
setError(err.response?.data?.message || err.message || 'Ошибка при входе');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -50,8 +76,8 @@ const LoginModal = ({ onLogin, onClose }) => {
|
||||||
variant="filled"
|
variant="filled"
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
size="normal"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -62,12 +88,45 @@ const LoginModal = ({ onLogin, onClose }) => {
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
size="normal"
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="toggle password visibility"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
edge="end"
|
||||||
|
sx={{
|
||||||
|
marginRight: '-12px',
|
||||||
|
alignSelf: 'flex-end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && (
|
||||||
<button type="submit">Войти</button>
|
<Typography color="error" sx={{ mt: 1 }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
py: 1.5,
|
||||||
|
fontSize: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Tabs, Tab, Box, styled } from "@mui/material";
|
import { Tabs, Tab, Box, styled, Typography } from "@mui/material";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
|
||||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
|
padding: theme.spacing(1, 2),
|
||||||
|
textTransform: 'none',
|
||||||
'&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
fontWeight: theme.typography.fontWeightMedium,
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
|
|
@ -14,9 +16,43 @@ const StyledTab = styled(Tab)(({ theme }) => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const TabLabel = ({ title, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
minWidth: 0 // Для корректного обрезания длинного текста
|
||||||
|
}}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
noWrap
|
||||||
|
sx={{
|
||||||
|
maxWidth: 120,
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<CloseIcon
|
||||||
|
fontSize="small"
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
'&:hover': {
|
||||||
|
color: 'error.main'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||||
const handleMouseDown = (e, id) => {
|
const handleMouseDown = (e, id) => {
|
||||||
if (e.button === 1) { // Middle mouse button
|
if (e.button === 1) { // Средняя кнопка мыши
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCloseTab(id);
|
onCloseTab(id);
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +62,12 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||||
onTabClick(newValue);
|
onTabClick(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Статические вкладки (сохраняем оригинальные id)
|
||||||
|
const staticTabs = [
|
||||||
|
{ id: "Главная", title: "Главная" },
|
||||||
|
{ id: "Визуализация", title: "Визуализация" }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
borderBottom: 1,
|
borderBottom: 1,
|
||||||
|
|
@ -39,42 +81,31 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
variant="scrollable"
|
variant="scrollable"
|
||||||
scrollButtons="auto"
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
aria-label="tabs"
|
aria-label="tabs"
|
||||||
>
|
>
|
||||||
{/* Статические вкладки */}
|
{/* Статические вкладки */}
|
||||||
<StyledTab
|
{staticTabs.map(tab => (
|
||||||
label="Главная"
|
<StyledTab
|
||||||
value="Главная"
|
key={`static_${tab.id}`} // Добавляем префикс для уникальности
|
||||||
onMouseDown={(e) => handleMouseDown(e, "Главная")}
|
label={tab.title}
|
||||||
/>
|
value={tab.id} // Используем id как value
|
||||||
<StyledTab
|
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
||||||
label="Визуализация"
|
/>
|
||||||
value="Визуализация"
|
))}
|
||||||
onMouseDown={(e) => handleMouseDown(e, "Визуализация")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Динамические вкладки */}
|
{/* Динамические вкладки */}
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<StyledTab
|
<StyledTab
|
||||||
key={tab.id}
|
key={`dynamic_${tab.id}`} // Добавляем префикс для уникальности
|
||||||
label={
|
label={
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
<TabLabel
|
||||||
<span>{tab.title}</span>
|
title={tab.title}
|
||||||
<CloseIcon
|
onClose={(e) => {
|
||||||
fontSize="small"
|
e.stopPropagation();
|
||||||
sx={{
|
onCloseTab(tab.id);
|
||||||
ml: 1,
|
}}
|
||||||
cursor: "pointer",
|
/>
|
||||||
'&:hover': {
|
|
||||||
color: 'error.main'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCloseTab(tab.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
value={tab.id}
|
value={tab.id}
|
||||||
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
IconButton, Menu, MenuItem, Avatar, Tooltip, Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
const ProfileMenu = ({ user, onLogout }) => {
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
|
const handleOpen = (event) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoutClick = () => {
|
||||||
|
handleClose();
|
||||||
|
onLogout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Профиль">
|
||||||
|
<IconButton onClick={handleOpen} size="small" sx={{ ml: 2 }}>
|
||||||
|
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||||
|
{user?.login?.[0]?.toUpperCase() || '?'}
|
||||||
|
</Avatar>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
onClick={handleClose}
|
||||||
|
PaperProps={{
|
||||||
|
elevation: 3,
|
||||||
|
sx: {
|
||||||
|
mt: 1.5,
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem disabled>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{user?.login || 'Неизвестный'}
|
||||||
|
</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleLogoutClick}>
|
||||||
|
Выйти
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileMenu;
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
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)
|
||||||
|
// });
|
||||||
|
|
||||||
|
if (!user || !allowedRoles.includes(user.role)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
@ -324,7 +324,7 @@ const TreeTable = ({ data }) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="ul" sx={{
|
<Box component="ul" sx={{
|
||||||
pl: 2,
|
pl: 2,
|
||||||
maxHeight: 200,
|
maxHeight: 400,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
listStyle: 'none'
|
listStyle: 'none'
|
||||||
}}>
|
}}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import axios from "axios"
|
||||||
|
|
||||||
|
export const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(
|
||||||
|
//`${import.meta.env.VITE_BACK_URL}/api/auth/check`,
|
||||||
|
'/api/auth/check',
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Auth check response:', data);
|
||||||
|
|
||||||
|
if (!data.user) {
|
||||||
|
return { isAuthenticated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: data.isAuthenticated,
|
||||||
|
user: {
|
||||||
|
id: data.user.id,
|
||||||
|
login: data.user.login,
|
||||||
|
role: data.user.role
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Auth check failed:', err);
|
||||||
|
return { isAuthenticated: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,29 +1,167 @@
|
||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
|
||||||
const LazyChartBatchRenderer = ({ charts, batchSize = 3, delay = 150 }) => {
|
const LazyChartBatchRenderer = ({ charts }) => {
|
||||||
const [visibleCharts, setVisibleCharts] = useState([]);
|
const [visibleIndices, setVisibleIndices] = useState(new Set());
|
||||||
|
const placeholderRefs = useRef([]);
|
||||||
|
const observerRef = useRef(null);
|
||||||
|
const cleanupTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
const ChartSkeleton = () => (
|
||||||
|
<Box sx={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
position: 'relative',
|
||||||
|
height: '400px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<Box sx={{ position: 'absolute', right: '20px', top: '20px' }}>
|
||||||
|
<Skeleton variant="circular" width={16} height={16} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Skeleton variant="text" width="40%" height={30} />
|
||||||
|
<Skeleton variant="text" width="30%" height={30} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton
|
||||||
|
variant="rectangular"
|
||||||
|
width="100%"
|
||||||
|
height="300px"
|
||||||
|
sx={{
|
||||||
|
transform: 'none',
|
||||||
|
animation: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, gap: 2 }}>
|
||||||
|
{[1, 2, 3, 4].map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
variant="rounded"
|
||||||
|
width={80}
|
||||||
|
height={36}
|
||||||
|
sx={{
|
||||||
|
transform: 'none',
|
||||||
|
animation: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const isElementFarFromViewport = useCallback((element) => {
|
||||||
|
if (!element) return true;
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const buffer = window.innerHeight * 1.5;
|
||||||
|
|
||||||
|
|
||||||
|
return rect.bottom < -buffer || rect.top > window.innerHeight + buffer;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const updateVisibleIndices = useCallback(() => {
|
||||||
|
const newVisibleIndices = new Set();
|
||||||
|
|
||||||
|
placeholderRefs.current.forEach((ref, index) => {
|
||||||
|
if (ref && !isElementFarFromViewport(ref)) {
|
||||||
|
newVisibleIndices.add(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setVisibleIndices(prev => {
|
||||||
|
if (newVisibleIndices.size === prev.size &&
|
||||||
|
Array.from(newVisibleIndices).every(i => prev.has(i))) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return newVisibleIndices;
|
||||||
|
});
|
||||||
|
}, [isElementFarFromViewport]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let index = 0;
|
observerRef.current = new IntersectionObserver(
|
||||||
const timer = setInterval(() => {
|
(entries) => {
|
||||||
setVisibleCharts((prev) => [
|
entries.forEach(entry => {
|
||||||
...prev,
|
if (entry.isIntersecting) {
|
||||||
...charts.slice(index, index + batchSize),
|
updateVisibleIndices();
|
||||||
]);
|
}
|
||||||
index += batchSize;
|
});
|
||||||
if (index >= charts.length) clearInterval(timer);
|
},
|
||||||
}, delay);
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: '500px 0px',
|
||||||
|
threshold: 0.01
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
placeholderRefs.current.forEach(ref => {
|
||||||
}, [charts]);
|
if (ref) observerRef.current.observe(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (cleanupTimeoutRef.current) {
|
||||||
|
clearTimeout(cleanupTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTimeoutRef.current = setTimeout(() => {
|
||||||
|
updateVisibleIndices();
|
||||||
|
|
||||||
|
setVisibleIndices(prev => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
placeholderRefs.current.forEach((ref, index) => {
|
||||||
|
if (ref && isElementFarFromViewport(ref) && prev.has(index)) {
|
||||||
|
updated.delete(index);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? updated : prev;
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', updateVisibleIndices, { passive: true });
|
||||||
|
|
||||||
|
updateVisibleIndices();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (cleanupTimeoutRef.current) clearTimeout(cleanupTimeoutRef.current);
|
||||||
|
if (observerRef.current) observerRef.current.disconnect();
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', updateVisibleIndices);
|
||||||
|
};
|
||||||
|
}, [updateVisibleIndices]);
|
||||||
|
|
||||||
|
const shouldShowChart = (index) => {
|
||||||
|
return visibleIndices.has(index) ||
|
||||||
|
visibleIndices.has(index - 1) ||
|
||||||
|
visibleIndices.has(index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
{visibleCharts.map((chart, idx) => (
|
{charts.map((chart, index) => (
|
||||||
<div key={idx}>{chart}</div>
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={(el) => (placeholderRefs.current[index] = el)}
|
||||||
|
data-index={index}
|
||||||
|
style={{
|
||||||
|
minHeight: '400px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
transition: 'opacity 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shouldShowChart(index) ? chart : <ChartSkeleton />}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LazyChartBatchRenderer;
|
export default React.memo(LazyChartBatchRenderer);
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Snackbar,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
const MetricsAnalyzer = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [metrics, setMetrics] = useState([]);
|
||||||
|
const [analysisResult, setAnalysisResult] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [openSnackbar, setOpenSnackbar] = useState(false);
|
||||||
|
|
||||||
|
const transformMetricsForAnalysis = (metrics) => {
|
||||||
|
return metrics.flatMap(metricResponse =>
|
||||||
|
metricResponse.data.map(metricData => ({
|
||||||
|
description: metricData.description,
|
||||||
|
device: parseInt(metricData.device, 10),
|
||||||
|
id: metricData.source_id,
|
||||||
|
name: metricData.__name__,
|
||||||
|
source: metricData.instance,
|
||||||
|
status: parseInt(metricData.status, 10),
|
||||||
|
timestamp: metricData.timestamp,
|
||||||
|
value: metricData.value.toString()
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyzeMetrics = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 1. Сначала загружаем метрики
|
||||||
|
const metricsResponse = await axios.get(`/api/metrics/all-values`);
|
||||||
|
setMetrics(metricsResponse.data);
|
||||||
|
|
||||||
|
// 2. Преобразуем и отправляем на анализ
|
||||||
|
const requestData = transformMetricsForAnalysis(metricsResponse.data);
|
||||||
|
const analysisResponse = await axios.get(`:5134/api/metrics/rest`, {
|
||||||
|
data: requestData,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAnalysisResult(analysisResponse.data);
|
||||||
|
setOpenSnackbar(true);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
'Ошибка при анализе метрик';
|
||||||
|
setError(errorMessage);
|
||||||
|
setOpenSnackbar(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSnackbar = () => {
|
||||||
|
setOpenSnackbar(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 800, margin: '0 auto', mt: 4 }}>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ mb: 3 }}>
|
||||||
|
Анализ метрик системы
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={analyzeMetrics}
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={loading ? <CircularProgress size={24} /> : null}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{loading ? 'Выполняется анализ...' : 'Проанализировать метрики'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{analysisResult && (
|
||||||
|
<Paper elevation={3} sx={{ p: 3, mt: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Результаты анализа
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{Array.isArray(analysisResult) ? (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Параметр</TableCell>
|
||||||
|
<TableCell>Результат</TableCell>
|
||||||
|
<TableCell>Описание</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{analysisResult.map((item, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>{item.name || item.parameter}</TableCell>
|
||||||
|
<TableCell>{item.value || item.result}</TableCell>
|
||||||
|
<TableCell>{item.description || '-'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : (
|
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
borderRadius: 1,
|
||||||
|
maxHeight: 400,
|
||||||
|
overflow: 'auto'
|
||||||
|
}}>
|
||||||
|
<Typography variant="body2" component="pre">
|
||||||
|
{JSON.stringify(analysisResult, null, 2)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={openSnackbar}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={handleCloseSnackbar}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
onClose={handleCloseSnackbar}
|
||||||
|
severity={error ? 'error' : 'success'}
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{error || 'Анализ метрик успешно завершен'}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetricsAnalyzer;
|
||||||
|
|
@ -1,22 +1,91 @@
|
||||||
import SystemStatusChart from "../../Charts/SystemStatusChart";
|
|
||||||
import TreeTable from "../UI/TreeTable";
|
import TreeTable from "../UI/TreeTable";
|
||||||
import FlowChart from "../TreeChart/FlowChart";
|
import FlowChart from "../TreeChart/FlowChart";
|
||||||
|
import { getStatusColor } from "../TreeChart/dataUtils";
|
||||||
|
import SystemChart from "../../Charts/SystemChart";
|
||||||
|
|
||||||
|
const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, handleOpenTab }) => {
|
||||||
|
const countStatuses = (data) => {
|
||||||
|
const counts = { green: 0, yellow: 0, orange: 0, red: 0 };
|
||||||
|
|
||||||
|
const countRecursive = (node) => {
|
||||||
|
if (node.status) {
|
||||||
|
counts[node.status]++;
|
||||||
|
}
|
||||||
|
if (node.items && node.items.length > 0) {
|
||||||
|
node.items.forEach(child => countRecursive(child));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data) countRecursive(data);
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
|
||||||
const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleOpenTab }) => {
|
|
||||||
if (activeTab === "Главная") {
|
if (activeTab === "Главная") {
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>Общий мониторинг состояния системы</h2>
|
<h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</h2>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
|
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
|
||||||
<label>Надежность системы</label>
|
<label>Надежность серверов</label>
|
||||||
<SystemStatusChart data={statusHistories.history1} />
|
<SystemChart
|
||||||
|
metricInfo={serverMetric}
|
||||||
|
chartHeight={580}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'inline-block', width: '48%' }}>
|
<div style={{ display: 'inline-block', width: '48%' }}>
|
||||||
<label>Функциональность системы</label>
|
<label>Функциональность приложений</label>
|
||||||
<SystemStatusChart data={statusHistories.history2} />
|
<SystemChart
|
||||||
|
metricInfo={appMetric}
|
||||||
|
chartHeight={580}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Контейнер для индикаторов статусов */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: '20px',
|
||||||
|
gap: '10px'
|
||||||
|
}}>
|
||||||
|
{Object.entries(statusCounts).map(([status, count]) => (
|
||||||
|
<div key={status} style={{
|
||||||
|
width: '30px',
|
||||||
|
height: '30px',
|
||||||
|
backgroundColor: getStatusColor(status),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
borderRadius: '5px',
|
||||||
|
boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
|
||||||
|
}}>
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>Статус компонентов системы</label>
|
<label>Статус компонентов системы</label>
|
||||||
<TreeTable data={treeData1} />
|
<TreeTable data={treeData1} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -24,7 +93,7 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO
|
||||||
} else if (activeTab === "Визуализация") {
|
} else if (activeTab === "Визуализация") {
|
||||||
return <FlowChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
|
return <FlowChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
|
||||||
} else {
|
} else {
|
||||||
const tabData = tabContent[activeTab];
|
const tabData = tabs.find(t => t.id === activeTab);
|
||||||
return tabData ? tabData.content : <p>Нет данных</p>;
|
return tabData ? tabData.content : <p>Нет данных</p>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,43 @@ const useTabs = (initialTab) => {
|
||||||
const [tabs, setTabs] = useState([]);
|
const [tabs, setTabs] = useState([]);
|
||||||
const [activeTab, setActiveTab] = useState(initialTab);
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
|
|
||||||
const handleOpenTab = useCallback((id, title) => {
|
const handleOpenTab = useCallback((newTab) => {
|
||||||
setTabs((prevTabs) =>
|
setTabs((prevTabs) => {
|
||||||
prevTabs.some((tab) => tab.id === id)
|
const exists = prevTabs.some((tab) => tab.id === newTab.id);
|
||||||
? prevTabs
|
if (!exists) {
|
||||||
: [...prevTabs, { id, title }]
|
return [...prevTabs, newTab];
|
||||||
);
|
}
|
||||||
setActiveTab(id);
|
return prevTabs;
|
||||||
|
});
|
||||||
|
setActiveTab(newTab.id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCloseTab = useCallback((id) => {
|
const handleCloseTab = useCallback((id) => {
|
||||||
setTabs((prevTabs) => prevTabs.filter((tab) => tab.id !== id));
|
setTabs((prevTabs) => {
|
||||||
if (activeTab === id) {
|
const newTabs = prevTabs.filter((tab) => tab.id !== id);
|
||||||
setActiveTab(tabs.length > 1 ? tabs[tabs.length - 2].id : initialTab);
|
if (activeTab === id) {
|
||||||
}
|
setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : initialTab);
|
||||||
}, [activeTab, tabs, initialTab]);
|
}
|
||||||
|
return newTabs;
|
||||||
|
});
|
||||||
|
}, [activeTab, initialTab]);
|
||||||
|
|
||||||
return { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab };
|
const updateTabContent = useCallback((id, content) => {
|
||||||
|
setTabs(prevTabs =>
|
||||||
|
prevTabs.map(tab =>
|
||||||
|
tab.id === id ? { ...tab, content } : tab
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
handleOpenTab,
|
||||||
|
handleCloseTab,
|
||||||
|
setActiveTab,
|
||||||
|
updateTabContent
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useTabs;
|
export default useTabs;
|
||||||
|
|
@ -76,7 +76,7 @@ export const lightTheme = createTheme({
|
||||||
|
|
||||||
// Фоновые цвета
|
// Фоновые цвета
|
||||||
background: {
|
background: {
|
||||||
default: "#6CACE4", // Основной фон приложения
|
default: "#FFFFFF", // Основной фон приложения
|
||||||
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
|
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -139,7 +139,7 @@ export const darkTheme = createTheme({
|
||||||
|
|
||||||
// Фоновые цвета
|
// Фоновые цвета
|
||||||
background: {
|
background: {
|
||||||
default: "#1E1E1E", // Основной фон приложения
|
default: "#2d2d2d", // Основной фон приложения
|
||||||
paper: "#2d2d2d", // Фон "бумажных" поверхностей
|
paper: "#2d2d2d", // Фон "бумажных" поверхностей
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<svg width="43" height="43" viewBox="0 0 43 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22.4391 0.0295059V0H21.5049H21.4951H20.5609V0.0295059C9.76424 0.48193 1.02264 8.95014 0.0884977 19.6116C0.0294994 20.2312 0 20.8607 0 21.5C0 22.1295 0.0294994 22.7589 0.0884977 23.3884C1.04231 34.3646 10.2756 43 21.4951 43H22.4391V39.2331H21.4951C12.37 39.2331 4.8182 32.3484 3.87423 23.3884H6.43083H14.6513C14.4349 22.7097 14.3169 21.9819 14.3169 21.2246C14.3169 20.6738 14.3858 20.1329 14.5038 19.6215H11.4752C12.37 14.8808 16.5884 11.3008 21.5049 11.3008C24.9367 11.3008 28.1226 13.0416 29.9909 15.8545H34.2584C32.0656 10.8484 27.0016 7.53385 21.5049 7.53385C14.5038 7.53385 8.58427 12.7761 7.65996 19.6215H6.2145H3.87423C4.8182 10.6615 12.37 3.77676 21.4951 3.77676H21.5049C30.63 3.77676 38.1818 10.6615 39.1258 19.6215H28.4962C28.6142 20.1427 28.6831 20.6738 28.6831 21.2246C28.6831 21.9819 28.5651 22.7097 28.3487 23.3884H28.919H31.5248H35.34H37.3067H43V21.5C43 9.95334 33.8552 0.511436 22.4391 0.0295059Z" fill="#428AC9"/>
|
||||||
|
<path d="M22.7045 32.25C22.3112 32.2992 21.9081 32.3287 21.5049 32.3287C17.2472 32.3287 13.5205 29.6436 12.016 25.8472H8.06311C9.70523 31.7681 15.1528 36.0956 21.5049 36.0956C21.9081 36.0956 22.3112 36.0759 22.7045 36.0366V32.25Z" fill="#428AC9"/>
|
||||||
|
<path d="M25.2611 24.3817C23.383 26.457 20.1873 26.6242 18.1125 24.7457C16.0377 22.8769 15.8706 19.6706 17.7388 17.5954C19.617 15.5201 22.8127 15.3529 24.8875 17.2315C26.9623 19.1002 27.1294 22.3065 25.2611 24.3817Z" fill="url(#paint0_radial_2_3)"/>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="paint0_radial_2_3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(19.8648 18.1752) scale(7.12571 7.12734)">
|
||||||
|
<stop stop-color="#4A96D2"/>
|
||||||
|
<stop offset="1" stop-color="#1F2466"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -2,8 +2,7 @@ import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
//import './Style/light-theme.css'; // Подключаем светлую тему по умолчанию
|
|
||||||
//import './Style/dark-theme.css'; // Подключаем темную тему
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +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']
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue