Compare commits
153 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
e5e4d75637 | |
|
|
3a142185a2 | |
|
|
0b600095ce | |
|
|
33a88d2a1a | |
|
|
daf9cab8ac | |
|
|
7400e77fa0 | |
|
|
86baaa29ff | |
|
|
14d2f3eb68 | |
|
|
558cf8eaba | |
|
|
585692c838 | |
|
|
3cef08f65b | |
|
|
04738fae91 | |
|
|
555c28d942 | |
|
|
97295a6748 | |
|
|
179baad012 | |
|
|
75fa0ebfe3 | |
|
|
f271fb5acf | |
|
|
c411142840 | |
|
|
06249fce3a | |
|
|
933ceb2547 | |
|
|
34f2010cae | |
|
|
205ddc71e0 | |
|
|
421d95565c | |
|
|
46cd1fa0fa | |
|
|
911bfb88d1 | |
|
|
1bcb15f655 | |
|
|
f8d822ace7 | |
|
|
fd5a202d74 | |
|
|
b1a760336d | |
|
|
00866d9d57 | |
|
|
c208813daa | |
|
|
f55fb2df56 | |
|
|
be3bf3b21e | |
|
|
4fb1975428 | |
|
|
08fde58a30 | |
|
|
6f0b15427a | |
|
|
d7c40ee04b | |
|
|
8fcace10b1 | |
|
|
140b058f41 | |
|
|
cb030a01d2 | |
|
|
dabdda4afe | |
|
|
6a73bd8104 | |
|
|
87a79f98d7 | |
|
|
15c20f1352 | |
|
|
ef5df6971d | |
|
|
61c623b93d | |
|
|
26276e0360 | |
|
|
c99b6add47 | |
|
|
b681add6fc | |
|
|
36a75ee93c | |
|
|
9f8d0072c2 | |
|
|
5e9e40aad2 | |
|
|
405bda3df9 | |
|
|
328018edfa | |
|
|
69a5e4ade1 | |
|
|
a931fd3ea4 | |
|
|
5bf3124fd4 | |
|
|
f87274d41a | |
|
|
b5b758ffa0 | |
|
|
12e1ff08f5 | |
|
|
350f375015 | |
|
|
fa32e75b5a | |
|
|
09a6082917 | |
|
|
cb7c22929a | |
|
|
c55806d180 | |
|
|
d70c7673b4 | |
|
|
8223cc4a27 | |
|
|
4088dacba4 | |
|
|
08e2c24a63 | |
|
|
efd8532ac3 | |
|
|
069cea21b0 | |
|
|
2b79159d35 | |
|
|
4dfd972615 | |
|
|
b9a2be4860 | |
|
|
d5aa312104 | |
|
|
bbbcd932ad | |
|
|
6fd5d1aed2 | |
|
|
40d8046617 | |
|
|
e47161acd1 | |
|
|
fd53b187d5 | |
|
|
b4d653f3a6 | |
|
|
54cf5504a4 | |
|
|
f38c8825fe | |
|
|
c7ebbcaf5c | |
|
|
d83f05e2b5 | |
|
|
dd0aa2d706 | |
|
|
bcdbd0f0fc | |
|
|
08eaa274b2 | |
|
|
22c5fcf02c | |
|
|
2c9813fbb9 | |
|
|
c1b0f10e62 | |
|
|
858f6e2c4c | |
|
|
4eed2f364c | |
|
|
5b25876056 | |
|
|
9ee15160e5 | |
|
|
e56b82bb66 | |
|
|
46484efdea | |
|
|
b6b3b36f5a | |
|
|
46da90fbb6 | |
|
|
64401cadbc | |
|
|
a24b89220c | |
|
|
32ece2f0ff | |
|
|
4405c693aa | |
|
|
6a4640ba93 | |
|
|
bd96278895 | |
|
|
ed2e03e202 | |
|
|
f69443d051 | |
|
|
cf38b25678 | |
|
|
3ee0c3a02c | |
|
|
b6b8042d11 | |
|
|
fc1db66288 | |
|
|
c077449b2c | |
|
|
88b7b8af77 | |
|
|
15a8a146fc | |
|
|
b7f6a6c386 | |
|
|
b601dedc0e | |
|
|
9399f544d2 | |
|
|
5ed1b448e5 | |
|
|
2d714b5985 | |
|
|
3fc7ee0ac3 | |
|
|
13101ac57c | |
|
|
175b4f993d | |
|
|
f5b92715ef | |
|
|
67f776efe3 | |
|
|
5ec58ab476 | |
|
|
372a53d4cb | |
|
|
2713142c7d | |
|
|
d50aeab21d | |
|
|
a54b1d0c39 | |
|
|
cfaa31acee | |
|
|
b390f3f03a | |
|
|
f88fd41192 | |
|
|
eed159e4ab | |
|
|
c90bef284c | |
|
|
306ac00efe | |
|
|
3c60e9d144 | |
|
|
f525d216e3 | |
|
|
a53942b264 | |
|
|
88b63959be | |
|
|
5ebebdc00a | |
|
|
a8ca18edc9 | |
|
|
448ea7a5a6 | |
|
|
5ef440384d | |
|
|
b7e5941500 | |
|
|
8a7d47bc47 | |
|
|
3d63dbe94c | |
|
|
c3f1192de4 | |
|
|
ff10808dd8 | |
|
|
101ce14197 | |
|
|
92d1236587 | |
|
|
bda6197120 | |
|
|
47159bb698 | |
|
|
c4f95448c1 |
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
|
@ -25,3 +25,16 @@ dist-ssr
|
||||||
|
|
||||||
*.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"]
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
def notify(String giteaUser, String giteaPass, String repositoryUrl, String repositoryName, String prId, String buildStatus) {
|
def notify(
|
||||||
|
String context,
|
||||||
|
String giteaUser,
|
||||||
|
String giteaPass,
|
||||||
|
String repositoryUrl,
|
||||||
|
String repositoryName,
|
||||||
|
String commitHash,
|
||||||
|
String buildStatus
|
||||||
|
) {
|
||||||
def status = buildStatus == 'success' ? 'success' : 'failure'
|
def status = buildStatus == 'success' ? 'success' : 'failure'
|
||||||
def description = buildStatus == 'success' ? 'Build succeeded' : 'Build failed'
|
def description = buildStatus == 'success' ? 'Build succeeded' : 'Build failed'
|
||||||
|
|
||||||
|
|
@ -6,8 +14,8 @@ def notify(String giteaUser, String giteaPass, String repositoryUrl, String repo
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-u "${giteaUser}:${giteaPass}" \
|
-u "${giteaUser}:${giteaPass}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"state": "${status}", "description": "${description}", "context": "ci/jenkins"}' \
|
-d '{"context":"${context}","state": "${status}", "description": "${description}"}' \
|
||||||
${repositoryUrl}/api/v1/repos/${repositoryName}/statuses/${prId}
|
${repositoryUrl}deployer3000/${repositoryName}/statuses/${commitHash}
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,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
|
||||||
// Merge the PR
|
|
||||||
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() // необходим для корректного отображения статусов
|
||||||
// Notify Gitea with the PR status
|
echo "PR ${prId} merged successfully into main!"
|
||||||
notify(GITEA_USER, GITEA_PASS, GITEA_REPOSITORY_URL, "trust-module-frontend", prId, "success")
|
sleep(time: 15, unit: 'SECONDS')
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/system_monitor_icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/system_monitor_icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Модуль доверия</title>
|
<title>Модуль устойчивого функционирования</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</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>
|
||||||
|
|
||||||
<!-- Крестик в центре, символизирующий мониторинг -->
|
|
||||||
<line x1="45" y1="45" x2="55" y2="55" stroke="#4CAF50" stroke-width="4" />
|
|
||||||
<line x1="55" y1="45" x2="45" y2="55" stroke="#4CAF50" stroke-width="4" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 729 B After Width: | Height: | Size: 1.8 KiB |
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();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", height: "100vh", overflow: "hidden" }}>
|
|
||||||
{!isAuthenticated && showLoginModal && (
|
|
||||||
<LoginModal onLogin={handleLogin} onClose={() => setShowLoginModal(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && <Dashboard />}
|
const handleAuthResponse = (authStatus) => {
|
||||||
</div>
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Если нет токена - просто очищаем данные
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('/api/auth/logout', {}, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
setShowLoginModal(true);
|
||||||
|
}
|
||||||
|
// Полноэкранный лоадер во время проверки авторизации
|
||||||
|
if (authState.isLoading) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Box sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 9999,
|
||||||
|
bgcolor: 'background.default'
|
||||||
|
}}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
Проверка авторизации...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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,220 +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 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);
|
|
||||||
setFilteredData(filtered); // Сохраняем отфильтрованные данные
|
|
||||||
} 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,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,137 +1,203 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import SidebarMenu from "./SidebarMenu";
|
import { Box, styled } from "@mui/material";
|
||||||
import TreeChart from "../TreeChart/TreeChart";
|
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
||||||
import "../../Style/Dashboard.css";
|
import generateTabContent from "../TreeChart/tabContent";
|
||||||
import ErrorIndicator from "../UI/ErrorIndicator";
|
import CustomTabs from "../UI/MUItabs";
|
||||||
import Tabs from "../UI/Tabs";
|
import useTabs from "../hooks/useTabs";
|
||||||
import menuData from "../TreeChart/menuData.json"; // Импортируем JSON-данные
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
import TreeTable from "../UI/TreeTable";
|
import TabContent from "../hooks/TabContent";
|
||||||
import { updateStatuses } from "../TreeChart/dataUtils"; // Функция обновления статусов
|
import menuData from "../TreeChart/menuData.json";
|
||||||
import generateTabContent from "../TreeChart/tabContent"; // Импортируем функцию generateTabContent
|
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',
|
||||||
const [tabContent, setTabContent] = useState({}); // Состояние для контента вкладок
|
width: '100vw',
|
||||||
const [treeData, setTreeData] = useState(menuData); // Загружаем меню в state
|
overflow: 'hidden',
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(250); // Начальная ширина сайдбара
|
backgroundColor: theme.palette.background.default,
|
||||||
const [isResizing, setIsResizing] = useState(false); // Состояние перетаскивания
|
color: theme.palette.text.primary,
|
||||||
const sidebarRef = useRef(null); // Референс на сайдбар
|
}));
|
||||||
|
|
||||||
|
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 [treeData1, setTreeData1] = useState(menuData);
|
||||||
|
const [treeData2, setTreeData2] = useState(menuData);
|
||||||
|
const [statusHistories, setStatusHistories] = useState({
|
||||||
|
history1: [],
|
||||||
|
history2: [],
|
||||||
|
});
|
||||||
|
|
||||||
// Генерация контента для вкладок на основе menuData
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generatedTabContent = generateTabContent(menuData);
|
const generatedTabContent = generateTabContent(menuData);
|
||||||
setTabContent(generatedTabContent);
|
setTabContent(generatedTabContent);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Обновление treeData каждые 30 секунд
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setTreeData((prevData) => {
|
const updatedData1 = JSON.parse(JSON.stringify(treeData1));
|
||||||
const updatedData = JSON.parse(JSON.stringify(prevData)); // Клонируем данные
|
const averageStatusValue1 = statusManager1.updateStatuses(updatedData1);
|
||||||
updateStatuses(updatedData); // Обновляем статусы
|
const statusPercentage1 = Math.max(0, Math.min(100, averageStatusValue1 * 100));
|
||||||
return updatedData;
|
|
||||||
});
|
const updatedData2 = JSON.parse(JSON.stringify(treeData2));
|
||||||
|
const averageStatusValue2 = statusManager2.updateStatuses(updatedData2);
|
||||||
|
const statusPercentage2 = Math.max(0, Math.min(100, averageStatusValue2 * 100));
|
||||||
|
|
||||||
|
setStatusHistories((prevHistories) => ({
|
||||||
|
history1: [
|
||||||
|
...prevHistories.history1.slice(-29),
|
||||||
|
{ time: new Date().toLocaleTimeString(), status: statusPercentage1 },
|
||||||
|
],
|
||||||
|
history2: [
|
||||||
|
...prevHistories.history2.slice(-29),
|
||||||
|
{ time: new Date().toLocaleTimeString(), status: statusPercentage2 },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTreeData1(updatedData1);
|
||||||
|
setTreeData2(updatedData2);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [treeData1, treeData2]);
|
||||||
|
|
||||||
// Обработчик начала перетаскивания
|
const handleMenuSelect = (item) => {
|
||||||
const startResizing = (e) => {
|
const tabId = `tab_${item.id}`;
|
||||||
e.preventDefault();
|
const tabTitle = item.title || 'Новая вкладка';
|
||||||
setIsResizing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Обработчик движения мыши
|
const tabContent = item.metric
|
||||||
const resize = (e) => {
|
? <MetricTabContent
|
||||||
if (isResizing) {
|
metricInfo={{
|
||||||
const newWidth = e.clientX; // Новая ширина сайдбара
|
name: item.metric,
|
||||||
if (newWidth > 100 && newWidth < 400) { // Ограничиваем минимальную и максимальную ширину
|
filters: item.filters,
|
||||||
setSidebarWidth(newWidth);
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
ranges: item.ranges,
|
||||||
|
context: {
|
||||||
|
device: item.filters?.device,
|
||||||
|
source_id: item.filters?.source_id,
|
||||||
|
parent: item
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
};
|
|
||||||
|
|
||||||
// Обработчик окончания перетаскивания
|
|
||||||
const stopResizing = () => {
|
|
||||||
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]);
|
|
||||||
|
|
||||||
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>
|
|
||||||
<TreeTable data={treeData.items} /> {/* Используем актуальные данные */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (activeTab === "Визуализация") {
|
|
||||||
return <TreeChart data={treeData} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
|
|
||||||
} else {
|
|
||||||
const tabData = tabContent[activeTab];
|
|
||||||
return tabData ? tabData.content : <p>Нет данных</p>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dashboard-container">
|
|
||||||
<div
|
|
||||||
className="sidebar"
|
|
||||||
ref={sidebarRef}
|
|
||||||
style={{ width: sidebarWidth }} // Динамическая ширина сайдбара
|
|
||||||
>
|
|
||||||
<SidebarMenu data={treeData} onOpenTab={handleOpenTab} sidebarWidth={sidebarWidth} />
|
|
||||||
{/* Элемент для перетаскивания */}
|
|
||||||
<div
|
|
||||||
className="sidebar-resizer"
|
|
||||||
onMouseDown={startResizing}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
: <div style={{ padding: 20 }}>Контент для <strong>{item.title}</strong></div>;
|
||||||
|
|
||||||
<div className="main-content" style={{ marginLeft: sidebarWidth }}>
|
const existingTab = tabs.find(tab => tab.id === tabId);
|
||||||
<Tabs
|
|
||||||
|
if (!existingTab) {
|
||||||
|
const newTab = {
|
||||||
|
id: tabId,
|
||||||
|
title: tabTitle,
|
||||||
|
content: tabContent,
|
||||||
|
type: item.metric ? 'metric' : 'menuItem',
|
||||||
|
metric: item.metric,
|
||||||
|
filters: item.filters,
|
||||||
|
ranges: item.ranges
|
||||||
|
};
|
||||||
|
handleOpenTab(newTab);
|
||||||
|
} else {
|
||||||
|
setActiveTab(tabId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Вспомогательная функция для получения всех дочерних элементов
|
||||||
|
const getAllChildren = (node) => {
|
||||||
|
let children = [];
|
||||||
|
if (node.items && node.items.length > 0) {
|
||||||
|
node.items.forEach((child) => {
|
||||||
|
children.push(child);
|
||||||
|
children = children.concat(getAllChildren(child));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContainer>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 12,
|
||||||
|
right: 20,
|
||||||
|
zIndex: (theme) => theme.zIndex.tooltip + 10,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
alignItems: 'center'
|
||||||
|
}}//ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
|
||||||
|
>
|
||||||
|
<AIAnalysisButton />
|
||||||
|
<ProfileMenu user={user} onLogout={onLogout} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Сайдбар */}
|
||||||
|
<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}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabClick={(id) => setActiveTab(id)}
|
onTabClick={setActiveTab}
|
||||||
onCloseTab={handleCloseTab}
|
onCloseTab={handleCloseTab}
|
||||||
/>
|
/>
|
||||||
<div className="content">
|
</Box>
|
||||||
{renderTabContent()}
|
|
||||||
</div>
|
{/* Остальной контент */}
|
||||||
</div>
|
<MainContent>
|
||||||
</div>
|
{/* Контент вкладки */}
|
||||||
|
<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 (
|
return (
|
||||||
<div className="menu-item" style={{ width: sidebarWidth - 20 }}> {/* Динамическая ширина */}
|
<Box
|
||||||
<div
|
sx={{
|
||||||
onClick={handleSingleClick} // Одинарный клик для разворачивания/сворачивания или открытия
|
position: 'absolute',
|
||||||
className="menu-item-header"
|
left: 0,
|
||||||
>
|
right: 0,
|
||||||
{/* Круглый индикатор статуса */}
|
height: '2px',
|
||||||
<div
|
backgroundColor: 'primary.main',
|
||||||
className={`status-indicator ${statusColor === "red" ? "blinking" : ""}`}
|
zIndex: 1001,
|
||||||
style={{ backgroundColor: statusColor }}
|
...(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',
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{item.title}</span>
|
|
||||||
|
|
||||||
{/* Иконка для открытия родителя */}
|
|
||||||
{hasChildren && (
|
|
||||||
<span
|
|
||||||
onClick={handleOpenParent}
|
|
||||||
className="open-parent-icon"
|
|
||||||
title="Открыть родителя"
|
|
||||||
>
|
|
||||||
📂
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Иконка для разворачивания/сворачивания */}
|
|
||||||
{hasChildren && <span>{isOpen ? "▲" : "▼"}</span>}
|
|
||||||
</div>
|
|
||||||
{isOpen && hasChildren && (
|
|
||||||
<div className="submenu">
|
|
||||||
{item.items.map((child, index) => (
|
|
||||||
<MenuItem key={index} item={child} onSelectItem={onSelectItem} sidebarWidth={sidebarWidth} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function SidebarMenu({ data, onOpenTab, sidebarWidth }) {
|
|
||||||
const handleSelectItem = (item) => {
|
|
||||||
onOpenTab(item.id, item.title);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<Box
|
||||||
<div className="sidebar-content" style={{ width: sidebarWidth }}> {/* Динамическая ширина */}
|
sx={{
|
||||||
<h2 className="sidebar-title">Меню</h2>
|
position: "relative",
|
||||||
<MenuItem item={data} onSelectItem={handleSelectItem} sidebarWidth={sidebarWidth} />
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
</div>
|
transition: "width 0.2s ease",
|
||||||
<div className="sidebar-footer" style={{ width: sidebarWidth }}> {/* Динамическая ширина */}
|
height: "100vh",
|
||||||
<h2 className="help">Помощь</h2>
|
}}
|
||||||
<h2 className="settings">Настройка</h2>
|
>
|
||||||
</div>
|
<Drawer
|
||||||
</div>
|
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)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Заголовок с логотипом */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
p: 2,
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
backgroundColor: "background.paper",
|
||||||
|
height: 80,
|
||||||
|
position: "relative",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
minHeight: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"& svg": {
|
||||||
|
width: "auto",
|
||||||
|
height: "40px", // Фиксированная высота для лого
|
||||||
|
objectFit: "contain",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<LogoSmall style={{
|
||||||
|
color: "inherit",
|
||||||
|
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px"
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<LogoFull style={{
|
||||||
|
color: "inherit",
|
||||||
|
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||||||
|
maxWidth: "180px",
|
||||||
|
height: "40px"
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleToggleCollapse}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "action.hover",
|
||||||
|
color: "text.primary"
|
||||||
|
},
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Основное содержимое меню */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
measuring={{
|
||||||
|
droppable: {
|
||||||
|
strategy: MeasuringStrategy.Always
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext items={menuItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<List
|
||||||
|
sx={{
|
||||||
|
overflowY: "auto",
|
||||||
|
flex: "1 1 auto",
|
||||||
|
py: 1,
|
||||||
|
px: 1,
|
||||||
|
position: 'relative',
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: '6px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'text.disabled',
|
||||||
|
borderRadius: '3px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb:hover': {
|
||||||
|
background: 'text.secondary',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<Box key={item.id} position="relative">
|
||||||
|
{dropIndicator.show && dropIndicator.targetId === item.id &&
|
||||||
|
dropIndicator.position !== 'inside' && (
|
||||||
|
<DropIndicator
|
||||||
|
position={dropIndicator.position}
|
||||||
|
targetId={dropIndicator.targetId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SortableMenuItem
|
||||||
|
item={item}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onSelectItem={onSelectItem}
|
||||||
|
isHovered={hoveredItem === item.id}
|
||||||
|
showDropIndicator={dropIndicator.show && dropIndicator.targetId === item.id && dropIndicator.position === 'inside'}
|
||||||
|
sidebarWidth={sidebarWidth}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeItem ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||||
|
maxWidth: 250,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
transform: 'rotate(5deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeItem.title}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
<SidebarFooter
|
||||||
|
collapsed={collapsed}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
setIsDarkMode={setIsDarkMode}
|
||||||
|
forceRefreshMenu={forceRefreshMenu}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<Tooltip title="Изменить ширину" placement="top">
|
||||||
|
<SidebarResizer onMouseDown={startResizing} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
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,160 +0,0 @@
|
||||||
import React, { useRef, useEffect, useMemo } from "react";
|
|
||||||
import * as d3 from "d3";
|
|
||||||
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 nodes = root.descendants();
|
|
||||||
const links = root.links();
|
|
||||||
|
|
||||||
// Применяем сохраненные позиции к узлам
|
|
||||||
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", 1000)
|
|
||||||
.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)) // Ограничиваем разлет по X
|
|
||||||
.force("y", d3.forceY(0).strength(0.05)) // Ограничиваем разлет по Y
|
|
||||||
.force("radial", d3.forceRadial(200, 0, 0).strength(0.02)) // Держим узлы ближе к центру
|
|
||||||
.alphaDecay(0.02) // Замедляем затухание
|
|
||||||
.alphaTarget(0.1);
|
|
||||||
|
|
||||||
// Запускаем симуляцию на 15 секунд, затем отключаем
|
|
||||||
setTimeout(() => {
|
|
||||||
simulationRef.current.stop(); // Останавливаем симуляцию
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
node.fx = node.x; // Фиксируем текущие позиции узлов
|
|
||||||
node.fy = node.y;
|
|
||||||
});
|
|
||||||
}, 15000); // 15 секунд
|
|
||||||
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!root || !chartRef.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) => d.data.title)
|
|
||||||
.attr("dx", 12)
|
|
||||||
.attr("dy", 4);
|
|
||||||
|
|
||||||
// Обновляем симуляцию
|
|
||||||
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.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.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,53 +1,87 @@
|
||||||
// Функция для генерации случайных статусов
|
const StatusManager = () => {
|
||||||
const getRandomStatus = () => {
|
const getRandomStatus = () => {
|
||||||
const statuses = [
|
const statuses = [
|
||||||
...Array(90).fill("green"), // 63/70 chance (примерно 90%)
|
...Array(90).fill("green"),
|
||||||
...Array(6).fill("yellow"), // 1/70 chance (примерно 1.43%)
|
...Array(6).fill("yellow"),
|
||||||
...Array(3).fill("orange"), // 1/70 chance (примерно 1.43%)
|
...Array(3).fill("orange"),
|
||||||
...Array(1).fill("red"), // 1/70 chance (примерно 1.43%)
|
...Array(1).fill("red"),
|
||||||
];
|
];
|
||||||
return statuses[Math.floor(Math.random() * statuses.length)];
|
return statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для обновления статусов в дереве
|
const getStatusWeight = (status) => {
|
||||||
const updateStatuses = (data) => {
|
switch (status) {
|
||||||
if (!data.items || data.items.length === 0) {
|
case "green": return 1;
|
||||||
// Если это элемент нижнего уровня, генерируем случайный статус
|
case "yellow": return 0.75;
|
||||||
data.status = getRandomStatus();
|
case "orange": return 0.5;
|
||||||
return data.status;
|
case "red": return 0.25;
|
||||||
|
default: return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рекурсивно обновляем статусы для всех дочерних элементов
|
|
||||||
let childStatuses = data.items.map((child) => updateStatuses(child));
|
|
||||||
|
|
||||||
// Определяем статус текущего элемента на основе статусов дочерних элементов
|
|
||||||
if (childStatuses.includes("red")) {
|
|
||||||
data.status = "red";
|
|
||||||
} else if (childStatuses.includes("orange")) {
|
|
||||||
data.status = "orange";
|
|
||||||
} else if (childStatuses.includes("yellow")) {
|
|
||||||
data.status = "yellow";
|
|
||||||
} else {
|
|
||||||
data.status = "green";
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.status;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для получения цвета по статусу
|
const updateStatuses = (data) => {
|
||||||
|
if (!data.items || data.items.length === 0) {
|
||||||
|
data.status = getRandomStatus();
|
||||||
|
return getStatusWeight(data.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
let childStatusWeights = data.items.map((child) => updateStatuses(child));
|
||||||
|
|
||||||
|
if (childStatusWeights.length === 0) {
|
||||||
|
data.status = "green";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageStatusWeight =
|
||||||
|
childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length;
|
||||||
|
|
||||||
|
data.status = getStatusFromWeight(averageStatusWeight);
|
||||||
|
|
||||||
|
return Math.max(0, averageStatusWeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusFromWeight = (weight) => {
|
||||||
|
if (weight >= 0.875) return "green";
|
||||||
|
if (weight >= 0.625) return "yellow";
|
||||||
|
if (weight >= 0.375) return "orange";
|
||||||
|
return "red";
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case "green": return "#4CAF50"; // Зеленый
|
||||||
|
case "yellow": return "#cebd21"; // Желтый
|
||||||
|
case "orange": return "#FF9800"; // Оранжевый
|
||||||
|
case "red": return "#F44336"; // Красный
|
||||||
|
default: return "#4CAF50"; // По умолчанию зеленый
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getRandomStatus,
|
||||||
|
updateStatuses,
|
||||||
|
getStatusColor,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusManager1 = StatusManager();
|
||||||
|
export const statusManager2 = StatusManager();
|
||||||
|
|
||||||
|
export const calculateStatusPercentage = (averageStatusValue) => {
|
||||||
|
return Math.max(0, Math.min(100, averageStatusValue * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "green":
|
case "green":
|
||||||
return "#4CAF50"; // Зеленый
|
return "#4CAF50"; // Зеленый
|
||||||
case "yellow":
|
case "yellow":
|
||||||
return "#FFEB3B"; // Желтый
|
return "#cebd21"; // Желтый
|
||||||
case "orange":
|
case "orange":
|
||||||
return "#FF9800"; // Оранжевый
|
return "#FF9800"; // Оранжевый
|
||||||
case "red":
|
case "red":
|
||||||
return "#F44336"; // Красный
|
return "#F44336"; // Красный
|
||||||
default:
|
default:
|
||||||
return "#4CAF50"; // Синий (или любой другой стандартный цвет)
|
return "#4CAF50"; // По умолчанию зеленый
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getRandomStatus, updateStatuses, getStatusColor };
|
|
||||||
|
|
@ -1,382 +0,0 @@
|
||||||
{
|
|
||||||
"title": "Сервис ВКС",
|
|
||||||
"id":"service_VKS",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Функциональные задачи",
|
|
||||||
"id":"functions",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "system_control",
|
|
||||||
"title": "Контроль системы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "system_management",
|
|
||||||
"title": "Система управления"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "conference",
|
|
||||||
"title": "Проведение ВКС"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "backup",
|
|
||||||
"title": "Резервное копирование"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "relay_info",
|
|
||||||
"title": "Ретрансляция информации"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id":"media_server_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id":"system_software_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id":"software_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id":"media_server_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id":"system_software_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1_2",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2_2",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3_2",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4_2",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id":"software_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4_2",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id":"media_server_3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id":"system_software_3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1_3",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2_3",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3_3",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4_3",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id":"software_3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4_3",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id":"media_server_4",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id":"system_software_4",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1_4",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2_4",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3_4",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4_4",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id":"software_4",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1_4",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2_4",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3_4",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4_4",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Медиа сервер",
|
|
||||||
"id":"media_server_5",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id":"system_software_5",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_system_software_1_5",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_2_5",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_3_5",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_system_software_4_5",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id":"software_5",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "media_software_1_5",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_2_5",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_3_5",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "media_software_4_5",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Сервер систем",
|
|
||||||
"id":"system_server_1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id":"system_software_6",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "copy_system_software_1",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_system_software_2",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_system_software_3",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_system_software_4",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id":"software_6",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "copy_software_1",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_software_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_software_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "copy_software_4",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Сервер систем",
|
|
||||||
"id":"system_server_2",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Аппаратное обеспечение",
|
|
||||||
"id":"system_software_7",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "control_system_software_1",
|
|
||||||
"title": "Центральный процессор"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_system_software_2",
|
|
||||||
"title": "Оперативная память"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_system_software_3",
|
|
||||||
"title": "Жесткий диск"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_system_software_4",
|
|
||||||
"title": "Сетевые адаптеры"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Программное обеспечение",
|
|
||||||
"id":"software_7",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "control_software_1",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_software_2",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_software_3",
|
|
||||||
"title": "ПО"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "control_software_4",
|
|
||||||
"title": "ПО"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +1,155 @@
|
||||||
import React, { lazy, Suspense } from "react";
|
import React, { lazy, Suspense } from "react";
|
||||||
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart'));
|
const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart"));
|
||||||
|
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
|
||||||
|
|
||||||
// Функция для генерации названия метрики на основе id
|
// Компонент Skeleton для графика
|
||||||
const getMetricName = (id) => {
|
const ChartSkeleton = () => (
|
||||||
return `zvks_apiforsnmp_measure_${id}`;
|
<Box sx={{ width: "100%" }}>
|
||||||
};
|
<Skeleton variant="text" width="60%" height={30} />
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} />
|
||||||
//!!!!!!!!!!Пофиксить вкладуи с eth4, во всех eth 1-4 открывается именно 4 !!!!!!!!!!!!!
|
</Box>
|
||||||
|
|
||||||
// Функция для рекурсивного сбора всех id потомков
|
|
||||||
const getAllChildIds = (node) => {
|
|
||||||
let ids = [];
|
|
||||||
if (node.id) {
|
|
||||||
ids.push(node.id); // Добавляем id текущего узла
|
|
||||||
}
|
|
||||||
if (node.items && node.items.length > 0) {
|
|
||||||
node.items.forEach((child) => {
|
|
||||||
ids = ids.concat(getAllChildIds(child)); // Рекурсивно собираем id потомков
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabContent = (data) => {
|
|
||||||
const tabContent = {};
|
|
||||||
|
|
||||||
|
|
||||||
const generateContent = (nodes) => {
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
|
|
||||||
|
|
||||||
// Если у узла есть вложенные элементы, рекурсивно обрабатываем их
|
|
||||||
if (node.items && node.items.length > 0) {
|
|
||||||
|
|
||||||
generateContent(node.items);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если у узла есть id, добавляем его в tabContent
|
|
||||||
if (node.id) {
|
|
||||||
|
|
||||||
|
|
||||||
let content = (
|
|
||||||
<div>
|
|
||||||
<h2>{node.title}</h2>
|
|
||||||
<p>Контент для {node.title}.</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Если у узла есть потомки, добавляем графики для всех потомков
|
// Компонент Skeleton для контейнера
|
||||||
if (node.items && node.items.length > 0) {
|
const ContainerSkeleton = () => (
|
||||||
const childIds = getAllChildIds(node); // Получаем все id потомков
|
<Box sx={{ width: "100%" }}>
|
||||||
const charts = childIds.map((id) => {
|
<Skeleton variant="text" width="40%" height={40} />
|
||||||
const metricName = getMetricName(id);
|
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
|
||||||
return (
|
<Box sx={{ mt: 2 }}>
|
||||||
<div key={id}>
|
{[...Array(3)].map((_, i) => (
|
||||||
<h3>{node.title} - {id}</h3>
|
<ChartSkeleton key={i} />
|
||||||
<Suspense fallback={<div>Загрузка графика...</div>}>
|
))}
|
||||||
<PrometheusChart metricName={metricName} />
|
</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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Основная функция построения контента вкладок
|
||||||
|
const tabContent = (data, cache = {}) => {
|
||||||
|
const tabContentMap = { ...cache };
|
||||||
|
|
||||||
|
if (!data || !data.items || data.items.length === 0) {
|
||||||
|
console.warn("Данные отсутствуют или массив items пуст", data);
|
||||||
|
return tabContentMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processNode = (node, parentContext = {}) => {
|
||||||
|
// Получаем полный контекст из всей цепочки родителей
|
||||||
|
const pathContext = parseContextFromPath(node);
|
||||||
|
const currentContext = { ...parentContext, ...pathContext };
|
||||||
|
|
||||||
|
// Генерируем уникальный ключ на основе пути
|
||||||
|
const path = [];
|
||||||
|
let current = node;
|
||||||
|
while (current) {
|
||||||
|
path.unshift(current.id);
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
const pathId = path.join('_');
|
||||||
|
|
||||||
|
if (Array.isArray(node.items) && node.items.length > 0) {
|
||||||
|
const children = node.items
|
||||||
|
.map((child) => processNode(child, currentContext))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div key={`${pathId}-container`}>
|
||||||
|
<h2>{node.title}</h2>
|
||||||
|
<Suspense fallback={<ContainerSkeleton />}>
|
||||||
|
<LazyChartBatchRenderer charts={children.map((c) => c.content)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
content = (
|
tabContentMap[pathId] = {
|
||||||
<div>
|
|
||||||
<h2>{node.title}</h2>
|
|
||||||
<p>Контент для {node.title}.</p>
|
|
||||||
{charts}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Если у узла нет потомков, добавляем график для него
|
|
||||||
|
|
||||||
const metricName = getMetricName(node.id);
|
|
||||||
content = (
|
|
||||||
<div>
|
|
||||||
<h2>{node.title}</h2>
|
|
||||||
<p>Контент для {node.title}.</p>
|
|
||||||
<Suspense fallback={<div>Загрузка графика...</div>}>
|
|
||||||
<PrometheusChart metricName={metricName} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем контент для текущего id
|
|
||||||
tabContent[node.id] = {
|
|
||||||
title: node.title,
|
title: node.title,
|
||||||
content: content,
|
content,
|
||||||
};
|
context: currentContext,
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Начинаем обработку с корневого уровня
|
return { content, context: currentContext };
|
||||||
if (data.items && data.items.length > 0) {
|
|
||||||
generateContent(data.items);
|
|
||||||
} else {
|
|
||||||
console.warn("Данные отсутствуют или массив items пуст");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabContent;
|
if (node.metric) {
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default tabContent; // Экспортируем только функцию
|
return { content, context: currentContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Узел без метрики и без вложенных — просто заголовок
|
||||||
|
const content = (
|
||||||
|
<div key={pathId}>
|
||||||
|
<h3>{node.title}</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
tabContentMap[pathId] = {
|
||||||
|
title: node.title,
|
||||||
|
content,
|
||||||
|
context: currentContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { content, context: currentContext };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
processNode(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка обработки данных:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabContentMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tabContent;
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import PrometheusChart from '../../Charts/PrometheusChart';
|
|
||||||
|
|
||||||
const tabContent = {
|
|
||||||
// Сервис ВКС
|
|
||||||
service1: { title: "Сервис ВКС", content: <div><h2>Сервис ВКС</h2></div> },
|
|
||||||
|
|
||||||
// Функциональные задачи
|
|
||||||
system_control: { title: "Контроль системы", content: <div><h2>Контроль системы</h2><p>Описание контроля.</p></div> },
|
|
||||||
system_management: { title: "Система управления", content: <div><h2>Система управления</h2><p>Описание системы управления.</p></div> },
|
|
||||||
conference: { title: "Проведение ВКС", content: <div><h2>Проведение ВКС</h2><p>Информация о проведении ВКС.</p></div> },
|
|
||||||
backup: { title: "Резервное копирование", content: <div><h2>Резервное копирование</h2><p>Процесс резервного копирования.</p></div> },
|
|
||||||
relay_info: { title: "Ретрансляция информации", content: <div><h2>Ретрансляция информации</h2><p>Детали ретрансляции.</p></div> },
|
|
||||||
|
|
||||||
// Медиа сервер 1
|
|
||||||
media_system_software_1: { title: "Центральный процессор", content: <div><h2>Центральный процессор</h2><p>Описание центрального процессора медиа сервера.</p></div> },
|
|
||||||
media_system_software_2: { title: "Оперативная память", content: <div><h2>Оперативная память</h2><p>Описание оперативной памяти медиа сервера.</p></div> },
|
|
||||||
media_system_software_3: { title: "Жесткий диск", content: <div><h2>Жесткий диск</h2><p>Описание жесткого диска медиа сервера.</p></div> },
|
|
||||||
media_system_software_4: { title: "Сетевые адаптеры", content: <div><h2>Сетевые адаптеры</h2><p>Описание сетевых адаптеров медиа сервера.</p></div> },
|
|
||||||
media_software_1: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><PrometheusChart /></div> },
|
|
||||||
media_software_2: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_3: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_4: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
|
|
||||||
// Медиа сервер 2
|
|
||||||
media_system_software_1_2: { title: "Центральный процессор", content: <div><h2>Центральный процессор</h2><p>Описание центрального процессора медиа сервера.</p></div> },
|
|
||||||
media_system_software_2_2: { title: "Оперативная память", content: <div><h2>Оперативная память</h2><p>Описание оперативной памяти медиа сервера.</p></div> },
|
|
||||||
media_system_software_3_2: { title: "Жесткий диск", content: <div><h2>Жесткий диск</h2><p>Описание жесткого диска медиа сервера.</p></div> },
|
|
||||||
media_system_software_4_2: { title: "Сетевые адаптеры", content: <div><h2>Сетевые адаптеры</h2><p>Описание сетевых адаптеров медиа сервера.</p></div> },
|
|
||||||
media_software_1_2: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><PrometheusChart /></div> },
|
|
||||||
media_software_2_2: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_3_2: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_4_2: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
|
|
||||||
// Медиа сервер 3
|
|
||||||
media_system_software_1_3: { title: "Центральный процессор", content: <div><h2>Центральный процессор</h2><p>Описание центрального процессора медиа сервера.</p></div> },
|
|
||||||
media_system_software_2_3: { title: "Оперативная память", content: <div><h2>Оперативная память</h2><p>Описание оперативной памяти медиа сервера.</p></div> },
|
|
||||||
media_system_software_3_3: { title: "Жесткий диск", content: <div><h2>Жесткий диск</h2><p>Описание жесткого диска медиа сервера.</p></div> },
|
|
||||||
media_system_software_4_3: { title: "Сетевые адаптеры", content: <div><h2>Сетевые адаптеры</h2><p>Описание сетевых адаптеров медиа сервера.</p></div> },
|
|
||||||
media_software_1_3: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><PrometheusChart /></div> },
|
|
||||||
media_software_2_3: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_3_3: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_4_3: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
|
|
||||||
// Медиа сервер 4
|
|
||||||
media_system_software_1_4: { title: "Центральный процессор", content: <div><h2>Центральный процессор</h2><p>Описание центрального процессора медиа сервера.</p></div> },
|
|
||||||
media_system_software_2_4: { title: "Оперативная память", content: <div><h2>Оперативная память</h2><p>Описание оперативной памяти медиа сервера.</p></div> },
|
|
||||||
media_system_software_3_4: { title: "Жесткий диск", content: <div><h2>Жесткий диск</h2><p>Описание жесткого диска медиа сервера.</p></div> },
|
|
||||||
media_system_software_4_4: { title: "Сетевые адаптеры", content: <div><h2>Сетевые адаптеры</h2><p>Описание сетевых адаптеров медиа сервера.</p></div> },
|
|
||||||
media_software_1_4: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><PrometheusChart /></div> },
|
|
||||||
media_software_2_4: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_3_4: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_4_4: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
|
|
||||||
// Медиа сервер 5
|
|
||||||
media_system_software_1_5: { title: "Центральный процессор", content: <div><h2>Центральный процессор</h2><p>Описание центрального процессора медиа сервера.</p></div> },
|
|
||||||
media_system_software_2_5: { title: "Оперативная память", content: <div><h2>Оперативная память</h2><p>Описание оперативной памяти медиа сервера.</p></div> },
|
|
||||||
media_system_software_3_5: { title: "Жесткий диск", content: <div><h2>Жесткий диск</h2><p>Описание жесткого диска медиа сервера.</p></div> },
|
|
||||||
media_system_software_4_5: { title: "Сетевые адаптеры", content: <div><h2>Сетевые адаптеры</h2><p>Описание сетевых адаптеров медиа сервера.</p></div> },
|
|
||||||
media_software_1_5: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><PrometheusChart /></div> },
|
|
||||||
media_software_2_5: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_3_5: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
media_software_4_5: { title: "ПО", content: <div><h2>Программное обеспечение медиа сервера</h2><p>Описание ПО медиа сервера.</p></div> },
|
|
||||||
|
|
||||||
// Сервер резервного копирования
|
|
||||||
copy_system_software_1: { title: "Центральный процессор", content: <div><h2>Центральный процессор</h2><p>Описание центрального процессора сервера резервного копирования.</p></div> },
|
|
||||||
copy_system_software_2: { title: "Оперативная память", content: <div><h2>Оперативная память</h2><p>Описание оперативной памяти сервера резервного копирования.</p></div> },
|
|
||||||
copy_system_software_3: { title: "Жесткий диск", content: <div><h2>Жесткий диск</h2><p>Описание жесткого диска сервера резервного копирования.</p></div> },
|
|
||||||
copy_system_software_4: { title: "Сетевые адаптеры", content: <div><h2>Сетевые адаптеры</h2><p>Описание сетевых адаптеров сервера резервного копирования.</p></div> },
|
|
||||||
copy_software_1: { title: "ПО", content: <div><h2>Программное обеспечение сервера резервного копирования</h2><p>Описание ПО сервера резервного копирования.</p></div> },
|
|
||||||
copy_software_2: { title: "ПО", content: <div><h2>Программное обеспечение сервера резервного копирования</h2><p>Описание ПО сервера резервного копирования.</p></div> },
|
|
||||||
copy_software_3: { title: "ПО", content: <div><h2>Программное обеспечение сервера резервного копирования</h2><p>Описание ПО сервера резервного копирования.</p></div> },
|
|
||||||
copy_software_4: { title: "ПО", content: <div><h2>Программное обеспечение сервера резервного копирования</h2><p>Описание ПО сервера резервного копирования.</p></div> },
|
|
||||||
|
|
||||||
// Сервер системы управления
|
|
||||||
control_system_software_1: { title: "Центральный процессор", content: <div><h2>Центральный процессор</h2><p>Описание центрального процессора сервера системы управления.</p></div> },
|
|
||||||
control_system_software_2: { title: "Оперативная память", content: <div><h2>Оперативная память</h2><p>Описание оперативной памяти сервера системы управления.</p></div> },
|
|
||||||
control_system_software_3: { title: "Жесткий диск", content: <div><h2>Жесткий диск</h2><p>Описание жесткого диска сервера системы управления.</p></div> },
|
|
||||||
control_system_software_4: { title: "Сетевые адаптеры", content: <div><h2>Сетевые адаптеры</h2><p>Описание сетевых адаптеров сервера системы управления.</p></div> },
|
|
||||||
control_software_1: { title: "ПО", content: <div><h2>Программное обеспечение сервера системы управления</h2><p>Описание ПО сервера системы управления.</p></div> },
|
|
||||||
control_software_2: { title: "ПО", content: <div><h2>Программное обеспечение сервера системы управления</h2><p>Описание ПО сервера системы управления.</p></div> },
|
|
||||||
control_software_3: { title: "ПО", content: <div><h2>Программное обеспечение сервера системы управления</h2><p>Описание ПО сервера системы управления.</p></div> },
|
|
||||||
control_software_4: { title: "ПО", content: <div><h2>Программное обеспечение сервера системы управления</h2><p>Описание ПО сервера системы управления.</p></div> },
|
|
||||||
|
|
||||||
// Сервер сбора и ретрансляции информации
|
|
||||||
system_software_1: { title: "Центральный процессор", content: <div><h2>Центральный процессор</h2><p>Описание центрального процессора сервера сбора и ретрансляции информации.</p></div> },
|
|
||||||
system_software_2: { title: "Оперативная память", content: <div><h2>Оперативная память</h2><p>Описание оперативной памяти сервера сбора и ретрансляции информации.</p></div> },
|
|
||||||
system_software_3: { title: "Жесткий диск", content: <div><h2>Жесткий диск</h2><p>Описание жесткого диска сервера сбора и ретрансляции информации.</p></div> },
|
|
||||||
system_software_4: { title: "Сетевые адаптеры", content: <div><h2>Сетевые адаптеры</h2><p>Описание сетевых адаптеров сервера сбора и ретрансляции информации.</p></div> },
|
|
||||||
software_1: { title: "ПО", content: <div><h2>Программное обеспечение сервера сбора и ретрансляции информации</h2><p>Описание ПО сервера сбора и ретрансляции информации.</p></div> },
|
|
||||||
software_2: { title: "ПО", content: <div><h2>Программное обеспечение сервера сбора и ретрансляции информации</h2><p>Описание ПО сервера сбора и ретрансляции информации.</p></div> },
|
|
||||||
software_3: { title: "ПО", content: <div><h2>Программное обеспечение сервера сбора и ретрансляции информации</h2><p>Описание ПО сервера сбора и ретрансляции информации.</p></div> },
|
|
||||||
software_4: { title: "ПО", content: <div><h2>Программное обеспечение сервера сбора и ретрансляции информации</h2><p>Описание ПО сервера сбора и ретрансляции информации.</p></div> },
|
|
||||||
};
|
|
||||||
|
|
||||||
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`,
|
||||||
|
'/api/auth/login',
|
||||||
|
{ 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 {
|
} else {
|
||||||
setError("Неверный логин или пароль");
|
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="Логин"
|
||||||
|
variant="filled"
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
<TextField
|
||||||
<label>Пароль:</label>
|
fullWidth
|
||||||
<input
|
id="user-password"
|
||||||
type="password"
|
label="Пароль"
|
||||||
|
variant="filled"
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
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>
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && (
|
||||||
<button type="submit">Войти</button>
|
<Typography color="error" sx={{ mt: 1 }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
py: 1.5,
|
||||||
|
fontSize: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -5,7 +5,6 @@ const Modal = ({ children, onClose }) => {
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
{children}
|
{children}
|
||||||
<button onClick={onClose}>Закрыть</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,76 +1,351 @@
|
||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import "../../Style/TreeTable.css";
|
import {
|
||||||
import { getStatusColor } from "../TreeChart/dataUtils"; // Импортируем функцию
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Button,
|
||||||
|
Collapse,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
useTheme,
|
||||||
|
Tooltip
|
||||||
|
} from '@mui/material';
|
||||||
|
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
||||||
|
|
||||||
const TreeTable = ({ data }) => {
|
const TreeTable = ({ data }) => {
|
||||||
// Фильтруем данные, чтобы убрать "Функциональные задачи"
|
const theme = useTheme();
|
||||||
const filteredData = data.filter((item) => item.title !== "Функциональные задачи");
|
const tableRef = useRef(null);
|
||||||
|
const [fontSize, setFontSize] = useState(16);
|
||||||
|
const [log, setLog] = useState([]);
|
||||||
|
const [isLogVisible, setIsLogVisible] = useState(false);
|
||||||
|
|
||||||
return (
|
const adjustFontSize = () => {
|
||||||
<div className="table-container">
|
if (tableRef.current) {
|
||||||
<table className="tree-table">
|
let newSize = 16;
|
||||||
<thead>
|
const maxWidth = window.innerWidth;
|
||||||
{/* Первый уровень: Заголовки "Медиа сервер" */}
|
|
||||||
<tr>
|
|
||||||
{filteredData.map((item, index) => (
|
|
||||||
<th key={index} colSpan="2" className="tree-table-header" style={{ backgroundColor: getStatusColor(item.status) }}>
|
|
||||||
{item.title}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
{/* Второй уровень: "АО" и "ПО" */}
|
|
||||||
<tr>
|
|
||||||
{filteredData.map((item, index) => (
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
<td className="tree-table-subheader" style={{ backgroundColor: getStatusColor(item.items[0]?.status) }}>
|
|
||||||
АО
|
|
||||||
</td>
|
|
||||||
<td className="tree-table-subheader" style={{ backgroundColor: getStatusColor(item.items[1]?.status) }}>
|
|
||||||
ПО
|
|
||||||
</td>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{/* Третий уровень: Вложенные элементы "АО" и "ПО" */}
|
|
||||||
{renderRows(filteredData)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция для отображения строк с вложенными элементами
|
while (tableRef.current.scrollWidth > maxWidth && newSize > 10) {
|
||||||
const renderRows = (data) => {
|
newSize -= 1;
|
||||||
const rows = [];
|
tableRef.current.style.fontSize = `${newSize}px`;
|
||||||
|
|
||||||
// Находим максимальное количество элементов среди всех "АО" и "ПО"
|
|
||||||
const maxItems = Math.max(
|
|
||||||
...data.flatMap((item) => [
|
|
||||||
item.items[0]?.items?.length || 0, // АО
|
|
||||||
item.items[1]?.items?.length || 0 // ПО
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Генерируем строки
|
|
||||||
for (let i = 0; i < maxItems; i++) {
|
|
||||||
rows.push(
|
|
||||||
<tr key={i} className="tree-table-row">
|
|
||||||
{data.map((item, index) => (
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
<td className="tree-table-cell" style={{ backgroundColor: getStatusColor(item.items[0]?.items[i]?.status) }}>
|
|
||||||
{item.items[0]?.items[i]?.title || ""}
|
|
||||||
</td>
|
|
||||||
<td className="tree-table-cell" style={{ backgroundColor: getStatusColor(item.items[1]?.items[i]?.status) }}>
|
|
||||||
{item.items[1]?.items[i]?.title || ""}
|
|
||||||
</td>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
while (tableRef.current.scrollWidth < maxWidth && newSize < 16) {
|
||||||
|
newSize += 1;
|
||||||
|
tableRef.current.style.fontSize = `${newSize}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFontSize(newSize);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adjustFontSize();
|
||||||
|
window.addEventListener('resize', adjustFontSize);
|
||||||
|
return () => window.removeEventListener('resize', adjustFontSize);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Логирование статусов
|
||||||
|
useEffect(() => {
|
||||||
|
const newLog = [];
|
||||||
|
const traverse = (items) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (["yellow", "orange", "red"].includes(item.status)) {
|
||||||
|
newLog.push({
|
||||||
|
title: item.title,
|
||||||
|
status: item.status,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.items) {
|
||||||
|
traverse(item.items);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
traverse(data.items);
|
||||||
|
setLog(prevLog => [...newLog, ...prevLog].slice(0, 50));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const filteredData = data.items.filter(item => item.title !== "Функциональные задачи");
|
||||||
|
|
||||||
|
// Компонент индикаторов статуса
|
||||||
|
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) => {
|
||||||
|
const colSpan = item.items ? item.items.length : 1;
|
||||||
|
return (
|
||||||
|
<TableCellWithTooltip
|
||||||
|
key={item.id}
|
||||||
|
colSpan={colSpan}
|
||||||
|
align="center"
|
||||||
|
title={item.title}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={item.status} />
|
||||||
|
<Typography component="span" variant="subtitle2" noWrap>
|
||||||
|
{item.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Рендер подзаголовков (второй уровень)
|
||||||
|
const renderSubHeaders = (items) => {
|
||||||
|
return items.flatMap((item) => {
|
||||||
|
if (item.items) {
|
||||||
|
return item.items.map((child) => (
|
||||||
|
<TableCellWithTooltip
|
||||||
|
key={child.id}
|
||||||
|
align="center"
|
||||||
|
title={child.title}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={child.status} />
|
||||||
|
<Typography component="span" variant="subtitle2" noWrap>
|
||||||
|
{child.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TableCellWithTooltip
|
||||||
|
key={item.id}
|
||||||
|
align="center"
|
||||||
|
title={item.title}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={item.status} />
|
||||||
|
<Typography component="span" variant="subtitle2" noWrap>
|
||||||
|
{item.title}
|
||||||
|
</Typography>
|
||||||
|
</TableCellWithTooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Рендер данных (третий уровень)
|
||||||
|
const 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 (
|
||||||
|
<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 (
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<TableContainer
|
||||||
|
component={Paper}
|
||||||
|
ref={tableRef}
|
||||||
|
sx={{
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
width: '100%',
|
||||||
|
'& .MuiTableCell-root': {
|
||||||
|
py: 1,
|
||||||
|
px: 2
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table sx={{ width: '100%', tableLayout: 'fixed' }}>
|
||||||
|
<TableHead>
|
||||||
|
{/* Основной заголовок таблицы */}
|
||||||
|
<TableRow>
|
||||||
|
<TableCellWithTooltip
|
||||||
|
colSpan={filteredData.reduce((acc, item) => acc + (item.items ? item.items.length : 1), 0)}
|
||||||
|
align="center"
|
||||||
|
title={data.title}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusIndicators status={data.status} />
|
||||||
|
<Typography component="span" variant="subtitle1" fontWeight="bold" noWrap>
|
||||||
|
{data.title}
|
||||||
|
</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) => (
|
||||||
|
<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}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default TreeTable;
|
export default TreeTable;
|
||||||
|
|
@ -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,73 +0,0 @@
|
||||||
/* Основной контейнер */
|
|
||||||
.dashboard-container {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
width: calc(100vw - 20px); /* Учитываем отступ */
|
|
||||||
overflow: hidden;
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Сайдбар */
|
|
||||||
.sidebar {
|
|
||||||
height: 100vh;
|
|
||||||
background-color: #3d74c7;
|
|
||||||
color: white;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: 999;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
/* Плавное изменение ширины */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Элемент для перетаскивания */
|
|
||||||
.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);
|
|
||||||
/* Эффект при наведении */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Основной контент */
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 20px;
|
|
||||||
margin-left: 50px;
|
|
||||||
transition: margin-left 0.2s ease;
|
|
||||||
overflow: auto; /* Позволяет прокручивать контент, если он не влезает */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Контент */
|
|
||||||
.content {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 2px 5px rgba(29, 1, 1, 0.521);
|
|
||||||
max-width: 100%; /* Гарантируем, что контент не выйдет за границы */
|
|
||||||
overflow: auto; /* Включаем скролл, если нужно */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Заголовки */
|
|
||||||
h2 {
|
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
/* DatePicker.css */
|
|
||||||
|
|
||||||
.react-datepicker-wrapper {
|
|
||||||
width: auto;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__input-container input {
|
|
||||||
width: 200px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
background-color: #fff;
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__input-container input:focus {
|
|
||||||
border-color: #0078d4;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker {
|
|
||||||
font-family: 'Segoe UI', sans-serif;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: #fff;
|
|
||||||
/* Непрозрачный фон */
|
|
||||||
z-index: 1000;
|
|
||||||
/* Календарь поверх других элементов */
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-popper {
|
|
||||||
z-index: 1000;
|
|
||||||
/* Календарь поверх других элементов */
|
|
||||||
pointer-events: auto;
|
|
||||||
/* Разрешить взаимодействие только с календарем */
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__header {
|
|
||||||
background-color: #f8f8f8;
|
|
||||||
/* Непрозрачный фон заголовка */
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__current-month {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation {
|
|
||||||
top: 12px;
|
|
||||||
border: 0.45rem solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation--previous {
|
|
||||||
left: 12px;
|
|
||||||
border-right-color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation--next {
|
|
||||||
right: 12px;
|
|
||||||
border-left-color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-names {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-name {
|
|
||||||
width: 28px;
|
|
||||||
line-height: 28px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month {
|
|
||||||
background-color: #fff;
|
|
||||||
/* Непрозрачный фон месяца */
|
|
||||||
margin: 0;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__week {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day {
|
|
||||||
width: 28px;
|
|
||||||
line-height: 28px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #333;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--selected {
|
|
||||||
background-color: #0078d4;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--selected:hover {
|
|
||||||
background-color: #005bb5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--outside-month {
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--disabled {
|
|
||||||
color: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
@ -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,51 +4,72 @@
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: rgb(255, 255, 255);
|
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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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: black;
|
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);
|
||||||
|
color: var(--modal-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal button {
|
.modal button {
|
||||||
padding: 10px 20px;
|
/* padding: 10px 20px; */
|
||||||
|
margin-top: 5vh;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
background: #08294b;
|
background: var(--modal--btn-background);
|
||||||
color: white;
|
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: #0056b3;
|
background: var(--hover-button);
|
||||||
|
color: var(--hover-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
/* Боковое меню */
|
|
||||||
.sidebar {
|
|
||||||
height: 100vh;
|
|
||||||
background-color: #3d74c7;
|
|
||||||
color: white;
|
|
||||||
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: white;
|
|
||||||
padding: 10px;
|
|
||||||
/* Добавляем отступы */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Элементы меню */
|
|
||||||
.menu-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: white;
|
|
||||||
width: 100%;
|
|
||||||
/* Ширина на всю ширину сайдбара */
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
/* Запрещаем сжатие */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Анимация мигания для красного индикатора */
|
|
||||||
@keyframes blink {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Полная видимость */
|
|
||||||
50% {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Полупрозрачность */
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Полная видимость */
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator.blinking {
|
|
||||||
animation: blink 1s infinite;
|
|
||||||
/* Бесконечная анимация с интервалом 1 секунда */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Подменю */
|
|
||||||
.submenu {
|
|
||||||
margin-left: 20px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Футер сайдбара */
|
|
||||||
.sidebar-footer {
|
|
||||||
padding: 10px;
|
|
||||||
background-color: #3d74c7;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
/* Разделительная линия */
|
|
||||||
flex-shrink: 0;
|
|
||||||
/* Запрещаем сжатие */
|
|
||||||
width: 100%;
|
|
||||||
/* Ширина на всю ширину сайдбара */
|
|
||||||
}
|
|
||||||
|
|
||||||
.help,
|
|
||||||
.settings {
|
|
||||||
color: white;
|
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
svg {
|
||||||
|
user-select: none;
|
||||||
|
/* Запрет выделения текста */
|
||||||
|
}
|
||||||
|
|
||||||
|
text {
|
||||||
|
pointer-events: none;
|
||||||
|
/* Запрет взаимодействия с текстом */
|
||||||
|
}
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
/* Контейнер для таблицы с прокруткой */
|
|
||||||
.table-container {
|
|
||||||
width: 100%;
|
|
||||||
/* Занимает всю доступную ширину */
|
|
||||||
overflow-x: auto;
|
|
||||||
/* Горизонтальная прокрутка при необходимости */
|
|
||||||
margin: 0 auto;
|
|
||||||
/* Центрирование контейнера */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для таблицы */
|
|
||||||
.tree-table {
|
|
||||||
width: auto;
|
|
||||||
/* Автоматическая ширина, чтобы таблица могла расширяться */
|
|
||||||
min-width: 95%;
|
|
||||||
/* Минимальная ширина таблицы */
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 0 auto;
|
|
||||||
/* Центрирование таблицы */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Заголовки таблицы (первый уровень) */
|
|
||||||
.tree-table th {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 8px;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
/* Запрет на перенос текста */
|
|
||||||
font-weight: bold;
|
|
||||||
/* Жирный шрифт для заголовков */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Подзаголовки (второй уровень: "АО" и "ПО") */
|
|
||||||
.tree-table-subheader {
|
|
||||||
font-weight: 500;
|
|
||||||
/* Жирный шрифт для подзаголовков */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ячейки таблицы */
|
|
||||||
.tree-table td {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 8px;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
/* Запрет на перенос текста */
|
|
||||||
font-weight: normal;
|
|
||||||
/* Обычный шрифт для ячеек */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Цвет фона для заголовков */
|
|
||||||
.tree-table-header {
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Чередование цвета строк */
|
|
||||||
.tree-table-row:nth-child(even) {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
/* src/Style/common.css */
|
|
||||||
|
|
||||||
/* Контейнер для вкладок */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
/* Расстояние между вкладками */
|
|
||||||
padding: 5px;
|
|
||||||
background-color: #3d74c7;
|
|
||||||
/* Цвет фона */
|
|
||||||
border-bottom: 2px solid #195fc9;
|
|
||||||
/* Линия под вкладками */
|
|
||||||
overflow-x: auto;
|
|
||||||
/* Прокрутка, если вкладок много */
|
|
||||||
border-radius: 5px;
|
|
||||||
/* Скругление углов */
|
|
||||||
white-space: nowrap;
|
|
||||||
/* Запрет переноса текста */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для отдельной вкладки */
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #3d74c7;
|
|
||||||
/* Цвет фона вкладки */
|
|
||||||
color: white;
|
|
||||||
/* Цвет текста */
|
|
||||||
padding: 5px 15px;
|
|
||||||
/* Отступы внутри вкладки */
|
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
/* Скругление углов */
|
|
||||||
cursor: pointer;
|
|
||||||
/* Курсор при наведении */
|
|
||||||
flex-shrink: 0;
|
|
||||||
/* Запрет сжатия */
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
/* Плавное изменение цвета */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Активная вкладка */
|
|
||||||
.tab.active {
|
|
||||||
background-color: #195fc9;
|
|
||||||
/* Цвет фона активной вкладки */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Кнопка закрытия вкладки */
|
|
||||||
.close-tab {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
/* Цвет крестика */
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-left: 10px;
|
|
||||||
/* Отступ от текста */
|
|
||||||
padding: 0;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
/* Плавное изменение цвета */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Эффект при наведении на кнопку закрытия */
|
|
||||||
.close-tab:hover {
|
|
||||||
color: #ff6b6b;
|
|
||||||
/* Цвет крестика при наведении */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Эффект при наведении на вкладку */
|
|
||||||
.tab:hover {
|
|
||||||
background-color: #195fc9;
|
|
||||||
/* Цвет фона при наведении */
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
/* Чуть темнее при наведении */
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
|
|
@ -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']
|
|
||||||
}
|
|
||||||
})
|
|
||||||