Compare commits
143 Commits
1.0.7-demo
...
main
| 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 | |
|
|
67f776efe3 | |
|
|
5ec58ab476 | |
|
|
372a53d4cb | |
|
|
2713142c7d | |
|
|
d50aeab21d | |
|
|
a54b1d0c39 | |
|
|
cfaa31acee | |
|
|
b390f3f03a | |
|
|
f88fd41192 | |
|
|
eed159e4ab | |
|
|
c90bef284c | |
|
|
306ac00efe | |
|
|
3c60e9d144 | |
|
|
a8ca18edc9 | |
|
|
b7e5941500 | |
|
|
3d63dbe94c | |
|
|
ff10808dd8 | |
|
|
92d1236587 | |
|
|
47159bb698 | |
|
|
c4f95448c1 |
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
|
@ -24,4 +24,17 @@ dist-ssr
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
*.les*
|
*.les*
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# Игнорировать .env файлы
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
.env.test
|
||||||
|
|
||||||
|
# Local configs
|
||||||
|
vite.config.js
|
||||||
|
vite.config.local.js
|
||||||
|
.env.local
|
||||||
|
*.local.*
|
||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
index.html
|
|
@ -1,13 +1,16 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/system_monitor_icon.svg" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/system_monitor_icon.svg" />
|
||||||
<title>Модуль доверия</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
</head>
|
<title>Модуль устойчивого функционирования</title>
|
||||||
<body>
|
</head>
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<body>
|
||||||
</body>
|
<div id="root"></div>
|
||||||
</html>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
35
package.json
|
|
@ -10,16 +10,31 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"recharts": "^2.15.1",
|
"@emotion/styled": "^11.14.0",
|
||||||
"d3": "^7.9.0",
|
"@mui/icons-material": "^6.4.8",
|
||||||
"react": "^18.3.1",
|
"@mui/material": "^6.4.7",
|
||||||
"react-dom": "^18.3.1",
|
"antd": "^5.24.7",
|
||||||
"chart.js": "^4.0.0",
|
|
||||||
"chartjs-chart-box-and-violin-plot": "^4.0.0",
|
|
||||||
"react-chartjs-2": "^5.0.0",
|
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"react-datepicker": "^8.1.0"
|
"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",
|
||||||
|
|
@ -31,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>
|
||||||
|
</svg>
|
||||||
<!-- Крестик в центре, символизирующий мониторинг -->
|
|
||||||
<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>
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 729 B After Width: | Height: | Size: 1.8 KiB |
216
src/App.jsx
|
|
@ -1,25 +1,211 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
|
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 "./Style/LoginModal.css"; // Импортируем стили
|
import { lightTheme, darkTheme } from "./Style/theme";
|
||||||
|
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(
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
);
|
||||||
|
|
||||||
const handleLogin = () => {
|
const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]);
|
||||||
setIsAuthenticated(true); // Устанавливаем авторизацию
|
|
||||||
setShowLoginModal(false); // Скрываем модальное окно
|
useEffect(() => {
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleLogout = async () => {
|
||||||
<div style={{ display: "flex", height: "100vh", overflow: "hidden" }}>
|
try {
|
||||||
{!isAuthenticated && showLoginModal && (
|
const token = localStorage.getItem('access_token');
|
||||||
<LoginModal onLogin={handleLogin} onClose={() => setShowLoginModal(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && <Dashboard />}
|
if (!token) {
|
||||||
</div>
|
// Если нет токена - просто очищаем данные
|
||||||
|
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 (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{!authState.isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Box sx={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 24,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 1200,
|
||||||
|
'& svg': { width: 400, height: 'auto' }
|
||||||
|
}}>
|
||||||
|
<Logo />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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,262 +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('http://192.168.2.39:3000/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,27 +1,52 @@
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import SidebarMenu from "./SidebarMenu";
|
import { Box, styled } from "@mui/material";
|
||||||
import TreeChart from "../TreeChart/TreeChart";
|
|
||||||
import "../../Style/Dashboard.css";
|
|
||||||
import SystemStatusChart from "../../Charts/SystemStatusChart";
|
|
||||||
import Tabs from "../UI/Tabs";
|
|
||||||
import menuData from "../TreeChart/menuData.json";
|
|
||||||
import TreeTable from "../UI/TreeTable";
|
|
||||||
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 useTabs from "../hooks/useTabs";
|
||||||
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
|
import TabContent from "../hooks/TabContent";
|
||||||
|
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 }) => ({
|
||||||
const [tabs, setTabs] = useState([]);
|
display: 'flex',
|
||||||
const [activeTab, setActiveTab] = useState("Главная");
|
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 [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);
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(250);
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
const [statusHistories, setStatusHistories] = useState({
|
const [statusHistories, setStatusHistories] = useState({
|
||||||
history1: [],
|
history1: [],
|
||||||
history2: [],
|
history2: [],
|
||||||
});
|
});
|
||||||
const sidebarRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generatedTabContent = generateTabContent(menuData);
|
const generatedTabContent = generateTabContent(menuData);
|
||||||
|
|
@ -40,11 +65,11 @@ const Dashboard = () => {
|
||||||
|
|
||||||
setStatusHistories((prevHistories) => ({
|
setStatusHistories((prevHistories) => ({
|
||||||
history1: [
|
history1: [
|
||||||
...prevHistories.history1.slice(-49),
|
...prevHistories.history1.slice(-29),
|
||||||
{ time: new Date().toLocaleTimeString(), status: statusPercentage1 },
|
{ time: new Date().toLocaleTimeString(), status: statusPercentage1 },
|
||||||
],
|
],
|
||||||
history2: [
|
history2: [
|
||||||
...prevHistories.history2.slice(-49),
|
...prevHistories.history2.slice(-29),
|
||||||
{ time: new Date().toLocaleTimeString(), status: statusPercentage2 },
|
{ time: new Date().toLocaleTimeString(), status: statusPercentage2 },
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
@ -56,101 +81,123 @@ const Dashboard = () => {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [treeData1, treeData2]);
|
}, [treeData1, treeData2]);
|
||||||
|
|
||||||
const startResizing = useCallback((e) => {
|
const handleMenuSelect = (item) => {
|
||||||
e.preventDefault();
|
const tabId = `tab_${item.id}`;
|
||||||
setIsResizing(true);
|
const tabTitle = item.title || 'Новая вкладка';
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resize = useCallback((e) => {
|
const tabContent = item.metric
|
||||||
if (isResizing) {
|
? <MetricTabContent
|
||||||
const newWidth = e.clientX;
|
metricInfo={{
|
||||||
if (newWidth > 100 && newWidth < 400) {
|
name: item.metric,
|
||||||
setSidebarWidth(newWidth);
|
filters: item.filters,
|
||||||
}
|
title: item.title,
|
||||||
}
|
description: item.description,
|
||||||
}, [isResizing]);
|
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 stopResizing = useCallback(() => {
|
const existingTab = tabs.find(tab => tab.id === tabId);
|
||||||
setIsResizing(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!existingTab) {
|
||||||
const handleMouseMove = (e) => resize(e);
|
const newTab = {
|
||||||
const handleMouseUp = () => stopResizing();
|
id: tabId,
|
||||||
|
title: tabTitle,
|
||||||
if (isResizing) {
|
content: tabContent,
|
||||||
window.addEventListener("mousemove", handleMouseMove);
|
type: item.metric ? 'metric' : 'menuItem',
|
||||||
window.addEventListener("mouseup", handleMouseUp);
|
metric: item.metric,
|
||||||
}
|
filters: item.filters,
|
||||||
|
ranges: item.ranges
|
||||||
return () => {
|
};
|
||||||
window.removeEventListener("mousemove", handleMouseMove);
|
handleOpenTab(newTab);
|
||||||
window.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
};
|
|
||||||
}, [isResizing, resize, stopResizing]);
|
|
||||||
|
|
||||||
const handleOpenTab = (id, title) => {
|
|
||||||
if (!tabs.some((tab) => tab.id === id)) {
|
|
||||||
setTabs([...tabs, { id, title }]);
|
|
||||||
}
|
|
||||||
setActiveTab(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseTab = (id) => {
|
|
||||||
const newTabs = tabs.filter((tab) => tab.id !== id);
|
|
||||||
setTabs(newTabs);
|
|
||||||
if (activeTab === id) {
|
|
||||||
setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : "Главная");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTabContent = () => {
|
|
||||||
if (activeTab === "Главная") {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Общий мониторинг состояния системы</h2>
|
|
||||||
<label>Процент доверия системы</label>
|
|
||||||
<SystemStatusChart data={statusHistories.history1} />
|
|
||||||
<label>Функциональность системы</label>
|
|
||||||
<SystemStatusChart data={statusHistories.history2} />
|
|
||||||
<label>Статус компонентов системы</label>
|
|
||||||
<TreeTable data={treeData1} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (activeTab === "Визуализация") {
|
|
||||||
return <TreeChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
|
|
||||||
} else {
|
} else {
|
||||||
const tabData = tabContent[activeTab];
|
setActiveTab(tabId);
|
||||||
return tabData ? tabData.content : <p>Нет данных</p>;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Вспомогательная функция для получения всех дочерних элементов
|
||||||
|
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>
|
||||||
<div
|
|
||||||
className="sidebar"
|
|
||||||
ref={sidebarRef}
|
|
||||||
style={{ width: sidebarWidth }}
|
|
||||||
>
|
|
||||||
<SidebarMenu data={treeData1} onOpenTab={handleOpenTab} sidebarWidth={sidebarWidth} />
|
|
||||||
<div
|
|
||||||
className="sidebar-resizer"
|
|
||||||
onMouseDown={startResizing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="main-content" style={{ marginLeft: sidebarWidth }}>
|
<Box
|
||||||
<Tabs
|
sx={{
|
||||||
tabs={tabs}
|
position: 'fixed',
|
||||||
activeTab={activeTab}
|
top: 12,
|
||||||
onTabClick={(id) => setActiveTab(id)}
|
right: 20,
|
||||||
onCloseTab={handleCloseTab}
|
zIndex: (theme) => theme.zIndex.tooltip + 10,
|
||||||
/>
|
pointerEvents: 'auto',
|
||||||
<div className="content">
|
display: 'flex',
|
||||||
{renderTabContent()}
|
gap: 1,
|
||||||
</div>
|
alignItems: 'center'
|
||||||
</div>
|
}}//ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
|
||||||
</div>
|
>
|
||||||
|
<AIAnalysisButton />
|
||||||
|
<ProfileMenu user={user} onLogout={onLogout} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Сайдбар */}
|
||||||
|
<SidebarMenuWrapper
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
setIsDarkMode={setIsDarkMode}
|
||||||
|
onMenuSelect={handleMenuSelect}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Основной контент */}
|
||||||
|
<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
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabClick={setActiveTab}
|
||||||
|
onCloseTab={handleCloseTab}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Остальной контент */}
|
||||||
|
<MainContent>
|
||||||
|
{/* Контент вкладки */}
|
||||||
|
<Content>
|
||||||
|
<TabContent
|
||||||
|
activeTab={activeTab}
|
||||||
|
statusHistories={statusHistories}
|
||||||
|
treeData1={treeData1}
|
||||||
|
tabContent={tabContent}
|
||||||
|
handleOpenTab={handleOpenTab}
|
||||||
|
tabs={tabs}
|
||||||
|
/>
|
||||||
|
</Content>
|
||||||
|
</MainContent>
|
||||||
|
</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,82 +1,538 @@
|
||||||
import React, { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import "../../Style/SidebarMenu.css";
|
import {
|
||||||
import { getStatusColor } from "../TreeChart/dataUtils"; // Импортируем только нужную функцию
|
Drawer,
|
||||||
|
List,
|
||||||
|
styled,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Box,
|
||||||
|
alpha
|
||||||
|
} from "@mui/material";
|
||||||
|
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 MenuItem = ({ item, onSelectItem, sidebarWidth }) => {
|
import {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
DndContext,
|
||||||
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
closestCenter,
|
||||||
const statusColor = getStatusColor(item.status);
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragOverlay,
|
||||||
|
MeasuringStrategy
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
|
||||||
// Обработчик одинарного клика (разворачивание/сворачивание или открытие элемента)
|
import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem";
|
||||||
const handleSingleClick = () => {
|
|
||||||
if (hasChildren) {
|
const SidebarMenu = ({
|
||||||
setIsOpen(!isOpen); // Разворачиваем/сворачиваем дочерние элементы
|
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 {
|
} else {
|
||||||
onSelectItem(item); // Если нет потомков, открываем элемент как вкладку
|
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) => {
|
||||||
const handleOpenParent = (e) => {
|
if (!parent || !child || !parent.items) return false;
|
||||||
e.stopPropagation(); // Останавливаем всплытие события, чтобы не сработал handleSingleClick
|
|
||||||
onSelectItem(item); // Открываем родителя
|
const checkChildren = (items, targetId) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === targetId) return true;
|
||||||
|
if (item.items && checkChildren(item.items, targetId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return checkChildren(parent.items, child.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SidebarResizer = styled("div")(({ theme }) => ({
|
||||||
|
width: "3px",
|
||||||
|
cursor: "col-resize",
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.3),
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.primary.main,
|
||||||
|
},
|
||||||
|
height: "100%",
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DropIndicator = ({ position, targetId }) => {
|
||||||
|
if (!targetId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '2px',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
zIndex: 1001,
|
||||||
|
...(position === 'above' && { top: 0 }),
|
||||||
|
...(position === 'below' && { bottom: 0 }),
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-3px',
|
||||||
|
left: '10%',
|
||||||
|
width: '80%',
|
||||||
|
height: '8px',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="menu-item" style={{ width: sidebarWidth - 20 }}> {/* Динамическая ширина */}
|
<Box
|
||||||
<div
|
sx={{
|
||||||
onClick={handleSingleClick} // Одинарный клик для разворачивания/сворачивания или открытия
|
position: "relative",
|
||||||
className="menu-item-header"
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
|
transition: "width 0.2s ease",
|
||||||
|
height: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
|
flexShrink: 0,
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
display: "flex",
|
||||||
|
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)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Круглый индикатор статуса */}
|
{/* Заголовок с логотипом */}
|
||||||
<div
|
<Box
|
||||||
className={`status-indicator ${statusColor === "red" ? "blinking" : ""}`}
|
sx={{
|
||||||
style={{ backgroundColor: statusColor }}
|
display: "flex",
|
||||||
/>
|
alignItems: "center",
|
||||||
<span>{item.title}</span>
|
justifyContent: "center",
|
||||||
|
p: 2,
|
||||||
{/* Иконка для открытия родителя */}
|
borderBottom: "1px solid",
|
||||||
{hasChildren && (
|
borderColor: "divider",
|
||||||
<span
|
backgroundColor: "background.paper",
|
||||||
onClick={handleOpenParent}
|
height: 80,
|
||||||
className="open-parent-icon"
|
position: "relative",
|
||||||
title="Открыть родителя"
|
transition: "all 0.2s ease",
|
||||||
|
minHeight: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"& svg": {
|
||||||
|
width: "auto",
|
||||||
|
height: "40px", // Фиксированная высота для лого
|
||||||
|
objectFit: "contain",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
📂
|
{collapsed ? (
|
||||||
</span>
|
<LogoSmall style={{
|
||||||
)}
|
color: "inherit",
|
||||||
|
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px"
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<LogoFull style={{
|
||||||
|
color: "inherit",
|
||||||
|
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||||||
|
maxWidth: "180px",
|
||||||
|
height: "40px"
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Иконка для разворачивания/сворачивания */}
|
<Tooltip
|
||||||
{hasChildren && <span>{isOpen ? "▲" : "▼"}</span>}
|
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
|
||||||
</div>
|
placement="right"
|
||||||
{isOpen && hasChildren && (
|
>
|
||||||
<div className="submenu">
|
<IconButton
|
||||||
{item.items.map((child, index) => (
|
onClick={handleToggleCollapse}
|
||||||
<MenuItem key={index} item={child} onSelectItem={onSelectItem} sidebarWidth={sidebarWidth} />
|
size="small"
|
||||||
))}
|
sx={{
|
||||||
</div>
|
color: "text.secondary",
|
||||||
)}
|
"&:hover": {
|
||||||
</div>
|
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>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function SidebarMenu({ data, onOpenTab, sidebarWidth }) {
|
|
||||||
const handleSelectItem = (item) => {
|
|
||||||
onOpenTab(item.id, item.title);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sidebar">
|
|
||||||
<div className="sidebar-content" style={{ width: sidebarWidth }}> {/* Динамическая ширина */}
|
|
||||||
<h2 className="sidebar-title">Меню</h2>
|
|
||||||
<MenuItem item={data} onSelectItem={handleSelectItem} sidebarWidth={sidebarWidth} />
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-footer" style={{ width: sidebarWidth }}> {/* Динамическая ширина */}
|
|
||||||
<h2 className="help">Помощь</h2>
|
|
||||||
<h2 className="settings">Настройка</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SidebarMenu;
|
export default SidebarMenu;
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
// // MenuItem.jsx
|
||||||
|
// import React, { useState } from "react";
|
||||||
|
// import {
|
||||||
|
// ListItem,
|
||||||
|
// ListItemIcon,
|
||||||
|
// ListItemText,
|
||||||
|
// Collapse,
|
||||||
|
// List,
|
||||||
|
// styled,
|
||||||
|
// Menu,
|
||||||
|
// MenuItem as MuiMenuItem
|
||||||
|
// } from "@mui/material";
|
||||||
|
// import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
||||||
|
// import StatusIndicator from "./StatusIndicator";
|
||||||
|
|
||||||
|
// const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||||
|
// cursor: "pointer",
|
||||||
|
// paddingLeft: theme.spacing(2 + level * 2),
|
||||||
|
// position: 'relative',
|
||||||
|
// '&:hover': {
|
||||||
|
// backgroundColor: theme.palette.action.hover,
|
||||||
|
// },
|
||||||
|
// '&.Mui-selected': {
|
||||||
|
// backgroundColor: theme.palette.custom.sidebarHover,
|
||||||
|
// },
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
|
// const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// const [contextMenu, setContextMenu] = useState(null);
|
||||||
|
// const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
||||||
|
|
||||||
|
// const handleContextMenu = (e) => {
|
||||||
|
// e.preventDefault();
|
||||||
|
// setContextMenu(
|
||||||
|
// contextMenu === null
|
||||||
|
// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
||||||
|
// : null
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleCloseContextMenu = () => {
|
||||||
|
// setContextMenu(null);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleToggle = (e) => {
|
||||||
|
// e.stopPropagation();
|
||||||
|
// setIsOpen(!isOpen);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FooterList>
|
||||||
|
{!collapsed ? (
|
||||||
|
<>
|
||||||
|
<FooterListItem>
|
||||||
|
<Button
|
||||||
|
onClick={handleSettingsOpen}
|
||||||
|
startIcon={<Settings />}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Настройки
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Tooltip title="Переключить тему">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: alpha('#000000', 0.1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Switch
|
||||||
|
checked={isDarkMode}
|
||||||
|
onChange={() => setIsDarkMode(!isDarkMode)}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</FooterListItem>
|
||||||
|
|
||||||
|
<FooterListItem button>
|
||||||
|
<Button
|
||||||
|
startIcon={<Help />}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Помощь
|
||||||
|
</Button>
|
||||||
|
</FooterListItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<FooterListItem sx={{ justifyContent: 'center' }}>
|
||||||
|
<Tooltip title="Настройки" placement="right">
|
||||||
|
<IconButton
|
||||||
|
onClick={handleSettingsOpen}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: alpha('#000000', 0.1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</FooterListItem>
|
||||||
|
)}
|
||||||
|
</FooterList>
|
||||||
|
|
||||||
|
<RoleBasedRender user={user} allowedRoles={['admin']}>
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={handleSettingsClose}
|
||||||
|
onMenuUpdate={forceRefreshMenu}
|
||||||
|
/>
|
||||||
|
</RoleBasedRender>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarFooter;
|
||||||
|
|
@ -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,172 +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 simulationRef = useRef(null);
|
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Применяем сохраненные позиции к узлам
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
const prev = nodePositions.current.get(node.data.id);
|
|
||||||
if (prev) {
|
|
||||||
node.x = prev.x;
|
|
||||||
node.y = prev.y;
|
|
||||||
node.fx = prev.fx ?? null;
|
|
||||||
node.fy = prev.fy ?? null;
|
|
||||||
} else {
|
|
||||||
const parent = node.parent;
|
|
||||||
node.x = parent ? parent.x + Math.random() * 50 - 25 : Math.random() * 1000;
|
|
||||||
node.y = parent ? parent.y + Math.random() * 50 - 25 : Math.random() * 1000;
|
|
||||||
}
|
|
||||||
nodePositions.current.set(node.data.id, { x: node.x, y: node.y, fx: node.fx, fy: node.fy });
|
|
||||||
});
|
|
||||||
|
|
||||||
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, 1000, 1000])
|
|
||||||
.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");
|
|
||||||
|
|
||||||
// Инициализация симуляции
|
|
||||||
simulationRef.current = d3.forceSimulation()
|
|
||||||
.force("link", d3.forceLink().id((d) => d.data.id).distance(80).strength(1))
|
|
||||||
.force("charge", d3.forceManyBody().strength(-200))
|
|
||||||
.force("center", d3.forceCenter(0, 0))
|
|
||||||
.force("collision", d3.forceCollide().radius(20))
|
|
||||||
.force("x", d3.forceX(0).strength(0.05))
|
|
||||||
.force("y", d3.forceY(0).strength(0.05))
|
|
||||||
.force("radial", d3.forceRadial(200, 0, 0).strength(0.02))
|
|
||||||
.alphaDecay(0.02)
|
|
||||||
.alphaTarget(0.1);
|
|
||||||
|
|
||||||
// Запускаем симуляцию на 15 секунд, затем отключаем
|
|
||||||
setTimeout(() => {
|
|
||||||
if (simulationRef.current) {
|
|
||||||
simulationRef.current.stop(); // Останавливаем симуляцию
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
node.fx = node.x; // Фиксируем текущие позиции узлов
|
|
||||||
node.fy = node.y;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 15000); // 15 секунд
|
|
||||||
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!root || !chartRef.current || !simulationRef.current) return; // Проверяем, что симуляция инициализирована
|
|
||||||
|
|
||||||
const svg = d3.select(chartRef.current);
|
|
||||||
const linkGroup = svg.select(".links");
|
|
||||||
const nodeGroup = svg.select(".nodes");
|
|
||||||
const labelGroup = svg.select(".labels");
|
|
||||||
|
|
||||||
// Обновляем связи
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Обновляем узлы
|
|
||||||
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)
|
|
||||||
.call(drag());
|
|
||||||
|
|
||||||
node.on("click", (event, d) => {
|
|
||||||
if (onNodeClick) {
|
|
||||||
onNodeClick(d.data.id, d.data.title);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обновляем текстовые метки
|
|
||||||
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"); // Запрет взаимодействия с текстом
|
|
||||||
|
|
||||||
// Обновляем симуляцию
|
|
||||||
simulationRef.current.nodes(nodes);
|
|
||||||
simulationRef.current.force("link").links(links);
|
|
||||||
simulationRef.current.alphaTarget(0.1).restart();
|
|
||||||
|
|
||||||
simulationRef.current.on("tick", () => {
|
|
||||||
link
|
|
||||||
.attr("x1", (d) => d.source.x)
|
|
||||||
.attr("y1", (d) => d.source.y)
|
|
||||||
.attr("x2", (d) => d.target.x)
|
|
||||||
.attr("y2", (d) => d.target.y);
|
|
||||||
|
|
||||||
node
|
|
||||||
.attr("cx", (d) => d.x)
|
|
||||||
.attr("cy", (d) => d.y);
|
|
||||||
|
|
||||||
text
|
|
||||||
.attr("x", (d) => d.x + 12)
|
|
||||||
.attr("y", (d) => d.y + 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
}, [root, links, nodes, onNodeClick]);
|
|
||||||
|
|
||||||
const drag = () => {
|
|
||||||
function dragstarted(event, d) {
|
|
||||||
if (!event.active && simulationRef.current) simulationRef.current.alphaTarget(0.3).restart();
|
|
||||||
d.fx = d.x;
|
|
||||||
d.fy = d.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragged(event, d) {
|
|
||||||
d.fx = event.x;
|
|
||||||
d.fy = event.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragended(event, d) {
|
|
||||||
if (!event.active && simulationRef.current) simulationRef.current.alphaTarget(0);
|
|
||||||
nodePositions.current.set(d.data.id, { x: d.x, y: d.y, fx: d.fx, fy: d.fy });
|
|
||||||
}
|
|
||||||
|
|
||||||
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":
|
||||||
|
|
|
||||||
|
|
@ -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 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Компонент Skeleton для контейнера
|
||||||
|
const ContainerSkeleton = () => (
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Skeleton variant="text" width="40%" height={40} />
|
||||||
|
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<ChartSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Утилита для извлечения контекста из пути
|
||||||
|
const parseContextFromPath = (node) => {
|
||||||
|
const context = {};
|
||||||
|
let current = node;
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
if (current.id.startsWith("device$")) {
|
||||||
|
context.device = current.id.split("$")[1];
|
||||||
|
context.deviceId = current.id;
|
||||||
|
}
|
||||||
|
if (current.id.startsWith("module$")) {
|
||||||
|
context.module = current.id;
|
||||||
|
context.source_id = current.id;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
//!!!!!!!!!!Пофиксить вкладуи с eth4, во всех eth 1-4 открывается именно 4 !!!!!!!!!!!!!
|
// Основная функция построения контента вкладок
|
||||||
|
const tabContent = (data, cache = {}) => {
|
||||||
|
const tabContentMap = { ...cache };
|
||||||
|
|
||||||
// Функция для рекурсивного сбора всех id потомков
|
if (!data || !data.items || data.items.length === 0) {
|
||||||
const getAllChildIds = (node) => {
|
console.warn("Данные отсутствуют или массив items пуст", data);
|
||||||
let ids = [];
|
return tabContentMap;
|
||||||
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 processNode = (node, parentContext = {}) => {
|
||||||
const tabContent = {};
|
// Получаем полный контекст из всей цепочки родителей
|
||||||
|
const pathContext = parseContextFromPath(node);
|
||||||
|
const currentContext = { ...parentContext, ...pathContext };
|
||||||
|
|
||||||
// Функция для рекурсивного обхода и сбора данных
|
// Генерируем уникальный ключ на основе пути
|
||||||
const generateContent = (nodes) => {
|
const path = [];
|
||||||
nodes.forEach((node) => {
|
let current = node;
|
||||||
// Если у узла есть вложенные элементы, рекурсивно обрабатываем их
|
while (current) {
|
||||||
if (node.items && node.items.length > 0) {
|
path.unshift(current.id);
|
||||||
// Создаем контент для родителя
|
current = current.parent;
|
||||||
const childrenContent = generateContent(node.items);
|
}
|
||||||
|
const pathId = path.join('_');
|
||||||
|
|
||||||
const content = (
|
if (Array.isArray(node.items) && node.items.length > 0) {
|
||||||
<div>
|
const children = node.items
|
||||||
<h2>{node.title}</h2>
|
.map((child) => processNode(child, currentContext))
|
||||||
<p>Контент для {node.title}.</p>
|
.filter(Boolean);
|
||||||
{childrenContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Сохраняем контент для текущего id
|
const content = (
|
||||||
tabContent[node.id] = {
|
<div key={`${pathId}-container`}>
|
||||||
title: node.title,
|
<h2>{node.title}</h2>
|
||||||
content: content,
|
<Suspense fallback={<ContainerSkeleton />}>
|
||||||
};
|
<LazyChartBatchRenderer charts={children.map((c) => c.content)} />
|
||||||
} else {
|
</Suspense>
|
||||||
// Если у узла нет вложенных элементов, это самый нижний уровень
|
</div>
|
||||||
const metricName = getMetricName(node.id);
|
);
|
||||||
const content = (
|
|
||||||
<div key={node.id}>
|
|
||||||
<h3>{node.title}</h3> {/* Используем title узла */}
|
|
||||||
<Suspense fallback={<div>Загрузка графика...</div>}>
|
|
||||||
<PrometheusChart metricName={metricName} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Сохраняем контент для текущего id
|
tabContentMap[pathId] = {
|
||||||
tabContent[node.id] = {
|
title: node.title,
|
||||||
title: node.title,
|
content,
|
||||||
content: content,
|
context: currentContext,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Возвращаем контент для всех потомков
|
return { content, context: currentContext };
|
||||||
return (
|
}
|
||||||
<div>
|
|
||||||
{nodes.map((node) => (
|
if (node.metric) {
|
||||||
<div key={node.id}>{tabContent[node.id].content}</div>
|
const chartKey = `${node.metric}-${currentContext.device || "all"}-${currentContext.module || "all"}-${pathId}`;
|
||||||
))}
|
|
||||||
|
const content = (
|
||||||
|
<div key={chartKey}>
|
||||||
|
<h3>{node.title}</h3>
|
||||||
|
{currentContext.device && <p>Устройство: {currentContext.device}</p>}
|
||||||
|
{currentContext.module && <p>Модуль: {currentContext.module}</p>}
|
||||||
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
|
<PrometheusChart
|
||||||
|
metricInfo={{
|
||||||
|
name: node.metric,
|
||||||
|
filters: {
|
||||||
|
...(currentContext.device && { device: currentContext.device }),
|
||||||
|
...(currentContext.source_id && { source_id: currentContext.source_id }),
|
||||||
|
},
|
||||||
|
title: node.title,
|
||||||
|
description: node.description,
|
||||||
|
context: currentContext,
|
||||||
|
}}
|
||||||
|
key={chartKey}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
tabContentMap[pathId] = {
|
||||||
|
title: node.title,
|
||||||
|
content,
|
||||||
|
context: currentContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { content, context: currentContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Узел без метрики и без вложенных — просто заголовок
|
||||||
|
const content = (
|
||||||
|
<div key={pathId}>
|
||||||
|
<h3>{node.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
tabContentMap[pathId] = {
|
||||||
|
title: node.title,
|
||||||
|
content,
|
||||||
|
context: currentContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { content, context: currentContext };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Начинаем обработку с корневого уровня
|
try {
|
||||||
if (data.items && data.items.length > 0) {
|
processNode(data);
|
||||||
generateContent(data.items);
|
} catch (error) {
|
||||||
} else {
|
console.error("Ошибка обработки данных:", error);
|
||||||
console.warn("Данные отсутствуют или массив items пуст");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabContent;
|
return tabContentMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default tabContent;
|
export default tabContent;
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogActions,
|
||||||
|
Typography,
|
||||||
|
IconButton
|
||||||
|
} from '@mui/material';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const AIAnalysisButton = ({ onAnalysisComplete }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [openModal, setOpenModal] = useState(false);
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Получаем данные из ClickHouse
|
||||||
|
console.log('Запрашиваем данные из ClickHouse...');
|
||||||
|
const metricsResponse = await axios.get('/api/clickhouse');
|
||||||
|
console.log('Получены данные из ClickHouse:', metricsResponse.data);
|
||||||
|
|
||||||
|
// 2. Отправляем в AI API
|
||||||
|
console.log('Отправляем данные в AI API:', metricsResponse.data);
|
||||||
|
const aiResponse = await axios.post(
|
||||||
|
'/ai-api/api/metrics/rest',
|
||||||
|
metricsResponse.data,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.log('Ответ от AI API:', aiResponse.data);
|
||||||
|
|
||||||
|
setResult(aiResponse.data);
|
||||||
|
setOpenModal(true);
|
||||||
|
if (onAnalysisComplete) {
|
||||||
|
onAnalysisComplete(aiResponse.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Детали ошибки:", err.response?.data);
|
||||||
|
setError(err.response?.data?.message || JSON.stringify(err.response?.data)) || "Ошибка при анализе данных";
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setOpenModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||||
|
sx={{
|
||||||
|
minWidth: '180px',
|
||||||
|
backgroundColor: '#4caf50',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '#388e3c',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Отправка в AI...' : 'Проанализировать AI'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Модальное окно с результатом */}
|
||||||
|
<Dialog
|
||||||
|
open={openModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
fullWidth={true}
|
||||||
|
maxWidth="lg"
|
||||||
|
scroll="paper"
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
Результат AI-анализа
|
||||||
|
<IconButton
|
||||||
|
aria-label="close"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
sx={{
|
||||||
|
color: (theme) => theme.palette.grey[500],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{result ? (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" gutterBottom>Данные анализа:</Typography>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: '#f5f5f5',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordWrap: 'break-word'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DialogContentText>Нет данных для отображения</DialogContentText>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseModal}>Закрыть</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(result, null, 2));
|
||||||
|
alert('Результат скопирован в буфер обмена');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Копировать
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIAnalysisButton;
|
||||||
|
|
@ -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,19 +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,
|
||||||
|
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] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (username === "admin" && password === "admin") {
|
try {
|
||||||
onLogin(); // Успешная авторизация
|
const { data } = await axios.post(
|
||||||
onClose(); // Закрыть модальное окно
|
//`${import.meta.env.VITE_BACK_URL}/api/auth/login`,
|
||||||
} else {
|
'/api/auth/login',
|
||||||
setError("Неверный логин или пароль");
|
{ login: username, password },
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Login response:', data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (!data.user?.role) {
|
||||||
|
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 {
|
||||||
|
setError(data.message || 'Ошибка авторизации');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
setError(err.response?.data?.message || err.message || 'Ошибка при входе');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -21,26 +69,64 @@ const LoginModal = ({ onLogin, onClose }) => {
|
||||||
<Modal onClose={onClose}>
|
<Modal onClose={onClose}>
|
||||||
<h2>Авторизация</h2>
|
<h2>Авторизация</h2>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div>
|
<TextField
|
||||||
<label>Логин:</label>
|
fullWidth
|
||||||
<input
|
id="user-login"
|
||||||
type="text"
|
label="Логин"
|
||||||
value={username}
|
variant="filled"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
margin="normal"
|
||||||
required
|
required
|
||||||
/>
|
value={username}
|
||||||
</div>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
<div>
|
/>
|
||||||
<label>Пароль:</label>
|
|
||||||
<input
|
<TextField
|
||||||
type="password"
|
fullWidth
|
||||||
value={password}
|
id="user-password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
label="Пароль"
|
||||||
required
|
variant="filled"
|
||||||
/>
|
margin="normal"
|
||||||
</div>
|
required
|
||||||
{error && <p className="error">{error}</p>}
|
type={showPassword ? 'text' : 'password'}
|
||||||
<button type="submit">Войти</button>
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
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 && (
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Tabs, Tab, Box, styled, Typography } from "@mui/material";
|
||||||
|
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 handleMouseDown = (e, id) => {
|
||||||
|
if (e.button === 1) { // Средняя кнопка мыши
|
||||||
|
e.preventDefault();
|
||||||
|
onCloseTab(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (event, newValue) => {
|
||||||
|
onTabClick(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Статические вкладки (сохраняем оригинальные id)
|
||||||
|
const staticTabs = [
|
||||||
|
{ id: "Главная", title: "Главная" },
|
||||||
|
{ id: "Визуализация", title: "Визуализация" }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
'& .MuiTabs-indicator': {
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={handleChange}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
|
aria-label="tabs"
|
||||||
|
>
|
||||||
|
{/* Статические вкладки */}
|
||||||
|
{staticTabs.map(tab => (
|
||||||
|
<StyledTab
|
||||||
|
key={`static_${tab.id}`} // Добавляем префикс для уникальности
|
||||||
|
label={tab.title}
|
||||||
|
value={tab.id} // Используем id как value
|
||||||
|
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Динамические вкладки */}
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<StyledTab
|
||||||
|
key={`dynamic_${tab.id}`} // Добавляем префикс для уникальности
|
||||||
|
label={
|
||||||
|
<TabLabel
|
||||||
|
title={tab.title}
|
||||||
|
onClose={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCloseTab(tab.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
value={tab.id}
|
||||||
|
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomTabs;
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -29,10 +44,11 @@ const TreeTable = ({ data }) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adjustFontSize();
|
adjustFontSize();
|
||||||
window.addEventListener("resize", adjustFontSize);
|
window.addEventListener('resize', adjustFontSize);
|
||||||
return () => window.removeEventListener("resize", adjustFontSize);
|
return () => window.removeEventListener('resize', adjustFontSize);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// Логирование статусов
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newLog = [];
|
const newLog = [];
|
||||||
const traverse = (items) => {
|
const traverse = (items) => {
|
||||||
|
|
@ -41,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) {
|
||||||
|
|
@ -50,100 +66,285 @@ const TreeTable = ({ data }) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
traverse(data.items);
|
traverse(data.items);
|
||||||
setLog(newLog);
|
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 className="status-indicator-bar" style={{ backgroundColor: statusManager1.getStatusColor(item.status) }} />
|
colSpan={colSpan}
|
||||||
<div className="status-indicator-bar" style={{ backgroundColor: statusManager2.getStatusColor(item.status), marginLeft: "5px" }} />
|
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}
|
{item.title}
|
||||||
</div>
|
</Typography>
|
||||||
</th>
|
</TableCellWithTooltip>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderRows = (items) => {
|
// Рендер подзаголовков (второй уровень)
|
||||||
if (!items || items.length === 0) return null;
|
const renderSubHeaders = (items) => {
|
||||||
const hasChildren = items.some((item) => item.items && item.items.length > 0);
|
return items.flatMap((item) => {
|
||||||
if (!hasChildren) return null;
|
if (item.items) {
|
||||||
return (
|
return item.items.map((child) => (
|
||||||
<tr className="tree-table-row">
|
<TableCellWithTooltip
|
||||||
{items.map((item) => {
|
key={child.id}
|
||||||
if (item.items && item.items.length > 0) {
|
align="center"
|
||||||
return (
|
title={child.title}
|
||||||
<React.Fragment key={item.id}>
|
sx={{
|
||||||
{item.items.map((child) => (
|
backgroundColor: theme.palette.background.paper,
|
||||||
<td key={child.id} className="tree-table-cell" title={child.title}>
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
<div className="cell-content">
|
padding: '8px',
|
||||||
<div className="status-indicator-bar" style={{ backgroundColor: statusManager1.getStatusColor(child.status) }} />
|
whiteSpace: 'nowrap',
|
||||||
<div className="status-indicator-bar" style={{ backgroundColor: statusManager2.getStatusColor(child.status), marginLeft: "5px" }} />
|
overflow: 'hidden',
|
||||||
{child.title}
|
textOverflow: 'ellipsis'
|
||||||
</div>
|
}}
|
||||||
</td>
|
>
|
||||||
))}
|
<StatusIndicators status={child.status} />
|
||||||
</React.Fragment>
|
<Typography component="span" variant="subtitle2" noWrap>
|
||||||
);
|
{child.title}
|
||||||
} else {
|
</Typography>
|
||||||
return (
|
</TableCellWithTooltip>
|
||||||
<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) }} />
|
return (
|
||||||
<div className="status-indicator-bar" style={{ backgroundColor: statusManager2.getStatusColor(item.status), marginLeft: "5px" }} />
|
<TableCellWithTooltip
|
||||||
{item.title}
|
key={item.id}
|
||||||
</div>
|
align="center"
|
||||||
</td>
|
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 renderDataCells = (items) => {
|
||||||
|
return items.flatMap((item) => {
|
||||||
|
if (item.items) {
|
||||||
|
return item.items.flatMap((child) => {
|
||||||
|
if (child.items) {
|
||||||
|
return child.items.map((subChild) => (
|
||||||
|
<TableCellWithTooltip
|
||||||
|
key={subChild.id}
|
||||||
|
title={subChild.title}
|
||||||
|
sx={{
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={subChild.status} />
|
||||||
|
<Typography component="span" variant="body2" noWrap>
|
||||||
|
{subChild.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
|
));
|
||||||
}
|
}
|
||||||
})}
|
return (
|
||||||
</tr>
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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" }} />
|
}}
|
||||||
{data.title}
|
>
|
||||||
</div>
|
<Table sx={{ width: '100%', tableLayout: 'fixed' }}>
|
||||||
</th>
|
<TableHead>
|
||||||
</tr>
|
{/* Основной заголовок таблицы */}
|
||||||
<tr>{renderHeaders(filteredData)}</tr>
|
<TableRow>
|
||||||
</thead>
|
<TableCellWithTooltip
|
||||||
<tbody>{renderRows(filteredData)}</tbody>
|
colSpan={filteredData.reduce((acc, item) => acc + (item.items ? item.items.length : 1), 0)}
|
||||||
</table>
|
align="center"
|
||||||
<button onClick={() => setIsLogVisible(!isLogVisible)} className="toggle-log-button">
|
title={data.title}
|
||||||
{isLogVisible ? "Скрыть лог" : "Показать лог"}
|
sx={{
|
||||||
</button>
|
backgroundColor: theme.palette.background.paper,
|
||||||
{isLogVisible && (
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
<div className="status-log">
|
padding: '8px'
|
||||||
<h3>Лог статусов</h3>
|
}}
|
||||||
<ul>
|
>
|
||||||
|
<StatusIndicators status={data.status} />
|
||||||
|
<Typography component="span" variant="subtitle1" fontWeight="bold" noWrap>
|
||||||
|
{data.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
|
{/* Строка с основными заголовками */}
|
||||||
|
<TableRow>
|
||||||
|
{renderMainHeaders(filteredData)}
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
|
{/* Строка с подзаголовками (которая пропала в предыдущей версии) */}
|
||||||
|
<TableRow>
|
||||||
|
{renderSubHeaders(filteredData)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
<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;
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import TreeTable from "../UI/TreeTable";
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</h2>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
|
||||||
|
<label>Надежность серверов</label>
|
||||||
|
<SystemChart
|
||||||
|
metricInfo={serverMetric}
|
||||||
|
chartHeight={580}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'inline-block', width: '48%' }}>
|
||||||
|
<label>Функциональность приложений</label>
|
||||||
|
<SystemChart
|
||||||
|
metricInfo={appMetric}
|
||||||
|
chartHeight={580}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<TreeTable data={treeData1} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (activeTab === "Визуализация") {
|
||||||
|
return <FlowChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
|
||||||
|
} else {
|
||||||
|
const tabData = tabs.find(t => t.id === activeTab);
|
||||||
|
return tabData ? tabData.content : <p>Нет данных</p>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabContent;
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
const useSidebarResize = (initialWidth = 250) => {
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(initialWidth);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
||||||
|
const startResizing = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resize = useCallback((e) => {
|
||||||
|
if (isResizing) {
|
||||||
|
const newWidth = e.clientX;
|
||||||
|
if (newWidth > 100 && newWidth < 400) {
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isResizing]);
|
||||||
|
|
||||||
|
const stopResizing = useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e) => resize(e);
|
||||||
|
const handleMouseUp = () => stopResizing();
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isResizing, resize, stopResizing]);
|
||||||
|
|
||||||
|
return { sidebarWidth, startResizing };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSidebarResize;
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
const useTabs = (initialTab) => {
|
||||||
|
const [tabs, setTabs] = useState([]);
|
||||||
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
|
|
||||||
|
const handleOpenTab = useCallback((newTab) => {
|
||||||
|
setTabs((prevTabs) => {
|
||||||
|
const exists = prevTabs.some((tab) => tab.id === newTab.id);
|
||||||
|
if (!exists) {
|
||||||
|
return [...prevTabs, newTab];
|
||||||
|
}
|
||||||
|
return prevTabs;
|
||||||
|
});
|
||||||
|
setActiveTab(newTab.id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseTab = useCallback((id) => {
|
||||||
|
setTabs((prevTabs) => {
|
||||||
|
const newTabs = prevTabs.filter((tab) => tab.id !== id);
|
||||||
|
if (activeTab === id) {
|
||||||
|
setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : initialTab);
|
||||||
|
}
|
||||||
|
return newTabs;
|
||||||
|
});
|
||||||
|
}, [activeTab, initialTab]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/* Основной контейнер */
|
|
||||||
.dashboard-container {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
width: calc(100vw - 20px);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-left: 20px;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Основной контент */
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 20px;
|
|
||||||
margin-left: 50px;
|
|
||||||
transition: margin-left 0.2s ease;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: var(--modal-background);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -13,45 +13,63 @@
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--modal-background);
|
background: var(--modal-background);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
/* padding-right: 3%; */
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
/* box-shadow: 0 0.3vh 2vh #1E1E1E; */
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--modal-text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal h2 {
|
.modal h2 {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 4vh;
|
||||||
|
color: var(--header-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal label {
|
.modal label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
color: var(--modal-text);
|
color: var(--modal-text);
|
||||||
|
font-size: larger;
|
||||||
|
font-weight: bolder;
|
||||||
|
padding-bottom: 1%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal input {
|
.modal input {
|
||||||
width: 100%;
|
/* width: 100%; */
|
||||||
padding: 8px;
|
/* max-width: fit-content; */
|
||||||
margin-bottom: 10px;
|
/* padding: 3%;
|
||||||
border: 1px solid #ccc;
|
padding-top: 3%;
|
||||||
border-radius: 4px;
|
padding-bottom: 3%;
|
||||||
|
margin-bottom: 10px; */
|
||||||
|
/* border: 1px solid #ccc; */
|
||||||
|
/* text-align: start; */
|
||||||
|
/* border-radius: 4px; */
|
||||||
|
/* font-size: larger; */
|
||||||
background-color: var(--modal-background);
|
background-color: var(--modal-background);
|
||||||
color: var(--modal-text);
|
color: var(--modal-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal button {
|
.modal button {
|
||||||
padding: 10px 20px;
|
/* padding: 10px 20px; */
|
||||||
|
margin-top: 5vh;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
background: var(--accent-color);
|
background: var(--modal--btn-background);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0.3vh 1vh #2c2c2c;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 4%;
|
||||||
|
padding-bottom: 4%;
|
||||||
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal button:hover {
|
.modal button:hover {
|
||||||
background: var(--accent-hover-color);
|
background: var(--hover-button);
|
||||||
|
color: var(--hover-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|
|
||||||
|
|
@ -1,105 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Заголовок меню */
|
|
||||||
.sidebar-title {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--sidebar-text-color);
|
|
||||||
/* Используем переменную для цвета текста */
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Элементы меню */
|
|
||||||
.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;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Футер сайдбара */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -48,7 +48,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background-color: #0056b3;
|
background-color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
caption {
|
caption {
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
.tree-table-container {
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 var(--table-border);
|
|
||||||
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 var(--table-border);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-content,
|
|
||||||
.header-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
width: 100%;
|
|
||||||
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,19 +0,0 @@
|
||||||
/* Темная тема, если пользователь предпочитает ее */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background-color: #1E1E1E;
|
|
||||||
--text-color: #E0E0E0;
|
|
||||||
/* Основной цвет текста (светлый) */
|
|
||||||
--sidebar-color: #2d2d2d;
|
|
||||||
/* Темный цвет сайдбара */
|
|
||||||
--sidebar-text-color: #E0E0E0;
|
|
||||||
/* Светлый текст в сайдбаре */
|
|
||||||
--modal-background: #333333;
|
|
||||||
--modal-text: #FFFFFF;
|
|
||||||
--table-border: #444444;
|
|
||||||
--table-header-background: #2d2d2d;
|
|
||||||
--table-cell-background: #333333;
|
|
||||||
--table-text-color: #E0E0E0;
|
|
||||||
/* Светлый текст в таблице */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/* Светлая тема по умолчанию */
|
|
||||||
:root {
|
|
||||||
--background-color: #FFFFFF;
|
|
||||||
--text-color: #333333;
|
|
||||||
/* Основной цвет текста (черный) */
|
|
||||||
--sidebar-color: #3d74c7;
|
|
||||||
/* Синий цвет сайдбара */
|
|
||||||
--sidebar-text-color: #FFFFFF;
|
|
||||||
/* Белый текст в сайдбаре и вкладках */
|
|
||||||
--modal-background: #FFFFFF;
|
|
||||||
--modal-text: #333333;
|
|
||||||
--table-border: #ddd;
|
|
||||||
--table-header-background: #f9f9f9;
|
|
||||||
--table-cell-background: #FFFFFF;
|
|
||||||
--table-text-color: #000000;
|
|
||||||
/* Черный текст в таблице */
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
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({
|
||||||
|
...commonThemeSettings, // Распаковываем общие настройки
|
||||||
|
|
||||||
|
// Цветовая палитра для светлой темы
|
||||||
|
palette: {
|
||||||
|
mode: "light", // Режим светлой темы
|
||||||
|
|
||||||
|
// Фоновые цвета
|
||||||
|
background: {
|
||||||
|
default: "#FFFFFF", // Основной фон приложения
|
||||||
|
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Текстовые цвета
|
||||||
|
text: {
|
||||||
|
primary: "#000000", // Основной цвет текста
|
||||||
|
secondary: "#333333", // Вторичный цвет текста
|
||||||
|
},
|
||||||
|
|
||||||
|
// Основные цвета UI
|
||||||
|
primary: {
|
||||||
|
main: "#3d74c7", // Основной брендовый цвет
|
||||||
|
contrastText: "#FFFFFF", // Цвет текста на кнопках primary цвета
|
||||||
|
},
|
||||||
|
|
||||||
|
// Дополнительные цвета UI
|
||||||
|
secondary: {
|
||||||
|
main: "#0f55bec2", // Вторичный брендовый цвет
|
||||||
|
},
|
||||||
|
|
||||||
|
divider: "#e0e0e0", // Цвет разделителей
|
||||||
|
|
||||||
|
// Кастомные цвета для специфических элементов
|
||||||
|
custom: {
|
||||||
|
background: "#D4EFFC", // Кастомный фоновый цвет
|
||||||
|
text: "#000000", // Кастомный цвет текста
|
||||||
|
sidebar: "#025EA1", // Фон боковой панели
|
||||||
|
sidebarText: "#FFFFFF", // Текст в боковой панели
|
||||||
|
sidebarHover: "rgba(255, 255, 255, 0.08)", // Цвет при наведении в боковой панели
|
||||||
|
modalBackground: "#FFFFFF", // Фон модальных окон
|
||||||
|
modalBtnBackground: "#0f55bec2", // Фон кнопок в модальных окнах
|
||||||
|
modalText: "#333333", // Текст в модальных окнах
|
||||||
|
tableBorder: "#ddd", // Границы таблиц
|
||||||
|
tableHeaderBackground: "#f9f9f9", // Фон заголовков таблиц
|
||||||
|
tableCellBackground: "#FFFFFF", // Фон ячеек таблиц
|
||||||
|
tableText: "#000000", // Текст в таблицах
|
||||||
|
treeChartText: "#000000", // Текст в древовидных диаграммах
|
||||||
|
scrollbarTrack: "#f1f1f1", // Цвет трека скроллбара
|
||||||
|
hoverButton: "#2d62b1", // Цвет кнопок при наведении
|
||||||
|
hoverText: "#FFFFFF", // Цвет текста при наведении
|
||||||
|
},
|
||||||
|
|
||||||
|
// Цвета для различных состояний
|
||||||
|
action: {
|
||||||
|
hover: "rgba(0, 0, 0, 0.04)", // Цвет при наведении на интерактивные элементы
|
||||||
|
selected: "rgba(0, 0, 0, 0.08)", // Цвет выбранных элементов
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Темная тема приложения
|
||||||
|
*/
|
||||||
|
export const darkTheme = createTheme({
|
||||||
|
...commonThemeSettings, // Распаковываем общие настройки
|
||||||
|
|
||||||
|
// Цветовая палитра для темной темы
|
||||||
|
palette: {
|
||||||
|
mode: "dark", // Режим темной темы
|
||||||
|
|
||||||
|
// Фоновые цвета
|
||||||
|
background: {
|
||||||
|
default: "#2d2d2d", // Основной фон приложения
|
||||||
|
paper: "#2d2d2d", // Фон "бумажных" поверхностей
|
||||||
|
},
|
||||||
|
|
||||||
|
// Текстовые цвета
|
||||||
|
text: {
|
||||||
|
primary: "#E0E0E0", // Основной цвет текста
|
||||||
|
secondary: "#B0B0B0", // Вторичный цвет текста
|
||||||
|
},
|
||||||
|
|
||||||
|
// Основные цвета UI
|
||||||
|
primary: {
|
||||||
|
main: "#3d74c7", // Основной брендовый цвет (может совпадать со светлой темой)
|
||||||
|
contrastText: "#FFFFFF", // Цвет текста на кнопках primary цвета
|
||||||
|
},
|
||||||
|
|
||||||
|
// Дополнительные цвета UI
|
||||||
|
secondary: {
|
||||||
|
main: "#0f55bec2", // Вторичный брендовый цвет
|
||||||
|
},
|
||||||
|
|
||||||
|
divider: "#444444", // Цвет разделителей
|
||||||
|
|
||||||
|
// Кастомные цвета для специфических элементов
|
||||||
|
custom: {
|
||||||
|
background: "#1E1E1E", // Кастомный фоновый цвет
|
||||||
|
text: "#E0E0E0", // Кастомный цвет текста
|
||||||
|
sidebar: "#2d2d2d", // Фон боковой панели
|
||||||
|
sidebarText: "#E0E0E0", // Текст в боковой панели
|
||||||
|
sidebarHover: "rgba(255, 255, 255, 0.16)", // Цвет при наведении в боковой панели
|
||||||
|
modalBackground: "#2d2d2d", // Фон модальных окон
|
||||||
|
modalBtnBackground: "#333333", // Фон кнопок в модальных окнах
|
||||||
|
modalText: "#FFFFFF", // Текст в модальных окнах
|
||||||
|
tableBorder: "#444444", // Границы таблиц
|
||||||
|
tableHeaderBackground: "#2d2d2d", // Фон заголовков таблиц
|
||||||
|
tableCellBackground: "#333333", // Фон ячеек таблиц
|
||||||
|
tableText: "#E0E0E0", // Текст в таблицах
|
||||||
|
treeChartText: "#FFFFFF", // Текст в древовидных диаграммах
|
||||||
|
scrollbarTrack: "#333", // Цвет трека скроллбара
|
||||||
|
hoverButton: "#333d4d", // Цвет кнопок при наведении
|
||||||
|
hoverText: "#E0E0E0", // Цвет текста при наведении
|
||||||
|
},
|
||||||
|
|
||||||
|
// Цвета для различных состояний
|
||||||
|
action: {
|
||||||
|
hover: "rgba(255, 255, 255, 0.08)", // Цвет при наведении на интерактивные элементы
|
||||||
|
selected: "rgba(255, 255, 255, 0.16)", // Цвет выбранных элементов
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
|
@ -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 |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
|
@ -75,24 +75,32 @@ button:focus-visible {
|
||||||
|
|
||||||
/* Глобальный стиль для WebKit-браузеров (Chrome, Edge, Safari) */
|
/* Глобальный стиль для WebKit-браузеров (Chrome, Edge, Safari) */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 10px; /* Толщина вертикального скролла */
|
width: 10px;
|
||||||
height: 10px; /* Толщина горизонтального скролла */
|
/* Толщина вертикального скролла */
|
||||||
|
height: 10px;
|
||||||
|
/* Толщина горизонтального скролла */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Фон скроллбара */
|
/* Фон скроллбара */
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1; /* Цвет фона */
|
background: var(--scrollbar-track-color, #025EA1);
|
||||||
border-radius: 10px; /* Скругление углов */
|
/* Цвет фона */
|
||||||
|
border-radius: 10px;
|
||||||
|
/* Скругление углов */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ползунок */
|
/* Ползунок */
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #3d74c7; /* Основной цвет */
|
background: #D4EFFC;
|
||||||
border-radius: 10px; /* Скругляем края */
|
/* Основной цвет */
|
||||||
border: 1px solid #1c36c9; /* Белая обводка */
|
border-radius: 10px;
|
||||||
|
/* Скругляем края */
|
||||||
|
border: 1px solid #1c36c9;
|
||||||
|
/* Белая обводка */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Эффект при наведении */
|
/* Эффект при наведении */
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #2b5aa5; /* Чуть темнее при наведении */
|
background: #2b5aa5;
|
||||||
|
/* Чуть темнее при наведении */
|
||||||
}
|
}
|
||||||
|
|
@ -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']
|
|
||||||
}
|
|
||||||
})
|
|
||||||