Compare commits

..

133 Commits
1.0.9 ... main

Author SHA1 Message Date
deployer3000 e5e4d75637 Merge pull request 'rc' (#66) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/66
2026-03-23 15:14:38 +03:00
Vladislav Drozdov 3a142185a2 Merge pull request 'redisign' (#65) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/65
2026-03-23 15:14:13 +03:00
DmitriyA 0b600095ce Merge branch 'main' of http://git.enode/deployer3000/trust-module-frontend into redisign 2026-03-23 07:58:24 -04:00
DmitriyA 33a88d2a1a add licensing component 2026-03-23 07:55:22 -04:00
deployer3000 daf9cab8ac Merge pull request 'rc' (#64) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/64
2025-12-02 12:47:58 +03:00
Vladislav Drozdov 7400e77fa0 Merge pull request 'redisign' (#63) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build started... Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/63
2025-12-02 12:38:51 +03:00
deployer3000 86baaa29ff Удалить vite.config.js
test-org/trust-module-frontend/pipeline/pr-rc Build queued... Details
2025-12-02 12:38:01 +03:00
DmitriyA 14d2f3eb68 version update 2025-12-02 04:34:56 -05:00
DmitriyA 558cf8eaba added formula 2025-10-21 09:14:20 -04:00
DmitriyA 585692c838 Merge branch 'rc' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-09-01 09:46:56 -04:00
deployer3000 3cef08f65b Merge pull request 'rc' (#62) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/62
2025-09-01 16:46:38 +03:00
Vladislav Drozdov 04738fae91 Merge pull request 'edit vite.config, added env' (#60) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/60
2025-09-01 16:45:01 +03:00
DmitriyA 555c28d942 Merge branch 'redisign' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-09-01 09:44:36 -04:00
DmitriyA 97295a6748 Merge branch 'rc' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-09-01 09:44:21 -04:00
deployer3000 179baad012 Merge branch 'rc' into redisign 2025-09-01 16:43:56 +03:00
DmitriyA 75fa0ebfe3 edit vite.config, added env 2025-09-01 09:42:34 -04:00
deployer3000 f271fb5acf Merge pull request 'rc' (#55) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/55
2025-09-01 16:26:30 +03:00
Vladislav Drozdov c411142840 Merge pull request 'redisign' (#59) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/59
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-09-01 16:23:55 +03:00
DmitriyA 06249fce3a sidebar redesign 2025-09-01 09:20:23 -04:00
DmitriyA 933ceb2547 added drag-and-drop 2025-09-01 08:16:59 -04:00
DmitriyA 34f2010cae added menu editor 2025-08-28 09:20:42 -04:00
SovietSpiderCat 205ddc71e0 added complex variables 2025-08-22 09:57:16 +03:00
SovietSpiderCat 421d95565c fixed WS 2025-08-20 00:17:20 +03:00
Vladislav Drozdov 46cd1fa0fa Merge pull request 'redisign' (#58) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/58
2025-08-14 15:58:55 +03:00
SovietSpiderCat 911bfb88d1 change endpoint for proxy 2025-08-14 14:03:34 +03:00
SovietSpiderCat 1bcb15f655 rework ws 2025-08-14 13:51:41 +03:00
Vladislav Drozdov f8d822ace7 Merge pull request 'test env' (#57) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/57
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-11 14:38:54 +03:00
SovietSpiderCat fd5a202d74 test env
test-org/trust-module-frontend/pipeline/pr-rc Build started... Details
2025-08-11 14:37:29 +03:00
Vladislav Drozdov b1a760336d Merge pull request 'redisign' (#56) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/56
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-08 16:07:09 +03:00
SovietSpiderCat 00866d9d57 added UserManagement
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-08-08 15:54:58 +03:00
Vladislav Drozdov c208813daa Merge pull request 'redisign' (#54) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/54
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-07 14:56:50 +03:00
SovietSpiderCat f55fb2df56 Merge branch 'redisign' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-08-07 04:45:21 +03:00
SovietSpiderCat be3bf3b21e fixed a bug with logout 2025-08-07 04:44:54 +03:00
DmitriyA 4fb1975428 added AI analyzer
test-org/trust-module-frontend/pipeline/pr-main Build started... Details
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-08-05 08:45:58 -04:00
SovietSpiderCat 08fde58a30 added proxy 2025-08-01 11:49:10 +03:00
SovietSpiderCat 6f0b15427a Merge branch 'redisign' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-07-31 11:15:43 +03:00
DmitriyA d7c40ee04b added real data on the main page 2025-07-30 18:35:19 -04:00
deployer3000 8fcace10b1 Merge pull request 'rc' (#52) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/52
2025-07-21 13:20:26 +03:00
Vladislav Drozdov 140b058f41 Merge pull request 'redisign' (#51) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/51
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-07-21 13:10:53 +03:00
DmitriyA cb030a01d2 added profile page and logout
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-07-16 10:22:06 -04:00
DmitriyA dabdda4afe Merge branch 'redisign' of http://git.enode/deployer3000/trust-module-frontend into redisign
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-07-16 09:59:52 -04:00
DmitriyA 6a73bd8104 Merge branch 'rc' of http://git.enode/deployer3000/trust-module-frontend into redisign 2025-07-16 09:59:33 -04:00
DmitriyA 87a79f98d7 adding roles 2025-07-16 09:50:19 -04:00
deployer3000 15c20f1352 Merge branch 'rc' into redisign
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-07-14 10:22:59 +03:00
DmitriyA ef5df6971d adding data analyzer
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-07-14 02:59:06 -04:00
DmitriyA 61c623b93d Range editor update 2025-07-08 04:46:12 -04:00
DmitriyA 26276e0360 Metric editor fix 2025-07-07 03:44:10 -04:00
deployer3000 c99b6add47 Merge pull request 'rc' (#50) from rc into main 2025-07-04 15:12:00 +03:00
Vladislav Drozdov b681add6fc Merge pull request 'Metric editor optimization' (#48) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/48
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-07-04 10:59:28 +03:00
DmitriyA 36a75ee93c Metric editor optimization
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-07-03 08:09:27 -04:00
deployer3000 9f8d0072c2 Merge pull request 'rc' (#47) from rc into main 2025-06-11 15:09:57 +03:00
Vladislav Drozdov 5e9e40aad2 Merge pull request 'redisign' (#46) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/46
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-06-11 15:01:46 +03:00
DmitriyA 405bda3df9 added ranages editor
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-06-11 07:30:34 -04:00
DmitriyA 328018edfa created modal window for settings 2025-06-11 04:49:48 -04:00
DmitriyA 69a5e4ade1 sidebar menu improvement 2025-06-10 09:29:14 -04:00
deployer3000 a931fd3ea4 Merge pull request 'rc' (#45) from rc into main 2025-06-06 14:39:35 +03:00
Vladislav Drozdov 5bf3124fd4 Merge pull request 'redisign' (#44) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/44
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-06-06 14:38:39 +03:00
DmitriyA f87274d41a improving charts
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-06-06 07:25:32 -04:00
DmitriyA b5b758ffa0 added ranges for charts 2025-06-05 08:08:13 -04:00
DmitriyA 12e1ff08f5 status color fix 2025-06-04 08:37:45 -04:00
deployer3000 350f375015 Merge pull request 'rc' (#43) from rc into main 2025-06-03 13:11:48 +03:00
Vladislav Drozdov fa32e75b5a Merge pull request 'redisign' (#42) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/42
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-06-03 13:09:36 +03:00
DmitriyA 09a6082917 websocket fix
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-06-03 05:58:27 -04:00
DmitriyA cb7c22929a charts update 2025-06-03 05:29:26 -04:00
deployer3000 c55806d180 Merge pull request 'rc' (#41) from rc into main 2025-05-28 14:36:53 +03:00
Vladislav Drozdov d70c7673b4 Merge pull request 'redisign' (#40) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/40
2025-05-28 14:31:44 +03:00
DmitriyA 8223cc4a27 Added environment variables
test-org/trust-module-frontend/pipeline/pr-main This commit looks good Details
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-05-28 01:47:18 -04:00
DmitriyA 4088dacba4 added logo 2025-05-28 01:36:11 -04:00
DmitriyA 08e2c24a63 tabs and charts update 2025-05-27 22:14:48 -04:00
DmitriyA efd8532ac3 automatic generation of tabs for the sidebar menu 2025-05-27 21:40:59 -04:00
DmitriyA 069cea21b0 automatic formation of the side menu 2025-05-27 20:45:30 -04:00
DmitriyA 2b79159d35 Created a new chart 2025-05-22 06:29:58 -04:00
DmitriyA 4dfd972615 graph refactor 2025-05-05 09:11:48 -04:00
DmitriyA b9a2be4860 prepared the graph for refactoring and added an indicator 2025-04-28 09:27:07 -04:00
DmitriyA d5aa312104 Added the logo to the side menu 2025-04-23 10:19:29 -04:00
DmitriyA bbbcd932ad improved the design 2025-04-23 09:54:17 -04:00
DmitriyA 6fd5d1aed2 fixed a bug with tabs 2025-04-23 08:48:09 -04:00
DmitriyA 40d8046617 modified the skeleton MUI 2025-04-23 08:25:15 -04:00
DmitriyA e47161acd1 fixed a bug with collapsing the menu, improved the MUI skeleton, fixed several visual bugs 2025-04-22 08:59:35 -04:00
YurijO fd53b187d5 Merge pull request 'authorization-token' (#38) from authorization-token into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/38
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
Reviewed-by: YurijO <ya@ya.ru>
2025-04-21 16:10:30 +03:00
DmitriyA b4d653f3a6 corrections after the code review
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-04-21 09:01:36 -04:00
DmitriyA 54cf5504a4 removed unnecessary components #2
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-04-21 03:32:30 -04:00
DmitriyA f38c8825fe removed unnecessary components 2025-04-21 03:28:14 -04:00
DmitriyA c7ebbcaf5c fixed a bug with multiple web socket connection 2025-04-21 02:55:11 -04:00
DmitriyA d83f05e2b5 set up an authorization session using tokens and cookies 2025-04-14 04:40:02 -04:00
deployer3000 dd0aa2d706 Merge pull request 'rc' (#37) from rc into main 2025-04-10 15:06:45 +03:00
YurijO bcdbd0f0fc Merge pull request 'redesign' (#36) from redesign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://192.168.2.61/deployer3000/trust-module-frontend/pulls/36
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
Reviewed-by: YurijO <ya@ya.ru>
2025-04-10 14:57:52 +03:00
DmitriyA 08eaa274b2 Merge pull request 'Converted time to constants' (#35) from debugging into redesign
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
Reviewed-on: http://192.168.2.61/deployer3000/trust-module-frontend/pulls/35
2025-04-10 12:36:47 +03:00
DmitriyA 22c5fcf02c Converted time to constants
test-org/trust-module-frontend/pipeline/pr-redesign Build queued... Details
2025-04-10 05:33:15 -04:00
yuobrezkov 2c9813fbb9 auto versioning added 2025-04-10 11:03:22 +03:00
DmitriyA c1b0f10e62 Merge pull request 'added an environment variable' (#33) from debugging into redesign
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/33
2025-04-09 17:19:13 +03:00
DmitriyA 858f6e2c4c added an environment variable 2025-04-09 10:17:45 -04:00
DmitriyA 4eed2f364c Merge pull request 'added the api prefix' (#31) from debugging into redesign
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/31
2025-04-09 17:03:45 +03:00
DmitriyA 5b25876056 added the api prefix
test-org/trust-module-frontend/pipeline/pr-redesign Build queued... Details
2025-04-09 10:00:44 -04:00
DmitriyA 9ee15160e5 Merge pull request 'optimized chart loading' (#30) from debugging into redesign
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/30
2025-04-09 16:17:23 +03:00
DmitriyA e56b82bb66 optimized chart loading
test-org/trust-module-frontend/pipeline/pr-redesign Build queued... Details
2025-04-09 09:15:25 -04:00
DmitriyA 46484efdea Merge pull request 'debugging' (#29) from debugging into redesign
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/29
2025-04-09 14:57:13 +03:00
DmitriyA b6b3b36f5a fixed data interpolation and range allocation
test-org/trust-module-frontend/pipeline/pr-redesign This commit looks good Details
2025-04-09 07:47:51 -04:00
DmitriyA 46da90fbb6 Fixed the date and time display 2025-04-08 01:42:47 -04:00
DmitriyA 64401cadbc fixed data transmission via a web socket and left data transmission via http requests for historical data 2025-04-07 10:27:08 -04:00
DmitriyA a24b89220c fixed bugs
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-04-04 15:31:20 -04:00
DmitriyA 32ece2f0ff refactoring, fixed bugs with the web socket
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-04-02 19:50:08 -04:00
DmitriyA 4405c693aa Established a connection to the back using a web socket
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-04-01 11:50:00 -04:00
DmitriyA 6a4640ba93 Deleted the trash files, added a button to minimize the side menu, and adjusted the styles
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-03-28 07:24:28 -04:00
DmitriyA bd96278895 redesign and fix graphics
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-03-27 10:09:58 -04:00
DmitriyA ed2e03e202 adjusting the chart legend 2025-03-26 06:53:41 -04:00
deployer3000 f69443d051 Merge pull request 'rc' (#27) from rc into main 2025-03-26 13:30:17 +03:00
yuobrezkov cf38b25678 conflict
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
2025-03-26 13:22:08 +03:00
DmitriyA 3ee0c3a02c environment variables
test-org/trust-module-frontend/pipeline/pr-rc There was a failure building this commit Details
2025-03-26 06:11:00 -04:00
yuobrezkov b6b8042d11 conflict 2025-03-26 12:28:33 +03:00
DmitriyA fc1db66288 redesign of graphs and visualizations
test-org/trust-module-frontend/pipeline/pr-rc There was a failure building this commit Details
2025-03-26 05:16:52 -04:00
DmitriyA c077449b2c redesign of graphs and visualizations 2025-03-26 05:16:43 -04:00
deployer3000 88b7b8af77 Merge pull request 'login modal ip changed' (#24) from rc into main 2025-03-26 10:07:46 +03:00
yuobrezkov 15a8a146fc login modal ip changed
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
2025-03-26 10:04:52 +03:00
deployer3000 b7f6a6c386 Merge pull request 'rc' (#23) from rc into main 2025-03-26 09:24:45 +03:00
yuobrezkov b601dedc0e deleted stagged
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
2025-03-26 09:19:48 +03:00
yuobrezkov 9399f544d2 added new domain 2025-03-26 09:18:04 +03:00
DmitriyA 5ed1b448e5 refactored the graph 2 2025-03-25 11:48:10 -04:00
DmitriyA 2d714b5985 added the logo, refactored the graph 2025-03-25 08:54:21 -04:00
DmitriyA 3fc7ee0ac3 Merge branch 'redesign' of http://git.enode/deployer3000/trust-module-frontend into redesign 2025-03-20 09:34:15 -04:00
DmitriyA 13101ac57c Rewrote the graph using react-flow 2025-03-20 09:31:54 -04:00
DmitriyA 175b4f993d Улучшил интерфейс, добавиви выделение и пойнтер для бокового меню 2025-03-20 03:53:16 -04:00
deployer3000 f5b92715ef Merge pull request 'rc' (#22) from rc into main 2025-03-19 17:01:39 +03:00
DmitriyA 5ec58ab476 Merge branch 'refactoring' of http://git.enode/deployer3000/trust-module-frontend into refactoring 2025-03-19 09:47:09 -04:00
deployer3000 2713142c7d Merge pull request 'rc' (#20) from rc into main 2025-03-19 16:25:45 +03:00
deployer3000 3c60e9d144 Merge pull request 'rc' (#18) from rc into main 2025-03-12 21:26:27 +03:00
deployer3000 a8ca18edc9 Merge pull request 'rc' (#16) from rc into main 2025-03-10 17:51:26 +03:00
deployer3000 b7e5941500 Merge pull request 'DEBUG info 2' (#15) from rc into main 2025-03-10 17:44:02 +03:00
deployer3000 3d63dbe94c Merge pull request 'DEBUG info' (#14) from rc into main 2025-03-10 17:41:06 +03:00
deployer3000 ff10808dd8 Merge pull request 'Notify after PR 2' (#13) from rc into main 2025-03-10 17:21:41 +03:00
deployer3000 92d1236587 Merge pull request 'Notify after PR' (#12) from rc into main
test-org/trust-module-frontend/pipeline/pr-main Build succeeded Details
2025-03-10 17:13:07 +03:00
deployer3000 47159bb698 Merge pull request 'rc' (#10) from rc into main 2025-03-10 15:31:12 +03:00
deployer3000 c4f95448c1 Merge pull request 'rc' (#6) from rc into main 2025-03-10 12:47:39 +03:00
80 changed files with 24245 additions and 4734 deletions

7
.dockerignore Normal file
View File

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

6
.gitignore vendored
View File

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

View File

@ -2,10 +2,14 @@ FROM node:22.13.0
WORKDIR /app 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"]

40
Jenkinsfile vendored
View File

@ -30,7 +30,23 @@ pipeline {
stage ('Initialize variables') { stage ('Initialize variables') {
steps { steps {
script { script {
env.IMAGE_TAG = sh(script: "git describe --tags --abbrev=0", returnStdout: true).trim() def hasTags = sh(script: "git tag -l | wc -l", returnStdout: true).trim().toInteger() > 0
echo "${hasTags}"
def lastVersion = "0.0.0"
if (hasTags) {
lastVersion = sh(script: "git describe --tags --abbrev=0", returnStdout: true).trim()
}
echo "Last version: ${lastVersion}"
def (major, minor, patch) = lastVersion.tokenize('.')
def newVersion = "${major}.${minor}.${patch.toInteger() + 1}"
echo "New version: ${newVersion}"
env.IMAGE_TAG = newVersion
env.NEW_VERSION = newVersion
} }
} }
} }
@ -71,17 +87,29 @@ pipeline {
echo "Attempting to merge PR ${env.CHANGE_ID} into master..." echo "Attempting to merge PR ${env.CHANGE_ID} into master..."
withCredentials([usernamePassword(credentialsId: 'gitea_creds', usernameVariable: 'GITEA_USER', passwordVariable: 'GITEA_PASS')]) { withCredentials([usernamePassword(credentialsId: 'gitea_creds', usernameVariable: 'GITEA_USER', passwordVariable: 'GITEA_PASS')]) {
def prId = env.CHANGE_ID def prId = env.CHANGE_ID
sh """ sh """
curl -X POST \ curl -X POST \
-u "${GITEA_USER}:${GITEA_PASS}" \ -u "${GITEA_USER}:${GITEA_PASS}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"do":"merge"}' \ -d '{"do":"merge"}' \
http://git.entcor/api/v1/repos/deployer3000/trust-module-frontend/pulls/${prId}/merge http://git.entcor/api/v1/repos/deployer3000/${env.IMAGE_NAME}/pulls/${prId}/merge
""" """
echo "PR ${prId} merged successfully into master!" def commitHash = sh(script: "git rev-parse HEAD~1", returnStdout: true).trim() // необходим для корректного отображения статусов
def context = "test-org/trust-module-frontend/pipeline/pr-${env.CHANGE_TARGET}" echo "PR ${prId} merged successfully into main!"
def commitHash = sh(script: "git rev-parse HEAD~1", returnStdout: true).trim() sleep(time: 15, unit: 'SECONDS')
notify(context, GITEA_USER, GITEA_PASS, env.GITEA_REPOSITORY_URL, "trust-module-frontend", commitHash, "success") sh "git checkout main && git pull origin main"
sh """
curl -v -X POST -u "${GITEA_USER}:${GITEA_PASS}" \
-H "Content-Type: application/json" \
-d '{"tag_name": "${env.NEW_VERSION}", "name": "Release ${env.NEW_VERSION}", "target_commitish": "main"}' \
"${env.GITEA_REPOSITORY_URL}deployer3000/${env.IMAGE_NAME}/releases"
"""
echo "New release succeeded!"
def context = "test-org/${env.IMAGE_NAME}/pipeline/pr-${env.CHANGE_TARGET}"
notify(context, GITEA_USER, GITEA_PASS, env.GITEA_REPOSITORY_URL, env.IMAGE_NAME, commitHash, "success")
} }
} }
} }

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

File diff suppressed because it is too large Load Diff

View File

@ -10,20 +10,31 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"chartjs-adapter-date-fns": "^3.0.0",
"recharts": "^2.15.1",
"d3": "^7.9.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"chart.js": "^4.0.0",
"chartjs-chart-box-and-violin-plot": "^4.0.0",
"react-chartjs-2": "^5.0.0",
"axios": "^1.7.9",
"react-datepicker": "^8.1.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.8",
"@mui/material": "^6.4.7", "@mui/material": "^6.4.7",
"@mui/icons-material": "^6.4.8" "antd": "^5.24.7",
"axios": "^1.7.9",
"chart.js": "^4.0.0",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-chart-box-and-violin-plot": "^4.0.0",
"d3": "^7.9.0",
"esbuild": "^0.25.8",
"react": "^18.3.1",
"react-chartjs-2": "^5.0.0",
"react-datepicker": "^8.1.0",
"react-dom": "^18.3.1",
"react-scripts": "^5.0.1",
"react-virtualized-auto-sizer": "1.0.26",
"react-window": "1.8.11",
"reactflow": "^11.11.4",
"recharts": "^2.15.1",
"socket.io-client": "^4.8.1",
"vite-plugin-svgr": "^4.3.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@dnd-kit/core": "^6.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
@ -35,6 +46,6 @@
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0", "globals": "^15.14.0",
"vite": "^6.0.5" "vite": "^7.1.0"
} }
} }

24
public/system_monitor_icon.svg Executable file → Normal file
View File

@ -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

View File

@ -1,35 +1,208 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo, useEffect } from "react";
import { ThemeProvider, CssBaseline, Switch, Box } from "@mui/material"; import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress, Typography } from "@mui/material";
import Dashboard from "./Components/Layout/Dashboard"; import Dashboard from "./Components/Layout/Dashboard";
import LoginModal from "./Components/UI/LoginModal"; import LoginModal from "./Components/UI/LoginModal";
import { lightTheme, darkTheme } from "./Style/theme"; import { lightTheme, darkTheme } from "./Style/theme";
import "./Style/LoginModal.css"; import Logo from './assets/images/logo.svg?react';
import { checkAuth } from "./Components/UI/auth";
import axios from "axios";
function App() { function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [authState, setAuthState] = useState({
const [showLoginModal, setShowLoginModal] = useState(true); isAuthenticated: false,
isLoading: true,
user: null
});
const [showLoginModal, setShowLoginModal] = useState(false);
const [isDarkMode, setIsDarkMode] = useState( const [isDarkMode, setIsDarkMode] = useState(
window.matchMedia("(prefers-color-scheme: dark)").matches window.matchMedia("(prefers-color-scheme: dark)").matches
); );
const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]); const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]);
const handleLogin = () => { useEffect(() => {
setIsAuthenticated(true); const verifyAuth = async () => {
try {
const savedToken = localStorage.getItem('access_token');
// Если есть токен, но нет пользователя - делаем запрос к серверу
if (savedToken && !localStorage.getItem('user')) {
const authStatus = await checkAuth();
handleAuthResponse(authStatus);
return;
}
// Если есть сохраненный пользователь
const savedUser = JSON.parse(localStorage.getItem('user'));
if (savedUser && savedToken) {
// Если у сохраненного пользователя нет роли - запрашиваем свежие данные
if (!savedUser.role) {
const authStatus = await checkAuth();
handleAuthResponse(authStatus);
} else {
setAuthState({
isAuthenticated: true,
isLoading: false,
user: savedUser
});
setShowLoginModal(false);
}
return;
}
// Стандартная проверка авторизации
const authStatus = await checkAuth();
handleAuthResponse(authStatus);
} catch (error) {
console.error('Auth verification error:', error);
handleAuthFailure();
}
};
const handleAuthResponse = (authStatus) => {
if (authStatus.isAuthenticated && authStatus.user?.role) {
const userToSave = {
id: authStatus.user.id,
login: authStatus.user.login,
role: authStatus.user.role
};
console.log('Saving user:', userToSave);
localStorage.setItem('user', JSON.stringify(userToSave));
setAuthState({
isAuthenticated: true,
isLoading: false,
user: userToSave
});
setShowLoginModal(false);
} else {
handleAuthFailure();
}
};
const handleAuthFailure = () => {
localStorage.removeItem('user');
localStorage.removeItem('access_token');
setAuthState({
isAuthenticated: false,
isLoading: false,
user: null
});
setShowLoginModal(true);
};
verifyAuth();
}, []);
const handleLogin = (userData) => {
setAuthState({
isAuthenticated: true,
isLoading: false,
user: {
id: userData.id,
login: userData.login,
role: userData.role
}
});
setShowLoginModal(false); setShowLoginModal(false);
}; };
const handleLogout = async () => {
try {
const token = localStorage.getItem('access_token');
if (!token) {
// Если нет токена - просто очищаем данные
cleanup();
return;
}
try {
await axios.post('/api/auth/logout', {}, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
} finally {
cleanup();
}
} catch (error) {
console.error('Logout error:', error);
cleanup();
}
};
function cleanup() {
localStorage.removeItem('access_token');
localStorage.removeItem('user');
setAuthState({
isAuthenticated: false,
isLoading: false,
user: null,
});
setShowLoginModal(true);
}
// Полноэкранный лоадер во время проверки авторизации
if (authState.isLoading) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
zIndex: 9999,
bgcolor: 'background.default'
}}>
<CircularProgress />
<Typography sx={{ mt: 2 }}>
Проверка авторизации...
</Typography>
</Box>
</ThemeProvider>
);
}
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
{!isAuthenticated && showLoginModal ? ( {!authState.isAuthenticated ? (
<LoginModal onLogin={handleLogin} onClose={() => setShowLoginModal(false)} /> <>
) : ( <Box sx={{
<Box sx={{ display: "flex", height: "100vh", overflow: "hidden", bgcolor: "background.default", color: "text.primary" }}> position: "fixed",
<Dashboard /> top: 24,
<Box sx={{ position: "absolute", top: 10, right: 10 }}> left: "50%",
<Switch checked={isDarkMode} onChange={() => setIsDarkMode((prev) => !prev)} /> transform: "translateX(-50%)",
zIndex: 1200,
'& svg': { width: 400, height: 'auto' }
}}>
<Logo />
</Box> </Box>
<LoginModal
open={showLoginModal}
onLogin={handleLogin}
onClose={() => setShowLoginModal(false)}
/>
</>
) : (
<Box sx={{
display: "flex",
height: "100vh",
overflow: "hidden",
bgcolor: "background.default"
}}>
<Dashboard
user={authState.user}
onLogout={handleLogout}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
/>
</Box> </Box>
)} )}
</ThemeProvider> </ThemeProvider>

View File

@ -1,45 +0,0 @@
import React from 'react';
import { BarChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Bar, ResponsiveContainer } from 'recharts';
const BarChartComponent = ({ chartData, metricName, metricType, colors }) => {
// Преобразуем данные для отображения
const data = Object.keys(chartData).map(instance => {
const instanceData = chartData[instance].reduce((acc, point) => {
if (point.value !== null) {
acc[point.quantile] = point.value;
}
return acc;
}, {});
return { instance, ...instanceData };
});
// Получаем все уникальные квантили
const allQuantiles = [...new Set(
Object.values(chartData).flat().map(point => point.quantile)
)];
return (
<div>
<h2>{metricName} ({metricType})</h2>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="instance" />
<YAxis />
<Tooltip />
<Legend />
{allQuantiles.map((quantile, index) => (
<Bar
key={quantile}
dataKey={quantile}
fill={colors[index % colors.length]}
name={`Quantile ${quantile}`}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
};
export default BarChartComponent;

View File

@ -1,12 +0,0 @@
import React from 'react';
const CounterComponent = ({ value, metricName }) => {
return (
<div style={{ textAlign: 'center', padding: '20px', border: '1px solid #ccc', borderRadius: '8px', margin: '10px' }}>
<h2>{metricName}</h2>
<p style={{ fontSize: '48px', fontWeight: 'bold', color: '#3e95cd' }}>{value}</p>
</div>
);
};
export default CounterComponent;

View File

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

View File

@ -1,29 +0,0 @@
import React from 'react';
import { ScatterChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Scatter, ResponsiveContainer } from 'recharts';
const ScatterChartComponent = ({ chartData, metricName, metricType, colors }) => {
return (
<div>
<h2>{metricName} ({metricType})</h2>
<ResponsiveContainer width="100%" height={400}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis dataKey="value" />
<Tooltip />
<Legend />
{Object.keys(chartData).map((instance, index) => (
<Scatter
key={instance}
data={chartData[instance]}
name={instance}
fill={colors[index % colors.length]}
/>
))}
</ScatterChart>
</ResponsiveContainer>
</div>
);
};
export default ScatterChartComponent;

View File

@ -0,0 +1,101 @@
import React from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer, ReferenceLine
} from 'recharts';
import { format } from 'date-fns';
const lineColors = {
'18': '#8884d8',
'19': '#82ca9d',
'default': '#ff8042'
};
const formatXAxis = (tickItem) => {
return format(new Date(tickItem), 'HH:mm:ss');
};
const formatTooltip = (value, name, props) => {
return [`${value.toFixed(2)}`, `Устройство ${name}`];
};
const LineChartComponent = ({
data = [],
multipleLines = true, // По умолчанию включаем множественные линии
lineKey = 'device', // Ключ для разделения линий
title,
description,
height = 400,
ranges = []
}) => {
if (!data || data.length === 0) return <div>Нет данных для отображения</div>;
// Создаем массив уникальных устройств
const devices = [...new Set(data.map(item => item.device))];
// Группируем данные по timestamp для правильного отображения
const timestamps = [...new Set(data.map(item => item.timestamp))].sort();
const chartData = timestamps.map(timestamp => {
const point = { timestamp };
// Для каждого устройства находим значение в этот timestamp
devices.forEach(device => {
const deviceData = data.find(item =>
item.timestamp === timestamp && item.device === device
);
point[`device_${device}`] = deviceData ? deviceData.value : null;
});
return point;
});
return (
<div style={{ width: '100%', height: `${height}px` }}>
<h3>{title}</h3>
{description && <p>{description}</p>}
<ResponsiveContainer width="100%" height="90%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={formatXAxis}
/>
<YAxis />
<Tooltip
formatter={formatTooltip}
labelFormatter={(label) => format(new Date(label), 'yyyy-MM-dd HH:mm:ss')}
/>
<Legend />
{devices.map(device => (
<Line
key={`line-${device}`}
type="monotone"
dataKey={`device_${device}`}
name={`Устройство ${device}`}
stroke={lineColors[device] || lineColors.default}
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}
isAnimationActive={false}
connectNulls={true}
/>
))}
{/* Добавляем диапазоны если они есть */}
{ranges.map((range, idx) => (
<ReferenceLine
key={`range-${idx}`}
y={range.value}
stroke={range.color}
label={range.label}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
};
export default LineChartComponent;

View File

@ -1,27 +0,0 @@
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const SystemStatusChart = ({ data }) => {
// Обрезаем массив, оставляя только последние 20 точек
const trimmedData = data.slice(-20);
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={trimmedData} // Используем обрезанный массив
margin={{
top: 5, right: 30, left: 20, bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="status" stroke="#8884d8" activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
);
};
export default SystemStatusChart;

View File

@ -1,267 +0,0 @@
import React, { useEffect, useState, useRef } from 'react';
import axios from 'axios';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import LineChartComponent from './Components/LineChartComponent';
const MAX_POINTS = 20;
const COLORS = ['#3e95cd', '#8e5ea2', '#3cba9f', '#e8c3b9', '#c45850'];
const TIME_RANGES = [
{ label: '1 минута', value: 60, interval: 3000 },
{ label: '5 минут', value: 300, interval: 15000 },
{ label: '30 минут', value: 1800, interval: 90000 },
{ label: '1 час', value: 3600, interval: 180000 },
{ label: '3 часа', value: 10800, interval: 540000 },
{ label: '6 часов', value: 21600, interval: 1080000 },
{ label: '12 часов', value: 43200, interval: 2160000 },
{ label: '24 часа', value: 86400, interval: 4320000 },
{ label: '2 дня', value: 172800, interval: 8640000 },
{ label: '7 дней', value: 604800, interval: 30240000 },
{ label: '30 дней', value: 2592000, interval: 129600000 },
{ label: '90 дней', value: 7776000, interval: 388800000 },
{ label: '6 месяцев', value: 15552000, interval: 777600000 },
{ label: '9 месяцев', value: 23328000, interval: 1166400000 },
{ label: '1 год', value: 31536000, interval: 1576800000 },
];
const PrometheusChart = ({ metricName }) => {
const [chartData, setChartData] = useState({});
const [selectedRange, setSelectedRange] = useState(TIME_RANGES[0]);
const [startDate, setStartDate] = useState(new Date());
const [endDate, setEndDate] = useState(new Date());
const [useCustomRange, setUseCustomRange] = useState(false);
const [selectedGraphRange, setSelectedGraphRange] = useState(null); // Выбранный диапазон
const [filteredData, setFilteredData] = useState(null); // Отфильтрованные данные
const intervalRef = useRef(null);
// Функция для интерполяции данных
const interpolateData = (data, minPoints = 15) => {
if (data.length >= minPoints) return data;
const interpolatedData = [];
for (let i = 0; i < data.length - 1; i++) {
interpolatedData.push(data[i]);
const currentPoint = data[i];
const nextPoint = data[i + 1];
// Вычисляем разницу во времени между точками
const currentTime = new Date(currentPoint.time).getTime();
const nextTime = new Date(nextPoint.time).getTime();
const timeDiff = nextTime - currentTime;
// Добавляем промежуточные точки
const steps = Math.ceil((minPoints - data.length) / (data.length - 1));
for (let j = 1; j <= steps; j++) {
const interpolatedTime = new Date(currentTime + (timeDiff * j) / (steps + 1)).toLocaleString();
const interpolatedPoint = { time: interpolatedTime };
// Интерполируем значения для каждой метрики
Object.keys(currentPoint).forEach(key => {
if (key !== 'time') {
const currentValue = currentPoint[key];
const nextValue = nextPoint[key];
interpolatedPoint[key] = currentValue + ((nextValue - currentValue) * j) / (steps + 1);
}
});
interpolatedData.push(interpolatedPoint);
}
}
interpolatedData.push(data[data.length - 1]); // Добавляем последнюю точку
return interpolatedData.slice(0, minPoints); // Обрезаем до minPoints
};
const fetchData = async () => {
try {
let start, end;
if (useCustomRange) {
start = Math.floor(startDate.getTime() / 1000);
end = Math.floor(endDate.getTime() / 1000);
} else {
end = Math.floor(Date.now() / 1000);
start = end - selectedRange.value;
}
let step;
const range = end - start;
if (range <= 3600) step = 5;
else if (range <= 21600) step = 30;
else if (range <= 86400) step = 120;
else step = 300;
const response = await axios.get('https://192.168.2.43:3000/metrics', {
params: { metric: metricName, start, end, step },
});
/*
const response = await axios.get(`${process.env.REACT_APP_BACK_URL}/metrics`, {
params: { metric: metricName, start, end, step },
}); */
const result = response.data;
let metrics = Array.isArray(result) ? result : result.data || [];
if (!Array.isArray(metrics)) {
metrics = [];
}
const timePoints = [];
for (let t = start; t <= end; t += step) {
const date = new Date(t * 1000);
const formattedTime = range > 86400
? date.toLocaleString([], { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
timePoints.push(formattedTime);
}
const updatedData = {};
metrics.forEach(m => {
const date = new Date(m.timestamp);
const formattedTime = range > 86400
? date.toLocaleString([], { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const key = `${m.instance}-${m.device || m.scrape_job}`;
if (!updatedData[key]) updatedData[key] = {};
updatedData[key][formattedTime] = m.value;
});
const chartData = {};
Object.keys(updatedData).forEach(key => {
chartData[key] = timePoints.map(time => ({
time,
value: updatedData[key][time] ?? null,
}));
});
setChartData(chartData);
} catch (error) {
console.error('Ошибка при загрузке метрик:', error);
}
};
useEffect(() => {
fetchData();
intervalRef.current = setInterval(() => {
fetchData();
}, selectedRange.interval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [metricName, selectedRange, useCustomRange, startDate, endDate]);
const handleRangeChange = (event) => {
const selectedValue = event.target.value;
const range = TIME_RANGES.find(range => range.value === parseInt(selectedValue, 10));
setSelectedRange(range);
setUseCustomRange(false);
setSelectedGraphRange(null); // Сбрасываем выбранный диапазон
setFilteredData(null); // Сбрасываем отфильтрованные данные
};
const handleCustomRangeChange = () => {
setUseCustomRange(true);
setSelectedGraphRange(null); // Сбрасываем выбранный диапазон
setFilteredData(null); // Сбрасываем отфильтрованные данные
};
useEffect(() => {
if (selectedGraphRange) {
const { startIndex, endIndex } = selectedGraphRange;
const allTimes = Object.values(chartData)
.flat()
.map(point => point.time)
.filter((time, index, self) => self.indexOf(time) === index);
const data = allTimes.map(time => {
const point = { time };
Object.keys(chartData).forEach(key => {
const instanceData = chartData[key].find(p => p.time === time);
point[key] = instanceData ? instanceData.value : null;
});
return point;
});
const filtered = data.slice(startIndex, endIndex + 1);
// Интерполируем данные, если точек меньше 15
const interpolated = interpolateData(filtered, 15);
setFilteredData(interpolated); // Сохраняем интерполированные данные
} else {
setFilteredData(null); // Сбрасываем фильтрацию, если диапазон не выбран
}
}, [selectedGraphRange, chartData]);
if (!Object.keys(chartData).length) return <p>Loading...</p>;
const allTimes = Object.values(chartData)
.flat()
.map(point => point.time)
.filter((time, index, self) => self.indexOf(time) === index);
const data = allTimes.map(time => {
const point = { time };
Object.keys(chartData).forEach(key => {
const instanceData = chartData[key].find(p => p.time === time);
point[key] = instanceData ? instanceData.value : null;
});
return point;
});
return (
<div>
<div>
<label htmlFor="time-range">Выберите временной диапазон: </label>
<select id="time-range" value={selectedRange.value} onChange={handleRangeChange}>
{TIME_RANGES.map(range => (
<option key={range.value} value={range.value}>{range.label}</option>
))}
</select>
</div>
<div>
<label>Или выберите другой диапазон: </label>
<div>
<label>Начальная дата: </label>
<DatePicker
selected={startDate}
onChange={(date) => setStartDate(date)}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="yyyy-MM-dd HH:mm"
/>
</div>
<div>
<label>Конечная дата: </label>
<DatePicker
selected={endDate}
onChange={(date) => setEndDate(date)}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="yyyy-MM-dd HH:mm"
/>
</div>
<button onClick={handleCustomRangeChange}>Использовать кастомный диапазон</button>
</div>
<LineChartComponent
chartData={chartData}
metricName={metricName}
colors={COLORS}
description={metricName}
onRangeSelect={setSelectedGraphRange}
filteredData={filteredData}
/>
</div>
);
};
export default PrometheusChart;

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

@ -0,0 +1,350 @@
import React, { useState, useEffect } from 'react';
import LineChartComponent from './LineChartComponent';
import DateRangeSelector from '../Charts2/Components/DateRangeSelector';
import metricsService from '../Charts2/Components/metricsService';
import { Button, Radio, message, Tag } from 'antd';
import moment from 'moment';
import StatusLogTable from '../Charts2/Components/StatusLogTable';
import { Box, IconButton, Tooltip as MuiTooltip } from '@mui/material';
import { ListAlt } from '@mui/icons-material';
const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
const {
name: metricName,
filters = {},
title = metricName,
description,
context = {},
ranges = []
} = metricInfo || {};
const { device, source_id } = context;
const [chartData, setChartData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [metricMeta, setMetricMeta] = useState({});
const [mode, setMode] = useState('realtime');
const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate());
const [endDate, setEndDate] = useState(moment().toDate());
const [isLiveUpdating, setIsLiveUpdating] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const [statusLogs, setStatusLogs] = useState([]);
const MAX_POINTS = 50;
const TIME_WINDOW_MS = 3600 * 1000;
// Эта функция может больше не понадобиться, так как
// сервис сам генерирует ключи, но оставьте для совместимости
const getSubscriptionKey = () => {
const filterParts = [];
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
if (source_id) filterParts.push(`source_id=${encodeURIComponent(source_id)}`);
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
};
const getStatusFromRanges = (value, ranges) => {
if (!ranges || ranges.length === 0) return 1;
for (const r of ranges) {
if (value >= r.min && value <= r.max) {
return r.status;
}
}
return 1;
};
const formatMetricData = (dataArray) => {
if (!Array.isArray(dataArray)) {
console.error('Expected array in formatMetricData, got:', typeof dataArray);
return [];
}
return dataArray.map(item => {
if (item.timestamp === undefined || item.value === undefined) {
console.warn('Invalid metric item:', item);
return null;
}
return {
...item,
timestamp: Number(item.timestamp),
value: parseFloat(item.value),
status: getStatusFromRanges(parseFloat(item.value), ranges),
name: item.__name__ || metricName,
device: item.device?.trim() || null,
source_id: item.source_id || null,
description: item.description || description
};
}).filter(Boolean)
.sort((a, b) => a.timestamp - b.timestamp);
};
const calculateStep = (startTime, endTime, maxPoints = 10000) => {
const durationSeconds = (endTime.getTime() - startTime.getTime()) / 1000;
return Math.max(Math.ceil(durationSeconds / maxPoints), 1);
};
const downsampleData = (data, maxPoints = MAX_POINTS) => {
if (data.length <= maxPoints) return [...data];
const sortedData = [...data].sort((a, b) => a.timestamp - b.timestamp);
const step = Math.max(1, Math.floor(sortedData.length / maxPoints));
const result = [];
for (let i = 0; i < sortedData.length; i += step) {
if (result.length >= maxPoints) break;
result.push(sortedData[i]);
}
if (result.length > 0) {
const lastOriginalPoint = sortedData[sortedData.length - 1];
if (result[result.length - 1].timestamp !== lastOriginalPoint.timestamp) {
result[result.length - 1] = lastOriginalPoint;
}
}
return result;
};
useEffect(() => {
if (chartData.length > 0) {
const newLogs = chartData.reduce((acc, point, index) => {
if (index === 0 || point.status !== chartData[index - 1].status) {
return [...acc, point];
}
return acc;
}, []);
setStatusLogs(newLogs);
}
}, [chartData]);
const fetchHistoricalData = async (start, end) => {
setIsLoading(true);
setError(null);
try {
const extendedFilters = {
...filters,
...(device && { device: device.toString() }),
...(source_id && { source_id: source_id.toString() })
};
const step = calculateStep(start, end);
// Используем новый метод для исторических данных
const data = await metricsService.fetchMetricsRange(
metricName,
start.getTime(), // Теперь передаем timestamp в миллисекундах
end.getTime(),
step,
extendedFilters
);
const formattedData = formatMetricData(data)
.sort((a, b) => a.timestamp - b.timestamp);
const limitedData = formattedData.length > MAX_POINTS
? downsampleData(formattedData, MAX_POINTS)
: formattedData;
if (limitedData.length > 0) {
setMetricMeta({
type: data[0]?.type,
description: data[0]?.description || description,
instance: data[0]?.instance,
job: data[0]?.job
});
}
setChartData(limitedData);
} catch (err) {
console.error(`Error loading historical data for ${metricName}:`, err);
setError(err.message);
message.error(`Failed to load historical data: ${err.message}`);
} finally {
setIsLoading(false);
}
};
const startRealtimeUpdates = () => {
setIsLiveUpdating(true);
setIsLoading(true);
const end = new Date();
const start = new Date(end.getTime() - TIME_WINDOW_MS);
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
// Изменяем параметры подписки
return metricsService.subscribeToMetric(
metricName, // Теперь передаем просто имя метрики
{ ...filters, device, source_id }, // Фильры отдельным параметром
(update) => { // Колбэк получает объект с данными
console.log('Received WS update:', update);
if (!update || !Array.isArray(update.data)) {
console.error('Invalid update format:', update);
return;
}
setChartData(prev => {
const now = Date.now();
const cutoffTime = now - TIME_WINDOW_MS;
const formattedNew = formatMetricData(update.data)
.filter(point => point.timestamp >= cutoffTime);
const filteredPrev = prev.filter(point =>
point.timestamp >= cutoffTime
);
const merged = [...filteredPrev, ...formattedNew]
.filter((v, i, a) =>
a.findIndex(t => t.timestamp === v.timestamp) === i
)
.sort((a, b) => a.timestamp - b.timestamp);
return merged.length > MAX_POINTS
? merged.slice(-MAX_POINTS)
: merged;
});
},
5000 // Интервал обновления (можно настроить)
);
};
const stopRealtimeUpdates = () => {
setIsLiveUpdating(false);
// Теперь отписываемся по метрике и фильтрам
metricsService.unsubscribeFromMetric(
metricName,
{ ...filters, device, source_id }
);
};
const handleCustomRangeApply = () => {
if (startDate && endDate) {
fetchHistoricalData(startDate, endDate);
}
};
useEffect(() => {
console.log('Metric changed:', { metricName, device, source_id, filters });
let unsubscribe;
const init = async () => {
if (mode === 'realtime') {
unsubscribe = startRealtimeUpdates();
} else {
await fetchHistoricalData(startDate, endDate);
}
};
init();
return () => {
if (unsubscribe) {
unsubscribe(); // Вызываем функцию отписки
}
if (mode === 'realtime') {
stopRealtimeUpdates(); // Дополнительная очистка
}
};
}, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров
const metaInfo = [
metricMeta.instance && `Instance: ${metricMeta.instance}`,
metricMeta.job && `Job: ${metricMeta.job}`,
metricMeta.type && `Type: ${metricMeta.type}`
].filter(Boolean).join(' | ');
return (
<div>
<div style={{ marginBottom: 16 }}>
<Radio.Group
value={mode}
onChange={(e) => setMode(e.target.value)}
buttonStyle="solid"
style={{ marginBottom: 10 }}
>
<Radio.Button value="realtime">Режим реального времени</Radio.Button>
<Radio.Button value="historical">Исторические данные</Radio.Button>
</Radio.Group>
{mode === 'historical' && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onApply={handleCustomRangeApply}
/>
)}
{mode === 'realtime' && isLiveUpdating && (
<Button
type="primary"
danger
onClick={() => setMode('historical')}
style={{ marginTop: 10 }}
>
Остановить обновление
</Button>
)}
</div>
{device && <Tag color="geekblue">Устройство: {device}</Tag>}
{source_id && <Tag color="purple">Модуль: {source_id.split('$')[1]}</Tag>}
<Box position="relative">
<MuiTooltip title={showLogs ? "Скрыть логи" : "Показать логи"}>
<IconButton
onClick={() => setShowLogs(!showLogs)}
sx={{
position: 'absolute',
right: 16,
top: 16,
zIndex: 1000,
bgcolor: 'background.paper',
boxShadow: 1
}}
>
<ListAlt />
</IconButton>
</MuiTooltip>
{isLoading ? (
<div>Загрузка графика...</div>
) : error ? (
<div>Ошибка: {error}</div>
) : chartData.length === 0 ? (
<div>Нет данных для метрики: {metricName}</div>
) : (
<>
<LineChartComponent
data={chartData}
title={title}
description={description}
metaInfo={metaInfo}
height={chartHeight}
additionalFilters={{
device,
source_id
}}
ranges={ranges}
/>
{showLogs && (
<StatusLogTable logs={statusLogs} />
)}
</>
)}
</Box>
</div>
);
};
export default SystemChart;

View File

@ -1,27 +0,0 @@
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const SystemStatusChart = ({ data }) => {
// Обрезаем массив, оставляя только последние 20 точек
const trimmedData = data.slice(-20);
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={trimmedData} // Используем обрезанный массив
margin={{
top: 5, right: 30, left: 20, bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="status" stroke="#8884d8" activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
);
};
export default SystemStatusChart;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import SidebarMenu from "./SidebarMenu"; import { Box, styled } from "@mui/material";
import "../../Style/Dashboard.css";
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils"; import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
import generateTabContent from "../TreeChart/tabContent"; import generateTabContent from "../TreeChart/tabContent";
import CustomTabs from "../UI/MUItabs"; import CustomTabs from "../UI/MUItabs";
@ -8,10 +7,39 @@ import useTabs from "../hooks/useTabs";
import useSidebarResize from "../hooks/useSidebarResize"; import useSidebarResize from "../hooks/useSidebarResize";
import TabContent from "../hooks/TabContent"; import TabContent from "../hooks/TabContent";
import menuData from "../TreeChart/menuData.json"; import menuData from "../TreeChart/menuData.json";
import SidebarMenuWrapper from "./SidebarMenuWrapper";
import MetricTabContent from "./MetricTabContent";
import ProfileMenu from "../UI/ProfileMenu";
import AIAnalysisButton from "../UI/AIAnalysisButton";
const Dashboard = () => { const DashboardContainer = styled(Box)(({ theme }) => ({
display: 'flex',
height: '100vh',
width: '100vw',
overflow: 'hidden',
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
}));
const MainContent = styled(Box)(({ theme }) => ({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2.5),
overflow: 'auto',
backgroundColor: theme.palette.background.default,
}));
const Content = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.custom.modalBackground,
borderRadius: '10px',
maxWidth: '100%',
overflow: 'auto',
color: theme.palette.custom.modalText,
}));
const Dashboard = ({ isDarkMode, setIsDarkMode, user, onLogout }) => {
const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная"); const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная");
const { sidebarWidth, startResizing } = useSidebarResize(250);
const [tabContent, setTabContent] = useState({}); const [tabContent, setTabContent] = useState({});
const [treeData1, setTreeData1] = useState(menuData); const [treeData1, setTreeData1] = useState(menuData);
const [treeData2, setTreeData2] = useState(menuData); const [treeData2, setTreeData2] = useState(menuData);
@ -20,13 +48,11 @@ const Dashboard = () => {
history2: [], history2: [],
}); });
// Генерация контента для вкладок
useEffect(() => { useEffect(() => {
const generatedTabContent = generateTabContent(menuData); const generatedTabContent = generateTabContent(menuData);
setTabContent(generatedTabContent); setTabContent(generatedTabContent);
}, []); }, []);
// Обновление статусов каждые 30 секунд
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
const updatedData1 = JSON.parse(JSON.stringify(treeData1)); const updatedData1 = JSON.parse(JSON.stringify(treeData1));
@ -55,36 +81,123 @@ const Dashboard = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [treeData1, treeData2]); }, [treeData1, treeData2]);
const handleMenuSelect = (item) => {
const tabId = `tab_${item.id}`;
const tabTitle = item.title || 'Новая вкладка';
const tabContent = item.metric
? <MetricTabContent
metricInfo={{
name: item.metric,
filters: item.filters,
title: item.title,
description: item.description,
ranges: item.ranges,
context: {
device: item.filters?.device,
source_id: item.filters?.source_id,
parent: item
}
}}
/>
: <div style={{ padding: 20 }}>Контент для <strong>{item.title}</strong></div>;
const existingTab = tabs.find(tab => tab.id === tabId);
if (!existingTab) {
const newTab = {
id: tabId,
title: tabTitle,
content: tabContent,
type: item.metric ? 'metric' : 'menuItem',
metric: item.metric,
filters: item.filters,
ranges: item.ranges
};
handleOpenTab(newTab);
} else {
setActiveTab(tabId);
}
};
// Вспомогательная функция для получения всех дочерних элементов
const getAllChildren = (node) => {
let children = [];
if (node.items && node.items.length > 0) {
node.items.forEach((child) => {
children.push(child);
children = children.concat(getAllChildren(child));
});
}
return children;
};
return ( return (
<div className="dashboard-container"> <DashboardContainer>
<Box
sx={{
position: 'fixed',
top: 12,
right: 20,
zIndex: (theme) => theme.zIndex.tooltip + 10,
pointerEvents: 'auto',
display: 'flex',
gap: 1,
alignItems: 'center'
}}//ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
>
<AIAnalysisButton />
<ProfileMenu user={user} onLogout={onLogout} />
</Box>
{/* Сайдбар */} {/* Сайдбар */}
<div className="sidebar" style={{ width: sidebarWidth }}> <SidebarMenuWrapper
<SidebarMenu data={treeData1} onOpenTab={handleOpenTab} sidebarWidth={sidebarWidth} startResizing={startResizing} /> isDarkMode={isDarkMode}
<div className="sidebar-resizer" onMouseDown={startResizing} /> setIsDarkMode={setIsDarkMode}
</div> onMenuSelect={handleMenuSelect}
user={user}
/>
{/* Основной контент */} {/* Основной контент */}
<div className="main-content"> <Box sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden'
}}>
{/* Вкладки */} {/* Вкладки */}
<CustomTabs <Box sx={{
tabs={tabs} borderBottom: 1,
activeTab={activeTab} borderColor: 'divider',
onTabClick={setActiveTab} backgroundColor: 'background.default',
onCloseTab={handleCloseTab} zIndex: 1,
/> transform: 'translateY(31px)'
}}>
{/* Контент вкладки */} <CustomTabs
<div className="content"> tabs={tabs}
<TabContent
activeTab={activeTab} activeTab={activeTab}
statusHistories={statusHistories} onTabClick={setActiveTab}
treeData1={treeData1} onCloseTab={handleCloseTab}
tabContent={tabContent}
handleOpenTab={handleOpenTab}
/> />
</div> </Box>
</div>
</div> {/* Остальной контент */}
<MainContent>
{/* Контент вкладки */}
<Content>
<TabContent
activeTab={activeTab}
statusHistories={statusHistories}
treeData1={treeData1}
tabContent={tabContent}
handleOpenTab={handleOpenTab}
tabs={tabs}
/>
</Content>
</MainContent>
</Box>
</DashboardContainer>
); );
}; };

View File

@ -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;

View File

@ -0,0 +1,472 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
TextField, Box, Typography, IconButton, Divider,
CircularProgress, Alert, Collapse, Tooltip, Button,
Card, CardContent, Chip, Dialog, DialogTitle,
DialogContent, DialogActions, Snackbar, Table,
TableBody, TableCell, TableContainer, TableHead,
TableRow, Paper, Badge
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import SearchIcon from '@mui/icons-material/Search';
import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import WarningIcon from '@mui/icons-material/Warning';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import axios from 'axios';
const FormulaItem = React.memo(({ formula, onEdit }) => {
const getMetricStatusColor = (found) => {
return found ? 'success' : 'error';
};
const formatValue = (value) => {
if (value === undefined) return 'N/A';
return value.toFixed(2);
};
return (
<Card sx={{ mb: 2, border: '1px solid', borderColor: 'divider' }}>
<CardContent>
{/* Заголовок с ID и статусом метрик */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box>
<Typography variant="h6" color="primary">
{formula.name}
</Typography>
<Typography variant="body2" color="text.secondary">
ID: {formula.id}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Badge
badgeContent={formula.metadata?.missingMetrics}
color="error"
sx={{ mr: 1 }}
>
<Chip
label={`${formula.metadata?.foundMetrics || 0}/${formula.metadata?.totalMetrics || 0} метрик`}
color={formula.metadata?.missingMetrics === 0 ? "success" : "warning"}
size="small"
/>
</Badge>
<Button
startIcon={<EditIcon />}
onClick={() => onEdit(formula)}
variant="outlined"
size="small"
>
Редактировать
</Button>
</Box>
</Box>
{/* Описание */}
<Typography variant="body2" color="text.secondary" gutterBottom>
{formula.description}
</Typography>
{/* Метрики */}
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
Метрики в формуле:
{formula.metadata?.missingMetrics > 0 && (
<WarningIcon color="warning" fontSize="small" />
)}
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ mb: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Метрика</TableCell>
<TableCell>Описание</TableCell>
<TableCell align="right">Значение</TableCell>
<TableCell>Статус</TableCell>
</TableRow>
</TableHead>
<TableBody>
{formula.enrichedMetrics?.map((metric, index) => (
<TableRow key={index}>
<TableCell>
<Box>
<Typography variant="body2" fontWeight="bold">
{metric.originalName}
</Typography>
<Typography variant="caption" color="text.secondary">
{metric.prometheusName}
</Typography>
</Box>
</TableCell>
<TableCell>
<Typography variant="body2">
{metric.description}
</Typography>
</TableCell>
<TableCell align="right">
<Typography
variant="body2"
fontWeight="bold"
color={metric.found ? 'text.primary' : 'text.disabled'}
>
{formatValue(metric.currentValue)}
</Typography>
</TableCell>
<TableCell>
<Chip
icon={metric.found ? <CheckCircleIcon /> : <WarningIcon />}
label={metric.found ? 'Найдена' : 'Не найдена'}
color={getMetricStatusColor(metric.found)}
size="small"
variant={metric.found ? "filled" : "outlined"}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
{/* Формула */}
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Формула с описанием метрик:
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
backgroundColor: 'primary.light',
color: 'primary.contrastText',
p: 2,
borderRadius: 1,
fontSize: '0.9rem',
fontFamily: 'monospace'
}}>
{formula.humanReadableFormula}
</Typography>
</Box>
</Box>
{/* Веса */}
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Веса (warr):
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{formula.values?.warr?.map((weight, index) => (
<Chip
key={index}
label={`warr[${index + 1}]: ${weight}`}
size="small"
variant="outlined"
/>
))}
</Box>
</Box>
</CardContent>
</Card>
);
});
const EditFormulaDialog = ({ open, formula, onClose, onSave }) => {
const [editedFormula, setEditedFormula] = useState('');
useEffect(() => {
if (formula) {
setEditedFormula(formula.formula || '');
}
}, [formula]);
const handleSave = () => {
if (formula && editedFormula.trim()) {
onSave(formula.id, editedFormula.trim());
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
Редактирование формулы: {formula?.name}
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" gutterBottom>
{formula?.description}
</Typography>
<Box sx={{ mt: 2, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Доступные переменные:
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label="statusarr[]" size="small" />
<Chip label="warr[]" size="small" />
</Box>
</Box>
<TextField
label="Формула"
value={editedFormula}
onChange={(e) => setEditedFormula(e.target.value)}
multiline
rows={6}
fullWidth
variant="outlined"
placeholder="Введите формулу..."
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
<Button
onClick={handleSave}
variant="contained"
startIcon={<SaveIcon />}
disabled={!editedFormula.trim()}
>
Сохранить
</Button>
</DialogActions>
</Dialog>
);
};
const FormulaEditor = () => {
const [formulas, setFormulas] = useState([]);
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [refreshing, setRefreshing] = useState(false);
const [editingFormula, setEditingFormula] = useState(null);
const [saveLoading, setSaveLoading] = useState(false);
const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' });
const showSnackbar = (message, severity = 'success') => {
setSnackbar({ open: true, message, severity });
};
const loadFormulas = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await axios.get('http://192.168.2.39:3000/api/enriched-formulas');
if (Array.isArray(response.data)) {
setFormulas(response.data);
showSnackbar(`Загружено ${response.data.length} формул`);
} else {
throw new Error('Некорректный формат данных');
}
} catch (err) {
console.error('Ошибка при загрузке формул:', err);
const errorMessage = axios.isAxiosError(err)
? `Ошибка сервера: ${err.response?.status} - ${err.response?.data?.message || err.message}`
: `Ошибка загрузки: ${err.message}`;
setError(errorMessage);
showSnackbar(errorMessage, 'error');
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
const handleEditFormula = (formula) => {
setEditingFormula(formula);
};
const handleSaveFormula = async (formulaId, newFormula) => {
try {
setSaveLoading(true);
await axios.post(`http://192.168.2.39:3000/api/formula/${formulaId}/update`, {
formula: newFormula
});
setFormulas(prev => prev.map(formula =>
formula.id === formulaId
? { ...formula, formula: newFormula }
: formula
));
setEditingFormula(null);
showSnackbar('Формула успешно обновлена!');
} catch (err) {
console.error('Ошибка при сохранении формулы:', err);
showSnackbar('Ошибка при сохранении формулы', 'error');
} finally {
setSaveLoading(false);
}
};
const refreshData = useCallback(() => {
setRefreshing(true);
loadFormulas();
}, [loadFormulas]);
const filteredFormulas = formulas.filter(formula =>
formula.id.toLowerCase().includes(filter.toLowerCase()) ||
formula.name.toLowerCase().includes(filter.toLowerCase()) ||
formula.description.toLowerCase().includes(filter.toLowerCase()) ||
formula.formula.toLowerCase().includes(filter.toLowerCase())
);
const totalMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.totalMetrics || 0), 0);
const foundMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.foundMetrics || 0), 0);
const missingMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.missingMetrics || 0), 0);
useEffect(() => {
loadFormulas();
}, [loadFormulas]);
return (
<Box sx={{ position: 'relative', p: 2 }}>
{/* Загрузка */}
{(loading || refreshing) && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
)}
{/* Ошибки */}
<Collapse in={!!error}>
<Alert
severity="error"
sx={{ mb: 2 }}
action={
<Button color="inherit" size="small" onClick={refreshData}>
Повторить
</Button>
}
>
{error}
</Alert>
</Collapse>
{/* Панель управления */}
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" color="primary" fontWeight="bold">
Редактор формул с метриками
</Typography>
<Button
onClick={refreshData}
variant="contained"
startIcon={<RefreshIcon />}
disabled={refreshing}
>
Обновить
</Button>
</Box>
{/* Статистика */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<Chip
label={`Формулы: ${formulas.length}`}
color="primary"
variant="outlined"
/>
<Chip
label={`Метрики: ${foundMetrics}/${totalMetrics}`}
color={missingMetrics === 0 ? "success" : "warning"}
variant="outlined"
/>
{missingMetrics > 0 && (
<Chip
label={`Отсутствуют: ${missingMetrics}`}
color="error"
variant="outlined"
/>
)}
</Box>
{/* Поиск */}
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1, mb: 2 }}>
<TextField
label="Поиск по формулам"
fullWidth
value={filter}
onChange={(e) => setFilter(e.target.value)}
variant="outlined"
placeholder="Введите ID, название или описание..."
size="small"
/>
<SearchIcon sx={{ color: 'action.active', mb: 0.5 }} />
</Box>
</Box>
<Divider sx={{ mb: 3 }} />
{/* Список формул */}
<Box sx={{ maxHeight: '70vh', overflowY: 'auto', pr: 1 }}>
{filteredFormulas.map((formula) => (
<FormulaItem
key={formula.id}
formula={formula}
onEdit={handleEditFormula}
/>
))}
{filteredFormulas.length === 0 && !loading && (
<Typography
color="text.secondary"
textAlign="center"
py={3}
variant="h6"
>
{filter ? 'Формулы не найдены' : 'Нет загруженных формул'}
</Typography>
)}
</Box>
{/* Статус бар */}
<Box sx={{
position: 'sticky',
bottom: 0,
backgroundColor: 'background.paper',
p: 1,
borderTop: 1,
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Typography variant="body2" color="text.secondary">
Всего формул: {formulas.length} Отфильтровано: {filteredFormulas.length}
</Typography>
<Typography variant="body2" color={missingMetrics === 0 ? "success.main" : "warning.main"}>
Метрики: {foundMetrics}/{totalMetrics} найдено
</Typography>
</Box>
{/* Диалог редактирования */}
<EditFormulaDialog
open={!!editingFormula}
formula={editingFormula}
onClose={() => setEditingFormula(null)}
onSave={handleSaveFormula}
/>
{/* Уведомления */}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity}
>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
};
export default React.memo(FormulaEditor);

View File

@ -0,0 +1,206 @@
// components/SettingsComponents/Licensing.jsx
import React, { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
List,
ListItem,
ListItemIcon,
ListItemText,
Divider,
Chip,
Button,
TextField,
InputAdornment,
IconButton,
Alert,
Stack,
Grid
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
Cancel as CancelIcon,
VpnKey as VpnKeyIcon,
Refresh as RefreshIcon,
Api as ApiIcon,
Devices as DevicesIcon,
Storage as StorageIcon,
Security as SecurityIcon,
ContentCopy as ContentCopyIcon
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
const StyledPaper = styled(Paper)(({ theme }) => ({
padding: theme.spacing(3),
marginBottom: theme.spacing(3),
backgroundColor: theme.palette.background.default,
}));
const LicenseKeyBox = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
fontFamily: 'monospace',
fontSize: '1.1rem',
letterSpacing: '0.5px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: theme.spacing(2),
}));
const Licensing = ({ onSave }) => {
const [hasChanges, setHasChanges] = useState(false);
const [licenseKey, setLicenseKey] = useState('ABCDE-FGHIJ-KLMNO-PQRST-UVWXY');
const [showCopySuccess, setShowCopySuccess] = useState(false);
// Текущий состав лицензии (заглушка)
const licenseFeatures = [
{ name: 'Модуль API', active: true, icon: ApiIcon, description: 'Полный доступ к API' },
{ name: 'Подключение устройств', active: true, icon: DevicesIcon, value: '', description: '' },
{ name: 'Модуль контроля параметров устойчивого функционирования компонентов, доверенного ПАК', active: true, icon: StorageIcon, value: '', description: '' },
//{ name: 'Расширенная безопасность', active: false, icon: SecurityIcon, description: '' },
];
// Уведомляем родительский компонент об изменениях
useEffect(() => {
if (onSave) {
onSave({
hasChanges,
saveChanges: handleSave
});
}
}, [hasChanges]);
const handleSave = async () => {
// Здесь будет логика сохранения
console.log('Сохранение лицензионных настроек');
setHasChanges(false);
return true;
};
const handleRefreshLicense = () => {
// Заглушка для обновления лицензии
const newKey = generateLicenseKey();
setLicenseKey(newKey);
setHasChanges(true);
};
const generateLicenseKey = () => {
// Заглушка для генерации ключа
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const segments = [];
for (let i = 0; i < 5; i++) {
let segment = '';
for (let j = 0; j < 5; j++) {
segment += chars[Math.floor(Math.random() * chars.length)];
}
segments.push(segment);
}
return segments.join('-');
};
const handleCopyKey = () => {
navigator.clipboard.writeText(licenseKey);
setShowCopySuccess(true);
setTimeout(() => setShowCopySuccess(false), 2000);
};
return (
<Box>
{/* Текущий состав лицензии */}
<StyledPaper elevation={0}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VpnKeyIcon color="primary" />
Текущий состав лицензии
</Typography>
<List>
{licenseFeatures.map((feature, index) => {
const IconComponent = feature.icon;
return (
<React.Fragment key={feature.name}>
<ListItem>
<ListItemIcon>
<IconComponent color={feature.active ? "primary" : "disabled"} />
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body1">{feature.name}</Typography>
{feature.value && (
<Chip
label={feature.value}
size="small"
color={feature.active ? "success" : "default"}
variant="outlined"
/>
)}
</Box>
}
secondary={feature.description}
/>
<ListItemIcon>
{feature.active ? (
<CheckCircleIcon color="success" />
) : (
<CancelIcon color="error" />
)}
</ListItemIcon>
</ListItem>
{index < licenseFeatures.length - 1 && <Divider variant="inset" component="li" />}
</React.Fragment>
);
})}
</List>
</StyledPaper>
{/* Идентификатор лицензии */}
<StyledPaper elevation={0}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Идентификатор лицензии
</Typography>
<Typography variant="body2" color="textSecondary" paragraph>
Этот ключ используется для активации и обновления лицензии
</Typography>
</Grid>
<Grid item xs={12}>
<LicenseKeyBox>
<Typography variant="body1" sx={{ fontFamily: 'monospace' }}>
{licenseKey}
</Typography>
<Box>
<IconButton onClick={handleCopyKey} size="small" title="Копировать">
<ContentCopyIcon />
</IconButton>
</Box>
</LicenseKeyBox>
{showCopySuccess && (
<Alert severity="success" sx={{ mt: 1 }}>Ключ скопирован в буфер обмена</Alert>
)}
</Grid>
<Grid item xs={12}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button
variant="contained"
color="primary"
//onClick={handleRefreshLicense}
//startIcon={<RefreshIcon />}
>
Обновить лицензию
</Button>
</Box>
</Grid>
</Grid>
</StyledPaper>
</Box>
);
};
export default Licensing;

View File

@ -0,0 +1,303 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Chip,
Collapse,
CircularProgress
} from '@mui/material';
import {
Edit as EditIcon,
Delete as DeleteIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon
} from '@mui/icons-material';
import axios from 'axios';
const MenuItemComponent = ({ item, level = 0, onEdit, onDelete }) => {
const [expanded, setExpanded] = useState(false);
const hasChildren = item.items && item.items.length > 0;
const handleToggle = () => {
if (hasChildren) {
setExpanded(!expanded);
}
};
return (
<>
<ListItem
sx={{
pl: level * 4,
borderBottom: '1px solid',
borderColor: 'divider'
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body1">{item.title}</Typography>
{item.isDynamic && (
<Chip
label="Динамический"
size="small"
color="info"
variant="outlined"
/>
)}
</Box>
}
secondary={item.id}
/>
<ListItemSecondaryAction>
{/* */}
<>
<IconButton
edge="end"
aria-label="edit"
onClick={() => onEdit(item)}
sx={{ mr: 1 }}
>
<EditIcon />
</IconButton>
<IconButton
edge="end"
aria-label="delete"
onClick={() => onDelete(item)}
color="error"
>
<DeleteIcon />
</IconButton>
</>
{hasChildren && (
<IconButton
edge="end"
aria-label="expand"
onClick={handleToggle}
sx={{ ml: 1 }}
>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
)}
</ListItemSecondaryAction>
</ListItem>
{hasChildren && (
<Collapse in={expanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.items.map((child) => (
<MenuItemComponent
key={child.id}
item={child}
level={level + 1}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</List>
</Collapse>
)}
</>
);
};
const EditDialog = ({ open, item, onClose, onSave }) => {
const [title, setTitle] = useState(item?.title || '');
useEffect(() => {
setTitle(item?.title || '');
}, [item]);
const handleSave = () => {
onSave(item.id, { title });
onClose();
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Редактировать элемент меню</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Название"
fullWidth
variant="outlined"
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
<Button onClick={handleSave} variant="contained">
Сохранить
</Button>
</DialogActions>
</Dialog>
);
};
const MenuEditor = ({ onSave }) => {
const [menuData, setMenuData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState(null);
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
fetchMenuData();
}, []);
const fetchMenuData = async () => {
try {
setLoading(true);
const response = await axios.get('/api/menu/full');
setMenuData(response.data);
setError(null);
} catch (err) {
setError('Ошибка загрузки меню');
console.error('Error fetching menu:', err);
} finally {
setLoading(false);
}
};
const handleEdit = (item) => {
setSelectedItem(item);
setEditDialogOpen(true);
};
const handleDelete = (item) => {
setSelectedItem(item);
setDeleteDialogOpen(true);
};
const handleEditSave = async (id, updates) => {
try {
await axios.put(`/api/menu/${id}`, updates);
setHasChanges(true);
fetchMenuData();
} catch (err) {
console.error('Error updating menu item:', err);
alert('Ошибка при сохранении изменений');
}
};
const handleDeleteConfirm = async () => {
try {
await axios.delete(`/api/menu/items/${selectedItem.id}`);
setHasChanges(true);
setDeleteDialogOpen(false);
fetchMenuData();
} catch (err) {
console.error('Error deleting menu item:', err);
alert('Ошибка при удалении элемента');
}
};
const handleSave = async () => {
if (hasChanges) {
onSave({
hasChanges: true, saveChanges: async () => {
// Принудительно обновляем кэш
try {
await axios.post('/api/menu/invalidate-cache');
return true;
} catch (err) {
console.error('Error invalidating cache:', err);
return false;
}
}
});
setHasChanges(false);
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 3 }}>
<Typography color="error">{error}</Typography>
</Box>
);
}
return (
<Box>
<Typography variant="h6" gutterBottom>
Редактирование меню
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Вы можете редактировать названия и удалять элементы меню. Динамические элементы (помечены синим) нельзя редактировать.
</Typography>
<List>
{menuData.items.map((item) => (
<MenuItemComponent
key={item.id}
item={item}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</List>
<EditDialog
open={editDialogOpen}
item={selectedItem}
onClose={() => setEditDialogOpen(false)}
onSave={handleEditSave}
/>
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Подтверждение удаления</DialogTitle>
<DialogContent>
<Typography>
Вы уверены, что хотите удалить элемент "{selectedItem?.title}"?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Отмена</Button>
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
Удалить
</Button>
</DialogActions>
</Dialog>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!hasChanges}
>
Применить изменения
</Button>
</Box>
</Box>
);
};
export default MenuEditor;

View File

@ -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);

View File

@ -0,0 +1,227 @@
import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
IconButton,
Typography,
Box,
CircularProgress,
Alert,
Snackbar,
Divider,
Tooltip
} from '@mui/material';
import { Add, Delete } from '@mui/icons-material';
import axios from 'axios';
const UserManagement = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [openDialog, setOpenDialog] = useState(false);
const [newUser, setNewUser] = useState({
login: '',
password: '',
role: 'user'
});
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
setLoading(true);
try {
const response = await axios.get('/api/auth/users', {
withCredentials: true
});
setUsers(response.data);
setError('');
} catch (err) {
setError('Не удалось загрузить пользователей');
console.error(err);
} finally {
setLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setNewUser(prev => ({ ...prev, [name]: value }));
};
const handleRoleChange = (e) => {
setNewUser(prev => ({ ...prev, role: e.target.value }));
};
const handleSubmit = async () => {
try {
await axios.post('/api/auth/users', newUser, {
withCredentials: true
});
setOpenDialog(false);
setNewUser({
login: '',
password: '',
role: 'user'
});
setSuccess('Пользователь успешно создан');
fetchUsers();
} catch (err) {
setError(err.response?.data?.message || 'Не удалось создать пользователя');
console.error(err);
}
};
const handleDelete = async (id) => {
try {
await axios.delete(`/api/auth/users/${id}`, {
withCredentials: true
});
setSuccess('Пользователь успешно удален');
fetchUsers();
} catch (err) {
setError(err.response?.data?.message || 'Не удалось удалить пользователя');
console.error(err);
}
};
return (
<Box sx={{ position: 'relative' }}>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>
{success}
</Alert>
)}
<Typography variant="h6" gutterBottom>
Управление пользователями
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setOpenDialog(true)}
>
Добавить пользователя
</Button>
</Box>
<Divider sx={{ mb: 2 }} />
{!loading && (
<TableContainer component={Paper} sx={{ maxHeight: '60vh', overflow: 'auto' }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Логин</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Роль</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id} hover>
<TableCell>{user.id}</TableCell>
<TableCell>{user.login}</TableCell>
<TableCell>{user.role === 'admin' ? 'Администратор' : 'Пользователь'}</TableCell>
<TableCell>
<Tooltip title={user.role === 'admin' ? 'Нельзя удалить администратора' : 'Удалить пользователя'}>
<IconButton
onClick={() => handleDelete(user.id)}
color="error"
disabled={user.role === 'admin'}
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)}>
<DialogTitle>Добавить нового пользователя</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2, minWidth: 400 }}>
<TextField
label="Логин"
name="login"
value={newUser.login}
onChange={handleInputChange}
fullWidth
variant="standard"
required
/>
<TextField
label="Пароль"
name="password"
type="password"
value={newUser.password}
onChange={handleInputChange}
fullWidth
variant="standard"
required
/>
<FormControl fullWidth variant="standard">
<InputLabel>Роль</InputLabel>
<Select
value={newUser.role}
onChange={handleRoleChange}
label="Роль"
>
<MenuItem value="user">Пользователь</MenuItem>
<MenuItem value="admin">Администратор</MenuItem>
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>Отмена</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={!newUser.login || !newUser.password}
>
Создать
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default UserManagement;

View File

@ -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 }));
}
};

View File

@ -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;

View File

@ -1,48 +1,537 @@
import React from "react"; import { useState, useEffect } from "react";
import { Drawer, List } from "@mui/material"; import {
import MenuItem from "./SidebarMenuComponents/MenuItem"; Drawer,
List,
styled,
IconButton,
Tooltip,
Box,
alpha
} from "@mui/material";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter"; import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
import useSidebarResize from "../hooks/useSidebarResize";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import LogoFull from "../../assets/images/logo.svg?react";
import LogoSmall from "../../assets/images/system_monitor_icon.svg?react";
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => { import {
const handleSelectItem = (id, title, children) => { DndContext,
onOpenTab(id, title, children); closestCenter,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
MeasuringStrategy
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem";
const SidebarMenu = ({
data,
isDarkMode,
setIsDarkMode,
onSelectItem,
forceRefreshMenu,
user,
}) => {
const [collapsed, setCollapsed] = useState(false);
const { sidebarWidth, startResizing } = useSidebarResize(320); // Увеличил минимальную ширину
const [menuItems, setMenuItems] = useState(data.items || []);
const [activeItem, setActiveItem] = useState(null);
const [hoveredItem, setHoveredItem] = useState(null);
const [dropIndicator, setDropIndicator] = useState({ show: false, position: null, targetId: null });
const sensors = useSensors(useSensor(PointerSensor, {
activationConstraint: {
distance: 4,
},
}));
useEffect(() => {
const cached = localStorage.getItem("menuTree");
if (cached) {
try {
setMenuItems(JSON.parse(cached));
} catch {
setMenuItems(data.items || []);
}
} else {
setMenuItems(data.items || []);
}
}, [data]);
const handleToggleCollapse = () => {
setCollapsed(!collapsed);
setHoveredItem(null);
}; };
return ( // Функции для работы с деревом (остаются без изменений)
<Drawer const findItemInTree = (items, id) => {
variant="permanent" for (const item of items) {
sx={{ if (item.id === id) return item;
width: sidebarWidth, if (item.items) {
flexShrink: 0, const found = findItemInTree(item.items, id);
"& .MuiDrawer-paper": { if (found) return found;
width: sidebarWidth, }
boxSizing: "border-box", }
display: "flex", return null;
flexDirection: "column", };
},
}}
>
<List>
<h2 style={{ padding: "16px", fontWeight: "bold" }}>Меню</h2>
<MenuItem item={data} onSelectItem={handleSelectItem} />
</List>
{/* Ресайзер */} const removeItemFromTree = (items, id) => {
<div return items.filter(item => {
onMouseDown={startResizing} if (item.id === id) return false;
style={{ if (item.items) {
width: "5px", item.items = removeItemFromTree(item.items, id);
cursor: "ew-resize", }
backgroundColor: "#ccc", return true;
height: "100%", });
position: "absolute", };
const addItemToFolder = (items, folderId, newItem) => {
return items.map(item => {
if (item.id === folderId) {
return {
...item,
items: [...(item.items || []), newItem]
};
}
if (item.items) {
return {
...item,
items: addItemToFolder(item.items, folderId, newItem)
};
}
return item;
});
};
const findParent = (items, childId, parent = null) => {
for (const item of items) {
if (item.id === childId) return parent;
if (item.items) {
const found = findParent(item.items, childId, item);
if (found) return found;
}
}
return null;
};
const addItemAtSameLevel = (items, parentId, newItem, afterId = null) => {
return items.map(item => {
if (item.id === parentId) {
const children = item.items || [];
const insertIndex = afterId ? children.findIndex(i => i.id === afterId) + 1 : children.length;
const newChildren = [
...children.slice(0, insertIndex),
newItem,
...children.slice(insertIndex)
];
return { ...item, items: newChildren };
}
if (item.items) {
return { ...item, items: addItemAtSameLevel(item.items, parentId, newItem, afterId) };
}
return item;
});
};
const handleDragStart = (event) => {
const { active } = event;
const item = findItemInTree(menuItems, active.id);
setActiveItem(item);
setDropIndicator({ show: false, position: null, targetId: null });
};
const handleDragEnd = (event) => {
const { active, over } = event;
setActiveItem(null);
setHoveredItem(null);
setDropIndicator({ show: false, position: null, targetId: null });
if (!over) return;
if (active.id === over.id) return;
const draggedItem = findItemInTree(menuItems, active.id);
if (!draggedItem) return;
const overItem = findItemInTree(menuItems, over.id);
// Проверяем, не пытаемся ли переместить элемент в его же потомка
if (isDescendant(draggedItem, overItem)) {
return;
}
let newTree;
if (dropIndicator.position === 'inside' && overItem && Array.isArray(overItem.items)) {
// Вставка внутрь папки
newTree = removeItemFromTree([...menuItems], active.id);
newTree = addItemToFolder(newTree, over.id, draggedItem);
} else {
// Вставка на том же уровне
const overParent = findParent(menuItems, over.id);
if (!overParent) return;
newTree = removeItemFromTree([...menuItems], active.id);
// Определяем позицию для вставки
let insertAfterId = null;
if (dropIndicator.position === 'below') {
insertAfterId = over.id;
} else if (dropIndicator.position === 'above') {
const siblings = overParent.items || [];
const overIndex = siblings.findIndex(item => item.id === over.id);
if (overIndex > 0) {
insertAfterId = siblings[overIndex - 1].id;
}
}
newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, insertAfterId);
}
setMenuItems(newTree);
localStorage.setItem("menuTree", JSON.stringify(newTree));
};
const handleDragOver = (event) => {
const { active, over } = event;
if (!over) {
setDropIndicator({ show: false, position: null, targetId: null });
return;
}
const overItem = findItemInTree(menuItems, over.id);
const activeItem = findItemInTree(menuItems, active.id);
if (!overItem || !activeItem || active.id === over.id) {
setDropIndicator({ show: false, position: null, targetId: null });
return;
}
// Проверяем, можно ли перемещать элемент
if (isDescendant(activeItem, overItem)) {
setDropIndicator({ show: false, position: null, targetId: null });
return;
}
const overRect = over.rect.current;
if (!overRect) return;
const relativeY = event.delta.y;
const isOverFolder = overItem && Array.isArray(overItem.items);
const isTopHalf = relativeY < overRect.height * 0.4;
const isBottomHalf = relativeY > overRect.height * 0.6;
if (isOverFolder && !isTopHalf && !isBottomHalf) {
// Показываем индикатор для вставки в папку
setDropIndicator({
show: true,
position: 'inside',
targetId: over.id
});
setHoveredItem(over.id);
} else if (isTopHalf) {
// Показываем индикатор для вставки выше
setDropIndicator({
show: true,
position: 'above',
targetId: over.id
});
setHoveredItem(null);
} else if (isBottomHalf) {
// Показываем индикатор для вставки ниже
setDropIndicator({
show: true,
position: 'below',
targetId: over.id
});
setHoveredItem(null);
} else {
setDropIndicator({ show: false, position: null, targetId: null });
setHoveredItem(null);
}
};
const isDescendant = (parent, child) => {
if (!parent || !child || !parent.items) return false;
const checkChildren = (items, targetId) => {
for (const item of items) {
if (item.id === targetId) return true;
if (item.items && checkChildren(item.items, targetId)) return true;
}
return false;
};
return checkChildren(parent.items, child.id);
};
const SidebarResizer = styled("div")(({ theme }) => ({
width: "3px",
cursor: "col-resize",
backgroundColor: alpha(theme.palette.primary.main, 0.3),
"&:hover": {
backgroundColor: theme.palette.primary.main,
},
height: "100%",
position: "absolute",
top: 0,
right: 0,
zIndex: 1000,
transition: "background-color 0.2s ease",
}));
const DropIndicator = ({ position, targetId }) => {
if (!targetId) return null;
return (
<Box
sx={{
position: 'absolute',
left: 0,
right: 0, right: 0,
top: 0, height: '2px',
backgroundColor: 'primary.main',
zIndex: 1001,
...(position === 'above' && { top: 0 }),
...(position === 'below' && { bottom: 0 }),
'&::before': {
content: '""',
position: 'absolute',
top: '-3px',
left: '10%',
width: '80%',
height: '8px',
backgroundColor: 'primary.main',
borderRadius: '2px',
}
}} }}
/> />
);
};
<SidebarFooter sidebarWidth={sidebarWidth} />
</Drawer> return (
<Box
sx={{
position: "relative",
width: collapsed ? 72 : sidebarWidth,
transition: "width 0.2s ease",
height: "100vh",
}}
>
<Drawer
variant="permanent"
sx={{
width: collapsed ? 72 : sidebarWidth,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: collapsed ? 72 : sidebarWidth,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
backgroundColor: "background.paper",
color: "text.primary",
transition: "width 0.2s ease, background-color 0.2s ease",
overflowX: "hidden",
borderRight: "1px solid",
borderColor: "divider",
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.08)",
},
}}
>
{/* Заголовок с логотипом */}
<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>
); );
}; };

View File

@ -1,55 +1,121 @@
import React from "react"; // // MenuItem.jsx
import { Drawer, List, ListItem, ListItemIcon, ListItemText, Collapse } from "@mui/material"; // import React, { useState } from "react";
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material"; // import {
// ListItem,
// ListItemIcon,
// ListItemText,
// Collapse,
// List,
// styled,
// Menu,
// MenuItem as MuiMenuItem
// } from "@mui/material";
// import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
// import StatusIndicator from "./StatusIndicator";
// Функция для сбора всех потомков // const StyledListItem = styled(ListItem)(({ theme, level }) => ({
const getAllChildren = (node) => { // cursor: "pointer",
let children = []; // paddingLeft: theme.spacing(2 + level * 2),
if (node.items && node.items.length > 0) { // position: 'relative',
node.items.forEach((child) => { // '&:hover': {
children.push(child); // Добавляем текущий элемент // backgroundColor: theme.palette.action.hover,
children = children.concat(getAllChildren(child)); // Рекурсивно добавляем потомков // },
}); // '&.Mui-selected': {
} // backgroundColor: theme.palette.custom.sidebarHover,
return children; // },
}; // }));
const MenuItem = ({ item, onSelectItem }) => { // const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
const [isOpen, setIsOpen] = React.useState(false); // const [isOpen, setIsOpen] = useState(false);
const hasChildren = Array.isArray(item.items) && item.items.length > 0; // const [contextMenu, setContextMenu] = useState(null);
// const hasChildren = Array.isArray(item.items) && item.items.length > 0;
const handleToggle = () => { // const handleContextMenu = (e) => {
setIsOpen(!isOpen); // e.preventDefault();
}; // setContextMenu(
// contextMenu === null
// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
// : null
// );
// };
const handleOpenTab = (e) => { // const handleCloseContextMenu = () => {
e.stopPropagation(); // Останавливаем всплытие события // setContextMenu(null);
const allChildren = getAllChildren(item); // Собираем всех потомков // };
onSelectItem(item.id, item.title, allChildren); // Передаем данные в родительский компонент
};
return ( // const handleToggle = (e) => {
<> // e.stopPropagation();
<ListItem component="div" onClick={handleToggle}> // setIsOpen(!isOpen);
<ListItemIcon> // };
<div onClick={handleOpenTab} style={{ cursor: "pointer" }}>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</div>
</ListItemIcon>
<ListItemText primary={item.title} />
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
</ListItem>
{hasChildren && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.items.map((child, index) => (
<MenuItem key={index} item={child} onSelectItem={onSelectItem} />
))}
</List>
</Collapse>
)}
</>
);
};
export default MenuItem; // const handleClick = () => {
// if (onSelectItem) {
// onSelectItem(item);
// }
// };
// return (
// <>
// <StyledListItem
// component="div"
// onClick={hasChildren ? handleToggle : handleClick}
// onContextMenu={handleContextMenu}
// level={level}
// sx={{
// pl: collapsed ? 2 : 2 + level * 2,
// justifyContent: collapsed ? 'center' : 'flex-start',
// }}
// >
// {!collapsed && <StatusIndicator status={item.status} />}
// <ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
// {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
// </ListItemIcon>
// {!collapsed && (
// <>
// <ListItemText
// primary={item.title}
// primaryTypographyProps={{
// color: 'custom.sidebarText'
// }}
// />
// {hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
// </>
// )}
// </StyledListItem>
// <Menu
// open={contextMenu !== null}
// onClose={handleCloseContextMenu}
// anchorReference="anchorPosition"
// anchorPosition={
// contextMenu !== null
// ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
// : undefined
// }
// >
// </Menu>
// {hasChildren && !collapsed && (
// <Collapse in={isOpen} timeout="auto" unmountOnExit>
// <List component="div" disablePadding>
// {item.items.map((child, index) => (
// <MenuItem
// key={child.id ?? index}
// item={child}
// onSelectItem={onSelectItem}
// onEdit={onEdit}
// level={level + 1}
// collapsed={collapsed}
// />
// ))}
// </List>
// </Collapse>
// )}
// </>
// );
// };
// export default MenuItem;

View File

@ -1,16 +1,153 @@
import React from "react"; import React, { useState } from "react";
import { List, ListItem, ListItemText } from "@mui/material"; import { Brightness4, Brightness7, Settings, Help } from "@mui/icons-material";
import {
IconButton,
Tooltip,
Box,
Button,
alpha
} from "@mui/material";
import {
List,
ListItem,
ListItemText,
styled,
Switch,
} from "@mui/material";
import SettingsModal from "../SettingsModal";
import { RoleBasedRender } from "../../UI/RoleBasedRender";
const FooterList = styled(List)(({ theme }) => ({
backgroundColor: 'background.paper',
padding: theme.spacing(1, 0),
borderTop: `1px solid ${theme.palette.divider}`,
marginTop: 'auto'
}));
const FooterListItem = styled(ListItem)(({ theme }) => ({
'&:hover': {
backgroundColor: alpha(theme.palette.action.hover, 0.4),
},
padding: theme.spacing(1, 2),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderRadius: '8px',
margin: '0 8px 4px',
transition: 'all 0.2s ease',
}));
const SidebarFooter = ({
collapsed,
isDarkMode,
setIsDarkMode,
forceRefreshMenu,
user
}) => {
const [settingsOpen, setSettingsOpen] = useState(false);
const handleSettingsOpen = () => {
setSettingsOpen(true);
};
const handleSettingsClose = () => {
setSettingsOpen(false);
};
const SidebarFooter = ({ sidebarWidth }) => {
return ( return (
<List sx={{ marginTop: "auto", backgroundColor: "#ffffff", padding: "10px 0" }}> <>
<ListItem button={true}> <FooterList>
<ListItemText primary="Помощь" sx={{ color: "#000000" }} /> {!collapsed ? (
</ListItem> <>
<ListItem button={true}> <FooterListItem>
<ListItemText primary="Настройка" sx={{ color: "#000000" }} /> <Button
</ListItem> onClick={handleSettingsOpen}
</List> startIcon={<Settings />}
sx={{
color: 'text.secondary',
textTransform: 'none',
fontSize: '0.875rem',
fontWeight: 500,
'&:hover': {
color: 'text.primary',
backgroundColor: 'transparent'
}
}}
>
Настройки
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title="Переключить тему">
<IconButton
size="small"
onClick={() => setIsDarkMode(!isDarkMode)}
sx={{
color: 'text.secondary',
'&:hover': {
color: 'text.primary',
backgroundColor: alpha('#000000', 0.1)
}
}}
>
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
</IconButton>
</Tooltip>
<Switch
checked={isDarkMode}
onChange={() => setIsDarkMode(!isDarkMode)}
size="small"
color="primary"
/>
</Box>
</FooterListItem>
<FooterListItem button>
<Button
startIcon={<Help />}
sx={{
color: 'text.secondary',
textTransform: 'none',
fontSize: '0.875rem',
fontWeight: 500,
'&:hover': {
color: 'text.primary',
backgroundColor: 'transparent'
}
}}
>
Помощь
</Button>
</FooterListItem>
</>
) : (
<FooterListItem sx={{ justifyContent: 'center' }}>
<Tooltip title="Настройки" placement="right">
<IconButton
onClick={handleSettingsOpen}
sx={{
color: 'text.secondary',
'&:hover': {
color: 'text.primary',
backgroundColor: alpha('#000000', 0.1)
}
}}
>
<Settings />
</IconButton>
</Tooltip>
</FooterListItem>
)}
</FooterList>
<RoleBasedRender user={user} allowedRoles={['admin']}>
<SettingsModal
open={settingsOpen}
onClose={handleSettingsClose}
onMenuUpdate={forceRefreshMenu}
/>
</RoleBasedRender>
</>
); );
}; };

View File

@ -0,0 +1,222 @@
import { useState } from "react";
import {
ListItem,
ListItemIcon,
ListItemText,
Collapse,
List,
IconButton,
Box,
alpha,
Typography,
Tooltip
} from "@mui/material";
import { ChevronRight, DragIndicator, Folder, FolderOpen } from "@mui/icons-material";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
const SortableMenuItem = ({
item,
collapsed,
onSelectItem,
level = 0,
isHovered = false,
showDropIndicator = false,
sidebarWidth = 300
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLocalHovered, setIsLocalHovered] = useState(false);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isOver
} = useSortable({
id: item.id,
data: {
type: 'menu-item',
item,
level
}
});
const style = {
transform: CSS.Transform.toString(transform),
transition: transition || 'all 0.2s ease',
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 1000 : 1,
};
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
const isFolder = hasChildren;
const isHighlighted = isHovered || isOver;
// Рассчитываем максимальную ширину текста в зависимости от уровня вложенности
const calculateMaxTextWidth = () => {
const baseWidth = sidebarWidth - 40; // Отступы и иконки
const levelOffset = level * 24; // Отступ для каждого уровня
return baseWidth - levelOffset - 60; // Оставляем место для иконок и отступов
};
const handleClick = (e) => {
e.stopPropagation();
if (hasChildren) {
setIsOpen(!isOpen);
} else {
onSelectItem?.(item);
}
};
const handleMouseEnter = () => {
setIsLocalHovered(true);
};
const handleMouseLeave = () => {
setIsLocalHovered(false);
};
const getBackgroundColor = (theme) => {
if (isDragging) return alpha(theme.palette.primary.main, 0.1);
if (isHighlighted) return alpha(theme.palette.primary.main, 0.08);
if (isLocalHovered) return alpha(theme.palette.action.hover, 0.4);
return 'transparent';
};
return (
<Box
ref={setNodeRef}
style={style}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
sx={{
position: 'relative',
'&::before': isHighlighted ? {
content: '""',
position: 'absolute',
left: 0,
top: 4,
bottom: 4,
width: 3,
backgroundColor: 'primary.main',
borderRadius: '0 2px 2px 0',
} : {},
...(showDropIndicator && {
backgroundColor: (theme) => alpha(theme.palette.primary.main, 0.1),
border: (theme) => `2px dashed ${theme.palette.primary.main}`,
borderRadius: '8px',
})
}}
>
<ListItem
button
sx={{
pl: collapsed ? 1 : Math.max(0.1, 0.1 + level * 0.1),
pr: 0.5,
py: 0.25,
minHeight: 32,
justifyContent: collapsed ? "center" : "flex-start",
backgroundColor: (theme) => getBackgroundColor(theme),
borderRadius: '6px',
margin: '1px 4px',
transition: 'all 0.2s ease',
}}
onClick={handleClick}
>
{!collapsed && (
<IconButton
{...attributes}
{...listeners}
size="small"
sx={{
cursor: isDragging ? "grabbing" : "grab",
mr: 1,
opacity: isLocalHovered || isDragging ? 1 : 0.4,
color: 'text.secondary',
'&:hover': {
color: 'text.primary',
backgroundColor: 'transparent'
},
flexShrink: 0
}}
>
<DragIndicator fontSize="small" />
</IconButton>
)}
{!collapsed && (
<>
<Tooltip title={item.title} placement="right" enterDelay={400} arrow>
<ListItemText
primary={
<Typography
variant="body2"
sx={{
fontWeight: isFolder ? 600 : 400,
color: isFolder ? 'text.primary' : 'text.secondary',
maxWidth: calculateMaxTextWidth(),
display: "-webkit-box",
WebkitLineClamp: 2, // максимум 2 строки
WebkitBoxOrient: "vertical",
overflow: "hidden",
lineHeight: 1.2,
fontSize: "0.85rem", // компактнее текст
}}
>
{item.title}
</Typography>
}
sx={{ mr: 0.5, flex: '1 1 auto', minWidth: 0 }}
/>
</Tooltip>
{hasChildren && (
<ChevronRight
sx={{
fontSize: 18,
color: 'text.disabled',
transform: isOpen ? 'rotate(90deg)' : 'none',
transition: 'transform 0.2s ease',
flexShrink: 0,
}}
/>
)}
</>
)}
</ListItem>
{hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List
disablePadding
sx={{
pl: 1.5,
borderLeft: (theme) => `1px solid ${alpha(theme.palette.divider, 0.1)}`,
marginLeft: 2,
position: 'relative',
}}
>
{item.items.map((child) => (
<Box key={child.id} position="relative">
<SortableMenuItem
item={child}
collapsed={collapsed}
onSelectItem={onSelectItem}
level={level + 1}
isHovered={isHovered}
showDropIndicator={showDropIndicator}
sidebarWidth={sidebarWidth}
/>
</Box>
))}
</List>
</Collapse>
)}
</Box>
);
};
export default SortableMenuItem;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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 };
};

View File

@ -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;

View File

@ -0,0 +1,3 @@
export const isLeafNode = (item) => {
return !item.items || item.items.length === 0;
};

View File

@ -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
};
};

View File

@ -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 };
};

View File

@ -1,189 +0,0 @@
import React, { useRef, useEffect, useMemo } from "react";
import * as d3 from "d3";
import "../../Style/TreeChart.css";
import { getStatusColor } from "./dataUtils";
const TreeChart = ({ data, onNodeClick }) => {
const chartRef = useRef();
const nodePositions = useRef(new Map());
const { root, nodes, links } = useMemo(() => {
if (!data || !data.items) return { root: null, nodes: [], links: [] };
const root = d3.hierarchy(data, (d) => d.items);
const maxDepth = d3.max(root.descendants(), (d) => d.depth);
// Фильтруем узлы, исключая последний уровень
const nodes = root.descendants().filter((d) => d.depth < maxDepth);
// Фильтруем связи
const links = nodes.filter((d) => d.parent).map((d) => ({
source: d.parent,
target: d,
}));
// Размещаем узлы иерархически
const center = { x: 0, y: 0 }; // Центральная точка
const baseRadius = 150; // Базовый радиус для 1-го уровня
const branchOffset = 80; // Смещение узлов вдоль ветки
const angleOffset = Math.PI / 4; // Угол смещения для дочерних ветвей
const spreadFactor = 1.5; // Коэффициент растяжения для последних узлов
nodes.forEach((node) => {
const prev = nodePositions.current.get(node.data.id);
if (prev) {
node.x = prev.x;
node.y = prev.y;
} else {
if (node.depth === 0) {
// Центральный узел
node.x = center.x;
node.y = center.y;
} else if (node.depth === 1) {
// Первый уровень - равномерно по окружности
const parent = node.parent;
const index = parent.children.indexOf(node);
const totalSiblings = parent.children.length;
const radius = baseRadius * node.depth;
const sectorAngle = (Math.PI * 2) / totalSiblings;
const angle = index * sectorAngle;
node.x = parent.x + radius * Math.cos(angle);
node.y = parent.y + radius * Math.sin(angle);
node.angle = angle; // Запоминаем угол для веток
} else {
// Второй уровень и дальше - ветка растет в направлении родителя
const parent = node.parent;
const siblings = parent.children || [];
const index = siblings.indexOf(node);
const totalSiblings = siblings.length;
const direction = parent.angle || 0;
const offsetAngle = ((index - (totalSiblings - 1) / 2) * angleOffset) / totalSiblings;
let distance = branchOffset;
if (!node.children || node.children.length === 0) {
// Если это последний узел, увеличиваем расстояние
distance *= spreadFactor + node.depth * 0.2; // Чем глубже, тем больше разброс
}
node.x = parent.x + distance * Math.cos(direction + offsetAngle);
node.y = parent.y + distance * Math.sin(direction + offsetAngle);
node.angle = direction + offsetAngle;
}
}
nodePositions.current.set(node.data.id, { x: node.x, y: node.y });
});
return { root, nodes, links };
}, [data]);
useEffect(() => {
if (!chartRef.current) return;
const svg = d3.select(chartRef.current)
.attr("width", 2000)
.attr("height", 2000)
.attr("viewBox", [-500, -500, 1500, 1500])
.attr("style", "max-width: 100%; height: auto;");
svg.append("g").attr("class", "links");
svg.append("g").attr("class", "nodes");
svg.append("g").attr("class", "labels");
// Очищаем предыдущие элементы
svg.selectAll(".links line").remove();
svg.selectAll(".nodes circle").remove();
svg.selectAll(".labels text").remove();
// Рисуем связи
const linkGroup = svg.select(".links");
const link = linkGroup
.selectAll("line")
.data(links, (d) => `${d.source.data.id}-${d.target.data.id}`)
.join("line")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);
// Рисуем узлы
const nodeGroup = svg.select(".nodes");
const node = nodeGroup
.selectAll("circle")
.data(nodes, (d) => d.data.id)
.join("circle")
.attr("fill", (d) => getStatusColor(d.data.status))
.attr("stroke", "#fff")
.attr("r", 7)
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.call(drag());
node.on("click", (event, d) => {
if (onNodeClick) {
onNodeClick(d.data.id, d.data.title);
}
});
// Рисуем текстовые метки
const labelGroup = svg.select(".labels");
const text = labelGroup
.selectAll("text")
.data(nodes, (d) => d.data.id)
.join("text")
.text((d) => (nodes.length > 50 ? "" : d.data.title)) // Скрываем текст, если узлов много
.attr("dx", 12)
.attr("dy", 4)
.style("user-select", "none") // Запрет выделения текста
.style("pointer-events", "none") // Запрет взаимодействия с текстом
.style("fill", "var(--TreeChart-text-color)") // Используем переменную для цвета текста
.attr("x", (d) => d.x + 12)
.attr("y", (d) => d.y + 4);
}, [root, links, nodes, onNodeClick]);
const drag = () => {
function dragstarted(event, d) {
d3.select(this).raise().attr("stroke", "#000");
}
function dragged(event, d) {
d.x = event.x;
d.y = event.y;
d3.select(this).attr("cx", d.x).attr("cy", d.y);
// Обновляем текстовую метку
d3.select(this.parentNode)
.select("text")
.attr("x", d.x + 12)
.attr("y", d.y + 4);
// Обновляем связи
d3.select(chartRef.current)
.selectAll(".links line")
.filter((link) => link.source === d || link.target === d)
.attr("x1", (link) => link.source.x)
.attr("y1", (link) => link.source.y)
.attr("x2", (link) => link.target.x)
.attr("y2", (link) => link.target.y);
}
function dragended(event, d) {
d3.select(this).attr("stroke", "#fff");
nodePositions.current.set(d.data.id, { x: d.x, y: d.y });
}
return d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended);
};
return <svg ref={chartRef} />;
};
export default TreeChart;

View File

@ -1,48 +1,43 @@
const StatusManager = () => { const StatusManager = () => {
const getRandomStatus = () => { const getRandomStatus = () => {
const statuses = [ const statuses = [
...Array(90).fill("green"), // 90% шанс ...Array(90).fill("green"),
...Array(6).fill("yellow"), // 6% шанс ...Array(6).fill("yellow"),
...Array(3).fill("orange"), // 3% шанс ...Array(3).fill("orange"),
...Array(1).fill("red"), // 1% шанс ...Array(1).fill("red"),
]; ];
return statuses[Math.floor(Math.random() * statuses.length)]; return statuses[Math.floor(Math.random() * statuses.length)];
}; };
const getStatusWeight = (status) => { const getStatusWeight = (status) => {
switch (status) { switch (status) {
case "green": return 1; // 100% здоровья case "green": return 1;
case "yellow": return 0.75; case "yellow": return 0.75;
case "orange": return 0.5; case "orange": return 0.5;
case "red": return 0.25; // 25% здоровья case "red": return 0.25;
default: return 1; // По умолчанию "green" default: return 1;
} }
}; };
const updateStatuses = (data) => { const updateStatuses = (data) => {
if (!data.items || data.items.length === 0) { if (!data.items || data.items.length === 0) {
// Если это элемент нижнего уровня, генерируем случайный статус
data.status = getRandomStatus(); data.status = getRandomStatus();
return getStatusWeight(data.status); return getStatusWeight(data.status);
} }
// Рекурсивно обновляем статусы для всех дочерних элементов
let childStatusWeights = data.items.map((child) => updateStatuses(child)); let childStatusWeights = data.items.map((child) => updateStatuses(child));
// Проверяем, есть ли дочерние элементы (избегаем деления на 0)
if (childStatusWeights.length === 0) { if (childStatusWeights.length === 0) {
data.status = "green"; data.status = "green";
return 1; return 1;
} }
// Вычисляем среднее арифметическое значение весов статусов
const averageStatusWeight = const averageStatusWeight =
childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length; childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length;
// Определяем статус текущего элемента
data.status = getStatusFromWeight(averageStatusWeight); data.status = getStatusFromWeight(averageStatusWeight);
return Math.max(0, averageStatusWeight); // Гарантия, что не будет отрицательных значений return Math.max(0, averageStatusWeight);
}; };
const getStatusFromWeight = (weight) => { const getStatusFromWeight = (weight) => {
@ -69,16 +64,13 @@ const StatusManager = () => {
}; };
}; };
// Создаем два независимых менеджера статусов
export const statusManager1 = StatusManager(); export const statusManager1 = StatusManager();
export const statusManager2 = StatusManager(); export const statusManager2 = StatusManager();
// Функция для расчета процентов здоровья системы
export const calculateStatusPercentage = (averageStatusValue) => { export const calculateStatusPercentage = (averageStatusValue) => {
return Math.max(0, Math.min(100, averageStatusValue * 100)); return Math.max(0, Math.min(100, averageStatusValue * 100));
}; };
// Экспортируем getStatusColor отдельно
export const getStatusColor = (status) => { export const getStatusColor = (status) => {
switch (status) { switch (status) {
case "green": case "green":

File diff suppressed because it is too large Load Diff

View File

@ -1,714 +0,0 @@
{
"title": "Сервис ЗВКС",
"id": "1",
"items": [
{
"title": "Функциональные задачи",
"id": "functional_tasks",
"items": [
{
"id": "system_control",
"title": "Контроль системы"
},
{
"id": "system_management",
"title": "Система управления"
},
{
"id": "conference",
"title": "Проведение ВКС"
},
{
"id": "backup",
"title": "Резервное копирование"
},
{
"id": "relay_info",
"title": "Ретрансляция информации"
}
]
},
{
"id": "18",
"title": "Graviton S2082I (device$18)",
"items": [
{
"id": "4",
"title": "OS Linux (module$4) АО",
"items": [
{
"id": "190",
"title": "Загрузка процессора за 1 минуту"
},
{
"id": "191",
"title": "Загрузка процессора за 5 минут"
},
{
"id": "192",
"title": "Загрузка процессора за 15 минут"
},
{
"id": "197",
"title": "Общий объем SWAP-файла"
},
{
"id": "198",
"title": "Используемый объем SWAP-файла"
},
{
"id": "199",
"title": "Общий объем физической оперативной памяти"
},
{
"id": "200",
"title": "Доступный объем физической оперативной памяти"
},
{
"id": "201",
"title": "Свободный объем физической и виртуальной оперативной памяти"
},
{
"id": "202",
"title": "Буферизованный объем оперативной памяти"
},
{
"id": "203",
"title": "Кэшированый объем оперативной памяти"
},
{
"id": "274",
"title": "Используемый объем SWAP-файла"
},
{
"id": "275",
"title": "Время затраченное процессором на процессы с пониженным приоритетом"
},
{
"id": "276",
"title": "Время затраченное процессором на процессы ядра ОС"
},
{
"id": "277",
"title": "Время простоя процессора"
},
{
"id": "278",
"title": "Общая емкость жестких дисков"
},
{
"id": "279",
"title": "Доступная емкость жестких дисков"
}
]
},
{
"id": "5",
"title": "Vinteo (module$5) ПО",
"items": [
{
"id": "31",
"title": "Общее количество участников"
},
{
"id": "32",
"title": "Ожидание соединения"
},
{
"id": "33",
"title": "Зарегистрированные абоненты"
},
{
"id": "34",
"title": "Количество пользоватей HLS"
},
{
"id": "35",
"title": "Общее количество P2P комнат"
},
{
"id": "36",
"title": "Общее количество конференций"
},
{
"id": "37",
"title": "Общее количество активных конференций"
},
{
"id": "38",
"title": "Статус записи"
},
{
"id": "39",
"title": "Общее количество сохранённых записей"
}
]
},
{
"id": "280",
"title": "Сетевой адаптер №1 (port$261) Eth_1",
"items": [
{
"id": "207",
"title": "Скорость порта Eth_1"
},
{
"id": "209",
"title": "Административное состояние порта Eth_1"
},
{
"id": "210",
"title": "Оперативное состояние порта Eth_1"
},
{
"id": "211",
"title": "Общее количество отправленных октетов Eth_1"
},
{
"id": "212",
"title": "Количество входящих Multicast пакетов Eth_1"
},
{
"id": "213",
"title": "Количество иcходящих Multiicast пакетов Eth_1"
},
{
"id": "214",
"title": "Количество входящих Broadcast пакетов Eth_1"
},
{
"id": "215",
"title": "Количество иcходящих Broadcast пакетов Eth_1"
},
{
"id": "216",
"title": "Количество входящих Unicast пакетов Eth_1"
},
{
"id": "217",
"title": "Количество иcходящих Unicast пакетов Eth_1"
},
{
"id": "218",
"title": "Количество входящих пакетов помеченные как отброшенные Eth_1"
},
{
"id": "219",
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_1"
},
{
"id": "220",
"title": "Количество входящих пакетов с ошибкой Eth_1"
},
{
"id": "221",
"title": "Количество исходящих пакетов с ошибкой Eth_1"
},
{
"id": "222",
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_1"
}
]
},
{
"id": "281",
"title": "Сетевой адаптер №2 (port$262) Eth_2",
"items": [
{
"id": "224",
"title": "Скорость порта Eth_2"
},
{
"id": "226",
"title": "Административное состояние порта Eth_2"
},
{
"id": "227",
"title": "Оперативное состояние порта Eth_2"
},
{
"id": "228",
"title": "Общее количество отправленных октетов Eth_2"
},
{
"id": "229",
"title": "Количество входящих Multicast пакетов Eth_2"
},
{
"id": "230",
"title": "Количество иcходящих Multiicast пакетов Eth_2"
},
{
"id": "231",
"title": "Количество входящих Broadcast пакетов Eth_2"
},
{
"id": "232",
"title": "Количество иcходящих Broadcast пакетов Eth_2"
},
{
"id": "233",
"title": "Количество входящих Unicast пакетов Eth_2"
},
{
"id": "234",
"title": "Количество иcходящих Unicast пакетов Eth_2"
},
{
"id": "235",
"title": "Количество входящих пакетов помеченные как отброшенные Eth_2"
},
{
"id": "236",
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_2"
},
{
"id": "237",
"title": "Количество входящих пакетов с ошибкой Eth_2"
},
{
"id": "238",
"title": "Количество исходящих пакетов с ошибкой Eth_2"
},
{
"id": "239",
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_2"
}
]
},
{
"id": "282",
"title": "Сетевой адаптер №3 (port$263) Eth_3",
"items": [
{
"id": "241",
"title": "Скорость порта Eth_3"
},
{
"id": "243",
"title": "Административное состояние порта Eth_3"
},
{
"id": "244",
"title": "Оперативное состояние порта Eth_3"
},
{
"id": "245",
"title": "Общее количество отправленных октетов Eth_3"
},
{
"id": "246",
"title": "Количество входящих Multicast пакетов Eth_3"
},
{
"id": "247",
"title": "Количество иcходящих Multiicast пакетов Eth_3"
},
{
"id": "248",
"title": "Количество входящих Broadcast пакетов Eth_3"
},
{
"id": "249",
"title": "Количество иcходящих Broadcast пакетов Eth_3"
},
{
"id": "250",
"title": "Количество входящих Unicast пакетов Eth_3"
},
{
"id": "251",
"title": "Количество иcходящих Unicast пакетов Eth_3"
},
{
"id": "252",
"title": "Количество входящих пакетов помеченные как отброшенные Eth_3"
},
{
"id": "253",
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_3"
},
{
"id": "254",
"title": "Количество входящих пакетов с ошибкой Eth_3"
},
{
"id": "255",
"title": "Количество исходящих пакетов с ошибкой Eth_3"
},
{
"id": "256",
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_3"
}
]
},
{
"id": "283",
"title": "Сетевой адаптер №4 (port$264) Eth_4",
"items": [
{
"id": "258",
"title": "Скорость порта Eth_4"
},
{
"id": "260",
"title": "Административное состояние порта Eth_4"
},
{
"id": "261",
"title": "Оперативное состояние порта Eth_4"
},
{
"id": "262",
"title": "Общее количество отправленных октетов Eth_4"
},
{
"id": "263",
"title": "Количество входящих Multicast пакетов Eth_4"
},
{
"id": "264",
"title": "Количество иcходящих Multiicast пакетов Eth_4"
},
{
"id": "265",
"title": "Количество входящих Broadcast пакетов Eth_4"
},
{
"id": "266",
"title": "Количество иcходящих Broadcast пакетов Eth_4"
},
{
"id": "267",
"title": "Количество входящих Unicast пакетов Eth_4"
},
{
"id": "268",
"title": "Количество иcходящих Unicast пакетов Eth_4"
},
{
"id": "269",
"title": "Количество входящих пакетов помеченные как отброшенные Eth_4"
},
{
"id": "270",
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_4"
},
{
"id": "271",
"title": "Количество входящих пакетов с ошибкой Eth_4"
},
{
"id": "272",
"title": "Количество исходящих пакетов с ошибкой Eth_4"
},
{
"id": "273",
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_4"
}
]
}
]
},
{
"title": "Медиа сервер",
"id": "media_server_1",
"items": [
{
"title": "Аппаратное обеспечение",
"id": "system_software_1",
"items": [
{
"id": "media_system_software_1_2",
"title": "Центральный процессор"
},
{
"id": "media_system_software_2_2",
"title": "Оперативная память"
},
{
"id": "media_system_software_3_2",
"title": "Жесткий диск"
},
{
"id": "media_system_software_4_2",
"title": "Сетевые адаптеры"
}
]
},
{
"title": "Программное обеспечение",
"id": "software_1",
"items": [
{
"id": "media_software_1_2",
"title": "ПО"
},
{
"id": "media_software_2_2",
"title": "ПО"
},
{
"id": "media_software_3_2",
"title": "ПО"
},
{
"id": "media_software_4_2",
"title": "ПО"
}
]
}
]
},
{
"title": "Медиа сервер",
"id": "media_server_2",
"items": [
{
"title": "Аппаратное обеспечение",
"id": "system_software_2",
"items": [
{
"id": "media_system_software_1_3",
"title": "Центральный процессор"
},
{
"id": "media_system_software_2_3",
"title": "Оперативная память"
},
{
"id": "media_system_software_3_3",
"title": "Жесткий диск"
},
{
"id": "media_system_software_4_3",
"title": "Сетевые адаптеры"
}
]
},
{
"title": "Программное обеспечение",
"id": "software_2",
"items": [
{
"id": "media_software_1_3",
"title": "ПО"
},
{
"id": "media_software_2_3",
"title": "ПО"
},
{
"id": "media_software_3_3",
"title": "ПО"
},
{
"id": "media_software_4_3",
"title": "ПО"
}
]
}
]
},
{
"title": "Медиа сервер",
"id": "media_server_3",
"items": [
{
"title": "Аппаратное обеспечение",
"id": "system_software_3",
"items": [
{
"id": "media_system_software_1_4",
"title": "Центральный процессор"
},
{
"id": "media_system_software_2_4",
"title": "Оперативная память"
},
{
"id": "media_system_software_3_4",
"title": "Жесткий диск"
},
{
"id": "media_system_software_4_4",
"title": "Сетевые адаптеры"
}
]
},
{
"title": "Программное обеспечение",
"id": "software_3",
"items": [
{
"id": "media_software_1_4",
"title": "ПО"
},
{
"id": "media_software_2_4",
"title": "ПО"
},
{
"id": "media_software_3_4",
"title": "ПО"
},
{
"id": "media_software_4_4",
"title": "ПО"
}
]
}
]
},
{
"title": "Медиа сервер",
"id": "media_server_4",
"items": [
{
"title": "Аппаратное обеспечение",
"id": "system_software_4",
"items": [
{
"id": "media_system_software_1_5",
"title": "Центральный процессор"
},
{
"id": "media_system_software_2_5",
"title": "Оперативная память"
},
{
"id": "media_system_software_3_5",
"title": "Жесткий диск"
},
{
"id": "media_system_software_4_5",
"title": "Сетевые адаптеры"
}
]
},
{
"title": "Программное обеспечение",
"id": "software_4",
"items": [
{
"id": "media_software_1_5",
"title": "ПО"
},
{
"id": "media_software_2_5",
"title": "ПО"
},
{
"id": "media_software_3_5",
"title": "ПО"
},
{
"id": "media_software_4_5",
"title": "ПО"
}
]
}
]
},
{
"title": "Сервер систем",
"id": "system_server_1",
"items": [
{
"title": "Аппаратное обеспечение",
"id": "system_software_5",
"items": [
{
"id": "copy_system_software_1",
"title": "Центральный процессор"
},
{
"id": "copy_system_software_2",
"title": "Оперативная память"
},
{
"id": "copy_system_software_3",
"title": "Жесткий диск"
},
{
"id": "copy_system_software_4",
"title": "Сетевые адаптеры"
}
]
},
{
"title": "Программное обеспечение",
"id": "software_5",
"items": [
{
"id": "copy_software_1",
"title": "ПО"
},
{
"id": "copy_software_2",
"title": "ПО"
},
{
"id": "copy_software_3",
"title": "ПО"
},
{
"id": "copy_software_4",
"title": "ПО"
}
]
}
]
},
{
"title": "Сервер систем",
"id": "system_server_2",
"items": [
{
"title": "Аппаратное обеспечение",
"id": "system_software_6",
"items": [
{
"id": "control_system_software_1",
"title": "Центральный процессор"
},
{
"id": "control_system_software_2",
"title": "Оперативная память"
},
{
"id": "control_system_software_3",
"title": "Жесткий диск"
},
{
"id": "control_system_software_4",
"title": "Сетевые адаптеры"
}
]
},
{
"title": "Программное обеспечение",
"id": "software_6",
"items": [
{
"id": "control_software_1",
"title": "ПО"
},
{
"id": "control_software_2",
"title": "ПО"
},
{
"id": "control_software_3",
"title": "ПО"
},
{
"id": "control_software_4",
"title": "ПО"
}
]
}
]
}
]
}

View File

@ -1,90 +1,155 @@
import React, { lazy, Suspense } from "react"; import React, { lazy, Suspense } from "react";
import Skeleton from "@mui/material/Skeleton";
import Box from "@mui/material/Box";
const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart')); const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart"));
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
// Функция для генерации названия метрики на основе id // Компонент Skeleton для графика
const getMetricName = (id) => { const ChartSkeleton = () => (
return `zvks_apiforsnmp_measure_${id}`; <Box sx={{ width: "100%" }}>
<Skeleton variant="text" width="60%" height={30} />
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} />
</Box>
);
// Компонент Skeleton для контейнера
const ContainerSkeleton = () => (
<Box sx={{ width: "100%" }}>
<Skeleton variant="text" width="40%" height={40} />
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
<Box sx={{ mt: 2 }}>
{[...Array(3)].map((_, i) => (
<ChartSkeleton key={i} />
))}
</Box>
</Box>
);
// Утилита для извлечения контекста из пути
const parseContextFromPath = (node) => {
const context = {};
let current = node;
while (current) {
if (current.id.startsWith("device$")) {
context.device = current.id.split("$")[1];
context.deviceId = current.id;
}
if (current.id.startsWith("module$")) {
context.module = current.id;
context.source_id = current.id;
}
current = current.parent;
}
return context;
}; };
//!!!!!!!!!!Пофиксить вкладуи с eth4, во всех eth 1-4 открывается именно 4 !!!!!!!!!!!!! // Основная функция построения контента вкладок
const tabContent = (data, cache = {}) => {
const tabContentMap = { ...cache };
// Функция для рекурсивного сбора всех id потомков if (!data || !data.items || data.items.length === 0) {
const getAllChildIds = (node) => { console.warn("Данные отсутствуют или массив items пуст", data);
let ids = []; return tabContentMap;
if (node.id) {
ids.push(node.id); // Добавляем id текущего узла
} }
if (node.items && node.items.length > 0) {
node.items.forEach((child) => {
ids = ids.concat(getAllChildIds(child)); // Рекурсивно собираем id потомков
});
}
return ids;
};
const tabContent = (data) => { const processNode = (node, parentContext = {}) => {
const tabContent = {}; // Получаем полный контекст из всей цепочки родителей
const pathContext = parseContextFromPath(node);
const currentContext = { ...parentContext, ...pathContext };
// Функция для рекурсивного обхода и сбора данных // Генерируем уникальный ключ на основе пути
const generateContent = (nodes) => { const path = [];
nodes.forEach((node) => { let current = node;
// Если у узла есть вложенные элементы, рекурсивно обрабатываем их while (current) {
if (node.items && node.items.length > 0) { path.unshift(current.id);
// Создаем контент для родителя current = current.parent;
const childrenContent = generateContent(node.items); }
const pathId = path.join('_');
const content = ( if (Array.isArray(node.items) && node.items.length > 0) {
<div> const children = node.items
<h2>{node.title}</h2> .map((child) => processNode(child, currentContext))
<p>Контент для {node.title}.</p> .filter(Boolean);
{childrenContent}
</div>
);
// Сохраняем контент для текущего id const content = (
tabContent[node.id] = { <div key={`${pathId}-container`}>
title: node.title, <h2>{node.title}</h2>
content: content, <Suspense fallback={<ContainerSkeleton />}>
}; <LazyChartBatchRenderer charts={children.map((c) => c.content)} />
} else { </Suspense>
// Если у узла нет вложенных элементов, это самый нижний уровень </div>
const metricName = getMetricName(node.id); );
const content = (
<div key={node.id}>
<h3>{node.title}</h3> {/* Используем title узла */}
<Suspense fallback={<div>Загрузка графика...</div>}>
<PrometheusChart metricName={metricName} />
</Suspense>
</div>
);
// Сохраняем контент для текущего id tabContentMap[pathId] = {
tabContent[node.id] = { title: node.title,
title: node.title, content,
content: content, context: currentContext,
}; };
}
});
// Возвращаем контент для всех потомков return { content, context: currentContext };
return ( }
<div>
{nodes.map((node) => ( if (node.metric) {
<div key={node.id}>{tabContent[node.id].content}</div> const chartKey = `${node.metric}-${currentContext.device || "all"}-${currentContext.module || "all"}-${pathId}`;
))}
const content = (
<div key={chartKey}>
<h3>{node.title}</h3>
{currentContext.device && <p>Устройство: {currentContext.device}</p>}
{currentContext.module && <p>Модуль: {currentContext.module}</p>}
<Suspense fallback={<ChartSkeleton />}>
<PrometheusChart
metricInfo={{
name: node.metric,
filters: {
...(currentContext.device && { device: currentContext.device }),
...(currentContext.source_id && { source_id: currentContext.source_id }),
},
title: node.title,
description: node.description,
context: currentContext,
}}
key={chartKey}
/>
</Suspense>
</div>
);
tabContentMap[pathId] = {
title: node.title,
content,
context: currentContext,
};
return { content, context: currentContext };
}
// Узел без метрики и без вложенных просто заголовок
const content = (
<div key={pathId}>
<h3>{node.title}</h3>
</div> </div>
); );
tabContentMap[pathId] = {
title: node.title,
content,
context: currentContext,
};
return { content, context: currentContext };
}; };
// Начинаем обработку с корневого уровня try {
if (data.items && data.items.length > 0) { processNode(data);
generateContent(data.items); } catch (error) {
} else { console.error("Ошибка обработки данных:", error);
console.warn("Данные отсутствуют или массив items пуст");
} }
return tabContent; return tabContentMap;
}; };
export default tabContent; export default tabContent;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,41 +1,67 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Modal from "./Modal"; import Modal from "./Modal";
import "../../Style/LoginModal.css"; import "../../Style/LoginModal.css";
import TextField from '@mui/material/TextField'; import {
TextField,
IconButton,
Button,
Typography,
InputAdornment
} from "@mui/material";
import {
Visibility,
VisibilityOff
} from "@mui/icons-material";
import axios from 'axios';
const LoginModal = ({ onLogin, onClose }) => { const LoginModal = ({ onLogin, onClose }) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showPassword, setShowPassword] = React.useState(false); const [showPassword, setShowPassword] = useState(false);
const handleClickShowPassword = () => setShowPassword((show) => !show);
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
// Отправляем данные на бэкенд const { data } = await axios.post(
console.log("Отправляем данные:", { username, password }); //`${import.meta.env.VITE_BACK_URL}/api/auth/login`,
const response = await fetch('http://192.168.2.39:3000/auth/login', { '/api/auth/login',
method: 'POST', { login: username, password },
headers: { {
'Content-Type': 'application/json', withCredentials: true,
}, headers: {
body: JSON.stringify({ login: username, password }), 'Content-Type': 'application/json',
}); },
}
);
const data = await response.json(); console.log('Login response:', data);
if (data.success) { if (data.success) {
onLogin(); // Успешная авторизация if (!data.user?.role) {
onClose(); // Закрыть модальное окно console.error('Role missing in response:', data);
throw new Error('Роль пользователя не получена');
}
const userData = {
id: data.user.id,
login: data.user.login,
role: data.user.role
};
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('user', JSON.stringify(userData));
console.log('User data saved:', userData);
onLogin(userData);
onClose();
} else { } else {
setError(data.message || "Неверный логин или пароль"); setError(data.message || 'Ошибка авторизации');
} }
} catch (err) { } catch (err) {
console.error('Ошибка при отправке запроса:', err); console.error('Login error:', err);
setError("Ошибка при подключении к серверу"); setError(err.response?.data?.message || err.message || 'Ошибка при входе');
} }
}; };
@ -50,8 +76,8 @@ const LoginModal = ({ onLogin, onClose }) => {
variant="filled" variant="filled"
margin="normal" margin="normal"
required required
value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
size="normal"
/> />
<TextField <TextField
@ -62,12 +88,45 @@ const LoginModal = ({ onLogin, onClose }) => {
margin="normal" margin="normal"
required required
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
size="normal" InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
edge="end"
sx={{
marginRight: '-12px',
alignSelf: 'flex-end'
}}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/> />
{error && <p className="error">{error}</p>} {error && (
<button type="submit">Войти</button> <Typography color="error" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Button
type="submit"
variant="contained"
fullWidth
sx={{
mt: 2,
py: 1.5,
fontSize: '1rem'
}}
>
Войти
</Button>
</form> </form>
</Modal> </Modal>
); );

View File

@ -1,10 +1,58 @@
import React from "react"; import React from "react";
import { Tabs, Tab, Box } from "@mui/material"; import { Tabs, Tab, Box, styled, Typography } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
const StyledTab = styled(Tab)(({ theme }) => ({
minHeight: 48,
padding: theme.spacing(1, 2),
textTransform: 'none',
'&.Mui-selected': {
color: theme.palette.primary.main,
fontWeight: theme.typography.fontWeightMedium,
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: '-2px',
},
}));
const TabLabel = ({ title, onClose }) => {
return (
<Box sx={{
display: "flex",
alignItems: "center",
minWidth: 0 // Для корректного обрезания длинного текста
}}>
<Typography
variant="body2"
noWrap
sx={{
maxWidth: 120,
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
>
{title}
</Typography>
<CloseIcon
fontSize="small"
sx={{
ml: 1,
cursor: "pointer",
flexShrink: 0,
'&:hover': {
color: 'error.main'
}
}}
onClick={onClose}
/>
</Box>
);
};
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
const handleMouseDown = (e, id) => { const handleMouseDown = (e, id) => {
if (e.button === 1) { if (e.button === 1) { // Средняя кнопка мыши
e.preventDefault(); e.preventDefault();
onCloseTab(id); onCloseTab(id);
} }
@ -14,43 +62,50 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
onTabClick(newValue); onTabClick(newValue);
}; };
// Статические вкладки (сохраняем оригинальные id)
const staticTabs = [
{ id: "Главная", title: "Главная" },
{ id: "Визуализация", title: "Визуализация" }
];
return ( return (
<Box sx={{ borderBottom: 1, borderColor: "divider" }}> <Box sx={{
borderBottom: 1,
borderColor: 'divider',
'& .MuiTabs-indicator': {
backgroundColor: 'primary.main',
}
}}>
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={handleChange} onChange={handleChange}
variant="scrollable" variant="scrollable"
scrollButtons="auto" scrollButtons="auto"
allowScrollButtonsMobile
aria-label="tabs" aria-label="tabs"
> >
{/* Всегда отображаемые вкладки */} {/* Статические вкладки */}
<Tab {staticTabs.map(tab => (
label="Главная" <StyledTab
value="Главная" key={`static_${tab.id}`} // Добавляем префикс для уникальности
onMouseDown={(e) => handleMouseDown(e, "Главная")} label={tab.title}
/> value={tab.id} // Используем id как value
<Tab onMouseDown={(e) => handleMouseDown(e, tab.id)}
label="Визуализация" />
value="Визуализация" ))}
onMouseDown={(e) => handleMouseDown(e, "Визуализация")}
/>
{/* Динамически добавляемые вкладки */} {/* Динамические вкладки */}
{tabs.map((tab) => ( {tabs.map((tab) => (
<Tab <StyledTab
key={tab.id} key={`dynamic_${tab.id}`} // Добавляем префикс для уникальности
label={ label={
<Box sx={{ display: "flex", alignItems: "center" }}> <TabLabel
<span>{tab.title}</span> title={tab.title}
<CloseIcon onClose={(e) => {
fontSize="small" e.stopPropagation();
sx={{ ml: 1, cursor: "pointer" }} onCloseTab(tab.id);
onClick={(e) => { }}
e.stopPropagation(); />
onCloseTab(tab.id);
}}
/>
</Box>
} }
value={tab.id} value={tab.id}
onMouseDown={(e) => handleMouseDown(e, tab.id)} onMouseDown={(e) => handleMouseDown(e, tab.id)}

View File

@ -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;

View File

@ -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;
};

View File

@ -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;

View File

@ -1,12 +1,27 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import "../../Style/TreeTable.css"; import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
Collapse,
Box,
Typography,
useTheme,
Tooltip
} from '@mui/material';
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils"; import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
const TreeTable = ({ data }) => { const TreeTable = ({ data }) => {
const theme = useTheme();
const tableRef = useRef(null); const tableRef = useRef(null);
const [fontSize, setFontSize] = useState(16); const [fontSize, setFontSize] = useState(16);
const [log, setLog] = useState([]); const [log, setLog] = useState([]);
const [isLogVisible, setIsLogVisible] = useState(true); const [isLogVisible, setIsLogVisible] = useState(false);
const adjustFontSize = () => { const adjustFontSize = () => {
if (tableRef.current) { if (tableRef.current) {
@ -27,6 +42,13 @@ const TreeTable = ({ data }) => {
} }
}; };
useEffect(() => {
adjustFontSize();
window.addEventListener('resize', adjustFontSize);
return () => window.removeEventListener('resize', adjustFontSize);
}, [data]);
// Логирование статусов
useEffect(() => { useEffect(() => {
const newLog = []; const newLog = [];
const traverse = (items) => { const traverse = (items) => {
@ -35,7 +57,7 @@ const TreeTable = ({ data }) => {
newLog.push({ newLog.push({
title: item.title, title: item.title,
status: item.status, status: item.status,
time: new Date().toLocaleTimeString(), // Добавляем время time: new Date().toLocaleTimeString(),
}); });
} }
if (item.items) { if (item.items) {
@ -44,200 +66,285 @@ const TreeTable = ({ data }) => {
}); });
}; };
traverse(data.items); traverse(data.items);
setLog(prevLog => [...newLog, ...prevLog].slice(0, 50));
// Ограничиваем количество сообщений до 50
setLog((prevLog) => [...newLog, ...prevLog].slice(0, 50));
}, [data]); }, [data]);
const filteredData = data.items.filter((item) => item.title !== "Функциональные задачи"); const filteredData = data.items.filter(item => item.title !== "Функциональные задачи");
// Функция для отображения заголовков // Компонент индикаторов статуса
const renderHeaders = (items) => { const StatusIndicators = ({ status }) => (
<>
<Box
sx={{
width: '4px',
height: '20px',
display: 'inline-block',
backgroundColor: statusManager1.getStatusColor(status),
marginRight: '4px',
verticalAlign: 'middle'
}}
/>
<Box
sx={{
width: '4px',
height: '20px',
display: 'inline-block',
backgroundColor: statusManager2.getStatusColor(status),
marginRight: '8px',
verticalAlign: 'middle'
}}
/>
</>
);
// Ячейка с тултипом
const TableCellWithTooltip = ({ children, title, ...props }) => (
<Tooltip title={title} arrow>
<TableCell {...props}>
{children}
</TableCell>
</Tooltip>
);
// Рендер заголовков (первый уровень)
const renderMainHeaders = (items) => {
return items.map((item) => { return items.map((item) => {
const colSpan = item.items ? item.items.length : 1; const colSpan = item.items ? item.items.length : 1;
return ( return (
<th key={item.id} colSpan={colSpan} className="tree-table-header" title={item.title}> <TableCellWithTooltip
<div className="header-content"> key={item.id}
<div colSpan={colSpan}
className="status-indicator-bar" align="center"
style={{ backgroundColor: statusManager1.getStatusColor(item.status) }} title={item.title}
/> sx={{
<div backgroundColor: theme.palette.background.paper,
className="status-indicator-bar" border: `1px solid ${theme.palette.divider}`,
style={{ padding: '8px',
backgroundColor: statusManager2.getStatusColor(item.status), whiteSpace: 'nowrap',
marginLeft: "5px", overflow: 'hidden',
}} textOverflow: 'ellipsis'
/> }}
>
<StatusIndicators status={item.status} />
<Typography component="span" variant="subtitle2" noWrap>
{item.title} {item.title}
</div> </Typography>
</th> </TableCellWithTooltip>
); );
}); });
}; };
// Функция для отображения подзаголовков // Рендер подзаголовков (второй уровень)
const renderSubHeaders = (items) => { const renderSubHeaders = (items) => {
return items.map((item) => { return items.flatMap((item) => {
if (item.items) { if (item.items) {
return item.items.map((child) => ( return item.items.map((child) => (
<th key={child.id} className="tree-table-header" title={child.title}> <TableCellWithTooltip
<div className="header-content"> key={child.id}
<div align="center"
className="status-indicator-bar" title={child.title}
style={{ backgroundColor: statusManager1.getStatusColor(child.status) }} sx={{
/> backgroundColor: theme.palette.background.paper,
<div border: `1px solid ${theme.palette.divider}`,
className="status-indicator-bar" padding: '8px',
style={{ whiteSpace: 'nowrap',
backgroundColor: statusManager2.getStatusColor(child.status), overflow: 'hidden',
marginLeft: "5px", textOverflow: 'ellipsis'
}} }}
/> >
<StatusIndicators status={child.status} />
<Typography component="span" variant="subtitle2" noWrap>
{child.title} {child.title}
</div> </Typography>
</th> </TableCellWithTooltip>
)); ));
} else {
return (
<th key={item.id} className="tree-table-header" title={item.title}>
<div className="header-content">
<div
className="status-indicator-bar"
style={{ backgroundColor: statusManager1.getStatusColor(item.status) }}
/>
<div
className="status-indicator-bar"
style={{
backgroundColor: statusManager2.getStatusColor(item.status),
marginLeft: "5px",
}}
/>
{item.title}
</div>
</th>
);
} }
return (
<TableCellWithTooltip
key={item.id}
align="center"
title={item.title}
sx={{
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
padding: '8px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
<StatusIndicators status={item.status} />
<Typography component="span" variant="subtitle2" noWrap>
{item.title}
</Typography>
</TableCellWithTooltip>
);
}); });
}; };
// Функция для отображения данных // Рендер данных (третий уровень)
const renderData = (items) => { const renderDataCells = (items) => {
return items.map((item) => { return items.flatMap((item) => {
if (item.items) { if (item.items) {
return item.items.map((child) => { return item.items.flatMap((child) => {
if (child.items) { if (child.items) {
return child.items.map((subChild) => ( return child.items.map((subChild) => (
<td key={subChild.id} className="tree-table-cell" title={subChild.title}> <TableCellWithTooltip
<div className="cell-content"> key={subChild.id}
<div title={subChild.title}
className="status-indicator-bar" sx={{
style={{ backgroundColor: statusManager1.getStatusColor(subChild.status) }} border: `1px solid ${theme.palette.divider}`,
/> padding: '8px',
<div whiteSpace: 'nowrap',
className="status-indicator-bar" overflow: 'hidden',
style={{ textOverflow: 'ellipsis'
backgroundColor: statusManager2.getStatusColor(subChild.status),
marginLeft: "5px",
}}
/>
<span className="cell-text">{subChild.title}</span>
</div>
</td>
));
} else {
return (
<td key={child.id} className="tree-table-cell" title={child.title}>
<div className="cell-content">
<div
className="status-indicator-bar"
style={{ backgroundColor: statusManager1.getStatusColor(child.status) }}
/>
<div
className="status-indicator-bar"
style={{
backgroundColor: statusManager2.getStatusColor(child.status),
marginLeft: "5px",
}}
/>
<span className="cell-text">{child.title}</span>
</div>
</td>
);
}
});
} else {
return (
<td key={item.id} className="tree-table-cell" title={item.title}>
<div className="cell-content">
<div
className="status-indicator-bar"
style={{ backgroundColor: statusManager1.getStatusColor(item.status) }}
/>
<div
className="status-indicator-bar"
style={{
backgroundColor: statusManager2.getStatusColor(item.status),
marginLeft: "5px",
}} }}
/> >
<span className="cell-text">{item.title}</span> <StatusIndicators status={subChild.status} />
</div> <Typography component="span" variant="body2" noWrap>
</td> {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 ( return (
<div className="tree-table-container"> <Box sx={{ width: '100%' }}>
<table ref={tableRef} className="tree-table" style={{ fontSize: `${fontSize}px` }}> <TableContainer
<thead> component={Paper}
<tr> ref={tableRef}
<th sx={{
colSpan={filteredData.reduce((acc, item) => acc + (item.items ? item.items.length : 1), 0)} fontSize: `${fontSize}px`,
className="tree-table-header" width: '100%',
title={data.title} '& .MuiTableCell-root': {
> py: 1,
<div className="header-content"> px: 2
<div }
className="status-indicator-bar" }}
style={{ backgroundColor: statusManager1.getStatusColor(data.status) }} >
/> <Table sx={{ width: '100%', tableLayout: 'fixed' }}>
<div <TableHead>
className="status-indicator-bar" {/* Основной заголовок таблицы */}
style={{ <TableRow>
backgroundColor: statusManager2.getStatusColor(data.status), <TableCellWithTooltip
marginLeft: "5px", colSpan={filteredData.reduce((acc, item) => acc + (item.items ? item.items.length : 1), 0)}
}} align="center"
/> title={data.title}
{data.title} sx={{
</div> backgroundColor: theme.palette.background.paper,
</th> border: `1px solid ${theme.palette.divider}`,
</tr> padding: '8px'
<tr>{renderHeaders(filteredData)}</tr> }}
<tr>{renderSubHeaders(filteredData)}</tr> >
</thead> <StatusIndicators status={data.status} />
<tbody> <Typography component="span" variant="subtitle1" fontWeight="bold" noWrap>
<tr className="tree-table-row">{renderData(filteredData)}</tr> {data.title}
</tbody> </Typography>
</table> </TableCellWithTooltip>
<button onClick={() => setIsLogVisible(!isLogVisible)} className="toggle-log-button"> </TableRow>
{isLogVisible ? "Скрыть лог" : "Показать лог"}
</button> {/* Строка с основными заголовками */}
{isLogVisible && ( <TableRow>
<div className="status-log"> {renderMainHeaders(filteredData)}
<h3>Лог статусов</h3> </TableRow>
<ul>
{/* Строка с подзаголовками (которая пропала в предыдущей версии) */}
<TableRow>
{renderSubHeaders(filteredData)}
</TableRow>
</TableHead>
<TableBody>
<TableRow>
{renderDataCells(filteredData)}
</TableRow>
</TableBody>
</Table>
</TableContainer>
<Button
variant="outlined"
onClick={() => setIsLogVisible(!isLogVisible)}
size="small"
sx={{ mt: 2 }}
>
{isLogVisible ? 'Скрыть историю изменения статусов' : 'Показать историю изменения статусов'}
</Button>
<Collapse in={isLogVisible}>
<Box sx={{
mt: 2,
p: 2,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
backgroundColor: theme.palette.background.paper
}}>
<Typography variant="h6" gutterBottom>
История изменения статусов
</Typography>
<Box component="ul" sx={{
pl: 2,
maxHeight: 400,
overflow: 'auto',
listStyle: 'none'
}}>
{log.map((entry, index) => ( {log.map((entry, index) => (
<li key={index} style={{ color: statusManager1.getStatusColor(entry.status) }}> <Box
component="li"
key={index}
sx={{
py: 1,
borderBottom: `1px solid ${theme.palette.divider}`,
color: statusManager1.getStatusColor(entry.status)
}}
>
[{entry.time}] {entry.status}: {entry.title} [{entry.time}] {entry.status}: {entry.title}
</li> </Box>
))} ))}
</ul> </Box>
</div> </Box>
)} </Collapse>
</div> </Box>
); );
}; };

View File

@ -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 };
}
};

View File

@ -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);

View File

@ -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;

View File

@ -1,30 +1,99 @@
import SystemStatusChart from "../../Charts/SystemStatusChart";
import TreeTable from "../UI/TreeTable"; import TreeTable from "../UI/TreeTable";
import TreeChart from "../TreeChart/TreeChart"; import FlowChart from "../TreeChart/FlowChart";
import { getStatusColor } from "../TreeChart/dataUtils";
import SystemChart from "../../Charts/SystemChart";
const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, handleOpenTab }) => {
const countStatuses = (data) => {
const counts = { green: 0, yellow: 0, orange: 0, red: 0 };
const countRecursive = (node) => {
if (node.status) {
counts[node.status]++;
}
if (node.items && node.items.length > 0) {
node.items.forEach(child => countRecursive(child));
}
};
if (data) countRecursive(data);
return counts;
};
const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleOpenTab }) => {
if (activeTab === "Главная") { if (activeTab === "Главная") {
const statusCounts = countStatuses(treeData1);
// Конфигурация для метрики серверов (с несколькими линиями)
const serverMetric = {
name: "zvks_server_li",
title: "Надежность системы",
description: "Уровень надежности системы",
multipleLines: true,
lineKey: "device",
};
// Конфигурация для метрики приложений (одна линия)
const appMetric = {
name: "zvks_application_li",
title: "Функциональность системы",
description: "Уровень функциональности системы",
multipleLines: false
};
return ( return (
<div> <div>
<h2>Общий мониторинг состояния системы</h2> <h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</h2>
<div> <div>
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}> <div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
<label>Надежность системы</label> <label>Надежность серверов</label>
<SystemStatusChart data={statusHistories.history1} /> <SystemChart
metricInfo={serverMetric}
chartHeight={580}
/>
</div> </div>
<div style={{ display: 'inline-block', width: '48%' }}> <div style={{ display: 'inline-block', width: '48%' }}>
<label>Функциональность системы</label> <label>Функциональность приложений</label>
<SystemStatusChart data={statusHistories.history2} /> <SystemChart
metricInfo={appMetric}
chartHeight={580}
/>
</div> </div>
</div> </div>
{/* Контейнер для индикаторов статусов */}
<div style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: '20px',
gap: '10px'
}}>
{Object.entries(statusCounts).map(([status, count]) => (
<div key={status} style={{
width: '30px',
height: '30px',
backgroundColor: getStatusColor(status),
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: 'white',
fontWeight: 'bold',
borderRadius: '5px',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
}}>
{count}
</div>
))}
</div>
<label>Статус компонентов системы</label> <label>Статус компонентов системы</label>
<TreeTable data={treeData1} /> <TreeTable data={treeData1} />
</div> </div>
); );
} else if (activeTab === "Визуализация") { } else if (activeTab === "Визуализация") {
return <TreeChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />; return <FlowChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
} else { } else {
const tabData = tabContent[activeTab]; const tabData = tabs.find(t => t.id === activeTab);
return tabData ? tabData.content : <p>Нет данных</p>; return tabData ? tabData.content : <p>Нет данных</p>;
} }
}; };

View File

@ -4,23 +4,43 @@ const useTabs = (initialTab) => {
const [tabs, setTabs] = useState([]); const [tabs, setTabs] = useState([]);
const [activeTab, setActiveTab] = useState(initialTab); const [activeTab, setActiveTab] = useState(initialTab);
const handleOpenTab = useCallback((id, title) => { const handleOpenTab = useCallback((newTab) => {
setTabs((prevTabs) => setTabs((prevTabs) => {
prevTabs.some((tab) => tab.id === id) const exists = prevTabs.some((tab) => tab.id === newTab.id);
? prevTabs if (!exists) {
: [...prevTabs, { id, title }] return [...prevTabs, newTab];
); }
setActiveTab(id); return prevTabs;
});
setActiveTab(newTab.id);
}, []); }, []);
const handleCloseTab = useCallback((id) => { const handleCloseTab = useCallback((id) => {
setTabs((prevTabs) => prevTabs.filter((tab) => tab.id !== id)); setTabs((prevTabs) => {
if (activeTab === id) { const newTabs = prevTabs.filter((tab) => tab.id !== id);
setActiveTab(tabs.length > 1 ? tabs[tabs.length - 2].id : initialTab); if (activeTab === id) {
} setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : initialTab);
}, [activeTab, tabs, initialTab]); }
return newTabs;
});
}, [activeTab, initialTab]);
return { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab }; const updateTabContent = useCallback((id, content) => {
setTabs(prevTabs =>
prevTabs.map(tab =>
tab.id === id ? { ...tab, content } : tab
)
);
}, []);
return {
tabs,
activeTab,
handleOpenTab,
handleCloseTab,
setActiveTab,
updateTabContent
};
}; };
export default useTabs; export default useTabs;

View File

@ -1,52 +0,0 @@
/* Основной контейнер */
.dashboard-container {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: var(--background-color);
color: var(--text-color);
}
/* Сайдбар */
.sidebar {
flex-shrink: 0;
height: 100vh;
overflow-y: auto;
background-color: var(--sidebar-color);
color: var(--sidebar-text-color);
transition: width 0.2s ease;
}
/* Основной контент */
.main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow: auto;
background-color: var(--background-color);
color: var(--text-color);
}
/* Контент */
.content {
background-color: var(--modal-background);
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.521);
max-width: 100%;
overflow: auto;
color: var(--text-color);
}
/* Заголовки */
h2 {
color: var(--text-color);
text-align: center;
}
p {
color: var(--text-color);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,143 +0,0 @@
/* Сайдбар */
.sidebar {
height: 100vh;
background-color: var(--sidebar-color);
color: var(--sidebar-text-color);
position: fixed;
left: 0;
top: 0;
z-index: 999;
overflow: hidden;
transition: width 0.2s ease;
display: flex;
flex-direction: column;
}
/* Контейнер для основного контента меню */
.sidebar-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 20px;
padding-right: 10px;
/* Отступ справа для скроллбара */
}
/* Заголовок меню */
.sidebar-title {
margin-bottom: 20px;
font-size: 1.5em;
font-weight: bold;
color: var(--sidebar-text-color);
padding: 10px;
text-align: center;
/* font-size: 2vh; */
}
/* Элементы меню */
.menu-item {
margin-bottom: 10px;
color: var(--sidebar-text-color);
width: 100%;
}
/* Элемент для перетаскивания */
.sidebar-resizer {
width: 5px;
height: 100%;
background-color: rgba(255, 255, 255, 0.1);
position: absolute;
right: 0;
top: 0;
cursor: ew-resize;
transition: background-color 0.2s ease;
z-index: 1000;
}
.sidebar-resizer:hover {
background-color: rgba(255, 255, 255, 0.3);
}
/* Стили для заголовка элемента меню */
.menu-item-header {
display: flex;
align-items: center;
justify-content: space-between;
/* Распределяем пространство между элементами */
padding: 10px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
width: 100%;
/* Занимаем всю доступную ширину */
box-sizing: border-box;
/* Учитываем padding в ширине */
}
/* Стили для текста элемента меню */
.menu-item-header span {
flex: 1;
/* Текст занимает все доступное пространство */
margin-right: 14px;
/* Отступ справа для текста */
overflow: hidden;
/* Скрываем текст, который не помещается */
text-overflow: ellipsis;
/* Добавляем многоточие, если текст не помещается */
}
/* Стили для иконок */
.menu-item-header .open-parent-icon,
.menu-item-header .toggle-icon {
flex-shrink: 0;
/* Запрещаем сжатие иконок */
margin-left: 1px;
/* Отступ между иконками */
cursor: pointer;
}
.menu-item-header:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Круглый индикатор статуса */
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 10px;
flex-shrink: 0;
}
/* Подменю */
.submenu {
margin-left: 20px;
/* Отступ слева для вложенных элементов */
margin-top: 10px;
}
/* Стили для элементов нижнего уровня вложенности */
/* Дополнительные отступы для элементов без иконок */
.menu-item:not(.has-children) .menu-item-header {
padding-right: 25px;
/* Добавляем отступ справа для элементов без иконок */
}
/* Футер сайдбара */
.sidebar-footer {
padding: 10px;
background-color: var(--sidebar-color);
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
width: 100%;
}
.help,
.settings {
color: var(--sidebar-text-color);
margin: 5px 0;
overflow-x: hidden;
text-align: left;
}

View File

@ -1,62 +0,0 @@
.tree-table-container {
width: 100%;
overflow-x: hidden;
/* Убираем горизонтальный скролл */
}
.tree-table {
width: 100%;
border-collapse: collapse;
text-align: center;
table-layout: fixed;
/* Фиксированная ширина колонок */
background-color: var(--table-cell-background);
color: var(--table-text-color);
}
.tree-table-header {
padding: 10px;
border: 1px solid black;
font-weight: bold;
white-space: nowrap;
/* Текст не переносится */
overflow: hidden;
/* Скрываем текст, который не помещается */
text-overflow: ellipsis;
/* Добавляем многоточие */
background-color: var(--table-header-background);
}
.tree-table-cell {
padding: 8px;
border: 1px solid black;
white-space: nowrap;
/* Текст не переносится */
overflow: hidden;
/* Скрываем текст, который не помещается */
text-overflow: ellipsis;
/* Добавляем многоточие */
}
.cell-content,
.header-content {
display: flex;
align-items: center;
gap: 2px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.status-indicator-bar {
width: 6px;
height: 20px;
border-radius: 3px;
flex-shrink: 0;
}

View File

@ -1,53 +0,0 @@
/* Контейнер для вкладок */
.tabs {
display: flex;
gap: 5px;
padding: 5px;
background-color: var(--sidebar-color);
border-bottom: 2px solid var(--accent-color);
overflow-x: auto;
border-radius: 5px;
white-space: nowrap;
}
/* Стили для отдельной вкладки */
.tab {
display: flex;
align-items: center;
background-color: var(--sidebar-color);
color: var(--sidebar-text-color);
/* Используем переменную для цвета текста */
padding: 5px 15px;
border-radius: 5px 5px 0 0;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.3s ease;
}
/* Активная вкладка */
.tab.active {
background-color: var(--accent-color);
}
/* Кнопка закрытия вкладки */
.close-tab {
background: none;
border: none;
color: var(--sidebar-text-color);
/* Используем переменную для цвета текста */
cursor: pointer;
font-size: 16px;
margin-left: 10px;
padding: 0;
transition: color 0.3s ease;
}
/* Эффект при наведении на кнопку закрытия */
.close-tab:hover {
color: #ff6b6b;
}
/* Эффект при наведении на вкладку */
.tab:hover {
background-color: var(--accent-hover-color);
}

View File

@ -1,25 +0,0 @@
/* Темная тема, если пользователь предпочитает ее */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #1E1E1E;
--text-color: #E0E0E0;
--header-color: #FFFFFF;
/* Основной цвет текста (светлый) */
--sidebar-color: #2d2d2d;
/* Темный цвет сайдбара */
--sidebar-text-color: #E0E0E0;
/* Светлый текст в сайдбаре */
--modal-background: #2d2d2d;
--modal--btn-background: #333333;
--modal-text: #FFFFFF;
--table-border: #444444;
--table-header-background: #2d2d2d;
--table-cell-background: #333333;
--table-text-color: #E0E0E0;
/* Светлый текст в таблице */
--TreeChart-text-color: #ffffff;
--scrollbar-track-color: #333;
/* hover for buttons */
--hover-button: #333d4d;
}
}

View File

@ -1,23 +0,0 @@
/* Светлая тема по умолчанию */
:root {
--background-color: #FFFFFF;
--text-color: #000000;
--header-color: #333333;
/* Основной цвет текста (черный) */
--sidebar-color: #3d74c7;
/* Синий цвет сайдбара */
--sidebar-text-color: #FFFFFF;
/* Белый текст в сайдбаре и вкладках */
--modal-background: #FFFFFF;
--modal--btn-background: #0f55bec2;
--modal-text: #333333;
--table-border: #ddd;
--table-header-background: #f9f9f9;
--table-cell-background: #FFFFFF;
--table-text-color: #000000;
/* Черный текст в таблице */
/* hover for buttons */
--hover-button: #2d62b1;
--hover-text-color: #FFFFFF
}

View File

@ -1,73 +1,191 @@
import { createTheme } from "@mui/material/styles"; import { createTheme } from "@mui/material/styles";
/**
* Общие настройки темы, применяемые для обеих тем (светлой и темной)
*/
const commonThemeSettings = {
// Настройки формы элементов
shape: {
borderRadius: 8, // Базовый радиус скругления углов для всех компонентов
},
// Переопределения стилей конкретных MUI компонентов
components: {
// Стили для компонента Drawer (боковое меню)
MuiDrawer: {
styleOverrides: {
paper: {
borderRight: 'none', // Убираем правую границу у бокового меню
}
},
MuiTab: {
styleOverrides: {
root: {
textTransform: 'none', // Убираем uppercase
minWidth: 'unset', // Убираем минимальную ширину
padding: '6px 16px',
'&:hover': {
color: 'primary.main',
opacity: 1,
},
'&.Mui-selected': {
color: 'primary.main',
},
'&.Mui-focusVisible': {
backgroundColor: 'action.selected',
},
},
},
},
MuiTabs: {
styleOverrides: {
indicator: {
height: 3, // Толщина индикатора
},
},
},
},
// Стили для кнопок-элементов списка
MuiListItemButton: {
styleOverrides: {
root: {
// Стиль для выбранного элемента
'&.Mui-selected': {
backgroundColor: 'rgba(255, 255, 255, 0.16)',
},
// Стиль при наведении на выбранный элемент
'&.Mui-selected:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.24)',
},
}
}
}
}
};
/**
* Светлая тема приложения
*/
export const lightTheme = createTheme({ export const lightTheme = createTheme({
...commonThemeSettings, // Распаковываем общие настройки
// Цветовая палитра для светлой темы
palette: { palette: {
mode: "light", mode: "light", // Режим светлой темы
// Фоновые цвета
background: { background: {
default: "#FFFFFF", default: "#FFFFFF", // Основной фон приложения
paper: "#FFFFFF", paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
}, },
// Текстовые цвета
text: { text: {
primary: "#000000", primary: "#000000", // Основной цвет текста
secondary: "#333333", // Вторичный цвет текста
}, },
// Основные цвета UI
primary: { primary: {
main: "#3d74c7", main: "#3d74c7", // Основной брендовый цвет
contrastText: "#FFFFFF", // Цвет текста на кнопках primary цвета
}, },
// Дополнительные цвета UI
secondary: { secondary: {
main: "#0f55bec2", main: "#0f55bec2", // Вторичный брендовый цвет
}, },
divider: "#e0e0e0", // Цвет разделителей
// Кастомные цвета для специфических элементов
custom: { custom: {
background: "#FFFFFF", background: "#D4EFFC", // Кастомный фоновый цвет
text: "#000000", text: "#000000", // Кастомный цвет текста
sidebar: "#3d74c7", sidebar: "#025EA1", // Фон боковой панели
sidebarText: "#FFFFFF", sidebarText: "#FFFFFF", // Текст в боковой панели
modalBackground: "#FFFFFF", sidebarHover: "rgba(255, 255, 255, 0.08)", // Цвет при наведении в боковой панели
modalBtnBackground: "#0f55bec2", modalBackground: "#FFFFFF", // Фон модальных окон
modalText: "#333333", modalBtnBackground: "#0f55bec2", // Фон кнопок в модальных окнах
tableBorder: "#ddd", modalText: "#333333", // Текст в модальных окнах
tableHeaderBackground: "#f9f9f9", tableBorder: "#ddd", // Границы таблиц
tableCellBackground: "#FFFFFF", tableHeaderBackground: "#f9f9f9", // Фон заголовков таблиц
tableText: "#000000", tableCellBackground: "#FFFFFF", // Фон ячеек таблиц
treeChartText: "#000000", tableText: "#000000", // Текст в таблицах
scrollbarTrack: "#f1f1f1", treeChartText: "#000000", // Текст в древовидных диаграммах
hoverButton: "#2d62b1", scrollbarTrack: "#f1f1f1", // Цвет трека скроллбара
hoverText: "#FFFFFF", hoverButton: "#2d62b1", // Цвет кнопок при наведении
hoverText: "#FFFFFF", // Цвет текста при наведении
}, },
// Цвета для различных состояний
action: {
hover: "rgba(0, 0, 0, 0.04)", // Цвет при наведении на интерактивные элементы
selected: "rgba(0, 0, 0, 0.08)", // Цвет выбранных элементов
}
}, },
}); });
/**
* Темная тема приложения
*/
export const darkTheme = createTheme({ export const darkTheme = createTheme({
...commonThemeSettings, // Распаковываем общие настройки
// Цветовая палитра для темной темы
palette: { palette: {
mode: "dark", mode: "dark", // Режим темной темы
// Фоновые цвета
background: { background: {
default: "#1E1E1E", default: "#2d2d2d", // Основной фон приложения
paper: "#2d2d2d", paper: "#2d2d2d", // Фон "бумажных" поверхностей
}, },
// Текстовые цвета
text: { text: {
primary: "#E0E0E0", primary: "#E0E0E0", // Основной цвет текста
secondary: "#B0B0B0", // Вторичный цвет текста
}, },
// Основные цвета UI
primary: { primary: {
main: "#2d2d2d", main: "#3d74c7", // Основной брендовый цвет (может совпадать со светлой темой)
contrastText: "#FFFFFF", // Цвет текста на кнопках primary цвета
}, },
// Дополнительные цвета UI
secondary: { secondary: {
main: "#333333", main: "#0f55bec2", // Вторичный брендовый цвет
}, },
divider: "#444444", // Цвет разделителей
// Кастомные цвета для специфических элементов
custom: { custom: {
background: "#1E1E1E", background: "#1E1E1E", // Кастомный фоновый цвет
text: "#E0E0E0", text: "#E0E0E0", // Кастомный цвет текста
sidebar: "#2d2d2d", sidebar: "#2d2d2d", // Фон боковой панели
sidebarText: "#E0E0E0", sidebarText: "#E0E0E0", // Текст в боковой панели
modalBackground: "#2d2d2d", sidebarHover: "rgba(255, 255, 255, 0.16)", // Цвет при наведении в боковой панели
modalBtnBackground: "#333333", modalBackground: "#2d2d2d", // Фон модальных окон
modalText: "#FFFFFF", modalBtnBackground: "#333333", // Фон кнопок в модальных окнах
tableBorder: "#444444", modalText: "#FFFFFF", // Текст в модальных окнах
tableHeaderBackground: "#2d2d2d", tableBorder: "#444444", // Границы таблиц
tableCellBackground: "#333333", tableHeaderBackground: "#2d2d2d", // Фон заголовков таблиц
tableText: "#E0E0E0", tableCellBackground: "#333333", // Фон ячеек таблиц
treeChartText: "#FFFFFF", tableText: "#E0E0E0", // Текст в таблицах
scrollbarTrack: "#333", treeChartText: "#FFFFFF", // Текст в древовидных диаграммах
hoverButton: "#333d4d", scrollbarTrack: "#333", // Цвет трека скроллбара
hoverText: "#E0E0E0", hoverButton: "#333d4d", // Цвет кнопок при наведении
hoverText: "#E0E0E0", // Цвет текста при наведении
}, },
// Цвета для различных состояний
action: {
hover: "rgba(255, 255, 255, 0.08)", // Цвет при наведении на интерактивные элементы
selected: "rgba(255, 255, 255, 0.16)", // Цвет выбранных элементов
}
}, },
}); });

319
src/assets/images/logo.svg Normal file
View File

@ -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

View File

@ -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

View File

@ -83,7 +83,7 @@ button:focus-visible {
/* Фон скроллбара */ /* Фон скроллбара */
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: var(--scrollbar-track-color, #f1f1f1); background: var(--scrollbar-track-color, #025EA1);
/* Цвет фона */ /* Цвет фона */
border-radius: 10px; border-radius: 10px;
/* Скругление углов */ /* Скругление углов */
@ -91,7 +91,7 @@ button:focus-visible {
/* Ползунок */ /* Ползунок */
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #3d74c7; background: #D4EFFC;
/* Основной цвет */ /* Основной цвет */
border-radius: 10px; border-radius: 10px;
/* Скругляем края */ /* Скругляем края */

View File

@ -2,8 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
//import './Style/light-theme.css'; // Подключаем светлую тему по умолчанию
//import './Style/dark-theme.css'; // Подключаем темную тему
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>

View File

@ -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']
}
})