Compare commits
133 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 | |
|
|
22c5fcf02c | |
|
|
2c9813fbb9 | |
|
|
c1b0f10e62 | |
|
|
858f6e2c4c | |
|
|
4eed2f364c | |
|
|
5b25876056 | |
|
|
9ee15160e5 | |
|
|
e56b82bb66 | |
|
|
46484efdea | |
|
|
b6b3b36f5a | |
|
|
46da90fbb6 | |
|
|
64401cadbc | |
|
|
a24b89220c | |
|
|
32ece2f0ff | |
|
|
4405c693aa | |
|
|
6a4640ba93 | |
|
|
bd96278895 | |
|
|
ed2e03e202 | |
|
|
f69443d051 | |
|
|
cf38b25678 | |
|
|
3ee0c3a02c | |
|
|
b6b8042d11 | |
|
|
fc1db66288 | |
|
|
c077449b2c | |
|
|
88b7b8af77 | |
|
|
15a8a146fc | |
|
|
b7f6a6c386 | |
|
|
b601dedc0e | |
|
|
9399f544d2 | |
|
|
5ed1b448e5 | |
|
|
2d714b5985 | |
|
|
3fc7ee0ac3 | |
|
|
13101ac57c | |
|
|
175b4f993d | |
|
|
f5b92715ef | |
|
|
5ec58ab476 | |
|
|
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
35
package.json
35
package.json
|
|
@ -10,20 +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/icons-material": "^6.4.8",
|
||||||
"@mui/material": "^6.4.7",
|
"@mui/material": "^6.4.7",
|
||||||
"@mui/icons-material": "^6.4.8"
|
"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-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",
|
||||||
|
|
@ -35,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
<svg
|
<svg width="43" height="43" viewBox="0 0 43 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
width="100" height="100"
|
<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"/>
|
||||||
viewBox="0 0 100 100"
|
<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"/>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<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)"/>
|
||||||
fill="none" stroke="black" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
|
<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)">
|
||||||
<circle cx="50" cy="50" r="45" stroke="#4CAF50" stroke-width="5" fill="none" />
|
<stop stop-color="#4A96D2"/>
|
||||||
|
<stop offset="1" stop-color="#1F2466"/>
|
||||||
<!-- График нагрузки -->
|
</radialGradient>
|
||||||
<polyline points="20,70 35,40 50,60 65,30 80,50" stroke="#4CAF50" stroke-width="5" fill="none" />
|
</defs>
|
||||||
|
|
||||||
<!-- Крестик в центре, символизирующий мониторинг -->
|
|
||||||
<line x1="45" y1="45" x2="55" y2="55" stroke="#4CAF50" stroke-width="4" />
|
|
||||||
<line x1="55" y1="45" x2="45" y2="55" stroke="#4CAF50" stroke-width="4" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 729 B After Width: | Height: | Size: 1.8 KiB |
201
src/App.jsx
201
src/App.jsx
|
|
@ -1,35 +1,208 @@
|
||||||
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 "./Style/LoginModal.css";
|
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 ? (
|
||||||
<LoginModal onLogin={handleLogin} onClose={() => setShowLoginModal(false)} />
|
<>
|
||||||
) : (
|
<Box sx={{
|
||||||
<Box sx={{ display: "flex", height: "100vh", overflow: "hidden", bgcolor: "background.default", color: "text.primary" }}>
|
position: "fixed",
|
||||||
<Dashboard />
|
top: 24,
|
||||||
<Box sx={{ position: "absolute", top: 10, right: 10 }}>
|
left: "50%",
|
||||||
<Switch checked={isDarkMode} onChange={() => setIsDarkMode((prev) => !prev)} />
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 1200,
|
||||||
|
'& svg': { width: 400, height: 'auto' }
|
||||||
|
}}>
|
||||||
|
<Logo />
|
||||||
</Box>
|
</Box>
|
||||||
|
<LoginModal
|
||||||
|
open={showLoginModal}
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onClose={() => setShowLoginModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
height: "100vh",
|
||||||
|
overflow: "hidden",
|
||||||
|
bgcolor: "background.default"
|
||||||
|
}}>
|
||||||
|
<Dashboard
|
||||||
|
user={authState.user}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
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,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,90 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Line, ResponsiveContainer } from 'recharts';
|
|
||||||
|
|
||||||
const LineChartComponent = ({ chartData, metricName, colors, description, onRangeSelect, filteredData }) => {
|
|
||||||
const [selectionStart, setSelectionStart] = useState(null);
|
|
||||||
const [selectionEnd, setSelectionEnd] = useState(null);
|
|
||||||
|
|
||||||
// Создаем массив уникальных временных меток
|
|
||||||
const allTimes = Object.values(chartData)
|
|
||||||
.flat()
|
|
||||||
.map(point => point.time)
|
|
||||||
.filter((time, index, self) => self.indexOf(time) === index);
|
|
||||||
|
|
||||||
// Формируем данные для графика
|
|
||||||
const data = allTimes.map(time => {
|
|
||||||
const point = { time };
|
|
||||||
Object.keys(chartData).forEach(key => {
|
|
||||||
const instanceData = chartData[key].find(p => p.time === time);
|
|
||||||
point[key] = instanceData ? instanceData.value : null;
|
|
||||||
});
|
|
||||||
return point;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Используем отфильтрованные данные, если они есть
|
|
||||||
const displayData = filteredData || data;
|
|
||||||
|
|
||||||
// Обработчик клика на графике
|
|
||||||
const handleClick = (e) => {
|
|
||||||
if (!e || !e.activeLabel) return;
|
|
||||||
|
|
||||||
const clickedTime = e.activeLabel;
|
|
||||||
|
|
||||||
if (!selectionStart) {
|
|
||||||
setSelectionStart(clickedTime);
|
|
||||||
} else if (!selectionEnd) {
|
|
||||||
setSelectionEnd(clickedTime);
|
|
||||||
|
|
||||||
const startIndex = data.findIndex(point => point.time === selectionStart);
|
|
||||||
const endIndex = data.findIndex(point => point.time === clickedTime);
|
|
||||||
|
|
||||||
onRangeSelect({ startIndex, endIndex });
|
|
||||||
|
|
||||||
setSelectionStart(null);
|
|
||||||
setSelectionEnd(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Кастомный Tooltip для отображения значения
|
|
||||||
const CustomTooltip = ({ active, payload, label }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="custom-tooltip" style={{ padding: '10px' }}>
|
|
||||||
<p>{`Время: ${label}`}</p>
|
|
||||||
{payload.map((entry, index) => (
|
|
||||||
<p key={index} style={{}}>
|
|
||||||
{`Значение: ${entry.value}`}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
|
||||||
<LineChart data={displayData} onClick={handleClick}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="time" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend />
|
|
||||||
{Object.keys(chartData).map((key, index) => (
|
|
||||||
<Line
|
|
||||||
key={key}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={key}
|
|
||||||
stroke={colors[index % colors.length]}
|
|
||||||
name={key}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default 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;
|
|
||||||
|
|
@ -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,27 +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);
|
|
||||||
|
|
||||||
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 />
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey="status" stroke="#8884d8" activeDot={{ r: 8 }} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SystemStatusChart;
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import DatePicker from 'react-datepicker';
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
|
||||||
import LineChartComponent from './Components/LineChartComponent';
|
|
||||||
|
|
||||||
const MAX_POINTS = 20;
|
|
||||||
const COLORS = ['#3e95cd', '#8e5ea2', '#3cba9f', '#e8c3b9', '#c45850'];
|
|
||||||
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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PrometheusChart = ({ metricName }) => {
|
|
||||||
const [chartData, setChartData] = useState({});
|
|
||||||
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 [selectedGraphRange, setSelectedGraphRange] = useState(null); // Выбранный диапазон
|
|
||||||
const [filteredData, setFilteredData] = useState(null); // Отфильтрованные данные
|
|
||||||
const intervalRef = useRef(null);
|
|
||||||
|
|
||||||
// Функция для интерполяции данных
|
|
||||||
const interpolateData = (data, minPoints = 15) => {
|
|
||||||
if (data.length >= minPoints) return data;
|
|
||||||
|
|
||||||
const interpolatedData = [];
|
|
||||||
for (let i = 0; i < data.length - 1; i++) {
|
|
||||||
interpolatedData.push(data[i]);
|
|
||||||
|
|
||||||
const currentPoint = data[i];
|
|
||||||
const nextPoint = data[i + 1];
|
|
||||||
|
|
||||||
// Вычисляем разницу во времени между точками
|
|
||||||
const currentTime = new Date(currentPoint.time).getTime();
|
|
||||||
const nextTime = new Date(nextPoint.time).getTime();
|
|
||||||
const timeDiff = nextTime - currentTime;
|
|
||||||
|
|
||||||
// Добавляем промежуточные точки
|
|
||||||
const steps = Math.ceil((minPoints - data.length) / (data.length - 1));
|
|
||||||
for (let j = 1; j <= steps; j++) {
|
|
||||||
const interpolatedTime = new Date(currentTime + (timeDiff * j) / (steps + 1)).toLocaleString();
|
|
||||||
const interpolatedPoint = { time: interpolatedTime };
|
|
||||||
|
|
||||||
// Интерполируем значения для каждой метрики
|
|
||||||
Object.keys(currentPoint).forEach(key => {
|
|
||||||
if (key !== 'time') {
|
|
||||||
const currentValue = currentPoint[key];
|
|
||||||
const nextValue = nextPoint[key];
|
|
||||||
interpolatedPoint[key] = currentValue + ((nextValue - currentValue) * j) / (steps + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
interpolatedData.push(interpolatedPoint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interpolatedData.push(data[data.length - 1]); // Добавляем последнюю точку
|
|
||||||
return interpolatedData.slice(0, minPoints); // Обрезаем до minPoints
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
let start, end;
|
|
||||||
|
|
||||||
if (useCustomRange) {
|
|
||||||
start = Math.floor(startDate.getTime() / 1000);
|
|
||||||
end = Math.floor(endDate.getTime() / 1000);
|
|
||||||
} else {
|
|
||||||
end = Math.floor(Date.now() / 1000);
|
|
||||||
start = end - selectedRange.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
let step;
|
|
||||||
const range = end - start;
|
|
||||||
if (range <= 3600) step = 5;
|
|
||||||
else if (range <= 21600) step = 30;
|
|
||||||
else if (range <= 86400) step = 120;
|
|
||||||
else step = 300;
|
|
||||||
|
|
||||||
const response = await axios.get('https://192.168.2.43:3000/metrics', {
|
|
||||||
params: { metric: metricName, start, end, step },
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
const response = await axios.get(`${process.env.REACT_APP_BACK_URL}/metrics`, {
|
|
||||||
params: { metric: metricName, start, end, step },
|
|
||||||
}); */
|
|
||||||
|
|
||||||
const result = response.data;
|
|
||||||
let metrics = Array.isArray(result) ? result : result.data || [];
|
|
||||||
|
|
||||||
if (!Array.isArray(metrics)) {
|
|
||||||
metrics = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const timePoints = [];
|
|
||||||
for (let t = start; t <= end; t += step) {
|
|
||||||
const date = new Date(t * 1000);
|
|
||||||
const formattedTime = range > 86400
|
|
||||||
? date.toLocaleString([], { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
||||||
: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
||||||
|
|
||||||
timePoints.push(formattedTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedData = {};
|
|
||||||
metrics.forEach(m => {
|
|
||||||
const date = new Date(m.timestamp);
|
|
||||||
const formattedTime = range > 86400
|
|
||||||
? date.toLocaleString([], { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
||||||
: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
||||||
|
|
||||||
const key = `${m.instance}-${m.device || m.scrape_job}`;
|
|
||||||
if (!updatedData[key]) updatedData[key] = {};
|
|
||||||
updatedData[key][formattedTime] = m.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartData = {};
|
|
||||||
Object.keys(updatedData).forEach(key => {
|
|
||||||
chartData[key] = timePoints.map(time => ({
|
|
||||||
time,
|
|
||||||
value: updatedData[key][time] ?? null,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
setChartData(chartData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при загрузке метрик:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
fetchData();
|
|
||||||
}, selectedRange.interval);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [metricName, selectedRange, useCustomRange, startDate, endDate]);
|
|
||||||
|
|
||||||
const handleRangeChange = (event) => {
|
|
||||||
const selectedValue = event.target.value;
|
|
||||||
const range = TIME_RANGES.find(range => range.value === parseInt(selectedValue, 10));
|
|
||||||
setSelectedRange(range);
|
|
||||||
setUseCustomRange(false);
|
|
||||||
setSelectedGraphRange(null); // Сбрасываем выбранный диапазон
|
|
||||||
setFilteredData(null); // Сбрасываем отфильтрованные данные
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomRangeChange = () => {
|
|
||||||
setUseCustomRange(true);
|
|
||||||
setSelectedGraphRange(null); // Сбрасываем выбранный диапазон
|
|
||||||
setFilteredData(null); // Сбрасываем отфильтрованные данные
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedGraphRange) {
|
|
||||||
const { startIndex, endIndex } = selectedGraphRange;
|
|
||||||
const allTimes = Object.values(chartData)
|
|
||||||
.flat()
|
|
||||||
.map(point => point.time)
|
|
||||||
.filter((time, index, self) => self.indexOf(time) === index);
|
|
||||||
|
|
||||||
const data = allTimes.map(time => {
|
|
||||||
const point = { time };
|
|
||||||
Object.keys(chartData).forEach(key => {
|
|
||||||
const instanceData = chartData[key].find(p => p.time === time);
|
|
||||||
point[key] = instanceData ? instanceData.value : null;
|
|
||||||
});
|
|
||||||
return point;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filtered = data.slice(startIndex, endIndex + 1);
|
|
||||||
|
|
||||||
// Интерполируем данные, если точек меньше 15
|
|
||||||
const interpolated = interpolateData(filtered, 15);
|
|
||||||
setFilteredData(interpolated); // Сохраняем интерполированные данные
|
|
||||||
} else {
|
|
||||||
setFilteredData(null); // Сбрасываем фильтрацию, если диапазон не выбран
|
|
||||||
}
|
|
||||||
}, [selectedGraphRange, chartData]);
|
|
||||||
|
|
||||||
if (!Object.keys(chartData).length) return <p>Loading...</p>;
|
|
||||||
|
|
||||||
const allTimes = Object.values(chartData)
|
|
||||||
.flat()
|
|
||||||
.map(point => point.time)
|
|
||||||
.filter((time, index, self) => self.indexOf(time) === index);
|
|
||||||
|
|
||||||
const data = allTimes.map(time => {
|
|
||||||
const point = { time };
|
|
||||||
Object.keys(chartData).forEach(key => {
|
|
||||||
const instanceData = chartData[key].find(p => p.time === time);
|
|
||||||
point[key] = instanceData ? instanceData.value : null;
|
|
||||||
});
|
|
||||||
return point;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="time-range">Выберите временной диапазон: </label>
|
|
||||||
<select id="time-range" value={selectedRange.value} onChange={handleRangeChange}>
|
|
||||||
{TIME_RANGES.map(range => (
|
|
||||||
<option key={range.value} value={range.value}>{range.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Или выберите другой диапазон: </label>
|
|
||||||
<div>
|
|
||||||
<label>Начальная дата: </label>
|
|
||||||
<DatePicker
|
|
||||||
selected={startDate}
|
|
||||||
onChange={(date) => setStartDate(date)}
|
|
||||||
showTimeSelect
|
|
||||||
timeFormat="HH:mm"
|
|
||||||
timeIntervals={15}
|
|
||||||
dateFormat="yyyy-MM-dd HH:mm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Конечная дата: </label>
|
|
||||||
<DatePicker
|
|
||||||
selected={endDate}
|
|
||||||
onChange={(date) => setEndDate(date)}
|
|
||||||
showTimeSelect
|
|
||||||
timeFormat="HH:mm"
|
|
||||||
timeIntervals={15}
|
|
||||||
dateFormat="yyyy-MM-dd HH:mm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button onClick={handleCustomRangeChange}>Использовать кастомный диапазон</button>
|
|
||||||
</div>
|
|
||||||
<LineChartComponent
|
|
||||||
chartData={chartData}
|
|
||||||
metricName={metricName}
|
|
||||||
colors={COLORS}
|
|
||||||
description={metricName}
|
|
||||||
onRangeSelect={setSelectedGraphRange}
|
|
||||||
filteredData={filteredData}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default 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,27 +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);
|
|
||||||
|
|
||||||
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 />
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey="status" stroke="#8884d8" activeDot={{ r: 8 }} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SystemStatusChart;
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import "../Style/SystemStatusTable.css";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const SystemStatusTable = () => {
|
|
||||||
const [systemData, setSystemData] = useState([]);
|
|
||||||
const [expandedRow, setExpandedRow] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Загрузка данных с бэкенда
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get("/trust.json"); // Укажите ваш endpoint
|
|
||||||
setSystemData(response.data);
|
|
||||||
setLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Обработчик для кнопки "Подробнее"
|
|
||||||
const handleDetailsClick = (id) => {
|
|
||||||
setExpandedRow(expandedRow === id ? null : id);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p>Загрузка данных...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <p>Ошибка: {error}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table>
|
|
||||||
<caption>
|
|
||||||
<h2>Состояние системы</h2>
|
|
||||||
</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Метрика</th>
|
|
||||||
<th>Значение</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th>Детали</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{systemData.map((item) => (
|
|
||||||
<React.Fragment key={item.id}>
|
|
||||||
<tr>
|
|
||||||
<td>{item.name}</td>
|
|
||||||
<td>{item.value}%</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status ${item.status}`}>{item.status}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button onClick={() => handleDetailsClick(item.id)}>
|
|
||||||
{expandedRow === item.id ? "Скрыть" : "Подробнее"}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{expandedRow === item.id && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan="4">
|
|
||||||
<div className="details">
|
|
||||||
<p>{item.details}</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SystemStatusTable;
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import "../Style/SystemStatusTable.css";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const SystemStatusTableSoftware = () => {
|
|
||||||
const [systemData, setSystemData] = useState([]);
|
|
||||||
const [expandedRow, setExpandedRow] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Загрузка данных с бэкенда
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get("/TrustSoftware.json"); // Укажите ваш endpoint
|
|
||||||
setSystemData(response.data);
|
|
||||||
setLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Обработчик для кнопки "Подробнее"
|
|
||||||
const handleDetailsClick = (id) => {
|
|
||||||
setExpandedRow(expandedRow === id ? null : id);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p>Загрузка данных...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <p>Ошибка: {error}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table>
|
|
||||||
<caption>
|
|
||||||
<h2>Состояние ПО</h2>
|
|
||||||
</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Метрика</th>
|
|
||||||
<th>Значение</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th>Детали</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{systemData.map((item) => (
|
|
||||||
<React.Fragment key={item.id}>
|
|
||||||
<tr>
|
|
||||||
<td>{item.name}</td>
|
|
||||||
<td>{item.value}%</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status ${item.status}`}>{item.status}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button onClick={() => handleDetailsClick(item.id)}>
|
|
||||||
{expandedRow === item.id ? "Скрыть" : "Подробнее"}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{expandedRow === item.id && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan="4">
|
|
||||||
<div className="details">
|
|
||||||
<p>{item.details}</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SystemStatusTableSoftware;
|
|
||||||
|
|
@ -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 SidebarMenu from "./SidebarMenu";
|
import { Box, styled } from "@mui/material";
|
||||||
import "../../Style/Dashboard.css";
|
|
||||||
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,10 +7,39 @@ 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 Dashboard = () => {
|
const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
height: '100vh',
|
||||||
|
width: '100vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MainContent = styled(Box)(({ theme }) => ({
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: theme.spacing(2.5),
|
||||||
|
overflow: 'auto',
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const Content = styled(Box)(({ theme }) => ({
|
||||||
|
backgroundColor: theme.palette.custom.modalBackground,
|
||||||
|
borderRadius: '10px',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
color: theme.palette.custom.modalText,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
@ -20,13 +48,11 @@ const Dashboard = () => {
|
||||||
history2: [],
|
history2: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Генерация контента для вкладок
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generatedTabContent = generateTabContent(menuData);
|
const generatedTabContent = generateTabContent(menuData);
|
||||||
setTabContent(generatedTabContent);
|
setTabContent(generatedTabContent);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Обновление статусов каждые 30 секунд
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const updatedData1 = JSON.parse(JSON.stringify(treeData1));
|
const updatedData1 = JSON.parse(JSON.stringify(treeData1));
|
||||||
|
|
@ -55,36 +81,123 @@ 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 (
|
||||||
<div className="dashboard-container">
|
<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>
|
||||||
|
|
||||||
{/* Сайдбар */}
|
{/* Сайдбар */}
|
||||||
<div className="sidebar" style={{ width: sidebarWidth }}>
|
<SidebarMenuWrapper
|
||||||
<SidebarMenu data={treeData1} onOpenTab={handleOpenTab} sidebarWidth={sidebarWidth} startResizing={startResizing} />
|
isDarkMode={isDarkMode}
|
||||||
<div className="sidebar-resizer" onMouseDown={startResizing} />
|
setIsDarkMode={setIsDarkMode}
|
||||||
</div>
|
onMenuSelect={handleMenuSelect}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Основной контент */}
|
{/* Основной контент */}
|
||||||
<div className="main-content">
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
{/* Вкладки */}
|
{/* Вкладки */}
|
||||||
|
<Box sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
backgroundColor: 'background.default',
|
||||||
|
zIndex: 1,
|
||||||
|
transform: 'translateY(31px)'
|
||||||
|
}}>
|
||||||
<CustomTabs
|
<CustomTabs
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabClick={setActiveTab}
|
onTabClick={setActiveTab}
|
||||||
onCloseTab={handleCloseTab}
|
onCloseTab={handleCloseTab}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Остальной контент */}
|
||||||
|
<MainContent>
|
||||||
{/* Контент вкладки */}
|
{/* Контент вкладки */}
|
||||||
<div className="content">
|
<Content>
|
||||||
<TabContent
|
<TabContent
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
statusHistories={statusHistories}
|
statusHistories={statusHistories}
|
||||||
treeData1={treeData1}
|
treeData1={treeData1}
|
||||||
tabContent={tabContent}
|
tabContent={tabContent}
|
||||||
handleOpenTab={handleOpenTab}
|
handleOpenTab={handleOpenTab}
|
||||||
|
tabs={tabs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Content>
|
||||||
</div>
|
</MainContent>
|
||||||
</div>
|
</Box>
|
||||||
|
</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,48 +1,537 @@
|
||||||
import React from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Drawer, List } from "@mui/material";
|
import {
|
||||||
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
Drawer,
|
||||||
|
List,
|
||||||
|
styled,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Box,
|
||||||
|
alpha
|
||||||
|
} from "@mui/material";
|
||||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
||||||
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
|
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||||
|
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||||
|
import LogoFull from "../../assets/images/logo.svg?react";
|
||||||
|
import LogoSmall from "../../assets/images/system_monitor_icon.svg?react";
|
||||||
|
|
||||||
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
|
import {
|
||||||
const handleSelectItem = (id, title, children) => {
|
DndContext,
|
||||||
onOpenTab(id, title, children);
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragOverlay,
|
||||||
|
MeasuringStrategy
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
|
||||||
|
import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem";
|
||||||
|
|
||||||
|
const SidebarMenu = ({
|
||||||
|
data,
|
||||||
|
isDarkMode,
|
||||||
|
setIsDarkMode,
|
||||||
|
onSelectItem,
|
||||||
|
forceRefreshMenu,
|
||||||
|
user,
|
||||||
|
}) => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const { sidebarWidth, startResizing } = useSidebarResize(320); // Увеличил минимальную ширину
|
||||||
|
const [menuItems, setMenuItems] = useState(data.items || []);
|
||||||
|
const [activeItem, setActiveItem] = useState(null);
|
||||||
|
const [hoveredItem, setHoveredItem] = useState(null);
|
||||||
|
const [dropIndicator, setDropIndicator] = useState({ show: false, position: null, targetId: null });
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = localStorage.getItem("menuTree");
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
setMenuItems(JSON.parse(cached));
|
||||||
|
} catch {
|
||||||
|
setMenuItems(data.items || []);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMenuItems(data.items || []);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleToggleCollapse = () => {
|
||||||
|
setCollapsed(!collapsed);
|
||||||
|
setHoveredItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Функции для работы с деревом (остаются без изменений)
|
||||||
|
const 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 (
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '2px',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
zIndex: 1001,
|
||||||
|
...(position === 'above' && { top: 0 }),
|
||||||
|
...(position === 'below' && { bottom: 0 }),
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-3px',
|
||||||
|
left: '10%',
|
||||||
|
width: '80%',
|
||||||
|
height: '8px',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
|
transition: "width 0.2s ease",
|
||||||
|
height: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
sx={{
|
sx={{
|
||||||
width: sidebarWidth,
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: sidebarWidth,
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
backgroundColor: "background.paper",
|
||||||
|
color: "text.primary",
|
||||||
|
transition: "width 0.2s ease, background-color 0.2s ease",
|
||||||
|
overflowX: "hidden",
|
||||||
|
borderRight: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.08)",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<List>
|
{/* Заголовок с логотипом */}
|
||||||
<h2 style={{ padding: "16px", fontWeight: "bold" }}>Меню</h2>
|
<Box
|
||||||
<MenuItem item={data} onSelectItem={handleSelectItem} />
|
sx={{
|
||||||
</List>
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
{/* Ресайзер */}
|
justifyContent: "center",
|
||||||
<div
|
p: 2,
|
||||||
onMouseDown={startResizing}
|
borderBottom: "1px solid",
|
||||||
style={{
|
borderColor: "divider",
|
||||||
width: "5px",
|
backgroundColor: "background.paper",
|
||||||
cursor: "ew-resize",
|
height: 80,
|
||||||
backgroundColor: "#ccc",
|
position: "relative",
|
||||||
height: "100%",
|
transition: "all 0.2s ease",
|
||||||
position: "absolute",
|
minHeight: 80,
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<SidebarFooter sidebarWidth={sidebarWidth} />
|
<Tooltip
|
||||||
|
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleToggleCollapse}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "action.hover",
|
||||||
|
color: "text.primary"
|
||||||
|
},
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Основное содержимое меню */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
measuring={{
|
||||||
|
droppable: {
|
||||||
|
strategy: MeasuringStrategy.Always
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext items={menuItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<List
|
||||||
|
sx={{
|
||||||
|
overflowY: "auto",
|
||||||
|
flex: "1 1 auto",
|
||||||
|
py: 1,
|
||||||
|
px: 1,
|
||||||
|
position: 'relative',
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: '6px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'text.disabled',
|
||||||
|
borderRadius: '3px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb:hover': {
|
||||||
|
background: 'text.secondary',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<Box key={item.id} position="relative">
|
||||||
|
{dropIndicator.show && dropIndicator.targetId === item.id &&
|
||||||
|
dropIndicator.position !== 'inside' && (
|
||||||
|
<DropIndicator
|
||||||
|
position={dropIndicator.position}
|
||||||
|
targetId={dropIndicator.targetId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SortableMenuItem
|
||||||
|
item={item}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onSelectItem={onSelectItem}
|
||||||
|
isHovered={hoveredItem === item.id}
|
||||||
|
showDropIndicator={dropIndicator.show && dropIndicator.targetId === item.id && dropIndicator.position === 'inside'}
|
||||||
|
sidebarWidth={sidebarWidth}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeItem ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||||
|
maxWidth: 250,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
transform: 'rotate(5deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeItem.title}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
<SidebarFooter
|
||||||
|
collapsed={collapsed}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
setIsDarkMode={setIsDarkMode}
|
||||||
|
forceRefreshMenu={forceRefreshMenu}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<Tooltip title="Изменить ширину" placement="top">
|
||||||
|
<SidebarResizer onMouseDown={startResizing} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,121 @@
|
||||||
import React from "react";
|
// // MenuItem.jsx
|
||||||
import { Drawer, List, ListItem, ListItemIcon, ListItemText, Collapse } from "@mui/material";
|
// import React, { useState } from "react";
|
||||||
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
// import {
|
||||||
|
// ListItem,
|
||||||
|
// ListItemIcon,
|
||||||
|
// ListItemText,
|
||||||
|
// Collapse,
|
||||||
|
// List,
|
||||||
|
// styled,
|
||||||
|
// Menu,
|
||||||
|
// MenuItem as MuiMenuItem
|
||||||
|
// } from "@mui/material";
|
||||||
|
// import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
||||||
|
// import StatusIndicator from "./StatusIndicator";
|
||||||
|
|
||||||
// Функция для сбора всех потомков
|
// const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||||
const getAllChildren = (node) => {
|
// cursor: "pointer",
|
||||||
let children = [];
|
// paddingLeft: theme.spacing(2 + level * 2),
|
||||||
if (node.items && node.items.length > 0) {
|
// position: 'relative',
|
||||||
node.items.forEach((child) => {
|
// '&:hover': {
|
||||||
children.push(child); // Добавляем текущий элемент
|
// backgroundColor: theme.palette.action.hover,
|
||||||
children = children.concat(getAllChildren(child)); // Рекурсивно добавляем потомков
|
// },
|
||||||
});
|
// '&.Mui-selected': {
|
||||||
}
|
// backgroundColor: theme.palette.custom.sidebarHover,
|
||||||
return children;
|
// },
|
||||||
};
|
// }));
|
||||||
|
|
||||||
const MenuItem = ({ item, onSelectItem }) => {
|
// const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
// const [isOpen, setIsOpen] = useState(false);
|
||||||
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
// const [contextMenu, setContextMenu] = useState(null);
|
||||||
|
// const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
||||||
|
|
||||||
const handleToggle = () => {
|
// const handleContextMenu = (e) => {
|
||||||
setIsOpen(!isOpen);
|
// e.preventDefault();
|
||||||
};
|
// setContextMenu(
|
||||||
|
// contextMenu === null
|
||||||
|
// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
||||||
|
// : null
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
const handleOpenTab = (e) => {
|
// const handleCloseContextMenu = () => {
|
||||||
e.stopPropagation(); // Останавливаем всплытие события
|
// setContextMenu(null);
|
||||||
const allChildren = getAllChildren(item); // Собираем всех потомков
|
// };
|
||||||
onSelectItem(item.id, item.title, allChildren); // Передаем данные в родительский компонент
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// const handleToggle = (e) => {
|
||||||
<>
|
// e.stopPropagation();
|
||||||
<ListItem component="div" onClick={handleToggle}>
|
// setIsOpen(!isOpen);
|
||||||
<ListItemIcon>
|
// };
|
||||||
<div onClick={handleOpenTab} style={{ cursor: "pointer" }}>
|
|
||||||
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
|
||||||
</div>
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary={item.title} />
|
|
||||||
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
|
||||||
</ListItem>
|
|
||||||
{hasChildren && (
|
|
||||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
|
||||||
<List component="div" disablePadding>
|
|
||||||
{item.items.map((child, index) => (
|
|
||||||
<MenuItem key={index} item={child} onSelectItem={onSelectItem} />
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuItem;
|
// const handleClick = () => {
|
||||||
|
// if (onSelectItem) {
|
||||||
|
// onSelectItem(item);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <>
|
||||||
|
// <StyledListItem
|
||||||
|
// component="div"
|
||||||
|
// onClick={hasChildren ? handleToggle : handleClick}
|
||||||
|
// onContextMenu={handleContextMenu}
|
||||||
|
// level={level}
|
||||||
|
// sx={{
|
||||||
|
// pl: collapsed ? 2 : 2 + level * 2,
|
||||||
|
// justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// {!collapsed && <StatusIndicator status={item.status} />}
|
||||||
|
|
||||||
|
// <ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
|
||||||
|
// {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
||||||
|
// </ListItemIcon>
|
||||||
|
|
||||||
|
// {!collapsed && (
|
||||||
|
// <>
|
||||||
|
// <ListItemText
|
||||||
|
// primary={item.title}
|
||||||
|
// primaryTypographyProps={{
|
||||||
|
// color: 'custom.sidebarText'
|
||||||
|
// }}
|
||||||
|
// />
|
||||||
|
// {hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// </StyledListItem>
|
||||||
|
|
||||||
|
// <Menu
|
||||||
|
// open={contextMenu !== null}
|
||||||
|
// onClose={handleCloseContextMenu}
|
||||||
|
// anchorReference="anchorPosition"
|
||||||
|
// anchorPosition={
|
||||||
|
// contextMenu !== null
|
||||||
|
// ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||||
|
// : undefined
|
||||||
|
// }
|
||||||
|
// >
|
||||||
|
|
||||||
|
// </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,16 +1,153 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { List, ListItem, ListItemText } from "@mui/material";
|
import { Brightness4, Brightness7, Settings, Help } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
alpha
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
styled,
|
||||||
|
Switch,
|
||||||
|
} from "@mui/material";
|
||||||
|
import SettingsModal from "../SettingsModal";
|
||||||
|
import { RoleBasedRender } from "../../UI/RoleBasedRender";
|
||||||
|
|
||||||
|
const FooterList = styled(List)(({ theme }) => ({
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
padding: theme.spacing(1, 0),
|
||||||
|
borderTop: `1px solid ${theme.palette.divider}`,
|
||||||
|
marginTop: 'auto'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const FooterListItem = styled(ListItem)(({ theme }) => ({
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: alpha(theme.palette.action.hover, 0.4),
|
||||||
|
},
|
||||||
|
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 = ({
|
||||||
|
collapsed,
|
||||||
|
isDarkMode,
|
||||||
|
setIsDarkMode,
|
||||||
|
forceRefreshMenu,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSettingsOpen = () => {
|
||||||
|
setSettingsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingsClose = () => {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const SidebarFooter = ({ sidebarWidth }) => {
|
|
||||||
return (
|
return (
|
||||||
<List sx={{ marginTop: "auto", backgroundColor: "#ffffff", padding: "10px 0" }}>
|
<>
|
||||||
<ListItem button={true}>
|
<FooterList>
|
||||||
<ListItemText primary="Помощь" sx={{ color: "#000000" }} />
|
{!collapsed ? (
|
||||||
</ListItem>
|
<>
|
||||||
<ListItem button={true}>
|
<FooterListItem>
|
||||||
<ListItemText primary="Настройка" sx={{ color: "#000000" }} />
|
<Button
|
||||||
</ListItem>
|
onClick={handleSettingsOpen}
|
||||||
</List>
|
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}
|
||||||
|
/>
|
||||||
|
</RoleBasedRender>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import ReactFlow, { Controls, Background } from 'reactflow';
|
||||||
|
import 'reactflow/dist/style.css';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import { useFlowChart } from './FlowChartComponents/useFlowChart';
|
||||||
|
import { useNodeHandlers } from './FlowChartComponents/useNodeHandlers';
|
||||||
|
import { useDataParser } from './FlowChartComponents/DataParser';
|
||||||
|
import NodeWrapper from './FlowChartComponents/NodeWrapper';
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
customNode: NodeWrapper
|
||||||
|
};
|
||||||
|
|
||||||
|
const FlowChart = ({ data }) => {
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
nodePositions,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
onNodesChange,
|
||||||
|
onEdgesChange,
|
||||||
|
setNodePositions,
|
||||||
|
collapsedNodes,
|
||||||
|
toggleNodeCollapse
|
||||||
|
} = useFlowChart(data);
|
||||||
|
|
||||||
|
const { parseData } = useDataParser(nodePositions, collapsedNodes);
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
const debouncedSetNodePositions = useMemo(
|
||||||
|
() => debounce(setNodePositions, 100),
|
||||||
|
[setNodePositions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { onNodeDrag, onNodeDragStop } = useNodeHandlers(debouncedSetNodePositions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { nodes: initialNodes, edges: initialEdges } = parseData(data);
|
||||||
|
setNodes(initialNodes);
|
||||||
|
setEdges(initialEdges);
|
||||||
|
|
||||||
|
// Автоматически сворачиваем узлы, которые являются родителями последнего уровня
|
||||||
|
if (!initialized.current && data) {
|
||||||
|
const findAndCollapseLastLevelParents = (items) => {
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.items && item.items.length > 0) {
|
||||||
|
const hasGrandchildren = item.items.some(child =>
|
||||||
|
child.items && child.items.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasGrandchildren) {
|
||||||
|
toggleNodeCollapse(item.id);
|
||||||
|
} else {
|
||||||
|
findAndCollapseLastLevelParents(item.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
findAndCollapseLastLevelParents(data.items || []);
|
||||||
|
initialized.current = true;
|
||||||
|
}
|
||||||
|
}, [data, parseData, setNodes, setEdges, toggleNodeCollapse]);
|
||||||
|
|
||||||
|
const onNodeClick = (event, node) => {
|
||||||
|
if (node.data.hasChildren) {
|
||||||
|
toggleNodeCollapse(node.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedSetNodePositions.cancel();
|
||||||
|
};
|
||||||
|
}, [debouncedSetNodePositions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '85vh', width: '100%' }}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onNodeDrag={onNodeDrag}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
|
nodeDragThreshold={1}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
fitView
|
||||||
|
>
|
||||||
|
<Background />
|
||||||
|
<Controls />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(FlowChart);
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { isLeafNode } from './nodeUtils';
|
||||||
|
import { getStatusColor } from '../dataUtils';
|
||||||
|
|
||||||
|
export const useDataParser = (nodePositions, collapsedNodes) => {
|
||||||
|
const getNodeStyle = useCallback((item, isLeaf) => ({
|
||||||
|
width: isLeaf ? 60 : 70,
|
||||||
|
height: isLeaf ? 60 : 70,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: getStatusColor(item.status),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'black',
|
||||||
|
border: '2px solid #fff',
|
||||||
|
fontSize: isLeaf ? '0.8rem' : '1rem'
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const getCenterNodeStyle = useCallback((item) => ({
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: getStatusColor(item.status),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'black',
|
||||||
|
border: '2px solid #fff',
|
||||||
|
fontSize: '1.2rem'
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const parseData = useCallback((data) => {
|
||||||
|
if (!data) return { nodes: [], edges: [] };
|
||||||
|
|
||||||
|
const nodes = [];
|
||||||
|
const edges = [];
|
||||||
|
const centerX = 500;
|
||||||
|
const centerY = 400;
|
||||||
|
const baseLevelRadius = 150;
|
||||||
|
|
||||||
|
const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI, parentRadius = 0) => {
|
||||||
|
if (!item || collapsedNodes[parentId]) return;
|
||||||
|
|
||||||
|
const nodeId = item.id;
|
||||||
|
const items = item.items || [];
|
||||||
|
const isLeaf = isLeafNode(item);
|
||||||
|
|
||||||
|
const savedPosition = nodePositions[nodeId];
|
||||||
|
let position = savedPosition || {
|
||||||
|
x: Math.round(centerX + Math.cos((angleStart + angleEnd) / 2) * (parentRadius + baseLevelRadius)),
|
||||||
|
y: Math.round(centerY + Math.sin((angleStart + angleEnd) / 2) * (parentRadius + baseLevelRadius))
|
||||||
|
};
|
||||||
|
|
||||||
|
const node = {
|
||||||
|
id: nodeId,
|
||||||
|
type: 'customNode',
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
...item,
|
||||||
|
label: item.title,
|
||||||
|
style: getNodeStyle(item, isLeaf),
|
||||||
|
hasChildren: items.length > 0,
|
||||||
|
collapsed: collapsedNodes[nodeId]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes.push(node);
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
edges.push({
|
||||||
|
id: `${parentId}-${nodeId}`,
|
||||||
|
source: parentId,
|
||||||
|
target: nodeId,
|
||||||
|
style: { stroke: isLeaf ? '#aaa' : '#666', strokeWidth: isLeaf ? 1 : 2 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collapsedNodes[nodeId] && items.length > 0) {
|
||||||
|
const spreadAngle = angleEnd - angleStart;
|
||||||
|
items.forEach((child, index) => {
|
||||||
|
if (!child) return;
|
||||||
|
const itemAngleStart = angleStart + (index / items.length) * spreadAngle;
|
||||||
|
const itemAngleEnd = angleStart + ((index + 1) / items.length) * spreadAngle;
|
||||||
|
traverse(child, nodeId, level + 1, itemAngleStart, itemAngleEnd, parentRadius + baseLevelRadius);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const centerNode = {
|
||||||
|
id: data.id,
|
||||||
|
type: 'customNode',
|
||||||
|
position: nodePositions[data.id] || { x: centerX, y: centerY },
|
||||||
|
style: getCenterNodeStyle(data),
|
||||||
|
data: { label: data.title, hasChildren: data.items.length > 0, collapsed: collapsedNodes[data.id] }
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
nodes.push(centerNode);
|
||||||
|
|
||||||
|
if (!collapsedNodes[data.id] && data.items.length > 0) {
|
||||||
|
const angleStep = (2 * Math.PI) / data.items.length;
|
||||||
|
data.items.forEach((child, index) => {
|
||||||
|
if (!child) return;
|
||||||
|
traverse(child, data.id, 1, index * angleStep, (index + 1) * angleStep, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}, [nodePositions, collapsedNodes, getNodeStyle, getCenterNodeStyle]);
|
||||||
|
|
||||||
|
return { parseData };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Handle } from 'reactflow';
|
||||||
|
|
||||||
|
const NodeWrapper = memo(({ id, data, selected }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...data.style,
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
padding: '0 8px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
title={data.label}
|
||||||
|
>
|
||||||
|
{/* Хендл для входящих соединений */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position="top"
|
||||||
|
style={{ visibility: 'hidden' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Обёртка для текста с ограничением ширины */}
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}>
|
||||||
|
{data.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.hasChildren && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#fff',
|
||||||
|
padding: '2px 5px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
border: '1px solid #aaa'
|
||||||
|
}}>
|
||||||
|
{data.collapsed ? '+' : '-'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Хендл для исходящих соединений */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position="bottom"
|
||||||
|
style={{ visibility: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default NodeWrapper;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const isLeafNode = (item) => {
|
||||||
|
return !item.items || item.items.length === 0;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useNodesState, useEdgesState } from 'reactflow';
|
||||||
|
import { statusManager1 } from '../dataUtils';
|
||||||
|
|
||||||
|
export const useFlowChart = (initialData) => {
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
|
const [nodePositions, setNodePositions] = useState({});
|
||||||
|
const [collapsedNodes, setCollapsedNodes] = useState({});
|
||||||
|
|
||||||
|
const toggleNodeCollapse = useCallback((nodeId) => {
|
||||||
|
setCollapsedNodes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nodeId]: !prev[nodeId]
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initializeNodePositions = useCallback((nodes) => {
|
||||||
|
const positions = {};
|
||||||
|
nodes.forEach(node => {
|
||||||
|
positions[node.id] = node.position;
|
||||||
|
});
|
||||||
|
setNodePositions(positions);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateStatuses = (data) => {
|
||||||
|
statusManager1.updateStatuses(data);
|
||||||
|
};
|
||||||
|
updateStatuses(initialData);
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
nodePositions,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
onNodesChange,
|
||||||
|
onEdgesChange,
|
||||||
|
setNodePositions,
|
||||||
|
collapsedNodes,
|
||||||
|
toggleNodeCollapse,
|
||||||
|
initializeNodePositions
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
export const useNodeHandlers = (debouncedSetNodePositions) => {
|
||||||
|
const onNodeDrag = useCallback((event, node) => {
|
||||||
|
node.position = {
|
||||||
|
x: Math.round(node.position.x),
|
||||||
|
y: Math.round(node.position.y)
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onNodeDragStop = useCallback((event, node) => {
|
||||||
|
node.position = {
|
||||||
|
x: Math.round(node.position.x),
|
||||||
|
y: Math.round(node.position.y)
|
||||||
|
};
|
||||||
|
debouncedSetNodePositions(prev => ({
|
||||||
|
...prev,
|
||||||
|
[node.id]: node.position
|
||||||
|
}));
|
||||||
|
}, [debouncedSetNodePositions]);
|
||||||
|
|
||||||
|
return { onNodeDrag, onNodeDragStop };
|
||||||
|
};
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
import React, { useRef, useEffect, useMemo } from "react";
|
|
||||||
import * as d3 from "d3";
|
|
||||||
import "../../Style/TreeChart.css";
|
|
||||||
import { getStatusColor } from "./dataUtils";
|
|
||||||
|
|
||||||
const TreeChart = ({ data, onNodeClick }) => {
|
|
||||||
const chartRef = useRef();
|
|
||||||
const nodePositions = useRef(new Map());
|
|
||||||
|
|
||||||
const { root, nodes, links } = useMemo(() => {
|
|
||||||
if (!data || !data.items) return { root: null, nodes: [], links: [] };
|
|
||||||
|
|
||||||
const root = d3.hierarchy(data, (d) => d.items);
|
|
||||||
const maxDepth = d3.max(root.descendants(), (d) => d.depth);
|
|
||||||
|
|
||||||
// Фильтруем узлы, исключая последний уровень
|
|
||||||
const nodes = root.descendants().filter((d) => d.depth < maxDepth);
|
|
||||||
|
|
||||||
// Фильтруем связи
|
|
||||||
const links = nodes.filter((d) => d.parent).map((d) => ({
|
|
||||||
source: d.parent,
|
|
||||||
target: d,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Размещаем узлы иерархически
|
|
||||||
const center = { x: 0, y: 0 }; // Центральная точка
|
|
||||||
const baseRadius = 150; // Базовый радиус для 1-го уровня
|
|
||||||
const branchOffset = 80; // Смещение узлов вдоль ветки
|
|
||||||
const angleOffset = Math.PI / 4; // Угол смещения для дочерних ветвей
|
|
||||||
const spreadFactor = 1.5; // Коэффициент растяжения для последних узлов
|
|
||||||
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
const prev = nodePositions.current.get(node.data.id);
|
|
||||||
if (prev) {
|
|
||||||
node.x = prev.x;
|
|
||||||
node.y = prev.y;
|
|
||||||
} else {
|
|
||||||
if (node.depth === 0) {
|
|
||||||
// Центральный узел
|
|
||||||
node.x = center.x;
|
|
||||||
node.y = center.y;
|
|
||||||
} else if (node.depth === 1) {
|
|
||||||
// Первый уровень - равномерно по окружности
|
|
||||||
const parent = node.parent;
|
|
||||||
const index = parent.children.indexOf(node);
|
|
||||||
const totalSiblings = parent.children.length;
|
|
||||||
|
|
||||||
const radius = baseRadius * node.depth;
|
|
||||||
const sectorAngle = (Math.PI * 2) / totalSiblings;
|
|
||||||
const angle = index * sectorAngle;
|
|
||||||
|
|
||||||
node.x = parent.x + radius * Math.cos(angle);
|
|
||||||
node.y = parent.y + radius * Math.sin(angle);
|
|
||||||
node.angle = angle; // Запоминаем угол для веток
|
|
||||||
} else {
|
|
||||||
// Второй уровень и дальше - ветка растет в направлении родителя
|
|
||||||
const parent = node.parent;
|
|
||||||
const siblings = parent.children || [];
|
|
||||||
const index = siblings.indexOf(node);
|
|
||||||
const totalSiblings = siblings.length;
|
|
||||||
|
|
||||||
const direction = parent.angle || 0;
|
|
||||||
const offsetAngle = ((index - (totalSiblings - 1) / 2) * angleOffset) / totalSiblings;
|
|
||||||
|
|
||||||
let distance = branchOffset;
|
|
||||||
if (!node.children || node.children.length === 0) {
|
|
||||||
// Если это последний узел, увеличиваем расстояние
|
|
||||||
distance *= spreadFactor + node.depth * 0.2; // Чем глубже, тем больше разброс
|
|
||||||
}
|
|
||||||
|
|
||||||
node.x = parent.x + distance * Math.cos(direction + offsetAngle);
|
|
||||||
node.y = parent.y + distance * Math.sin(direction + offsetAngle);
|
|
||||||
node.angle = direction + offsetAngle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nodePositions.current.set(node.data.id, { x: node.x, y: node.y });
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return { root, nodes, links };
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chartRef.current) return;
|
|
||||||
|
|
||||||
const svg = d3.select(chartRef.current)
|
|
||||||
.attr("width", 2000)
|
|
||||||
.attr("height", 2000)
|
|
||||||
.attr("viewBox", [-500, -500, 1500, 1500])
|
|
||||||
.attr("style", "max-width: 100%; height: auto;");
|
|
||||||
|
|
||||||
svg.append("g").attr("class", "links");
|
|
||||||
svg.append("g").attr("class", "nodes");
|
|
||||||
svg.append("g").attr("class", "labels");
|
|
||||||
|
|
||||||
// Очищаем предыдущие элементы
|
|
||||||
svg.selectAll(".links line").remove();
|
|
||||||
svg.selectAll(".nodes circle").remove();
|
|
||||||
svg.selectAll(".labels text").remove();
|
|
||||||
|
|
||||||
// Рисуем связи
|
|
||||||
const linkGroup = svg.select(".links");
|
|
||||||
const link = linkGroup
|
|
||||||
.selectAll("line")
|
|
||||||
.data(links, (d) => `${d.source.data.id}-${d.target.data.id}`)
|
|
||||||
.join("line")
|
|
||||||
.attr("stroke", "#999")
|
|
||||||
.attr("stroke-opacity", 0.6)
|
|
||||||
.attr("x1", (d) => d.source.x)
|
|
||||||
.attr("y1", (d) => d.source.y)
|
|
||||||
.attr("x2", (d) => d.target.x)
|
|
||||||
.attr("y2", (d) => d.target.y);
|
|
||||||
|
|
||||||
// Рисуем узлы
|
|
||||||
const nodeGroup = svg.select(".nodes");
|
|
||||||
const node = nodeGroup
|
|
||||||
.selectAll("circle")
|
|
||||||
.data(nodes, (d) => d.data.id)
|
|
||||||
.join("circle")
|
|
||||||
.attr("fill", (d) => getStatusColor(d.data.status))
|
|
||||||
.attr("stroke", "#fff")
|
|
||||||
.attr("r", 7)
|
|
||||||
.attr("cx", (d) => d.x)
|
|
||||||
.attr("cy", (d) => d.y)
|
|
||||||
.call(drag());
|
|
||||||
|
|
||||||
node.on("click", (event, d) => {
|
|
||||||
if (onNodeClick) {
|
|
||||||
onNodeClick(d.data.id, d.data.title);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Рисуем текстовые метки
|
|
||||||
const labelGroup = svg.select(".labels");
|
|
||||||
const text = labelGroup
|
|
||||||
.selectAll("text")
|
|
||||||
.data(nodes, (d) => d.data.id)
|
|
||||||
.join("text")
|
|
||||||
.text((d) => (nodes.length > 50 ? "" : d.data.title)) // Скрываем текст, если узлов много
|
|
||||||
.attr("dx", 12)
|
|
||||||
.attr("dy", 4)
|
|
||||||
.style("user-select", "none") // Запрет выделения текста
|
|
||||||
.style("pointer-events", "none") // Запрет взаимодействия с текстом
|
|
||||||
.style("fill", "var(--TreeChart-text-color)") // Используем переменную для цвета текста
|
|
||||||
.attr("x", (d) => d.x + 12)
|
|
||||||
.attr("y", (d) => d.y + 4);
|
|
||||||
|
|
||||||
}, [root, links, nodes, onNodeClick]);
|
|
||||||
|
|
||||||
const drag = () => {
|
|
||||||
function dragstarted(event, d) {
|
|
||||||
d3.select(this).raise().attr("stroke", "#000");
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragged(event, d) {
|
|
||||||
d.x = event.x;
|
|
||||||
d.y = event.y;
|
|
||||||
d3.select(this).attr("cx", d.x).attr("cy", d.y);
|
|
||||||
|
|
||||||
// Обновляем текстовую метку
|
|
||||||
d3.select(this.parentNode)
|
|
||||||
.select("text")
|
|
||||||
.attr("x", d.x + 12)
|
|
||||||
.attr("y", d.y + 4);
|
|
||||||
|
|
||||||
// Обновляем связи
|
|
||||||
d3.select(chartRef.current)
|
|
||||||
.selectAll(".links line")
|
|
||||||
.filter((link) => link.source === d || link.target === d)
|
|
||||||
.attr("x1", (link) => link.source.x)
|
|
||||||
.attr("y1", (link) => link.source.y)
|
|
||||||
.attr("x2", (link) => link.target.x)
|
|
||||||
.attr("y2", (link) => link.target.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragended(event, d) {
|
|
||||||
d3.select(this).attr("stroke", "#fff");
|
|
||||||
nodePositions.current.set(d.data.id, { x: d.x, y: d.y });
|
|
||||||
}
|
|
||||||
|
|
||||||
return d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended);
|
|
||||||
};
|
|
||||||
|
|
||||||
return <svg ref={chartRef} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TreeChart;
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,714 +0,0 @@
|
||||||
{
|
|
||||||
"title": "Сервис ЗВКС",
|
|
||||||
"id": "1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Функциональные задачи",
|
|
||||||
"id": "functional_tasks",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "system_control",
|
|
||||||
"title": "Контроль системы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "system_management",
|
|
||||||
"title": "Система управления"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "conference",
|
|
||||||
"title": "Проведение ВКС"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "backup",
|
|
||||||
"title": "Резервное копирование"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "relay_info",
|
|
||||||
"title": "Ретрансляция информации"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "18",
|
|
||||||
"title": "Graviton S2082I (device$18)",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "4",
|
|
||||||
"title": "OS Linux (module$4) АО",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "190",
|
|
||||||
"title": "Загрузка процессора за 1 минуту"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "191",
|
|
||||||
"title": "Загрузка процессора за 5 минут"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "192",
|
|
||||||
"title": "Загрузка процессора за 15 минут"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "197",
|
|
||||||
"title": "Общий объем SWAP-файла"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "198",
|
|
||||||
"title": "Используемый объем SWAP-файла"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "199",
|
|
||||||
"title": "Общий объем физической оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "200",
|
|
||||||
"title": "Доступный объем физической оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "201",
|
|
||||||
"title": "Свободный объем физической и виртуальной оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "202",
|
|
||||||
"title": "Буферизованный объем оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "203",
|
|
||||||
"title": "Кэшированый объем оперативной памяти"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "274",
|
|
||||||
"title": "Используемый объем SWAP-файла"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "275",
|
|
||||||
"title": "Время затраченное процессором на процессы с пониженным приоритетом"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "276",
|
|
||||||
"title": "Время затраченное процессором на процессы ядра ОС"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "277",
|
|
||||||
"title": "Время простоя процессора"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "278",
|
|
||||||
"title": "Общая емкость жестких дисков"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "279",
|
|
||||||
"title": "Доступная емкость жестких дисков"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "5",
|
|
||||||
"title": "Vinteo (module$5) ПО",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "31",
|
|
||||||
"title": "Общее количество участников"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "32",
|
|
||||||
"title": "Ожидание соединения"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "33",
|
|
||||||
"title": "Зарегистрированные абоненты"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "34",
|
|
||||||
"title": "Количество пользоватей HLS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "35",
|
|
||||||
"title": "Общее количество P2P комнат"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "36",
|
|
||||||
"title": "Общее количество конференций"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "37",
|
|
||||||
"title": "Общее количество активных конференций"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "38",
|
|
||||||
"title": "Статус записи"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "39",
|
|
||||||
"title": "Общее количество сохранённых записей"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "280",
|
|
||||||
"title": "Сетевой адаптер №1 (port$261) Eth_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "207",
|
|
||||||
"title": "Скорость порта Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "209",
|
|
||||||
"title": "Административное состояние порта Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "210",
|
|
||||||
"title": "Оперативное состояние порта Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "211",
|
|
||||||
"title": "Общее количество отправленных октетов Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "212",
|
|
||||||
"title": "Количество входящих Multicast пакетов Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "213",
|
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "214",
|
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "215",
|
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "216",
|
|
||||||
"title": "Количество входящих Unicast пакетов Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "217",
|
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "218",
|
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "219",
|
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "220",
|
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "221",
|
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "222",
|
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "281",
|
|
||||||
"title": "Сетевой адаптер №2 (port$262) Eth_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "224",
|
|
||||||
"title": "Скорость порта Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "226",
|
|
||||||
"title": "Административное состояние порта Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "227",
|
|
||||||
"title": "Оперативное состояние порта Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "228",
|
|
||||||
"title": "Общее количество отправленных октетов Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "229",
|
|
||||||
"title": "Количество входящих Multicast пакетов Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "230",
|
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "231",
|
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "232",
|
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "233",
|
|
||||||
"title": "Количество входящих Unicast пакетов Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "234",
|
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "235",
|
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "236",
|
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "237",
|
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "238",
|
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "239",
|
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_2"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "282",
|
|
||||||
"title": "Сетевой адаптер №3 (port$263) Eth_3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "241",
|
|
||||||
"title": "Скорость порта Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "243",
|
|
||||||
"title": "Административное состояние порта Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "244",
|
|
||||||
"title": "Оперативное состояние порта Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "245",
|
|
||||||
"title": "Общее количество отправленных октетов Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "246",
|
|
||||||
"title": "Количество входящих Multicast пакетов Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "247",
|
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "248",
|
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "249",
|
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "250",
|
|
||||||
"title": "Количество входящих Unicast пакетов Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "251",
|
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "252",
|
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "253",
|
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "254",
|
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "255",
|
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "256",
|
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_3"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "283",
|
|
||||||
"title": "Сетевой адаптер №4 (port$264) Eth_4",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "258",
|
|
||||||
"title": "Скорость порта Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "260",
|
|
||||||
"title": "Административное состояние порта Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "261",
|
|
||||||
"title": "Оперативное состояние порта Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "262",
|
|
||||||
"title": "Общее количество отправленных октетов Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "263",
|
|
||||||
"title": "Количество входящих Multicast пакетов Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "264",
|
|
||||||
"title": "Количество иcходящих Multiicast пакетов Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "265",
|
|
||||||
"title": "Количество входящих Broadcast пакетов Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "266",
|
|
||||||
"title": "Количество иcходящих Broadcast пакетов Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "267",
|
|
||||||
"title": "Количество входящих Unicast пакетов Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "268",
|
|
||||||
"title": "Количество иcходящих Unicast пакетов Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "269",
|
|
||||||
"title": "Количество входящих пакетов помеченные как отброшенные Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "270",
|
|
||||||
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "271",
|
|
||||||
"title": "Количество входящих пакетов с ошибкой Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "272",
|
|
||||||
"title": "Количество исходящих пакетов с ошибкой Eth_4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "273",
|
|
||||||
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_4"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id": "media_server_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id": "system_software_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1_2",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2_2",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3_2",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4_2",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id": "software_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4_2",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id": "media_server_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id": "system_software_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1_3",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2_3",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3_3",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4_3",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id": "software_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4_3",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id": "media_server_3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id": "system_software_3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1_4",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2_4",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3_4",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4_4",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id": "software_3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1_4",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2_4",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3_4",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4_4",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id": "media_server_4",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id": "system_software_4",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1_5",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2_5",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3_5",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4_5",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id": "software_4",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1_5",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2_5",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3_5",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4_5",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Сервер систем",
|
|
||||||
"id": "system_server_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id": "system_software_5",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "copy_system_software_1",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_system_software_2",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_system_software_3",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_system_software_4",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id": "software_5",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "copy_software_1",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_software_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_software_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_software_4",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Сервер систем",
|
|
||||||
"id": "system_server_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id": "system_software_6",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "control_system_software_1",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_system_software_2",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_system_software_3",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_system_software_4",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id": "software_6",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "control_software_1",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_software_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_software_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_software_4",
|
|
||||||
"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";
|
||||||
|
|
||||||
// Функция для генерации названия метрики на основе 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 }} />
|
||||||
//!!!!!!!!!!Пофиксить вкладуи с eth4, во всех eth 1-4 открывается именно 4 !!!!!!!!!!!!!
|
</Box>
|
||||||
|
|
||||||
// Функция для рекурсивного сбора всех id потомков
|
|
||||||
const getAllChildIds = (node) => {
|
|
||||||
let ids = [];
|
|
||||||
if (node.id) {
|
|
||||||
ids.push(node.id); // Добавляем id текущего узла
|
|
||||||
}
|
|
||||||
if (node.items && node.items.length > 0) {
|
|
||||||
node.items.forEach((child) => {
|
|
||||||
ids = ids.concat(getAllChildIds(child)); // Рекурсивно собираем id потомков
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabContent = (data) => {
|
|
||||||
const tabContent = {};
|
|
||||||
|
|
||||||
// Функция для рекурсивного обхода и сбора данных
|
|
||||||
const generateContent = (nodes) => {
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
// Если у узла есть вложенные элементы, рекурсивно обрабатываем их
|
|
||||||
if (node.items && node.items.length > 0) {
|
|
||||||
// Создаем контент для родителя
|
|
||||||
const childrenContent = generateContent(node.items);
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<div>
|
|
||||||
<h2>{node.title}</h2>
|
|
||||||
<p>Контент для {node.title}.</p>
|
|
||||||
{childrenContent}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Сохраняем контент для текущего id
|
// Компонент Skeleton для контейнера
|
||||||
tabContent[node.id] = {
|
const ContainerSkeleton = () => (
|
||||||
title: node.title,
|
<Box sx={{ width: "100%" }}>
|
||||||
content: content,
|
<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;
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
// Если у узла нет вложенных элементов, это самый нижний уровень
|
// Основная функция построения контента вкладок
|
||||||
const metricName = getMetricName(node.id);
|
const tabContent = (data, cache = {}) => {
|
||||||
|
const tabContentMap = { ...cache };
|
||||||
|
|
||||||
|
if (!data || !data.items || data.items.length === 0) {
|
||||||
|
console.warn("Данные отсутствуют или массив items пуст", data);
|
||||||
|
return tabContentMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processNode = (node, parentContext = {}) => {
|
||||||
|
// Получаем полный контекст из всей цепочки родителей
|
||||||
|
const pathContext = parseContextFromPath(node);
|
||||||
|
const currentContext = { ...parentContext, ...pathContext };
|
||||||
|
|
||||||
|
// Генерируем уникальный ключ на основе пути
|
||||||
|
const path = [];
|
||||||
|
let current = node;
|
||||||
|
while (current) {
|
||||||
|
path.unshift(current.id);
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
const pathId = path.join('_');
|
||||||
|
|
||||||
|
if (Array.isArray(node.items) && node.items.length > 0) {
|
||||||
|
const children = node.items
|
||||||
|
.map((child) => processNode(child, currentContext))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div key={node.id}>
|
<div key={`${pathId}-container`}>
|
||||||
<h3>{node.title}</h3> {/* Используем title узла */}
|
<h2>{node.title}</h2>
|
||||||
<Suspense fallback={<div>Загрузка графика...</div>}>
|
<Suspense fallback={<ContainerSkeleton />}>
|
||||||
<PrometheusChart metricName={metricName} />
|
<LazyChartBatchRenderer charts={children.map((c) => c.content)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
tabContentMap[pathId] = {
|
||||||
|
title: node.title,
|
||||||
|
content,
|
||||||
|
context: currentContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Начинаем обработку с корневого уровня
|
return { content, context: currentContext };
|
||||||
if (data.items && data.items.length > 0) {
|
|
||||||
generateContent(data.items);
|
|
||||||
} else {
|
|
||||||
console.warn("Данные отсутствуют или массив items пуст");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabContent;
|
// Узел без метрики и без вложенных — просто заголовок
|
||||||
|
const content = (
|
||||||
|
<div key={pathId}>
|
||||||
|
<h3>{node.title}</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
tabContentMap[pathId] = {
|
||||||
|
title: node.title,
|
||||||
|
content,
|
||||||
|
context: currentContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { content, context: currentContext };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
processNode(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка обработки данных:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
const StyledButton = styled(Button)(({ theme }) => ({
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
// Дополнительные стили
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CustomButton = ({
|
||||||
|
children,
|
||||||
|
variant = 'contained',
|
||||||
|
color = 'primary',
|
||||||
|
loading = false,
|
||||||
|
startIcon,
|
||||||
|
endIcon,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<StyledButton
|
||||||
|
variant={variant}
|
||||||
|
color={color}
|
||||||
|
startIcon={startIcon && !loading ? startIcon : undefined}
|
||||||
|
endIcon={endIcon && !loading ? endIcon : undefined}
|
||||||
|
disabled={loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={24} /> : children}
|
||||||
|
</StyledButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomButton;
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import criticalIcon from "../../assets/images/critical.png"; // Красный треугольник
|
|
||||||
import warningIcon from "../../assets/images/warning.png"; // Желтый треугольник
|
|
||||||
import "../../Style/ErrorIndicator.css"; // Подключаем стили
|
|
||||||
|
|
||||||
const ErrorIndicator = ({ criticalCount, warningCount }) => {
|
|
||||||
return (
|
|
||||||
<div className="error-indicator">
|
|
||||||
{/* Красный индикатор (критические ошибки) */}
|
|
||||||
<div className="error-item critical">
|
|
||||||
<img src={criticalIcon} alt="Критическая ошибка" />
|
|
||||||
<span>{criticalCount}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Желтый индикатор (предупреждения) */}
|
|
||||||
<div className="error-item warning">
|
|
||||||
<img src={warningIcon} alt="Предупреждение" />
|
|
||||||
<span>{warningCount}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ErrorIndicator;
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
import "../Style/Expandable.css"
|
|
||||||
|
|
||||||
const ExpandableInfo = ({ details }) => {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const toggleExpand = () => {
|
|
||||||
setIsExpanded(!isExpanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="expandable-info">
|
|
||||||
<button onClick={toggleExpand} className="expand-button">
|
|
||||||
{isExpanded ? "Скрыть" : "Подробнее"}
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="details-menu">
|
|
||||||
{details.map((detail, index) => (
|
|
||||||
<div key={index} className="detail-item">
|
|
||||||
<span className="label">{detail.label}:</span>
|
|
||||||
<span className="value">{detail.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpandableInfo;
|
|
||||||
|
|
@ -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 TextField from '@mui/material/TextField';
|
import {
|
||||||
|
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(
|
||||||
console.log("Отправляем данные:", { username, password });
|
//`${import.meta.env.VITE_BACK_URL}/api/auth/login`,
|
||||||
const response = await fetch('http://192.168.2.39:3000/auth/login', {
|
'/api/auth/login',
|
||||||
method: 'POST',
|
{ login: username, password },
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ login: username, password }),
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
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,10 +1,58 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Tabs, Tab, Box } 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 }) => ({
|
||||||
|
minHeight: 48,
|
||||||
|
padding: theme.spacing(1, 2),
|
||||||
|
textTransform: 'none',
|
||||||
|
'&.Mui-selected': {
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: `2px solid ${theme.palette.primary.main}`,
|
||||||
|
outlineOffset: '-2px',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TabLabel = ({ title, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
minWidth: 0 // Для корректного обрезания длинного текста
|
||||||
|
}}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
noWrap
|
||||||
|
sx={{
|
||||||
|
maxWidth: 120,
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<CloseIcon
|
||||||
|
fontSize="small"
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
'&:hover': {
|
||||||
|
color: 'error.main'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||||
const handleMouseDown = (e, id) => {
|
const handleMouseDown = (e, id) => {
|
||||||
if (e.button === 1) {
|
if (e.button === 1) { // Средняя кнопка мыши
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCloseTab(id);
|
onCloseTab(id);
|
||||||
}
|
}
|
||||||
|
|
@ -14,43 +62,50 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||||
onTabClick(newValue);
|
onTabClick(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Статические вкладки (сохраняем оригинальные id)
|
||||||
|
const staticTabs = [
|
||||||
|
{ id: "Главная", title: "Главная" },
|
||||||
|
{ id: "Визуализация", title: "Визуализация" }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
<Box sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
'& .MuiTabs-indicator': {
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
variant="scrollable"
|
variant="scrollable"
|
||||||
scrollButtons="auto"
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
aria-label="tabs"
|
aria-label="tabs"
|
||||||
>
|
>
|
||||||
{/* Всегда отображаемые вкладки */}
|
{/* Статические вкладки */}
|
||||||
<Tab
|
{staticTabs.map(tab => (
|
||||||
label="Главная"
|
<StyledTab
|
||||||
value="Главная"
|
key={`static_${tab.id}`} // Добавляем префикс для уникальности
|
||||||
onMouseDown={(e) => handleMouseDown(e, "Главная")}
|
label={tab.title}
|
||||||
/>
|
value={tab.id} // Используем id как value
|
||||||
<Tab
|
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
||||||
label="Визуализация"
|
|
||||||
value="Визуализация"
|
|
||||||
onMouseDown={(e) => handleMouseDown(e, "Визуализация")}
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Динамически добавляемые вкладки */}
|
{/* Динамические вкладки */}
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Tab
|
<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"
|
|
||||||
sx={{ ml: 1, cursor: "pointer" }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onCloseTab(tab.id);
|
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;
|
||||||
|
};
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import "../../Style/common.css"; // Общие стили для табов
|
|
||||||
|
|
||||||
const Tabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
|
||||||
const handleMouseDown = (e, id) => {
|
|
||||||
// Проверяем, была ли нажата средняя кнопка мыши (button === 1)
|
|
||||||
if (e.button === 1) {
|
|
||||||
e.preventDefault(); // Предотвращаем стандартное поведение (например, прокрутку)
|
|
||||||
onCloseTab(id); // Закрываем вкладку
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tabs">
|
|
||||||
{/* Всегда отображаемые вкладки */}
|
|
||||||
<div
|
|
||||||
className={`tab ${activeTab === "Главная" ? "active" : ""}`}
|
|
||||||
onClick={() => onTabClick("Главная")}
|
|
||||||
onMouseDown={(e) => handleMouseDown(e, "Главная")} // Добавляем обработчик для СКМ
|
|
||||||
>
|
|
||||||
<span>Главная</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`tab ${activeTab === "Визуализация" ? "active" : ""}`}
|
|
||||||
onClick={() => onTabClick("Визуализация")}
|
|
||||||
onMouseDown={(e) => handleMouseDown(e, "Визуализация")} // Добавляем обработчик для СКМ
|
|
||||||
>
|
|
||||||
<span>Визуализация</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Динамически добавляемые вкладки */}
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
className={`tab ${activeTab === tab.id ? "active" : ""}`}
|
|
||||||
onClick={() => onTabClick(tab.id)}
|
|
||||||
onMouseDown={(e) => handleMouseDown(e, tab.id)} // Добавляем обработчик для СКМ
|
|
||||||
>
|
|
||||||
<span>{tab.title}</span>
|
|
||||||
<button
|
|
||||||
className="close-tab"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCloseTab(tab.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Tabs;
|
|
||||||
|
|
@ -1,12 +1,27 @@
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import "../../Style/TreeTable.css";
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Button,
|
||||||
|
Collapse,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
useTheme,
|
||||||
|
Tooltip
|
||||||
|
} from '@mui/material';
|
||||||
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
||||||
|
|
||||||
const TreeTable = ({ data }) => {
|
const TreeTable = ({ data }) => {
|
||||||
|
const theme = useTheme();
|
||||||
const tableRef = useRef(null);
|
const tableRef = useRef(null);
|
||||||
const [fontSize, setFontSize] = useState(16);
|
const [fontSize, setFontSize] = useState(16);
|
||||||
const [log, setLog] = useState([]);
|
const [log, setLog] = useState([]);
|
||||||
const [isLogVisible, setIsLogVisible] = useState(true);
|
const [isLogVisible, setIsLogVisible] = useState(false);
|
||||||
|
|
||||||
const adjustFontSize = () => {
|
const adjustFontSize = () => {
|
||||||
if (tableRef.current) {
|
if (tableRef.current) {
|
||||||
|
|
@ -27,6 +42,13 @@ const TreeTable = ({ data }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adjustFontSize();
|
||||||
|
window.addEventListener('resize', adjustFontSize);
|
||||||
|
return () => window.removeEventListener('resize', adjustFontSize);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Логирование статусов
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newLog = [];
|
const newLog = [];
|
||||||
const traverse = (items) => {
|
const traverse = (items) => {
|
||||||
|
|
@ -35,7 +57,7 @@ const TreeTable = ({ data }) => {
|
||||||
newLog.push({
|
newLog.push({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
time: new Date().toLocaleTimeString(), // Добавляем время
|
time: new Date().toLocaleTimeString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item.items) {
|
if (item.items) {
|
||||||
|
|
@ -44,200 +66,285 @@ const TreeTable = ({ data }) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
traverse(data.items);
|
traverse(data.items);
|
||||||
|
setLog(prevLog => [...newLog, ...prevLog].slice(0, 50));
|
||||||
// Ограничиваем количество сообщений до 50
|
|
||||||
setLog((prevLog) => [...newLog, ...prevLog].slice(0, 50));
|
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const filteredData = data.items.filter((item) => item.title !== "Функциональные задачи");
|
const filteredData = data.items.filter(item => item.title !== "Функциональные задачи");
|
||||||
|
|
||||||
// Функция для отображения заголовков
|
// Компонент индикаторов статуса
|
||||||
const renderHeaders = (items) => {
|
const StatusIndicators = ({ status }) => (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '4px',
|
||||||
|
height: '20px',
|
||||||
|
display: 'inline-block',
|
||||||
|
backgroundColor: statusManager1.getStatusColor(status),
|
||||||
|
marginRight: '4px',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '4px',
|
||||||
|
height: '20px',
|
||||||
|
display: 'inline-block',
|
||||||
|
backgroundColor: statusManager2.getStatusColor(status),
|
||||||
|
marginRight: '8px',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ячейка с тултипом
|
||||||
|
const TableCellWithTooltip = ({ children, title, ...props }) => (
|
||||||
|
<Tooltip title={title} arrow>
|
||||||
|
<TableCell {...props}>
|
||||||
|
{children}
|
||||||
|
</TableCell>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Рендер заголовков (первый уровень)
|
||||||
|
const renderMainHeaders = (items) => {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const colSpan = item.items ? item.items.length : 1;
|
const colSpan = item.items ? item.items.length : 1;
|
||||||
return (
|
return (
|
||||||
<th key={item.id} colSpan={colSpan} className="tree-table-header" title={item.title}>
|
<TableCellWithTooltip
|
||||||
<div className="header-content">
|
key={item.id}
|
||||||
<div
|
colSpan={colSpan}
|
||||||
className="status-indicator-bar"
|
align="center"
|
||||||
style={{ backgroundColor: statusManager1.getStatusColor(item.status) }}
|
title={item.title}
|
||||||
/>
|
sx={{
|
||||||
<div
|
backgroundColor: theme.palette.background.paper,
|
||||||
className="status-indicator-bar"
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
style={{
|
padding: '8px',
|
||||||
backgroundColor: statusManager2.getStatusColor(item.status),
|
whiteSpace: 'nowrap',
|
||||||
marginLeft: "5px",
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<StatusIndicators status={item.status} />
|
||||||
|
<Typography component="span" variant="subtitle2" noWrap>
|
||||||
{item.title}
|
{item.title}
|
||||||
</div>
|
</Typography>
|
||||||
</th>
|
</TableCellWithTooltip>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для отображения подзаголовков
|
// Рендер подзаголовков (второй уровень)
|
||||||
const renderSubHeaders = (items) => {
|
const renderSubHeaders = (items) => {
|
||||||
return items.map((item) => {
|
return items.flatMap((item) => {
|
||||||
if (item.items) {
|
if (item.items) {
|
||||||
return item.items.map((child) => (
|
return item.items.map((child) => (
|
||||||
<th key={child.id} className="tree-table-header" title={child.title}>
|
<TableCellWithTooltip
|
||||||
<div className="header-content">
|
key={child.id}
|
||||||
<div
|
align="center"
|
||||||
className="status-indicator-bar"
|
title={child.title}
|
||||||
style={{ backgroundColor: statusManager1.getStatusColor(child.status) }}
|
sx={{
|
||||||
/>
|
backgroundColor: theme.palette.background.paper,
|
||||||
<div
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
className="status-indicator-bar"
|
padding: '8px',
|
||||||
style={{
|
whiteSpace: 'nowrap',
|
||||||
backgroundColor: statusManager2.getStatusColor(child.status),
|
overflow: 'hidden',
|
||||||
marginLeft: "5px",
|
textOverflow: 'ellipsis'
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<StatusIndicators status={child.status} />
|
||||||
|
<Typography component="span" variant="subtitle2" noWrap>
|
||||||
{child.title}
|
{child.title}
|
||||||
</div>
|
</Typography>
|
||||||
</th>
|
</TableCellWithTooltip>
|
||||||
));
|
));
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<th key={item.id} className="tree-table-header" title={item.title}>
|
|
||||||
<div className="header-content">
|
|
||||||
<div
|
|
||||||
className="status-indicator-bar"
|
|
||||||
style={{ backgroundColor: statusManager1.getStatusColor(item.status) }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="status-indicator-bar"
|
|
||||||
style={{
|
|
||||||
backgroundColor: statusManager2.getStatusColor(item.status),
|
|
||||||
marginLeft: "5px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<TableCellWithTooltip
|
||||||
|
key={item.id}
|
||||||
|
align="center"
|
||||||
|
title={item.title}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={item.status} />
|
||||||
|
<Typography component="span" variant="subtitle2" noWrap>
|
||||||
|
{item.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для отображения данных
|
// Рендер данных (третий уровень)
|
||||||
const renderData = (items) => {
|
const renderDataCells = (items) => {
|
||||||
return items.map((item) => {
|
return items.flatMap((item) => {
|
||||||
if (item.items) {
|
if (item.items) {
|
||||||
return item.items.map((child) => {
|
return item.items.flatMap((child) => {
|
||||||
if (child.items) {
|
if (child.items) {
|
||||||
return child.items.map((subChild) => (
|
return child.items.map((subChild) => (
|
||||||
<td key={subChild.id} className="tree-table-cell" title={subChild.title}>
|
<TableCellWithTooltip
|
||||||
<div className="cell-content">
|
key={subChild.id}
|
||||||
<div
|
title={subChild.title}
|
||||||
className="status-indicator-bar"
|
sx={{
|
||||||
style={{ backgroundColor: statusManager1.getStatusColor(subChild.status) }}
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
/>
|
padding: '8px',
|
||||||
<div
|
whiteSpace: 'nowrap',
|
||||||
className="status-indicator-bar"
|
overflow: 'hidden',
|
||||||
style={{
|
textOverflow: 'ellipsis'
|
||||||
backgroundColor: statusManager2.getStatusColor(subChild.status),
|
|
||||||
marginLeft: "5px",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<span className="cell-text">{subChild.title}</span>
|
<StatusIndicators status={subChild.status} />
|
||||||
</div>
|
<Typography component="span" variant="body2" noWrap>
|
||||||
</td>
|
{subChild.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
));
|
));
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<td key={child.id} className="tree-table-cell" title={child.title}>
|
|
||||||
<div className="cell-content">
|
|
||||||
<div
|
|
||||||
className="status-indicator-bar"
|
|
||||||
style={{ backgroundColor: statusManager1.getStatusColor(child.status) }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="status-indicator-bar"
|
|
||||||
style={{
|
|
||||||
backgroundColor: statusManager2.getStatusColor(child.status),
|
|
||||||
marginLeft: "5px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="cell-text">{child.title}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<TableCellWithTooltip
|
||||||
|
key={child.id}
|
||||||
|
title={child.title}
|
||||||
|
sx={{
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={child.status} />
|
||||||
|
<Typography component="span" variant="body2" noWrap>
|
||||||
|
{child.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<td key={item.id} className="tree-table-cell" title={item.title}>
|
|
||||||
<div className="cell-content">
|
|
||||||
<div
|
|
||||||
className="status-indicator-bar"
|
|
||||||
style={{ backgroundColor: statusManager1.getStatusColor(item.status) }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="status-indicator-bar"
|
|
||||||
style={{
|
|
||||||
backgroundColor: statusManager2.getStatusColor(item.status),
|
|
||||||
marginLeft: "5px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="cell-text">{item.title}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<TableCellWithTooltip
|
||||||
|
key={item.id}
|
||||||
|
title={item.title}
|
||||||
|
sx={{
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={item.status} />
|
||||||
|
<Typography component="span" variant="body2" noWrap>
|
||||||
|
{item.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tree-table-container">
|
<Box sx={{ width: '100%' }}>
|
||||||
<table ref={tableRef} className="tree-table" style={{ fontSize: `${fontSize}px` }}>
|
<TableContainer
|
||||||
<thead>
|
component={Paper}
|
||||||
<tr>
|
ref={tableRef}
|
||||||
<th
|
sx={{
|
||||||
colSpan={filteredData.reduce((acc, item) => acc + (item.items ? item.items.length : 1), 0)}
|
fontSize: `${fontSize}px`,
|
||||||
className="tree-table-header"
|
width: '100%',
|
||||||
title={data.title}
|
'& .MuiTableCell-root': {
|
||||||
>
|
py: 1,
|
||||||
<div className="header-content">
|
px: 2
|
||||||
<div
|
}
|
||||||
className="status-indicator-bar"
|
|
||||||
style={{ backgroundColor: statusManager1.getStatusColor(data.status) }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="status-indicator-bar"
|
|
||||||
style={{
|
|
||||||
backgroundColor: statusManager2.getStatusColor(data.status),
|
|
||||||
marginLeft: "5px",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Table sx={{ width: '100%', tableLayout: 'fixed' }}>
|
||||||
|
<TableHead>
|
||||||
|
{/* Основной заголовок таблицы */}
|
||||||
|
<TableRow>
|
||||||
|
<TableCellWithTooltip
|
||||||
|
colSpan={filteredData.reduce((acc, item) => acc + (item.items ? item.items.length : 1), 0)}
|
||||||
|
align="center"
|
||||||
|
title={data.title}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={data.status} />
|
||||||
|
<Typography component="span" variant="subtitle1" fontWeight="bold" noWrap>
|
||||||
{data.title}
|
{data.title}
|
||||||
</div>
|
</Typography>
|
||||||
</th>
|
</TableCellWithTooltip>
|
||||||
</tr>
|
</TableRow>
|
||||||
<tr>{renderHeaders(filteredData)}</tr>
|
|
||||||
<tr>{renderSubHeaders(filteredData)}</tr>
|
{/* Строка с основными заголовками */}
|
||||||
</thead>
|
<TableRow>
|
||||||
<tbody>
|
{renderMainHeaders(filteredData)}
|
||||||
<tr className="tree-table-row">{renderData(filteredData)}</tr>
|
</TableRow>
|
||||||
</tbody>
|
|
||||||
</table>
|
{/* Строка с подзаголовками (которая пропала в предыдущей версии) */}
|
||||||
<button onClick={() => setIsLogVisible(!isLogVisible)} className="toggle-log-button">
|
<TableRow>
|
||||||
{isLogVisible ? "Скрыть лог" : "Показать лог"}
|
{renderSubHeaders(filteredData)}
|
||||||
</button>
|
</TableRow>
|
||||||
{isLogVisible && (
|
</TableHead>
|
||||||
<div className="status-log">
|
|
||||||
<h3>Лог статусов</h3>
|
<TableBody>
|
||||||
<ul>
|
<TableRow>
|
||||||
|
{renderDataCells(filteredData)}
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setIsLogVisible(!isLogVisible)}
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
{isLogVisible ? 'Скрыть историю изменения статусов' : 'Показать историю изменения статусов'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Collapse in={isLogVisible}>
|
||||||
|
<Box sx={{
|
||||||
|
mt: 2,
|
||||||
|
p: 2,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
borderRadius: 1,
|
||||||
|
backgroundColor: theme.palette.background.paper
|
||||||
|
}}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
История изменения статусов
|
||||||
|
</Typography>
|
||||||
|
<Box component="ul" sx={{
|
||||||
|
pl: 2,
|
||||||
|
maxHeight: 400,
|
||||||
|
overflow: 'auto',
|
||||||
|
listStyle: 'none'
|
||||||
|
}}>
|
||||||
{log.map((entry, index) => (
|
{log.map((entry, index) => (
|
||||||
<li key={index} style={{ color: statusManager1.getStatusColor(entry.status) }}>
|
<Box
|
||||||
|
component="li"
|
||||||
|
key={index}
|
||||||
|
sx={{
|
||||||
|
py: 1,
|
||||||
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
color: statusManager1.getStatusColor(entry.status)
|
||||||
|
}}
|
||||||
|
>
|
||||||
[{entry.time}] {entry.status}: {entry.title}
|
[{entry.time}] {entry.status}: {entry.title}
|
||||||
</li>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
</Collapse>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
|
||||||
|
const LazyChartBatchRenderer = ({ charts }) => {
|
||||||
|
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(() => {
|
||||||
|
observerRef.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
updateVisibleIndices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: '500px 0px',
|
||||||
|
threshold: 0.01
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
placeholderRefs.current.forEach(ref => {
|
||||||
|
if (ref) observerRef.current.observe(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (cleanupTimeoutRef.current) {
|
||||||
|
clearTimeout(cleanupTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTimeoutRef.current = setTimeout(() => {
|
||||||
|
updateVisibleIndices();
|
||||||
|
|
||||||
|
setVisibleIndices(prev => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
placeholderRefs.current.forEach((ref, index) => {
|
||||||
|
if (ref && isElementFarFromViewport(ref) && prev.has(index)) {
|
||||||
|
updated.delete(index);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? updated : prev;
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', updateVisibleIndices, { passive: true });
|
||||||
|
|
||||||
|
updateVisibleIndices();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (cleanupTimeoutRef.current) clearTimeout(cleanupTimeoutRef.current);
|
||||||
|
if (observerRef.current) observerRef.current.disconnect();
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', updateVisibleIndices);
|
||||||
|
};
|
||||||
|
}, [updateVisibleIndices]);
|
||||||
|
|
||||||
|
const shouldShowChart = (index) => {
|
||||||
|
return visibleIndices.has(index) ||
|
||||||
|
visibleIndices.has(index - 1) ||
|
||||||
|
visibleIndices.has(index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{charts.map((chart, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={(el) => (placeholderRefs.current[index] = el)}
|
||||||
|
data-index={index}
|
||||||
|
style={{
|
||||||
|
minHeight: '400px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
transition: 'opacity 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shouldShowChart(index) ? chart : <ChartSkeleton />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(LazyChartBatchRenderer);
|
||||||
|
|
@ -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,30 +1,99 @@
|
||||||
import SystemStatusChart from "../../Charts/SystemStatusChart";
|
|
||||||
import TreeTable from "../UI/TreeTable";
|
import TreeTable from "../UI/TreeTable";
|
||||||
import TreeChart from "../TreeChart/TreeChart";
|
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>
|
||||||
);
|
);
|
||||||
} else if (activeTab === "Визуализация") {
|
} else if (activeTab === "Визуализация") {
|
||||||
return <TreeChart 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) => {
|
||||||
|
const newTabs = prevTabs.filter((tab) => tab.id !== id);
|
||||||
if (activeTab === id) {
|
if (activeTab === id) {
|
||||||
setActiveTab(tabs.length > 1 ? tabs[tabs.length - 2].id : initialTab);
|
setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : initialTab);
|
||||||
}
|
}
|
||||||
}, [activeTab, tabs, initialTab]);
|
return newTabs;
|
||||||
|
});
|
||||||
|
}, [activeTab, initialTab]);
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
/* Основной контейнер */
|
|
||||||
.dashboard-container {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100vw;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Сайдбар */
|
|
||||||
.sidebar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 100vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: var(--sidebar-color);
|
|
||||||
color: var(--sidebar-text-color);
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Основной контент */
|
|
||||||
.main-content {
|
|
||||||
flex-grow: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 20px;
|
|
||||||
overflow: auto;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Контент */
|
|
||||||
.content {
|
|
||||||
background-color: var(--modal-background);
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.521);
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Заголовки */
|
|
||||||
h2 {
|
|
||||||
color: var(--text-color);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
.error-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-item img {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-item span {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.critical span {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning span {
|
|
||||||
color: orange;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
.expandable-info {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-button {
|
|
||||||
background-color: #444;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-button:hover {
|
|
||||||
background-color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-menu {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
/* Сайдбар */
|
|
||||||
.sidebar {
|
|
||||||
height: 100vh;
|
|
||||||
background-color: var(--sidebar-color);
|
|
||||||
color: var(--sidebar-text-color);
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: 999;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Контейнер для основного контента меню */
|
|
||||||
.sidebar-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
padding-right: 10px;
|
|
||||||
/* Отступ справа для скроллбара */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Заголовок меню */
|
|
||||||
.sidebar-title {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 1.5em;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--sidebar-text-color);
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
/* font-size: 2vh; */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Элементы меню */
|
|
||||||
.menu-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: var(--sidebar-text-color);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Элемент для перетаскивания */
|
|
||||||
.sidebar-resizer {
|
|
||||||
width: 5px;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
cursor: ew-resize;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-resizer:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для заголовка элемента меню */
|
|
||||||
.menu-item-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
/* Распределяем пространство между элементами */
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
width: 100%;
|
|
||||||
/* Занимаем всю доступную ширину */
|
|
||||||
box-sizing: border-box;
|
|
||||||
/* Учитываем padding в ширине */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для текста элемента меню */
|
|
||||||
.menu-item-header span {
|
|
||||||
flex: 1;
|
|
||||||
/* Текст занимает все доступное пространство */
|
|
||||||
margin-right: 14px;
|
|
||||||
/* Отступ справа для текста */
|
|
||||||
overflow: hidden;
|
|
||||||
/* Скрываем текст, который не помещается */
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
/* Добавляем многоточие, если текст не помещается */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для иконок */
|
|
||||||
.menu-item-header .open-parent-icon,
|
|
||||||
.menu-item-header .toggle-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
/* Запрещаем сжатие иконок */
|
|
||||||
margin-left: 1px;
|
|
||||||
/* Отступ между иконками */
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item-header:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Круглый индикатор статуса */
|
|
||||||
.status-indicator {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Подменю */
|
|
||||||
.submenu {
|
|
||||||
margin-left: 20px;
|
|
||||||
/* Отступ слева для вложенных элементов */
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для элементов нижнего уровня вложенности */
|
|
||||||
|
|
||||||
/* Дополнительные отступы для элементов без иконок */
|
|
||||||
.menu-item:not(.has-children) .menu-item-header {
|
|
||||||
padding-right: 25px;
|
|
||||||
/* Добавляем отступ справа для элементов без иконок */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Футер сайдбара */
|
|
||||||
.sidebar-footer {
|
|
||||||
padding: 10px;
|
|
||||||
background-color: var(--sidebar-color);
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help,
|
|
||||||
.settings {
|
|
||||||
color: var(--sidebar-text-color);
|
|
||||||
margin: 5px 0;
|
|
||||||
overflow-x: hidden;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
.tree-table-container {
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: hidden;
|
|
||||||
/* Убираем горизонтальный скролл */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
text-align: center;
|
|
||||||
table-layout: fixed;
|
|
||||||
/* Фиксированная ширина колонок */
|
|
||||||
background-color: var(--table-cell-background);
|
|
||||||
color: var(--table-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-table-header {
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid black;
|
|
||||||
font-weight: bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
/* Текст не переносится */
|
|
||||||
overflow: hidden;
|
|
||||||
/* Скрываем текст, который не помещается */
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
/* Добавляем многоточие */
|
|
||||||
background-color: var(--table-header-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-table-cell {
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid black;
|
|
||||||
white-space: nowrap;
|
|
||||||
/* Текст не переносится */
|
|
||||||
overflow: hidden;
|
|
||||||
/* Скрываем текст, который не помещается */
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
/* Добавляем многоточие */
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-content,
|
|
||||||
.header-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-text {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-bar {
|
|
||||||
width: 6px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
/* Контейнер для вкладок */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 5px;
|
|
||||||
background-color: var(--sidebar-color);
|
|
||||||
border-bottom: 2px solid var(--accent-color);
|
|
||||||
overflow-x: auto;
|
|
||||||
border-radius: 5px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для отдельной вкладки */
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background-color: var(--sidebar-color);
|
|
||||||
color: var(--sidebar-text-color);
|
|
||||||
/* Используем переменную для цвета текста */
|
|
||||||
padding: 5px 15px;
|
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Активная вкладка */
|
|
||||||
.tab.active {
|
|
||||||
background-color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Кнопка закрытия вкладки */
|
|
||||||
.close-tab {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--sidebar-text-color);
|
|
||||||
/* Используем переменную для цвета текста */
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-left: 10px;
|
|
||||||
padding: 0;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Эффект при наведении на кнопку закрытия */
|
|
||||||
.close-tab:hover {
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Эффект при наведении на вкладку */
|
|
||||||
.tab:hover {
|
|
||||||
background-color: var(--accent-hover-color);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/* Темная тема, если пользователь предпочитает ее */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background-color: #1E1E1E;
|
|
||||||
--text-color: #E0E0E0;
|
|
||||||
--header-color: #FFFFFF;
|
|
||||||
/* Основной цвет текста (светлый) */
|
|
||||||
--sidebar-color: #2d2d2d;
|
|
||||||
/* Темный цвет сайдбара */
|
|
||||||
--sidebar-text-color: #E0E0E0;
|
|
||||||
/* Светлый текст в сайдбаре */
|
|
||||||
--modal-background: #2d2d2d;
|
|
||||||
--modal--btn-background: #333333;
|
|
||||||
--modal-text: #FFFFFF;
|
|
||||||
--table-border: #444444;
|
|
||||||
--table-header-background: #2d2d2d;
|
|
||||||
--table-cell-background: #333333;
|
|
||||||
--table-text-color: #E0E0E0;
|
|
||||||
/* Светлый текст в таблице */
|
|
||||||
--TreeChart-text-color: #ffffff;
|
|
||||||
--scrollbar-track-color: #333;
|
|
||||||
/* hover for buttons */
|
|
||||||
--hover-button: #333d4d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
/* Светлая тема по умолчанию */
|
|
||||||
:root {
|
|
||||||
--background-color: #FFFFFF;
|
|
||||||
--text-color: #000000;
|
|
||||||
--header-color: #333333;
|
|
||||||
/* Основной цвет текста (черный) */
|
|
||||||
--sidebar-color: #3d74c7;
|
|
||||||
/* Синий цвет сайдбара */
|
|
||||||
--sidebar-text-color: #FFFFFF;
|
|
||||||
/* Белый текст в сайдбаре и вкладках */
|
|
||||||
--modal-background: #FFFFFF;
|
|
||||||
--modal--btn-background: #0f55bec2;
|
|
||||||
--modal-text: #333333;
|
|
||||||
--table-border: #ddd;
|
|
||||||
--table-header-background: #f9f9f9;
|
|
||||||
--table-cell-background: #FFFFFF;
|
|
||||||
--table-text-color: #000000;
|
|
||||||
/* Черный текст в таблице */
|
|
||||||
|
|
||||||
/* hover for buttons */
|
|
||||||
--hover-button: #2d62b1;
|
|
||||||
--hover-text-color: #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +1,191 @@
|
||||||
import { createTheme } from "@mui/material/styles";
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общие настройки темы, применяемые для обеих тем (светлой и темной)
|
||||||
|
*/
|
||||||
|
const commonThemeSettings = {
|
||||||
|
// Настройки формы элементов
|
||||||
|
shape: {
|
||||||
|
borderRadius: 8, // Базовый радиус скругления углов для всех компонентов
|
||||||
|
},
|
||||||
|
|
||||||
|
// Переопределения стилей конкретных MUI компонентов
|
||||||
|
components: {
|
||||||
|
// Стили для компонента Drawer (боковое меню)
|
||||||
|
MuiDrawer: {
|
||||||
|
styleOverrides: {
|
||||||
|
paper: {
|
||||||
|
borderRight: 'none', // Убираем правую границу у бокового меню
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTab: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: 'none', // Убираем uppercase
|
||||||
|
minWidth: 'unset', // Убираем минимальную ширину
|
||||||
|
padding: '6px 16px',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'primary.main',
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
color: 'primary.main',
|
||||||
|
},
|
||||||
|
'&.Mui-focusVisible': {
|
||||||
|
backgroundColor: 'action.selected',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTabs: {
|
||||||
|
styleOverrides: {
|
||||||
|
indicator: {
|
||||||
|
height: 3, // Толщина индикатора
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Стили для кнопок-элементов списка
|
||||||
|
MuiListItemButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
// Стиль для выбранного элемента
|
||||||
|
'&.Mui-selected': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.16)',
|
||||||
|
},
|
||||||
|
// Стиль при наведении на выбранный элемент
|
||||||
|
'&.Mui-selected:hover': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.24)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Светлая тема приложения
|
||||||
|
*/
|
||||||
export const lightTheme = createTheme({
|
export const lightTheme = createTheme({
|
||||||
|
...commonThemeSettings, // Распаковываем общие настройки
|
||||||
|
|
||||||
|
// Цветовая палитра для светлой темы
|
||||||
palette: {
|
palette: {
|
||||||
mode: "light",
|
mode: "light", // Режим светлой темы
|
||||||
|
|
||||||
|
// Фоновые цвета
|
||||||
background: {
|
background: {
|
||||||
default: "#FFFFFF",
|
default: "#FFFFFF", // Основной фон приложения
|
||||||
paper: "#FFFFFF",
|
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Текстовые цвета
|
||||||
text: {
|
text: {
|
||||||
primary: "#000000",
|
primary: "#000000", // Основной цвет текста
|
||||||
|
secondary: "#333333", // Вторичный цвет текста
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Основные цвета UI
|
||||||
primary: {
|
primary: {
|
||||||
main: "#3d74c7",
|
main: "#3d74c7", // Основной брендовый цвет
|
||||||
|
contrastText: "#FFFFFF", // Цвет текста на кнопках primary цвета
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Дополнительные цвета UI
|
||||||
secondary: {
|
secondary: {
|
||||||
main: "#0f55bec2",
|
main: "#0f55bec2", // Вторичный брендовый цвет
|
||||||
},
|
},
|
||||||
|
|
||||||
|
divider: "#e0e0e0", // Цвет разделителей
|
||||||
|
|
||||||
|
// Кастомные цвета для специфических элементов
|
||||||
custom: {
|
custom: {
|
||||||
background: "#FFFFFF",
|
background: "#D4EFFC", // Кастомный фоновый цвет
|
||||||
text: "#000000",
|
text: "#000000", // Кастомный цвет текста
|
||||||
sidebar: "#3d74c7",
|
sidebar: "#025EA1", // Фон боковой панели
|
||||||
sidebarText: "#FFFFFF",
|
sidebarText: "#FFFFFF", // Текст в боковой панели
|
||||||
modalBackground: "#FFFFFF",
|
sidebarHover: "rgba(255, 255, 255, 0.08)", // Цвет при наведении в боковой панели
|
||||||
modalBtnBackground: "#0f55bec2",
|
modalBackground: "#FFFFFF", // Фон модальных окон
|
||||||
modalText: "#333333",
|
modalBtnBackground: "#0f55bec2", // Фон кнопок в модальных окнах
|
||||||
tableBorder: "#ddd",
|
modalText: "#333333", // Текст в модальных окнах
|
||||||
tableHeaderBackground: "#f9f9f9",
|
tableBorder: "#ddd", // Границы таблиц
|
||||||
tableCellBackground: "#FFFFFF",
|
tableHeaderBackground: "#f9f9f9", // Фон заголовков таблиц
|
||||||
tableText: "#000000",
|
tableCellBackground: "#FFFFFF", // Фон ячеек таблиц
|
||||||
treeChartText: "#000000",
|
tableText: "#000000", // Текст в таблицах
|
||||||
scrollbarTrack: "#f1f1f1",
|
treeChartText: "#000000", // Текст в древовидных диаграммах
|
||||||
hoverButton: "#2d62b1",
|
scrollbarTrack: "#f1f1f1", // Цвет трека скроллбара
|
||||||
hoverText: "#FFFFFF",
|
hoverButton: "#2d62b1", // Цвет кнопок при наведении
|
||||||
|
hoverText: "#FFFFFF", // Цвет текста при наведении
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Цвета для различных состояний
|
||||||
|
action: {
|
||||||
|
hover: "rgba(0, 0, 0, 0.04)", // Цвет при наведении на интерактивные элементы
|
||||||
|
selected: "rgba(0, 0, 0, 0.08)", // Цвет выбранных элементов
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Темная тема приложения
|
||||||
|
*/
|
||||||
export const darkTheme = createTheme({
|
export const darkTheme = createTheme({
|
||||||
|
...commonThemeSettings, // Распаковываем общие настройки
|
||||||
|
|
||||||
|
// Цветовая палитра для темной темы
|
||||||
palette: {
|
palette: {
|
||||||
mode: "dark",
|
mode: "dark", // Режим темной темы
|
||||||
|
|
||||||
|
// Фоновые цвета
|
||||||
background: {
|
background: {
|
||||||
default: "#1E1E1E",
|
default: "#2d2d2d", // Основной фон приложения
|
||||||
paper: "#2d2d2d",
|
paper: "#2d2d2d", // Фон "бумажных" поверхностей
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Текстовые цвета
|
||||||
text: {
|
text: {
|
||||||
primary: "#E0E0E0",
|
primary: "#E0E0E0", // Основной цвет текста
|
||||||
|
secondary: "#B0B0B0", // Вторичный цвет текста
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Основные цвета UI
|
||||||
primary: {
|
primary: {
|
||||||
main: "#2d2d2d",
|
main: "#3d74c7", // Основной брендовый цвет (может совпадать со светлой темой)
|
||||||
|
contrastText: "#FFFFFF", // Цвет текста на кнопках primary цвета
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Дополнительные цвета UI
|
||||||
secondary: {
|
secondary: {
|
||||||
main: "#333333",
|
main: "#0f55bec2", // Вторичный брендовый цвет
|
||||||
},
|
},
|
||||||
|
|
||||||
|
divider: "#444444", // Цвет разделителей
|
||||||
|
|
||||||
|
// Кастомные цвета для специфических элементов
|
||||||
custom: {
|
custom: {
|
||||||
background: "#1E1E1E",
|
background: "#1E1E1E", // Кастомный фоновый цвет
|
||||||
text: "#E0E0E0",
|
text: "#E0E0E0", // Кастомный цвет текста
|
||||||
sidebar: "#2d2d2d",
|
sidebar: "#2d2d2d", // Фон боковой панели
|
||||||
sidebarText: "#E0E0E0",
|
sidebarText: "#E0E0E0", // Текст в боковой панели
|
||||||
modalBackground: "#2d2d2d",
|
sidebarHover: "rgba(255, 255, 255, 0.16)", // Цвет при наведении в боковой панели
|
||||||
modalBtnBackground: "#333333",
|
modalBackground: "#2d2d2d", // Фон модальных окон
|
||||||
modalText: "#FFFFFF",
|
modalBtnBackground: "#333333", // Фон кнопок в модальных окнах
|
||||||
tableBorder: "#444444",
|
modalText: "#FFFFFF", // Текст в модальных окнах
|
||||||
tableHeaderBackground: "#2d2d2d",
|
tableBorder: "#444444", // Границы таблиц
|
||||||
tableCellBackground: "#333333",
|
tableHeaderBackground: "#2d2d2d", // Фон заголовков таблиц
|
||||||
tableText: "#E0E0E0",
|
tableCellBackground: "#333333", // Фон ячеек таблиц
|
||||||
treeChartText: "#FFFFFF",
|
tableText: "#E0E0E0", // Текст в таблицах
|
||||||
scrollbarTrack: "#333",
|
treeChartText: "#FFFFFF", // Текст в древовидных диаграммах
|
||||||
hoverButton: "#333d4d",
|
scrollbarTrack: "#333", // Цвет трека скроллбара
|
||||||
hoverText: "#E0E0E0",
|
hoverButton: "#333d4d", // Цвет кнопок при наведении
|
||||||
|
hoverText: "#E0E0E0", // Цвет текста при наведении
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Цвета для различных состояний
|
||||||
|
action: {
|
||||||
|
hover: "rgba(255, 255, 255, 0.08)", // Цвет при наведении на интерактивные элементы
|
||||||
|
selected: "rgba(255, 255, 255, 0.16)", // Цвет выбранных элементов
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 28.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 273.81 45.36" style="enable-background:new 0 0 273.81 45.36;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#428AC9;}
|
||||||
|
.st1{fill:url(#SVGID_1_);}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<rect x="58.03" y="1.44" class="st0" width="1.62" height="40.83"/>
|
||||||
|
<path class="st0" d="M29.84,0.03V0h-0.95h-0.01h-0.95v0.03C16.95,0.49,8.06,9.1,7.11,19.94c-0.06,0.63-0.09,1.27-0.09,1.92
|
||||||
|
c0,0.64,0.03,1.28,0.09,1.92c0.97,11.16,10.36,19.94,21.77,19.94h0.96v-3.83h-0.96c-9.28,0-16.96-7-17.92-16.11h2.6h8.36
|
||||||
|
c-0.22-0.69-0.34-1.43-0.34-2.2c0-0.56,0.07-1.11,0.19-1.63h-3.08c0.91-4.82,5.2-8.46,10.2-8.46c3.49,0,6.73,1.77,8.63,4.63h4.34
|
||||||
|
c-2.23-5.09-7.38-8.46-12.97-8.46c-7.12,0-13.14,5.33-14.08,12.29h-1.47h-2.38c0.96-9.11,8.64-16.11,17.92-16.11h0.01
|
||||||
|
c9.28,0,16.96,7,17.92,16.11H36c0.12,0.53,0.19,1.07,0.19,1.63c0,0.77-0.12,1.51-0.34,2.2h0.58h2.65h3.88h2h5.79v-1.92
|
||||||
|
C50.75,10.12,41.45,0.52,29.84,0.03z"/>
|
||||||
|
<path class="st0" d="M30.11,32.79c-0.4,0.05-0.81,0.08-1.22,0.08c-4.33,0-8.12-2.73-9.65-6.59h-4.02
|
||||||
|
c1.67,6.02,7.21,10.42,13.67,10.42c0.41,0,0.82-0.02,1.22-0.06V32.79z"/>
|
||||||
|
|
||||||
|
<radialGradient id="SVGID_1_" cx="-5958.7173" cy="3785.8042" r="51.5778" gradientTransform="matrix(0.1405 0 0 0.1405 864.4218 -513.426)" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" style="stop-color:#4A96D2"/>
|
||||||
|
<stop offset="1" style="stop-color:#1F2466"/>
|
||||||
|
</radialGradient>
|
||||||
|
<path class="st1" d="M32.71,24.79c-1.91,2.11-5.16,2.28-7.27,0.37c-2.11-1.9-2.28-5.16-0.38-7.27c1.91-2.11,5.16-2.28,7.27-0.37
|
||||||
|
C34.44,19.42,34.61,22.68,32.71,24.79z"/>
|
||||||
|
<g>
|
||||||
|
<path d="M77.34,11.48h-1.17V3.5h-4.29v7.98h-1.17V2.41h6.62V11.48z"/>
|
||||||
|
<path d="M80.63,14.2h-1.09V4.87h1.04v0.95h0.03c0.16-0.23,0.35-0.42,0.55-0.56c0.2-0.14,0.41-0.25,0.61-0.32
|
||||||
|
c0.2-0.07,0.4-0.11,0.58-0.14c0.18-0.02,0.33-0.03,0.45-0.03c0.52,0,0.96,0.08,1.33,0.24c0.37,0.16,0.67,0.38,0.9,0.67
|
||||||
|
c0.23,0.29,0.4,0.64,0.51,1.06c0.11,0.42,0.16,0.88,0.16,1.39c0,0.48-0.06,0.93-0.17,1.35s-0.29,0.78-0.52,1.09
|
||||||
|
c-0.23,0.31-0.53,0.56-0.89,0.74c-0.36,0.18-0.78,0.27-1.26,0.27c-0.21,0-0.41-0.02-0.62-0.05c-0.21-0.03-0.41-0.09-0.6-0.17
|
||||||
|
c-0.19-0.08-0.38-0.18-0.54-0.3c-0.17-0.12-0.31-0.27-0.43-0.45h-0.03V14.2z M82.61,10.6c0.68,0,1.17-0.22,1.47-0.65
|
||||||
|
c0.3-0.44,0.45-1.06,0.45-1.86c0-0.37-0.03-0.7-0.1-1c-0.06-0.29-0.17-0.54-0.32-0.73c-0.15-0.19-0.35-0.34-0.6-0.45
|
||||||
|
s-0.56-0.16-0.93-0.16c-0.64,0-1.12,0.22-1.46,0.67s-0.5,1.04-0.5,1.76c0,0.73,0.17,1.32,0.5,1.76S81.96,10.6,82.61,10.6z"/>
|
||||||
|
<path d="M90.1,11.58c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C91.03,11.5,90.59,11.58,90.1,11.58z M90.1,10.6
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C89.03,10.4,89.51,10.6,90.1,10.6z"/>
|
||||||
|
<path d="M95.98,11.48h-1.09V4.87h4.34v0.98h-3.25V11.48z"/>
|
||||||
|
<path d="M101.46,14.2h-1.09V4.87h1.04v0.95h0.03c0.16-0.23,0.35-0.42,0.55-0.56c0.2-0.14,0.41-0.25,0.61-0.32
|
||||||
|
c0.2-0.07,0.4-0.11,0.58-0.14c0.18-0.02,0.33-0.03,0.45-0.03c0.52,0,0.96,0.08,1.33,0.24c0.37,0.16,0.67,0.38,0.9,0.67
|
||||||
|
c0.23,0.29,0.4,0.64,0.51,1.06c0.11,0.42,0.16,0.88,0.16,1.39c0,0.48-0.06,0.93-0.17,1.35s-0.29,0.78-0.52,1.09
|
||||||
|
c-0.23,0.31-0.53,0.56-0.89,0.74c-0.36,0.18-0.78,0.27-1.26,0.27c-0.21,0-0.41-0.02-0.62-0.05c-0.21-0.03-0.41-0.09-0.6-0.17
|
||||||
|
c-0.19-0.08-0.38-0.18-0.54-0.3c-0.17-0.12-0.31-0.27-0.43-0.45h-0.03V14.2z M103.44,10.6c0.68,0,1.17-0.22,1.47-0.65
|
||||||
|
c0.3-0.44,0.45-1.06,0.45-1.86c0-0.37-0.03-0.7-0.1-1c-0.06-0.29-0.17-0.54-0.32-0.73c-0.15-0.19-0.35-0.34-0.6-0.45
|
||||||
|
s-0.56-0.16-0.93-0.16c-0.64,0-1.12,0.22-1.46,0.67s-0.5,1.04-0.5,1.76c0,0.73,0.17,1.32,0.5,1.76S102.79,10.6,103.44,10.6z"/>
|
||||||
|
<path d="M112.92,11.48h-0.96v-1.21h-0.03c-0.07,0.18-0.17,0.35-0.32,0.51c-0.14,0.16-0.31,0.3-0.5,0.42
|
||||||
|
c-0.19,0.12-0.4,0.21-0.63,0.28s-0.48,0.1-0.73,0.1c-0.3,0-0.58-0.05-0.83-0.14s-0.46-0.23-0.64-0.39
|
||||||
|
c-0.18-0.17-0.32-0.37-0.41-0.59c-0.1-0.22-0.15-0.47-0.15-0.73c0-0.44,0.12-0.8,0.35-1.08C108.3,8.38,108.61,8.16,109,8
|
||||||
|
c0.38-0.16,0.82-0.27,1.31-0.34s1-0.11,1.52-0.14V7.19c0-0.48-0.11-0.84-0.34-1.08s-0.63-0.36-1.2-0.36
|
||||||
|
c-0.32,0-0.66,0.04-1.02,0.12s-0.67,0.19-0.93,0.32l-0.19-0.89c0.27-0.15,0.6-0.27,1.01-0.38c0.41-0.1,0.82-0.16,1.26-0.16
|
||||||
|
c0.49,0,0.9,0.06,1.22,0.17c0.32,0.12,0.58,0.29,0.77,0.51s0.32,0.5,0.4,0.84c0.08,0.34,0.12,0.73,0.12,1.17V11.48z M111.83,8.37
|
||||||
|
c-0.37,0.02-0.73,0.04-1.08,0.08c-0.35,0.03-0.66,0.1-0.94,0.19s-0.5,0.22-0.67,0.38c-0.17,0.16-0.25,0.37-0.25,0.63
|
||||||
|
c0,0.27,0.09,0.5,0.28,0.68c0.19,0.19,0.48,0.28,0.89,0.28c0.52,0,0.94-0.16,1.28-0.48s0.5-0.79,0.5-1.41V8.37z"/>
|
||||||
|
<path d="M121.53,4.87l0.65,6.61h-1.04l-0.39-4.53V6.28h-0.03l-0.21,0.66l-1.66,4.53h-0.95l-1.66-4.53l-0.21-0.66h-0.03v0.65
|
||||||
|
l-0.39,4.55h-1.04l0.65-6.61h1.22l1.94,5.4h0.03l1.88-5.4H121.53z"/>
|
||||||
|
<path d="M130.54,4.87l0.65,6.61h-1.04l-0.39-4.53V6.28h-0.03l-0.21,0.66l-1.66,4.53h-0.95l-1.66-4.53l-0.21-0.66h-0.03v0.65
|
||||||
|
l-0.39,4.55h-1.04l0.65-6.61h1.22l1.94,5.4h0.03l1.88-5.4H130.54z"/>
|
||||||
|
<path d="M138.5,11.48h-1.09V8.54h-3.39v2.94h-1.09V4.87h1.09v2.68h3.39V4.87h1.09V11.48z"/>
|
||||||
|
<path d="M143.3,11.58c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C144.23,11.5,143.79,11.58,143.3,11.58z M143.3,10.6
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C142.23,10.4,142.7,10.6,143.3,10.6z"/>
|
||||||
|
<path d="M153.17,11.08c-0.16,0.14-0.41,0.26-0.76,0.36c-0.35,0.1-0.78,0.15-1.28,0.15c-1.17,0-2.03-0.3-2.58-0.91
|
||||||
|
c-0.56-0.6-0.84-1.45-0.84-2.53c0-0.51,0.07-0.97,0.2-1.39c0.13-0.42,0.33-0.77,0.59-1.07c0.26-0.3,0.57-0.53,0.94-0.69
|
||||||
|
c0.37-0.16,0.78-0.24,1.25-0.24c0.53,0,0.98,0.09,1.35,0.26c0.37,0.17,0.67,0.41,0.9,0.71c0.23,0.3,0.4,0.65,0.5,1.06
|
||||||
|
c0.1,0.41,0.16,0.84,0.16,1.31v0.35h-4.74c0.06,0.73,0.28,1.26,0.65,1.62c0.37,0.35,0.95,0.53,1.72,0.53
|
||||||
|
c0.42,0,0.77-0.04,1.04-0.11s0.51-0.17,0.72-0.29L153.17,11.08z M152.42,7.54c0.01-0.25-0.02-0.49-0.09-0.71
|
||||||
|
c-0.07-0.22-0.17-0.41-0.31-0.57c-0.14-0.16-0.32-0.29-0.53-0.38c-0.22-0.09-0.47-0.14-0.75-0.14c-0.56,0-1,0.15-1.31,0.45
|
||||||
|
c-0.31,0.3-0.5,0.75-0.57,1.33H152.42z"/>
|
||||||
|
<path d="M160.94,11.58c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C161.87,11.5,161.43,11.58,160.94,11.58z M160.94,10.6
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C159.87,10.4,160.35,10.6,160.94,10.6z"/>
|
||||||
|
<path d="M166.4,6.35c0.18-0.48,0.49-0.85,0.91-1.1c0.43-0.25,0.95-0.38,1.57-0.38c0.94,0,1.65,0.28,2.14,0.84s0.73,1.38,0.73,2.46
|
||||||
|
c0,1.07-0.26,1.91-0.79,2.51c-0.53,0.6-1.27,0.9-2.24,0.9c-0.6,0-1.1-0.09-1.52-0.29c-0.42-0.19-0.76-0.46-1.02-0.82
|
||||||
|
c-0.26-0.36-0.45-0.79-0.57-1.3c-0.12-0.5-0.17-1.08-0.17-1.72c0-1.04,0.07-1.91,0.22-2.6c0.15-0.69,0.39-1.25,0.72-1.67
|
||||||
|
c0.33-0.42,0.77-0.73,1.3-0.93s1.19-0.31,1.97-0.36c0.25-0.02,0.48-0.04,0.67-0.07s0.41-0.08,0.63-0.14v1.02
|
||||||
|
c-0.11,0.03-0.22,0.05-0.31,0.08c-0.1,0.03-0.2,0.05-0.3,0.07c-0.11,0.02-0.23,0.03-0.36,0.05s-0.29,0.03-0.47,0.05
|
||||||
|
c-0.57,0.05-1.05,0.13-1.43,0.24c-0.38,0.11-0.7,0.28-0.93,0.53c-0.24,0.24-0.42,0.57-0.54,0.98c-0.12,0.41-0.2,0.96-0.23,1.63
|
||||||
|
H166.4z M166.64,8.17c0,0.35,0.04,0.68,0.12,0.98c0.08,0.3,0.21,0.55,0.38,0.77c0.17,0.22,0.39,0.38,0.65,0.5
|
||||||
|
c0.26,0.12,0.57,0.17,0.93,0.17c0.62,0,1.09-0.22,1.39-0.65c0.31-0.43,0.46-1.02,0.46-1.78c0-0.76-0.16-1.34-0.47-1.73
|
||||||
|
c-0.32-0.39-0.81-0.59-1.48-0.59c-0.37,0-0.68,0.07-0.93,0.19c-0.25,0.13-0.45,0.3-0.61,0.51c-0.16,0.21-0.27,0.46-0.34,0.74
|
||||||
|
C166.68,7.58,166.64,7.87,166.64,8.17z"/>
|
||||||
|
<path d="M178.51,11.08c-0.16,0.14-0.41,0.26-0.76,0.36c-0.35,0.1-0.78,0.15-1.28,0.15c-1.17,0-2.03-0.3-2.58-0.91
|
||||||
|
c-0.56-0.6-0.84-1.45-0.84-2.53c0-0.51,0.07-0.97,0.2-1.39c0.13-0.42,0.33-0.77,0.59-1.07c0.26-0.3,0.57-0.53,0.94-0.69
|
||||||
|
c0.37-0.16,0.78-0.24,1.25-0.24c0.53,0,0.98,0.09,1.35,0.26c0.37,0.17,0.67,0.41,0.9,0.71c0.23,0.3,0.4,0.65,0.5,1.06
|
||||||
|
c0.1,0.41,0.16,0.84,0.16,1.31v0.35h-4.74c0.06,0.73,0.28,1.26,0.65,1.62c0.37,0.35,0.95,0.53,1.72,0.53
|
||||||
|
c0.42,0,0.77-0.04,1.04-0.11s0.51-0.17,0.72-0.29L178.51,11.08z M177.76,7.54c0.01-0.25-0.02-0.49-0.09-0.71
|
||||||
|
c-0.07-0.22-0.17-0.41-0.31-0.57c-0.14-0.16-0.32-0.29-0.53-0.38c-0.22-0.09-0.47-0.14-0.75-0.14c-0.56,0-1,0.15-1.31,0.45
|
||||||
|
c-0.31,0.3-0.5,0.75-0.57,1.33H177.76z"/>
|
||||||
|
<path d="M185.3,11.24c-0.15,0.07-0.36,0.14-0.65,0.22c-0.29,0.08-0.69,0.12-1.21,0.12c-0.56,0-1.05-0.08-1.46-0.24
|
||||||
|
c-0.41-0.16-0.75-0.39-1.02-0.69s-0.48-0.67-0.61-1.09c-0.13-0.42-0.2-0.9-0.2-1.44c0-1.06,0.29-1.89,0.86-2.47
|
||||||
|
c0.57-0.59,1.37-0.88,2.39-0.88c0.48,0,0.86,0.04,1.13,0.11c0.27,0.07,0.49,0.13,0.66,0.17l-0.21,0.93
|
||||||
|
c-0.16-0.05-0.36-0.1-0.6-0.16s-0.51-0.08-0.84-0.08c-0.69,0-1.23,0.18-1.63,0.55s-0.6,0.94-0.6,1.72c0,0.89,0.21,1.54,0.63,1.96
|
||||||
|
c0.42,0.42,1.03,0.62,1.81,0.62c0.26,0,0.51-0.03,0.75-0.08c0.24-0.05,0.43-0.11,0.59-0.18L185.3,11.24z"/>
|
||||||
|
<path d="M192.26,11.48h-1.09V5.85h-3.32v5.62h-1.09V4.87h5.49V11.48z"/>
|
||||||
|
<path d="M199.41,11.08c-0.16,0.14-0.41,0.26-0.76,0.36c-0.35,0.1-0.78,0.15-1.28,0.15c-1.17,0-2.03-0.3-2.58-0.91
|
||||||
|
c-0.56-0.6-0.84-1.45-0.84-2.53c0-0.51,0.07-0.97,0.2-1.39c0.13-0.42,0.33-0.77,0.59-1.07c0.26-0.3,0.57-0.53,0.94-0.69
|
||||||
|
c0.37-0.16,0.78-0.24,1.25-0.24c0.53,0,0.98,0.09,1.35,0.26c0.37,0.17,0.67,0.41,0.9,0.71c0.23,0.3,0.4,0.65,0.5,1.06
|
||||||
|
c0.1,0.41,0.16,0.84,0.16,1.31v0.35h-4.74c0.06,0.73,0.28,1.26,0.65,1.62c0.37,0.35,0.95,0.53,1.72,0.53
|
||||||
|
c0.42,0,0.77-0.04,1.04-0.11s0.51-0.17,0.72-0.29L199.41,11.08z M198.66,7.54c0.01-0.25-0.02-0.49-0.09-0.71
|
||||||
|
c-0.07-0.22-0.17-0.41-0.31-0.57c-0.14-0.16-0.32-0.29-0.53-0.38c-0.22-0.09-0.47-0.14-0.75-0.14c-0.56,0-1,0.15-1.31,0.45
|
||||||
|
c-0.31,0.3-0.5,0.75-0.57,1.33H198.66z"/>
|
||||||
|
<path d="M206.22,11.48h-1.09v-2.6c-0.04,0.01-0.12,0.03-0.22,0.05c-0.1,0.03-0.23,0.05-0.38,0.08s-0.31,0.06-0.5,0.08
|
||||||
|
c-0.19,0.02-0.38,0.03-0.58,0.03c-0.35,0-0.68-0.03-0.98-0.1c-0.3-0.07-0.56-0.18-0.77-0.34c-0.22-0.16-0.38-0.36-0.51-0.6
|
||||||
|
c-0.12-0.25-0.18-0.55-0.18-0.9V4.87h1.09v1.94c0,0.27,0.04,0.49,0.12,0.65c0.08,0.17,0.19,0.3,0.33,0.4
|
||||||
|
c0.14,0.1,0.31,0.17,0.52,0.21c0.2,0.04,0.43,0.06,0.67,0.06c0.33,0,0.61-0.03,0.85-0.08c0.24-0.05,0.42-0.1,0.54-0.14V4.87h1.09
|
||||||
|
V11.48z"/>
|
||||||
|
<path d="M213.37,11.08c-0.16,0.14-0.41,0.26-0.76,0.36c-0.35,0.1-0.78,0.15-1.28,0.15c-1.17,0-2.03-0.3-2.58-0.91
|
||||||
|
c-0.56-0.6-0.84-1.45-0.84-2.53c0-0.51,0.07-0.97,0.2-1.39c0.13-0.42,0.33-0.77,0.59-1.07c0.26-0.3,0.57-0.53,0.94-0.69
|
||||||
|
c0.37-0.16,0.78-0.24,1.25-0.24c0.53,0,0.98,0.09,1.35,0.26c0.37,0.17,0.67,0.41,0.9,0.71c0.23,0.3,0.4,0.65,0.5,1.06
|
||||||
|
c0.1,0.41,0.16,0.84,0.16,1.31v0.35h-4.74c0.06,0.73,0.28,1.26,0.65,1.62c0.37,0.35,0.95,0.53,1.72,0.53
|
||||||
|
c0.42,0,0.77-0.04,1.04-0.11s0.51-0.17,0.72-0.29L213.37,11.08z M212.62,7.54c0.01-0.25-0.02-0.49-0.09-0.71
|
||||||
|
c-0.07-0.22-0.17-0.41-0.31-0.57c-0.14-0.16-0.32-0.29-0.53-0.38c-0.22-0.09-0.47-0.14-0.75-0.14c-0.56,0-1,0.15-1.31,0.45
|
||||||
|
c-0.31,0.3-0.5,0.75-0.57,1.33H212.62z"/>
|
||||||
|
<path d="M220.98,11.48h-1.09V8.54h-3.39v2.94h-1.09V4.87h1.09v2.68h3.39V4.87h1.09V11.48z"/>
|
||||||
|
<path d="M223.05,11.48V4.87h1.09v4.21l-0.04,0.87h0.01l0.43-0.69l3.08-4.39h1.04v6.61h-1.09V7.16l0.04-0.76h-0.01l-0.4,0.65
|
||||||
|
l-3.11,4.43H223.05z"/>
|
||||||
|
<path d="M235.81,11.08c-0.16,0.14-0.41,0.26-0.76,0.36c-0.35,0.1-0.78,0.15-1.28,0.15c-1.17,0-2.03-0.3-2.58-0.91
|
||||||
|
c-0.56-0.6-0.84-1.45-0.84-2.53c0-0.51,0.07-0.97,0.2-1.39c0.13-0.42,0.33-0.77,0.59-1.07c0.26-0.3,0.57-0.53,0.94-0.69
|
||||||
|
c0.37-0.16,0.78-0.24,1.25-0.24c0.53,0,0.98,0.09,1.35,0.26c0.37,0.17,0.67,0.41,0.9,0.71c0.23,0.3,0.4,0.65,0.5,1.06
|
||||||
|
c0.1,0.41,0.16,0.84,0.16,1.31v0.35h-4.74c0.06,0.73,0.28,1.26,0.65,1.62c0.37,0.35,0.95,0.53,1.72,0.53
|
||||||
|
c0.42,0,0.77-0.04,1.04-0.11s0.51-0.17,0.72-0.29L235.81,11.08z M235.06,7.54c0.01-0.25-0.02-0.49-0.09-0.71
|
||||||
|
c-0.07-0.22-0.17-0.41-0.31-0.57c-0.14-0.16-0.32-0.29-0.53-0.38c-0.22-0.09-0.47-0.14-0.75-0.14c-0.56,0-1,0.15-1.31,0.45
|
||||||
|
c-0.31,0.3-0.5,0.75-0.57,1.33H235.06z"/>
|
||||||
|
<path d="M71.68,23.32h0.35l2.5-2.9h1.4l-2.81,3.1c0.1,0.05,0.19,0.11,0.27,0.19c0.07,0.07,0.15,0.16,0.24,0.27l2.63,3.06h-1.48
|
||||||
|
l-2-2.45c-0.13-0.16-0.24-0.28-0.32-0.34s-0.21-0.09-0.36-0.09h-0.42v2.88h-1.09v-6.61h1.09V23.32z"/>
|
||||||
|
<path d="M79.87,27.13c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C80.8,27.05,80.36,27.13,79.87,27.13z M79.87,26.14
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C78.8,25.95,79.27,26.14,79.87,26.14z"/>
|
||||||
|
<path d="M90.23,27.02h-1.09v-2.94h-3.39v2.94h-1.09v-6.61h1.09v2.68h3.39v-2.68h1.09V27.02z"/>
|
||||||
|
<path d="M94.73,27.02h-1.09V21.4h-2.29v-0.98h5.67v0.98h-2.29V27.02z"/>
|
||||||
|
<path d="M99.22,29.74h-1.09v-9.33h1.04v0.95h0.03c0.16-0.23,0.35-0.42,0.55-0.56c0.2-0.14,0.41-0.25,0.61-0.32
|
||||||
|
c0.2-0.07,0.4-0.11,0.58-0.14c0.18-0.02,0.33-0.03,0.45-0.03c0.52,0,0.96,0.08,1.33,0.24c0.37,0.16,0.67,0.38,0.9,0.67
|
||||||
|
c0.23,0.29,0.4,0.64,0.51,1.06c0.11,0.42,0.16,0.88,0.16,1.39c0,0.48-0.06,0.93-0.17,1.35s-0.29,0.78-0.52,1.09
|
||||||
|
c-0.23,0.31-0.53,0.56-0.89,0.74c-0.36,0.18-0.78,0.27-1.26,0.27c-0.21,0-0.41-0.02-0.62-0.05c-0.21-0.03-0.41-0.09-0.6-0.17
|
||||||
|
c-0.19-0.08-0.38-0.18-0.54-0.3c-0.17-0.12-0.31-0.27-0.43-0.45h-0.03V29.74z M101.2,26.14c0.68,0,1.17-0.22,1.47-0.65
|
||||||
|
c0.3-0.44,0.45-1.06,0.45-1.86c0-0.37-0.03-0.7-0.1-1c-0.06-0.29-0.17-0.54-0.32-0.73c-0.15-0.19-0.35-0.34-0.6-0.45
|
||||||
|
s-0.56-0.16-0.93-0.16c-0.64,0-1.12,0.22-1.46,0.67s-0.5,1.04-0.5,1.76c0,0.73,0.17,1.32,0.5,1.76S100.55,26.14,101.2,26.14z"/>
|
||||||
|
<path d="M108.69,27.13c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C109.63,27.05,109.18,27.13,108.69,27.13z M108.69,26.14
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C107.62,25.95,108.1,26.14,108.69,26.14z"/>
|
||||||
|
<path d="M113.32,27.13c-0.12,0-0.23-0.01-0.32-0.03c-0.09-0.02-0.17-0.04-0.23-0.06V26c0.07,0.03,0.12,0.04,0.16,0.05
|
||||||
|
s0.09,0.01,0.16,0.01c0.22,0,0.41-0.1,0.56-0.29c0.15-0.19,0.27-0.5,0.36-0.92c0.09-0.42,0.16-0.96,0.19-1.61
|
||||||
|
c0.04-0.66,0.06-1.45,0.06-2.38v-0.44h4.51v6.61h-1.09V21.4h-2.38v0.16c0,1.01-0.03,1.87-0.1,2.57c-0.07,0.7-0.18,1.28-0.34,1.72
|
||||||
|
c-0.16,0.45-0.36,0.77-0.62,0.97C113.99,27.03,113.68,27.13,113.32,27.13z"/>
|
||||||
|
<path d="M123.62,24.5c-0.22,0-0.39,0.04-0.52,0.12c-0.13,0.08-0.25,0.21-0.36,0.39l-1.1,2.02h-1.31l1.19-2.09
|
||||||
|
c0.1-0.18,0.21-0.32,0.32-0.41c0.11-0.1,0.22-0.16,0.31-0.21c-0.47-0.09-0.84-0.29-1.13-0.59c-0.29-0.3-0.43-0.71-0.43-1.25
|
||||||
|
c0-0.34,0.07-0.65,0.2-0.91c0.13-0.26,0.31-0.47,0.54-0.64s0.49-0.3,0.79-0.38c0.3-0.09,0.62-0.13,0.96-0.13h2.67v6.61h-1.09V24.5
|
||||||
|
H123.62z M124.67,23.51V21.4h-1.46c-0.48,0-0.84,0.08-1.08,0.25c-0.24,0.17-0.36,0.45-0.36,0.85c0,0.38,0.15,0.64,0.44,0.79
|
||||||
|
c0.29,0.15,0.67,0.22,1.11,0.22H124.67z"/>
|
||||||
|
<path d="M136.34,27.02h-1.09V21.4h-3.32v5.62h-1.09v-6.61h5.49V27.02z"/>
|
||||||
|
<path d="M143.11,27.02h-0.96v-1.21h-0.03c-0.07,0.18-0.17,0.35-0.32,0.51c-0.14,0.16-0.31,0.3-0.5,0.42
|
||||||
|
c-0.19,0.12-0.4,0.21-0.63,0.28s-0.48,0.1-0.73,0.1c-0.3,0-0.58-0.05-0.83-0.14s-0.46-0.23-0.64-0.39
|
||||||
|
c-0.18-0.17-0.32-0.37-0.41-0.59c-0.1-0.22-0.15-0.47-0.15-0.73c0-0.44,0.12-0.8,0.35-1.08c0.23-0.28,0.54-0.49,0.93-0.65
|
||||||
|
s0.82-0.27,1.31-0.34s1-0.11,1.52-0.14v-0.34c0-0.48-0.11-0.84-0.34-1.08s-0.63-0.36-1.2-0.36c-0.32,0-0.66,0.04-1.02,0.12
|
||||||
|
s-0.67,0.19-0.93,0.32l-0.19-0.89c0.27-0.15,0.6-0.27,1.01-0.38c0.41-0.1,0.82-0.16,1.26-0.16c0.49,0,0.9,0.06,1.22,0.17
|
||||||
|
c0.32,0.12,0.58,0.29,0.77,0.51s0.32,0.5,0.4,0.84c0.08,0.34,0.12,0.73,0.12,1.17V27.02z M142.03,23.91
|
||||||
|
c-0.37,0.02-0.73,0.04-1.08,0.08c-0.35,0.03-0.66,0.1-0.94,0.19s-0.5,0.22-0.67,0.38c-0.17,0.16-0.25,0.37-0.25,0.63
|
||||||
|
c0,0.27,0.09,0.5,0.28,0.68c0.19,0.19,0.48,0.28,0.89,0.28c0.52,0,0.94-0.16,1.28-0.48s0.5-0.79,0.5-1.41V23.91z"/>
|
||||||
|
<path d="M146.21,29.74h-1.09v-9.33h1.04v0.95h0.03c0.16-0.23,0.35-0.42,0.55-0.56c0.2-0.14,0.41-0.25,0.61-0.32
|
||||||
|
c0.2-0.07,0.4-0.11,0.58-0.14c0.18-0.02,0.33-0.03,0.45-0.03c0.52,0,0.96,0.08,1.33,0.24c0.37,0.16,0.67,0.38,0.9,0.67
|
||||||
|
c0.23,0.29,0.4,0.64,0.51,1.06c0.11,0.42,0.16,0.88,0.16,1.39c0,0.48-0.06,0.93-0.17,1.35s-0.29,0.78-0.52,1.09
|
||||||
|
c-0.23,0.31-0.53,0.56-0.89,0.74c-0.36,0.18-0.78,0.27-1.26,0.27c-0.21,0-0.41-0.02-0.62-0.05c-0.21-0.03-0.41-0.09-0.6-0.17
|
||||||
|
c-0.19-0.08-0.38-0.18-0.54-0.3c-0.17-0.12-0.31-0.27-0.43-0.45h-0.03V29.74z M148.19,26.14c0.68,0,1.17-0.22,1.47-0.65
|
||||||
|
c0.3-0.44,0.45-1.06,0.45-1.86c0-0.37-0.03-0.7-0.1-1c-0.06-0.29-0.17-0.54-0.32-0.73c-0.15-0.19-0.35-0.34-0.6-0.45
|
||||||
|
s-0.56-0.16-0.93-0.16c-0.64,0-1.12,0.22-1.46,0.67s-0.5,1.04-0.5,1.76c0,0.73,0.17,1.32,0.5,1.76S147.54,26.14,148.19,26.14z"/>
|
||||||
|
<path d="M157.66,27.02h-0.96v-1.21h-0.03c-0.07,0.18-0.17,0.35-0.32,0.51c-0.14,0.16-0.31,0.3-0.5,0.42
|
||||||
|
c-0.19,0.12-0.4,0.21-0.63,0.28s-0.48,0.1-0.73,0.1c-0.3,0-0.58-0.05-0.83-0.14s-0.46-0.23-0.64-0.39
|
||||||
|
c-0.18-0.17-0.32-0.37-0.41-0.59c-0.1-0.22-0.15-0.47-0.15-0.73c0-0.44,0.12-0.8,0.35-1.08c0.23-0.28,0.54-0.49,0.93-0.65
|
||||||
|
s0.82-0.27,1.31-0.34s1-0.11,1.52-0.14v-0.34c0-0.48-0.11-0.84-0.34-1.08s-0.63-0.36-1.2-0.36c-0.32,0-0.66,0.04-1.02,0.12
|
||||||
|
s-0.67,0.19-0.93,0.32l-0.19-0.89c0.27-0.15,0.6-0.27,1.01-0.38c0.41-0.1,0.82-0.16,1.26-0.16c0.49,0,0.9,0.06,1.22,0.17
|
||||||
|
c0.32,0.12,0.58,0.29,0.77,0.51s0.32,0.5,0.4,0.84c0.08,0.34,0.12,0.73,0.12,1.17V27.02z M156.58,23.91
|
||||||
|
c-0.37,0.02-0.73,0.04-1.08,0.08c-0.35,0.03-0.66,0.1-0.94,0.19s-0.5,0.22-0.67,0.38c-0.17,0.16-0.25,0.37-0.25,0.63
|
||||||
|
c0,0.27,0.09,0.5,0.28,0.68c0.19,0.19,0.48,0.28,0.89,0.28c0.52,0,0.94-0.16,1.28-0.48s0.5-0.79,0.5-1.41V23.91z"/>
|
||||||
|
<path d="M166.28,20.42l0.65,6.61h-1.04l-0.39-4.53v-0.66h-0.03l-0.21,0.66l-1.66,4.53h-0.95l-1.66-4.53l-0.21-0.66h-0.03v0.65
|
||||||
|
l-0.39,4.55h-1.04l0.65-6.61h1.22l1.94,5.4h0.03l1.88-5.4H166.28z"/>
|
||||||
|
<path d="M173.76,26.62c-0.16,0.14-0.41,0.26-0.76,0.36c-0.35,0.1-0.78,0.15-1.28,0.15c-1.17,0-2.03-0.3-2.58-0.91
|
||||||
|
c-0.56-0.6-0.84-1.45-0.84-2.53c0-0.51,0.07-0.97,0.2-1.39c0.13-0.42,0.33-0.77,0.59-1.07c0.26-0.3,0.57-0.53,0.94-0.69
|
||||||
|
c0.37-0.16,0.78-0.24,1.25-0.24c0.53,0,0.98,0.09,1.35,0.26c0.37,0.17,0.67,0.41,0.9,0.71c0.23,0.3,0.4,0.65,0.5,1.06
|
||||||
|
c0.1,0.41,0.16,0.84,0.16,1.31v0.35h-4.74c0.06,0.73,0.28,1.26,0.65,1.62c0.37,0.35,0.95,0.53,1.72,0.53
|
||||||
|
c0.42,0,0.77-0.04,1.04-0.11s0.51-0.17,0.72-0.29L173.76,26.62z M173,23.09c0.01-0.25-0.02-0.49-0.09-0.71
|
||||||
|
c-0.07-0.22-0.17-0.41-0.31-0.57c-0.14-0.16-0.32-0.29-0.53-0.38c-0.22-0.09-0.47-0.14-0.75-0.14c-0.56,0-1,0.15-1.31,0.45
|
||||||
|
c-0.31,0.3-0.5,0.75-0.57,1.33H173z"/>
|
||||||
|
<path d="M178.21,27.02h-1.09V21.4h-2.29v-0.98h5.67v0.98h-2.29V27.02z"/>
|
||||||
|
<path d="M182.71,29.74h-1.09v-9.33h1.04v0.95h0.03c0.16-0.23,0.35-0.42,0.55-0.56c0.2-0.14,0.41-0.25,0.61-0.32
|
||||||
|
c0.2-0.07,0.4-0.11,0.58-0.14c0.18-0.02,0.33-0.03,0.45-0.03c0.52,0,0.96,0.08,1.33,0.24c0.37,0.16,0.67,0.38,0.9,0.67
|
||||||
|
c0.23,0.29,0.4,0.64,0.51,1.06c0.11,0.42,0.16,0.88,0.16,1.39c0,0.48-0.06,0.93-0.17,1.35s-0.29,0.78-0.52,1.09
|
||||||
|
c-0.23,0.31-0.53,0.56-0.89,0.74c-0.36,0.18-0.78,0.27-1.26,0.27c-0.21,0-0.41-0.02-0.62-0.05c-0.21-0.03-0.41-0.09-0.6-0.17
|
||||||
|
c-0.19-0.08-0.38-0.18-0.54-0.3c-0.17-0.12-0.31-0.27-0.43-0.45h-0.03V29.74z M184.69,26.14c0.68,0,1.17-0.22,1.47-0.65
|
||||||
|
c0.3-0.44,0.45-1.06,0.45-1.86c0-0.37-0.03-0.7-0.1-1c-0.06-0.29-0.17-0.54-0.32-0.73c-0.15-0.19-0.35-0.34-0.6-0.45
|
||||||
|
s-0.56-0.16-0.93-0.16c-0.64,0-1.12,0.22-1.46,0.67s-0.5,1.04-0.5,1.76c0,0.73,0.17,1.32,0.5,1.76S184.03,26.14,184.69,26.14z"/>
|
||||||
|
<path d="M192.18,27.13c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C193.11,27.05,192.67,27.13,192.18,27.13z M192.18,26.14
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C191.11,25.95,191.58,26.14,192.18,26.14z"/>
|
||||||
|
<path d="M196.97,27.02v-6.61h2.47c0.81,0,1.43,0.12,1.85,0.36c0.42,0.24,0.63,0.68,0.63,1.31c0,0.36-0.11,0.68-0.32,0.95
|
||||||
|
c-0.22,0.27-0.5,0.46-0.86,0.55v0.04c0.42,0.07,0.77,0.23,1.04,0.49c0.27,0.25,0.41,0.61,0.41,1.06c0,0.36-0.06,0.66-0.18,0.9
|
||||||
|
c-0.12,0.24-0.29,0.43-0.51,0.57s-0.48,0.24-0.79,0.3c-0.31,0.06-0.64,0.08-1,0.08H196.97z M198.06,23.14h1.35
|
||||||
|
c0.16,0,0.32-0.01,0.49-0.02s0.32-0.05,0.46-0.11c0.14-0.06,0.25-0.15,0.34-0.27c0.09-0.12,0.14-0.28,0.14-0.49
|
||||||
|
c0-0.18-0.03-0.33-0.09-0.44c-0.06-0.11-0.15-0.2-0.27-0.26c-0.12-0.06-0.25-0.1-0.41-0.12c-0.16-0.02-0.33-0.03-0.52-0.03h-1.48
|
||||||
|
V23.14z M198.06,26.04h1.67c0.41,0,0.73-0.07,0.96-0.2c0.23-0.13,0.35-0.39,0.35-0.76c0-0.23-0.05-0.42-0.15-0.55
|
||||||
|
c-0.1-0.13-0.23-0.24-0.38-0.31c-0.16-0.07-0.33-0.12-0.51-0.14s-0.37-0.03-0.55-0.03h-1.39V26.04z"/>
|
||||||
|
<path d="M71.11,44.64c0.22-0.19,0.46-0.43,0.69-0.73c0.24-0.3,0.41-0.62,0.52-0.95l0.05-0.18l-2.71-6.81h1.18l2.1,5.44l1.76-5.44
|
||||||
|
h1.1l-2.42,7.01c-0.08,0.24-0.18,0.48-0.3,0.73c-0.12,0.24-0.25,0.47-0.38,0.68c-0.13,0.21-0.28,0.4-0.43,0.57
|
||||||
|
c-0.15,0.17-0.3,0.3-0.43,0.41L71.11,44.64z"/>
|
||||||
|
<path d="M81.73,42.34c-0.15,0.07-0.36,0.14-0.65,0.22c-0.29,0.08-0.69,0.12-1.21,0.12c-0.56,0-1.05-0.08-1.46-0.24
|
||||||
|
c-0.41-0.16-0.75-0.39-1.02-0.69s-0.48-0.67-0.61-1.09c-0.13-0.42-0.2-0.9-0.2-1.44c0-1.06,0.29-1.89,0.86-2.47
|
||||||
|
c0.57-0.59,1.37-0.88,2.39-0.88c0.48,0,0.86,0.04,1.13,0.11c0.27,0.07,0.49,0.13,0.66,0.17l-0.21,0.93
|
||||||
|
c-0.16-0.05-0.36-0.1-0.6-0.16s-0.51-0.08-0.84-0.08c-0.69,0-1.23,0.18-1.63,0.55s-0.6,0.94-0.6,1.72c0,0.89,0.21,1.54,0.63,1.96
|
||||||
|
c0.42,0.42,1.03,0.62,1.81,0.62c0.26,0,0.51-0.03,0.75-0.08c0.24-0.05,0.43-0.11,0.59-0.18L81.73,42.34z"/>
|
||||||
|
<path d="M85.81,42.57h-1.09v-5.62h-2.29v-0.98h5.67v0.98h-2.29V42.57z"/>
|
||||||
|
<path d="M91.94,42.67c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C92.87,42.6,92.43,42.67,91.94,42.67z M91.94,41.69
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C90.87,41.5,91.34,41.69,91.94,41.69z"/>
|
||||||
|
<path d="M96.73,42.57v-6.61h1.09v4.21l-0.04,0.87h0.01l0.43-0.69l3.08-4.39h1.04v6.61h-1.09v-4.31l0.04-0.76h-0.01l-0.4,0.65
|
||||||
|
l-3.11,4.43H96.73z M101.63,33.11c-0.02,0.29-0.08,0.54-0.19,0.75s-0.26,0.39-0.44,0.54c-0.18,0.14-0.4,0.25-0.64,0.32
|
||||||
|
c-0.25,0.07-0.5,0.1-0.77,0.1c-0.28,0-0.54-0.04-0.78-0.1c-0.24-0.07-0.45-0.17-0.63-0.32c-0.18-0.14-0.33-0.32-0.45-0.54
|
||||||
|
c-0.12-0.22-0.18-0.47-0.19-0.75l0.92-0.1c0.04,0.31,0.17,0.54,0.37,0.7c0.2,0.16,0.46,0.23,0.76,0.23s0.55-0.08,0.76-0.23
|
||||||
|
c0.2-0.16,0.33-0.39,0.37-0.7L101.63,33.11z"/>
|
||||||
|
<path d="M109.2,42.57h-1.09v-2.6c-0.04,0.01-0.12,0.03-0.22,0.05c-0.1,0.03-0.23,0.05-0.38,0.08s-0.31,0.06-0.5,0.08
|
||||||
|
c-0.19,0.02-0.38,0.03-0.58,0.03c-0.35,0-0.68-0.03-0.98-0.1c-0.3-0.07-0.56-0.18-0.77-0.34c-0.22-0.16-0.38-0.36-0.51-0.6
|
||||||
|
c-0.12-0.25-0.18-0.55-0.18-0.9v-2.31h1.09v1.94c0,0.27,0.04,0.49,0.12,0.65c0.08,0.17,0.19,0.3,0.33,0.4
|
||||||
|
c0.14,0.1,0.31,0.17,0.52,0.21c0.2,0.04,0.43,0.06,0.67,0.06c0.33,0,0.61-0.03,0.85-0.08c0.24-0.05,0.42-0.1,0.54-0.14v-3.04h1.09
|
||||||
|
V42.57z"/>
|
||||||
|
<path d="M111.27,42.57v-6.61h1.09v4.21l-0.04,0.87h0.01l0.43-0.69l3.08-4.39h1.04v6.61h-1.09v-4.31l0.04-0.76h-0.01l-0.4,0.65
|
||||||
|
l-3.11,4.43H111.27z"/>
|
||||||
|
<path d="M118.95,42.57v-6.61h2.47c0.81,0,1.43,0.12,1.85,0.36c0.42,0.24,0.63,0.68,0.63,1.31c0,0.36-0.11,0.68-0.32,0.95
|
||||||
|
c-0.22,0.27-0.5,0.46-0.86,0.55v0.04c0.42,0.07,0.77,0.23,1.04,0.49c0.27,0.25,0.41,0.61,0.41,1.06c0,0.36-0.06,0.66-0.18,0.9
|
||||||
|
c-0.12,0.24-0.29,0.43-0.51,0.57s-0.48,0.24-0.79,0.3c-0.31,0.06-0.64,0.08-1,0.08H118.95z M120.04,38.68h1.35
|
||||||
|
c0.16,0,0.32-0.01,0.49-0.02s0.32-0.05,0.46-0.11c0.14-0.06,0.25-0.15,0.34-0.27c0.09-0.12,0.14-0.28,0.14-0.49
|
||||||
|
c0-0.18-0.03-0.33-0.09-0.44c-0.06-0.11-0.15-0.2-0.27-0.26c-0.12-0.06-0.25-0.1-0.41-0.12c-0.16-0.02-0.33-0.03-0.52-0.03h-1.48
|
||||||
|
V38.68z M120.04,41.59h1.67c0.41,0,0.73-0.07,0.96-0.2c0.23-0.13,0.35-0.39,0.35-0.76c0-0.23-0.05-0.42-0.15-0.55
|
||||||
|
c-0.1-0.13-0.23-0.24-0.38-0.31c-0.16-0.07-0.33-0.12-0.51-0.14s-0.37-0.03-0.55-0.03h-1.39V41.59z"/>
|
||||||
|
<path d="M128.57,42.67c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C129.5,42.6,129.06,42.67,128.57,42.67z M128.57,41.69
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C127.5,41.5,127.97,41.69,128.57,41.69z"/>
|
||||||
|
<path d="M134.45,42.57h-1.09v-6.61h4.34v0.98h-3.25V42.57z"/>
|
||||||
|
<path d="M141.43,42.67c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C142.36,42.6,141.92,42.67,141.43,42.67z M141.43,41.69
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C140.36,41.5,140.83,41.69,141.43,41.69z"/>
|
||||||
|
<path d="M152.3,42.65c-0.09,0.02-0.17,0.03-0.25,0.03s-0.16,0-0.24,0c-0.99,0-1.73-0.29-2.22-0.86c-0.49-0.57-0.73-1.43-0.73-2.56
|
||||||
|
c0-1.08,0.25-1.92,0.75-2.51c0.5-0.59,1.24-0.89,2.22-0.89c0.08,0,0.16,0,0.24,0c0.08,0,0.16,0.01,0.24,0.03v-3.03h1.09v3.04
|
||||||
|
c0.16-0.03,0.33-0.04,0.49-0.04c1.01,0,1.76,0.28,2.23,0.83c0.48,0.55,0.72,1.39,0.72,2.53c0,0.54-0.06,1.02-0.19,1.44
|
||||||
|
c-0.13,0.43-0.31,0.79-0.56,1.09c-0.25,0.3-0.56,0.53-0.93,0.69c-0.37,0.16-0.8,0.24-1.28,0.24h-0.48v2.62h-1.09V42.65z
|
||||||
|
M152.3,36.83c-0.12-0.03-0.28-0.04-0.47-0.04c-0.28,0-0.54,0.04-0.76,0.14s-0.41,0.24-0.57,0.43c-0.16,0.2-0.27,0.46-0.36,0.77
|
||||||
|
c-0.08,0.32-0.12,0.7-0.12,1.16c0,0.85,0.16,1.48,0.47,1.87s0.8,0.58,1.44,0.58c0.06,0,0.12,0,0.17,0s0.12,0,0.19-0.01V36.83z
|
||||||
|
M153.39,41.74h0.47c0.57,0,1.01-0.2,1.33-0.6c0.32-0.4,0.48-1.05,0.48-1.97c0-0.85-0.15-1.47-0.46-1.83s-0.8-0.55-1.47-0.55
|
||||||
|
c-0.05,0-0.11,0-0.17,0.01c-0.06,0-0.12,0.01-0.18,0.02V41.74z"/>
|
||||||
|
<path d="M159.04,44.64c0.22-0.19,0.46-0.43,0.69-0.73c0.24-0.3,0.41-0.62,0.52-0.95l0.05-0.18l-2.71-6.81h1.18l2.1,5.44l1.76-5.44
|
||||||
|
h1.1l-2.42,7.01c-0.08,0.24-0.18,0.48-0.3,0.73c-0.12,0.24-0.25,0.47-0.38,0.68c-0.13,0.21-0.28,0.4-0.43,0.57
|
||||||
|
c-0.15,0.17-0.3,0.3-0.43,0.41L159.04,44.64z"/>
|
||||||
|
<path d="M170.48,42.57h-1.09v-2.94h-3.39v2.94h-1.09v-6.61h1.09v2.68h3.39v-2.68h1.09V42.57z"/>
|
||||||
|
<path d="M173.64,38.87h0.35l2.5-2.9h1.4l-2.81,3.1c0.1,0.05,0.19,0.11,0.27,0.19c0.07,0.07,0.15,0.16,0.24,0.27l2.63,3.06h-1.48
|
||||||
|
l-2-2.45c-0.13-0.16-0.24-0.28-0.32-0.34s-0.21-0.09-0.36-0.09h-0.42v2.88h-1.09v-6.61h1.09V38.87z"/>
|
||||||
|
<path d="M185.65,44.44h-1.04v-1.87h-5.31v-6.61h1.09v5.62h3.17v-5.62h1.09v5.62h1V44.44z"/>
|
||||||
|
<path d="M187.07,42.57v-6.61h1.09v4.21l-0.04,0.87h0.01l0.43-0.69l3.08-4.39h1.04v6.61h-1.09v-4.31l0.04-0.76h-0.01l-0.4,0.65
|
||||||
|
l-3.11,4.43H187.07z"/>
|
||||||
|
<path d="M197.48,42.67c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C198.41,42.6,197.97,42.67,197.48,42.67z M197.48,41.69
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C196.41,41.5,196.88,41.69,197.48,41.69z"/>
|
||||||
|
<path d="M207.84,42.57h-1.09v-2.94h-3.39v2.94h-1.09v-6.61h1.09v2.68h3.39v-2.68h1.09V42.57z"/>
|
||||||
|
<path d="M209.91,42.57v-6.61H211v4.21l-0.04,0.87h0.01l0.43-0.69l3.08-4.39h1.04v6.61h-1.09v-4.31l0.04-0.76h-0.01l-0.4,0.65
|
||||||
|
l-3.11,4.43H209.91z"/>
|
||||||
|
<path d="M218.68,45.29h-1.09v-9.33h1.04v0.95h0.03c0.16-0.23,0.35-0.42,0.55-0.56c0.2-0.14,0.41-0.25,0.61-0.32
|
||||||
|
c0.2-0.07,0.4-0.11,0.58-0.14c0.18-0.02,0.33-0.03,0.45-0.03c0.52,0,0.96,0.08,1.33,0.24c0.37,0.16,0.67,0.38,0.9,0.67
|
||||||
|
c0.23,0.29,0.4,0.64,0.51,1.06c0.11,0.42,0.16,0.88,0.16,1.39c0,0.48-0.06,0.93-0.17,1.35s-0.29,0.78-0.52,1.09
|
||||||
|
c-0.23,0.31-0.53,0.56-0.89,0.74c-0.36,0.18-0.78,0.27-1.26,0.27c-0.21,0-0.41-0.02-0.62-0.05c-0.21-0.03-0.41-0.09-0.6-0.17
|
||||||
|
c-0.19-0.08-0.38-0.18-0.54-0.3c-0.17-0.12-0.31-0.27-0.43-0.45h-0.03V45.29z M220.67,41.69c0.68,0,1.17-0.22,1.47-0.65
|
||||||
|
c0.3-0.44,0.45-1.06,0.45-1.86c0-0.37-0.03-0.7-0.1-1c-0.06-0.29-0.17-0.54-0.32-0.73c-0.15-0.19-0.35-0.34-0.6-0.45
|
||||||
|
s-0.56-0.16-0.93-0.16c-0.64,0-1.12,0.22-1.46,0.67s-0.5,1.04-0.5,1.76c0,0.73,0.17,1.32,0.5,1.76S220.01,41.69,220.67,41.69z"/>
|
||||||
|
<path d="M228.16,42.67c-1.04,0-1.81-0.29-2.33-0.87c-0.52-0.58-0.78-1.43-0.78-2.53c0-1.08,0.27-1.92,0.8-2.51
|
||||||
|
c0.53-0.6,1.3-0.89,2.31-0.89c1.04,0,1.81,0.29,2.33,0.87c0.52,0.58,0.78,1.42,0.78,2.54c0,0.53-0.07,1-0.21,1.42
|
||||||
|
c-0.14,0.42-0.34,0.78-0.6,1.08c-0.26,0.29-0.59,0.52-0.98,0.67C229.09,42.6,228.65,42.67,228.16,42.67z M228.16,41.69
|
||||||
|
c0.65,0,1.13-0.2,1.46-0.59c0.32-0.39,0.49-1,0.49-1.83c0-0.82-0.15-1.43-0.45-1.83c-0.3-0.4-0.8-0.6-1.5-0.6
|
||||||
|
c-0.62,0-1.1,0.2-1.44,0.6c-0.34,0.4-0.5,1.01-0.5,1.83c0,0.84,0.17,1.45,0.52,1.84C227.09,41.5,227.56,41.69,228.16,41.69z"/>
|
||||||
|
<path d="M232.95,42.57v-6.61h2.47c0.81,0,1.43,0.12,1.85,0.36c0.42,0.24,0.63,0.68,0.63,1.31c0,0.36-0.11,0.68-0.32,0.95
|
||||||
|
c-0.22,0.27-0.5,0.46-0.86,0.55v0.04c0.42,0.07,0.77,0.23,1.04,0.49c0.27,0.25,0.41,0.61,0.41,1.06c0,0.36-0.06,0.66-0.18,0.9
|
||||||
|
c-0.12,0.24-0.29,0.43-0.51,0.57s-0.48,0.24-0.79,0.3c-0.31,0.06-0.64,0.08-1,0.08H232.95z M234.04,38.68h1.35
|
||||||
|
c0.16,0,0.32-0.01,0.49-0.02s0.32-0.05,0.46-0.11c0.14-0.06,0.25-0.15,0.34-0.27c0.09-0.12,0.14-0.28,0.14-0.49
|
||||||
|
c0-0.18-0.03-0.33-0.09-0.44c-0.06-0.11-0.15-0.2-0.27-0.26c-0.12-0.06-0.25-0.1-0.41-0.12c-0.16-0.02-0.33-0.03-0.52-0.03h-1.48
|
||||||
|
V38.68z M234.04,41.59h1.67c0.41,0,0.73-0.07,0.96-0.2c0.23-0.13,0.35-0.39,0.35-0.76c0-0.23-0.05-0.42-0.15-0.55
|
||||||
|
c-0.1-0.13-0.23-0.24-0.38-0.31c-0.16-0.07-0.33-0.12-0.51-0.14s-0.37-0.03-0.55-0.03h-1.39V41.59z"/>
|
||||||
|
<path d="M244.54,42.57h-0.96v-1.21h-0.03c-0.07,0.18-0.17,0.35-0.32,0.51c-0.14,0.16-0.31,0.3-0.5,0.42
|
||||||
|
c-0.19,0.12-0.4,0.21-0.63,0.28s-0.48,0.1-0.73,0.1c-0.3,0-0.58-0.05-0.83-0.14s-0.46-0.23-0.64-0.39
|
||||||
|
c-0.18-0.17-0.32-0.37-0.41-0.59c-0.1-0.22-0.15-0.47-0.15-0.73c0-0.44,0.12-0.8,0.35-1.08c0.23-0.28,0.54-0.49,0.93-0.65
|
||||||
|
s0.82-0.27,1.31-0.34s1-0.11,1.52-0.14v-0.34c0-0.48-0.11-0.84-0.34-1.08s-0.63-0.36-1.2-0.36c-0.32,0-0.66,0.04-1.02,0.12
|
||||||
|
s-0.67,0.19-0.93,0.32l-0.19-0.89c0.27-0.15,0.6-0.27,1.01-0.38c0.41-0.1,0.82-0.16,1.26-0.16c0.49,0,0.9,0.06,1.22,0.17
|
||||||
|
c0.32,0.12,0.58,0.29,0.77,0.51s0.32,0.5,0.4,0.84c0.08,0.34,0.12,0.73,0.12,1.17V42.57z M243.46,39.46
|
||||||
|
c-0.37,0.02-0.73,0.04-1.08,0.08c-0.35,0.03-0.66,0.1-0.94,0.19s-0.5,0.22-0.67,0.38c-0.17,0.16-0.25,0.37-0.25,0.63
|
||||||
|
c0,0.27,0.09,0.5,0.28,0.68c0.19,0.19,0.48,0.28,0.89,0.28c0.52,0,0.94-0.16,1.28-0.48s0.5-0.79,0.5-1.41V39.46z"/>
|
||||||
|
<path d="M252.12,42.57h-1.09v-2.94h-3.39v2.94h-1.09v-6.61h1.09v2.68h3.39v-2.68h1.09V42.57z"/>
|
||||||
|
<path d="M254.2,42.57v-6.61h1.09v4.21l-0.04,0.87h0.01l0.43-0.69l3.08-4.39h1.04v6.61h-1.09v-4.31l0.04-0.76h-0.01l-0.4,0.65
|
||||||
|
l-3.11,4.43H254.2z"/>
|
||||||
|
<path d="M264.65,40.04c-0.22,0-0.39,0.04-0.52,0.12c-0.13,0.08-0.25,0.21-0.36,0.39l-1.1,2.02h-1.31l1.19-2.09
|
||||||
|
c0.1-0.18,0.21-0.32,0.32-0.41c0.11-0.1,0.22-0.16,0.31-0.21c-0.47-0.09-0.84-0.29-1.13-0.59c-0.29-0.3-0.43-0.71-0.43-1.25
|
||||||
|
c0-0.34,0.07-0.65,0.2-0.91c0.13-0.26,0.31-0.47,0.54-0.64s0.49-0.3,0.79-0.38c0.3-0.09,0.62-0.13,0.96-0.13h2.67v6.61h-1.09
|
||||||
|
v-2.53H264.65z M265.7,39.06v-2.11h-1.46c-0.48,0-0.84,0.08-1.08,0.25c-0.24,0.17-0.36,0.45-0.36,0.85c0,0.38,0.15,0.64,0.44,0.79
|
||||||
|
c0.29,0.15,0.67,0.22,1.11,0.22H265.7z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 33 KiB |
|
|
@ -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 |
|
|
@ -83,7 +83,7 @@ button:focus-visible {
|
||||||
|
|
||||||
/* Фон скроллбара */
|
/* Фон скроллбара */
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--scrollbar-track-color, #f1f1f1);
|
background: var(--scrollbar-track-color, #025EA1);
|
||||||
/* Цвет фона */
|
/* Цвет фона */
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
/* Скругление углов */
|
/* Скругление углов */
|
||||||
|
|
@ -91,7 +91,7 @@ button:focus-visible {
|
||||||
|
|
||||||
/* Ползунок */
|
/* Ползунок */
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #3d74c7;
|
background: #D4EFFC;
|
||||||
/* Основной цвет */
|
/* Основной цвет */
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
/* Скругляем края */
|
/* Скругляем края */
|
||||||
|
|
|
||||||
|
|
@ -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,11 +0,0 @@
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
host: true,
|
|
||||||
allowedHosts: ['dev.msf.enode']
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue