Compare commits
284
Commits
896f1ee3a7
...
v4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6473ff8bbd | ||
|
|
e99700a916 | ||
|
|
3e0c968e7f | ||
|
|
a3291d6b2b | ||
|
|
0746fb4f1e | ||
|
|
f1fd378f83 | ||
|
|
23d7b67813 | ||
|
|
0703d2eccd | ||
|
|
a5bada97bf | ||
|
|
ccd1b15683 | ||
|
|
50a7edd50f | ||
|
|
972f3ab5ca | ||
|
|
8539317f2d | ||
|
|
4705e2489b | ||
|
|
a71072168d | ||
|
|
e529aa23ec | ||
|
|
164507513c | ||
|
|
b9d45bb8b7 | ||
|
|
fd9b8b3c22 | ||
|
|
81347c6c8d | ||
|
|
897dd52eac | ||
|
|
99f8c5d0bf | ||
|
|
817f550f4d | ||
|
|
114d7b2731 | ||
|
|
5d66f04286 | ||
|
|
f77d71f22c | ||
|
|
e631ffab51 | ||
|
|
113fa59a63 | ||
|
|
1dcdc0e6c2 | ||
|
|
31ce1ae02a | ||
|
|
baa230c0d2 | ||
|
|
aab9ed0601 | ||
|
|
2f4b1f6671 | ||
|
|
e1198b0c91 | ||
|
|
b792cede50 | ||
|
|
79828eace9 | ||
|
|
8012968ab5 | ||
|
|
624c0d6fd3 | ||
|
|
f5829fb35c | ||
|
|
7b7ef1ab45 | ||
|
|
963828f9e8 | ||
|
|
661b42f776 | ||
|
|
d31c90b60e | ||
|
|
d68b3aa104 | ||
|
|
d53100f5bd | ||
|
|
e1b5bb4c97 | ||
|
|
d43a77e368 | ||
|
|
945d67289a | ||
|
|
489cb3b312 | ||
|
|
6010a95208 | ||
|
|
4d91b4fe04 | ||
|
|
b76cbc15cb | ||
|
|
d657b03abc | ||
|
|
7029fa2897 | ||
|
|
dcf1853efb | ||
|
|
80de7160b9 | ||
|
|
53be4cd4ad | ||
|
|
228a1b9525 | ||
|
|
b09ba5cd77 | ||
|
|
cc92502de2 | ||
|
|
9de7fd104c | ||
|
|
1f391d9398 | ||
|
|
b56df61c8f | ||
|
|
8035b7751f | ||
|
|
657c23d237 | ||
|
|
6290a84cd3 | ||
|
|
07bcbe70c2 | ||
|
|
3655428da4 | ||
|
|
3840a91ba5 | ||
|
|
69e360b940 | ||
|
|
4370e13524 | ||
|
|
f48e30ab44 | ||
|
|
5e6435e2e1 | ||
|
|
02a45fce71 | ||
|
|
71685ad310 | ||
|
|
00d977dfb5 | ||
|
|
574c0d25c2 | ||
|
|
513a22008b | ||
|
|
2e02d550f6 | ||
|
|
b35f787626 | ||
|
|
e4394ef35c | ||
|
|
a44b49fdeb | ||
|
|
3f41bd4c7c | ||
|
|
f0db9ed013 | ||
|
|
d397612fa5 | ||
|
|
00afe2d6b2 | ||
|
|
0b5ec5bd85 | ||
|
|
a14f10d048 | ||
|
|
3fda43cf2c | ||
|
|
353d14d64e | ||
|
|
c3fba50de8 | ||
|
|
9798e7fe95 | ||
|
|
484dd56312 | ||
|
|
586f8246fb | ||
|
|
800f94354b | ||
|
|
fbd740aef7 | ||
|
|
7d3804202f | ||
|
|
1548de7064 | ||
|
|
431314b973 | ||
|
|
4c7af43be7 | ||
|
|
1be7e3bb22 | ||
|
|
64d9178784 | ||
|
|
8c13ae9df4 | ||
|
|
f80e962767 | ||
|
|
afa093781e | ||
|
|
f0cb694fa6 | ||
|
|
28a6978243 | ||
|
|
236f9f9d2e | ||
|
|
e72b3f539d | ||
|
|
56f1baa7ba | ||
|
|
ef7a4817c9 | ||
|
|
8e8ce7cec0 | ||
|
|
4ad5821b5e | ||
|
|
dfbd38d9a3 | ||
|
|
c3c30b1c75 | ||
|
|
f9ac5ac3a3 | ||
|
|
357503ee2f | ||
|
|
e269b14f5e | ||
|
|
9a8b72de82 | ||
|
|
8cc3575902 | ||
|
|
1fda3a5f4e | ||
|
|
fdc39060a1 | ||
|
|
283ab53b80 | ||
|
|
89d71ff060 | ||
|
|
b1834e988b | ||
|
|
6f13f39685 | ||
|
|
dbca38d31a | ||
|
|
fbe9a07727 | ||
|
|
8b362f0f55 | ||
|
|
9f85351b6b | ||
|
|
b5eb47e690 | ||
|
|
034e0d1eee | ||
|
|
29161ae0cb | ||
|
|
89c75f8e2e | ||
|
|
270cee077f | ||
|
|
1a283f64f6 | ||
|
|
99b346fd94 | ||
|
|
5c03ec7bd0 | ||
|
|
31ed777334 | ||
|
|
9dc931a10b | ||
|
|
d29f1aef51 | ||
|
|
ab4ba37d53 | ||
|
|
d9667a06e6 | ||
|
|
baa2fc8044 | ||
|
|
98b39c5703 | ||
|
|
c9babc1634 | ||
|
|
fe67f1c10b | ||
|
|
08693affc3 | ||
|
|
c475762fde | ||
|
|
965eb7467b | ||
|
|
48ee2a962e | ||
|
|
7be656c3f2 | ||
|
|
683f837b36 | ||
|
|
cfd7b53a54 | ||
|
|
d3b21d3dc0 | ||
|
|
f8a50581e4 | ||
|
|
5ede9e079d | ||
|
|
d6fc877e90 | ||
|
|
e0c7ec32d6 | ||
|
|
e071d5060f | ||
|
|
c3d41a32d8 | ||
|
|
d3ebe5b2b1 | ||
|
|
17cd5a8f43 | ||
|
|
7b92d1eb70 | ||
|
|
94337c8980 | ||
|
|
820b73e622 | ||
|
|
48f47d66ca | ||
|
|
f38f682a6f | ||
|
|
18926a7274 | ||
|
|
f67a8f888c | ||
|
|
73f03c21cd | ||
|
|
3514276855 | ||
|
|
ba0557e877 | ||
|
|
1959b1d1d0 | ||
|
|
8278017459 | ||
|
|
2b957c6d9a | ||
|
|
630ceb30b7 | ||
|
|
4c5aaf60bd | ||
|
|
60f55e447f | ||
|
|
a0b5eb910f | ||
|
|
06799406a3 | ||
|
|
49b62fbfb5 | ||
|
|
7cd33384b7 | ||
|
|
e09976b64e | ||
|
|
804f132d42 | ||
|
|
8f3f1bd8a4 | ||
|
|
9482ab6d70 | ||
|
|
d799c38edf | ||
|
|
29fcee8ab1 | ||
|
|
4c05913694 | ||
|
|
3f42c6226a | ||
|
|
89a86f7c72 | ||
|
|
17ab2a30ec | ||
|
|
21bd829979 | ||
|
|
c0d35c2e51 | ||
|
|
ee357ed59a | ||
|
|
838ca6b220 | ||
|
|
dfcbdb35be | ||
|
|
a3023daac9 | ||
|
|
7af6719f4b | ||
|
|
dd94f9f887 | ||
|
|
1f49b02ba0 | ||
|
|
0935eacd3f | ||
|
|
f2c3562000 | ||
|
|
6fad114b22 | ||
|
|
f380be5d21 | ||
|
|
87f8283191 | ||
|
|
f13b532f1a | ||
|
|
915304f800 | ||
|
|
3dbdb6f794 | ||
|
|
08a64b5be4 | ||
|
|
5af5abb02d | ||
|
|
8ad4f23a49 | ||
|
|
5c57e160fd | ||
|
|
46c9515ed1 | ||
|
|
a6cf9f7130 | ||
|
|
cf6d8b61a3 | ||
|
|
9784ec581e | ||
|
|
75d2366462 | ||
|
|
700e6674e5 | ||
|
|
4ce3bf4a9f | ||
|
|
f8713e0bf9 | ||
|
|
23c863714c | ||
|
|
3fade5f1c3 | ||
|
|
7509d1c781 | ||
|
|
1c953ed9d8 | ||
|
|
59082aa0d0 | ||
|
|
6efe497e18 | ||
|
|
57db189375 | ||
|
|
38ece61b31 | ||
|
|
a80740b802 | ||
|
|
dbc94d71a0 | ||
|
|
eca744999c | ||
|
|
cfcb44d5f0 | ||
|
|
571ec06522 | ||
|
|
698d42ce31 | ||
|
|
ebec5c613f | ||
|
|
0e0f4380d7 | ||
|
|
62387aa6d5 | ||
|
|
a322de4129 | ||
|
|
94efee905a | ||
|
|
3a7578ce26 | ||
|
|
af8af84ab7 | ||
|
|
1fe9cf4794 | ||
|
|
01a15d97c6 | ||
|
|
336a5b42a6 | ||
|
|
5cc19b8c3e | ||
|
|
b87af004ee | ||
|
|
8a8a55e7b6 | ||
|
|
79ca6e2d05 | ||
|
|
34a52ed826 | ||
|
|
c7f55dfee9 | ||
|
|
caa566aa8f | ||
|
|
87064cb89d | ||
|
|
9f058e5811 | ||
|
|
e7650b961b | ||
|
|
0387491ada | ||
|
|
d79f831032 | ||
|
|
a42e4189c3 | ||
|
|
73bc623fdf | ||
|
|
dd089453bc | ||
|
|
2d6c90ab31 | ||
|
|
d76355b6ef | ||
|
|
9fbda4f4e5 | ||
|
|
337a4ce944 | ||
|
|
56b264a724 | ||
|
|
635b791118 | ||
|
|
1e28fa33f5 | ||
|
|
3b60a5885b | ||
|
|
ba006edec0 | ||
|
|
6077d3bc5b | ||
|
|
c75f5f6b82 | ||
|
|
219ca7450c | ||
|
|
c792645a55 | ||
|
|
793b8acaea | ||
|
|
6b8e060d54 | ||
|
|
bdc7dd2bb3 | ||
|
|
562f09753b | ||
|
|
91fd3baccc | ||
|
|
9a51265a4f | ||
|
|
a2910832be | ||
|
|
ba6c7b9618 | ||
|
|
6a90f4079d | ||
|
|
1651700609 |
@@ -0,0 +1,110 @@
|
|||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H5","location":"frontend/src/App.jsx:route-change","message":"Route changed in MainLayout","data":{"pathname":"/easy-switch","layout":"fluid"},"timestamp":1771918574971}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/alerts"},"timestamp":1771918574974}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/communities"},"timestamp":1771918574974}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/communities","status":200},"timestamp":1771918575149}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/servers"},"timestamp":1771918575150}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/alerts","status":200},"timestamp":1771918575158}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/network-config"},"timestamp":1771918575261}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/servers","status":200},"timestamp":1771918575260}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/network-config","status":200},"timestamp":1771918575342}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-configs"},"timestamp":1771918575343}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-configs","status":200},"timestamp":1771918575431}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/srv-5"},"timestamp":1771918575432}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-vpsville"},"timestamp":1771918575432}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-ihor"},"timestamp":1771918575431}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-dc"},"timestamp":1771918575432}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/nsk-servhost"},"timestamp":1771918575432}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-vpsville","status":200},"timestamp":1771918575519}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-ihor","status":200},"timestamp":1771918575641}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/srv-5","status":200},"timestamp":1771918575645}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/nsk-servhost","status":200},"timestamp":1771918575648}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-dc","status":200},"timestamp":1771918575649}
|
||||||
|
{"sessionId":"378b5f","runId":"post-fix","hypothesisId":"H6","location":"frontend/src/contexts/PingContext.jsx:requestPings","message":"Ping batch scheduled","data":{"scope":"easy-switch","count":20,"force":false},"timestamp":1771918575650}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575651}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575652}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771918575653}
|
||||||
|
{"sessionId":"378b5f","runId":"post-fix","hypothesisId":"H6","location":"frontend/src/contexts/PingContext.jsx:requestPings","message":"Ping batch scheduled","data":{"scope":"easy-switch","count":0,"force":false},"timestamp":1771918575667}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918581171}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918581264}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918585199}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918585287}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918589219}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918589300}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918590962}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918593250}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918593325}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918594992}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918596225}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918599017}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918600849}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918601366}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918603038}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918604878}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918606344}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918608918}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918610433}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"POST","url":"/mikrotik/ping","status":200},"timestamp":1771918614512}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/alerts"},"timestamp":1771918634979}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/alerts","status":200},"timestamp":1771918635314}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/alerts"},"timestamp":1771918694983}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/alerts","status":200},"timestamp":1771918695315}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/alerts"},"timestamp":1771919865915}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/communities"},"timestamp":1771919865915}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H5","location":"frontend/src/App.jsx:route-change","message":"Route changed in MainLayout","data":{"pathname":"/easy-switch","layout":"fluid"},"timestamp":1771919865914}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/servers"},"timestamp":1771919865941}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/communities","status":200},"timestamp":1771919865941}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/alerts","status":200},"timestamp":1771919865942}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/servers","status":200},"timestamp":1771919865956}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/network-config"},"timestamp":1771919865956}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/network-config","status":200},"timestamp":1771919866039}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-configs"},"timestamp":1771919866040}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-ihor"},"timestamp":1771919866114}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-vpsville"},"timestamp":1771919866114}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-configs","status":200},"timestamp":1771919866113}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/srv-5"},"timestamp":1771919866114}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/msk-dc"},"timestamp":1771919866114}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"GET","url":"/server-filters/nsk-servhost"},"timestamp":1771919866114}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-ihor","status":200},"timestamp":1771919866194}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-vpsville","status":200},"timestamp":1771919866308}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/srv-5","status":200},"timestamp":1771919866314}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/msk-dc","status":200},"timestamp":1771919866322}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:response","message":"API response received","data":{"method":"GET","url":"/server-filters/nsk-servhost","status":200},"timestamp":1771919866323}
|
||||||
|
{"sessionId":"378b5f","runId":"post-fix","hypothesisId":"H6","location":"frontend/src/contexts/PingContext.jsx:requestPings","message":"Ping batch scheduled","data":{"scope":"easy-switch","count":20,"force":false},"timestamp":1771919866323}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866325}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866326}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"baseline","hypothesisId":"H1_H2","location":"frontend/src/lib/api.js:request","message":"API request started","data":{"method":"POST","url":"/mikrotik/ping"},"timestamp":1771919866327}
|
||||||
|
{"sessionId":"378b5f","runId":"post-fix","hypothesisId":"H6","location":"frontend/src/contexts/PingContext.jsx:requestPings","message":"Ping batch scheduled","data":{"scope":"easy-switch","count":0,"force":false},"timestamp":1771919866389}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
---
|
||||||
|
name: Анализ и оптимизация Router Lists UI
|
||||||
|
overview: Комплексный анализ проекта Router Lists UI (RouterOS Manager) — управление BGP-списками, MikroTik и S3. План включает предложения по новым фичам и оптимизациям архитектуры, производительности и качества кода.
|
||||||
|
todos: []
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Анализ проекта Router Lists UI
|
||||||
|
|
||||||
|
## Текущее состояние проекта
|
||||||
|
|
||||||
|
**Router Lists UI** — fullstack-приложение для управления BGP-списками (домены, IP-диапазоны, ASN), фильтрами и конфигурациями MikroTik на базе S3 (Yandex Object Storage).
|
||||||
|
|
||||||
|
### Архитектура
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend [Frontend - Vite + React]
|
||||||
|
App[App.jsx]
|
||||||
|
Pages[20+ страниц]
|
||||||
|
Hooks[useApiQuery, useErrorHandler]
|
||||||
|
API[api.js + axios]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend [Backend - Express]
|
||||||
|
Server[server.js]
|
||||||
|
Routes[30+ роутов]
|
||||||
|
S3Service[s3Service]
|
||||||
|
Schedulers[4 планировщика]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph External [Внешние сервисы]
|
||||||
|
S3[S3 / Yandex Object Storage]
|
||||||
|
MikroTik[MikroTik RouterOS API]
|
||||||
|
BGP[BGP Background Service]
|
||||||
|
end
|
||||||
|
|
||||||
|
App --> Pages
|
||||||
|
Pages --> Hooks
|
||||||
|
Hooks --> API
|
||||||
|
API -->|REST| Server
|
||||||
|
Server --> Routes
|
||||||
|
Routes --> S3Service
|
||||||
|
S3Service --> S3
|
||||||
|
Routes --> MikroTik
|
||||||
|
Server --> Schedulers
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Технологический стек
|
||||||
|
|
||||||
|
|
||||||
|
| Слой | Технологии |
|
||||||
|
| -------- | ------------------------------------------------------------------------------- |
|
||||||
|
| Frontend | React 19, Vite 7, Tabler, TanStack Query, React Router, Recharts, @xyflow/react |
|
||||||
|
| Backend | Express, AWS SDK (S3), Pino, Prometheus, Helmet, Rate Limit |
|
||||||
|
| Инфра | Docker, Gitea Actions, S3 |
|
||||||
|
|
||||||
|
|
||||||
|
### Сильные стороны
|
||||||
|
|
||||||
|
- Продуманная обработка ошибок (retry, ErrorBoundary, NetworkErrorHandler)
|
||||||
|
- ETag/If-None-Match для условных GET и кеширования
|
||||||
|
- Soft-locks для предотвращения гонок при редактировании
|
||||||
|
- Prometheus-метрики, health/ready endpoints
|
||||||
|
- i18n (RU/EN), темы (light/dark), два варианта layout
|
||||||
|
- Command Palette, горячие клавиши
|
||||||
|
- Типы в `frontend/src/types/index.ts` (частичная типизация)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендуемые фичи
|
||||||
|
|
||||||
|
### 1. Аутентификация и авторизация (высокий приоритет)
|
||||||
|
|
||||||
|
**Проблема:** API открыт без авторизации — любой с доступом к сети может изменять данные.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
- JWT или session-based auth (express-session + passport)
|
||||||
|
- Роли: `viewer` (только чтение), `editor` (CRUD), `admin` (всё + настройки)
|
||||||
|
- Middleware на backend для проверки токена/сессии
|
||||||
|
- Страница логина, защищённые роуты на frontend
|
||||||
|
|
||||||
|
### 2. Аудит-лог изменений
|
||||||
|
|
||||||
|
- Логирование всех мутаций (кто, когда, что изменил)
|
||||||
|
- Хранение в S3 (`audit/YYYY-MM-DD.jsonl`) или отдельная таблица
|
||||||
|
- UI: страница «История изменений» с фильтрами по ресурсу, пользователю, дате
|
||||||
|
|
||||||
|
### 3. Уведомления и алерты
|
||||||
|
|
||||||
|
- Расширить [AlertsContext](frontend/src/contexts/AlertsContext.jsx): email/Telegram при критичных событиях
|
||||||
|
- Настраиваемые триггеры: сервер недоступен, превышение лимита ресурсов
|
||||||
|
- Интеграция с webhook (например, Slack/Discord)
|
||||||
|
|
||||||
|
### 4. Экспорт/импорт в разных форматах
|
||||||
|
|
||||||
|
- Экспорт доменов/ASN/IP в CSV, JSON, plain text
|
||||||
|
- Импорт из CSV с маппингом колонок
|
||||||
|
- Bulk-операции: «заменить community для выбранных» уже есть — добавить «импорт из файла»
|
||||||
|
|
||||||
|
### 5. Расширенный поиск и фильтры
|
||||||
|
|
||||||
|
- Полнотекстовый поиск по доменам (при больших объёмах — индексация на backend)
|
||||||
|
- Фильтры: по community, по дате добавления (если добавить метаданные)
|
||||||
|
- Сохранённые фильтры (уже есть [SavedFilters](frontend/src/components/SavedFilters.jsx)) — синхронизация с backend
|
||||||
|
|
||||||
|
### 6. Дашборд и отчёты
|
||||||
|
|
||||||
|
- Кастомные виджеты на Dashboard (drag-and-drop)
|
||||||
|
- Отчёты: топ доменов по community, динамика изменений
|
||||||
|
- Scheduled reports (PDF/email по расписанию)
|
||||||
|
|
||||||
|
### 7. API-документация
|
||||||
|
|
||||||
|
- OpenAPI/Swagger для backend API
|
||||||
|
- Интерактивная документация (Swagger UI) на `/api-docs`
|
||||||
|
- Генерация клиента для внешних интеграций
|
||||||
|
|
||||||
|
### 8. Мобильная версия (PWA)
|
||||||
|
|
||||||
|
- Service Worker для офлайн-просмотра кешированных данных
|
||||||
|
- Адаптивные таблицы (уже используется Tabler)
|
||||||
|
- Установка как PWA (manifest.json)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Оптимизации проекта
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### 1. Code splitting и lazy loading
|
||||||
|
|
||||||
|
**Проблема:** Все 20+ страниц импортируются синхронно в [App.jsx](frontend/src/App.jsx) — большой начальный бандл.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Вместо: import Dashboard from './Dashboard';
|
||||||
|
const Dashboard = lazy(() => import('./Dashboard'));
|
||||||
|
// Обернуть Routes в <Suspense fallback={<PageSkeleton />}>
|
||||||
|
```
|
||||||
|
|
||||||
|
Применить для тяжёлых страниц: `TrafficDashboard`, `NetworkMapDashboard`, `RouteOptimizerPage`, `FilterManager`, `MikrotikTools`, `OspfToolsPage`.
|
||||||
|
|
||||||
|
#### 2. Виртуализация таблиц
|
||||||
|
|
||||||
|
- Используется `@tanstack/react-virtual` — проверить, что все большие списки (domains, ip-ranges, asns) используют виртуализацию
|
||||||
|
- В [DomainsNewManager](frontend/src/DomainsNewManager.jsx) — пагинация на backend (`offset`/`limit`), но при `limit=0` загружаются все данные — рассмотреть серверную пагинацию для больших объёмов
|
||||||
|
|
||||||
|
#### 3. Оптимизация API-запросов
|
||||||
|
|
||||||
|
- На Easy Switch при открытии идёт множество параллельных `POST /mikrotik/ping` — добавить batch endpoint `POST /mikrotik/ping-batch` для снижения нагрузки
|
||||||
|
- Prefetch при наведении на пункты меню (React Query `prefetchQuery`)
|
||||||
|
- Дедупликация запросов к `/communities` — уже кешируется в React Query, но вызывается из многих компонентов
|
||||||
|
|
||||||
|
#### 4. Унификация работы с данными
|
||||||
|
|
||||||
|
- Часть страниц использует [useApiQuery](frontend/src/hooks/useApiQuery.js) (domains, ipRanges, asns, servers, communities), часть — ручные `useState` + `api.get/post`
|
||||||
|
- Мигрировать `DomainsNewManager`, `IPRangesManager`, `ASNsNewManager` на единые хуки из `useApiQuery` для консистентности и оптимистичных обновлений
|
||||||
|
|
||||||
|
#### 5. Миграция на TypeScript
|
||||||
|
|
||||||
|
- Есть [types/index.ts](frontend/src/types/index.ts), но большинство компонентов — `.jsx`
|
||||||
|
- Поэтапная миграция: сначала `api.js`, `useApiQuery.js`, затем страницы
|
||||||
|
- Включить `strict` в tsconfig для новых файлов
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### 1. Кеширование S3-ответов
|
||||||
|
|
||||||
|
- Добавить in-memory cache (например, `node-cache`) для часто читаемых объектов (`communities`, `servers`, `filters`) с TTL 30–60 сек
|
||||||
|
- Уменьшит количество обращений к S3 при активной работе
|
||||||
|
|
||||||
|
#### 2. Пул соединений и таймауты
|
||||||
|
|
||||||
|
- Проверить настройки AWS SDK: connection pooling, таймауты для S3
|
||||||
|
- Для долгих операций (address-lists, speed-test) — рассмотреть фоновые задачи (очередь jobs)
|
||||||
|
|
||||||
|
#### 3. Валидация и санитизация
|
||||||
|
|
||||||
|
- Использовать единую схему валидации (Ajv) для всех JSON-эндпоинтов
|
||||||
|
- Добавить rate limit на чувствительные операции (apply config, run script)
|
||||||
|
|
||||||
|
#### 4. Структура роутов
|
||||||
|
|
||||||
|
- [server.js](backend/server.js) — ~450 строк, много роутов в одном файле
|
||||||
|
- Вынести группы роутов в отдельные файлы: `textDataRoutes`, `jsonDataRoutes`, `mikrotikRoutes` и подключать через `app.use('/api/...', router)`
|
||||||
|
|
||||||
|
### Инфраструктура и DevOps
|
||||||
|
|
||||||
|
#### 1. Тестирование
|
||||||
|
|
||||||
|
- **Сейчас:** `npm test` — заглушка, vitest упоминается в tsconfig, но тестов нет
|
||||||
|
- Добавить unit-тесты для: `validators`, `apiErrorHandler`, `s3Helpers`, `mikrotikConfig`
|
||||||
|
- E2E (Playwright) для критичных сценариев: логин, добавление домена, применение фильтра
|
||||||
|
|
||||||
|
#### 2. CI/CD
|
||||||
|
|
||||||
|
- Добавить шаг `npm run lint` и `npm run test` в Gitea Actions перед сборкой Docker
|
||||||
|
- Отдельный workflow для frontend: `npm run build` + проверка размера бандла
|
||||||
|
|
||||||
|
#### 3. Мониторинг
|
||||||
|
|
||||||
|
- Prometheus уже есть — добавить алерты (например, в Alertmanager) на `http_errors_total`, `http_request_duration_seconds`
|
||||||
|
- Health check: проверка доступности S3 в `/ready`
|
||||||
|
|
||||||
|
#### 4. Безопасность
|
||||||
|
|
||||||
|
- Регулярное обновление зависимостей (`npm audit`)
|
||||||
|
- Секреты (ENCRYPTION_KEY, AWS keys) — только через env, не в коде
|
||||||
|
- Рассмотреть Vault или аналог для production
|
||||||
|
|
||||||
|
### Документация и DX
|
||||||
|
|
||||||
|
- README — хороший, но API описан текстом; добавить ссылку на OpenAPI
|
||||||
|
- CONTRIBUTING.md с правилами коммитов, кодстайла
|
||||||
|
- Архитектурная диаграмма в репозитории (C4 или подобная)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Приоритизация
|
||||||
|
|
||||||
|
|
||||||
|
| Категория | Действие | Приоритет |
|
||||||
|
| ------------------ | -------------------------- | --------- |
|
||||||
|
| Безопасность | Аутентификация | Высокий |
|
||||||
|
| Производительность | Lazy loading страниц | Высокий |
|
||||||
|
| Производительность | Batch ping endpoint | Средний |
|
||||||
|
| Качество | Unit-тесты | Высокий |
|
||||||
|
| Качество | Миграция на TS | Средний |
|
||||||
|
| UX | Аудит-лог | Средний |
|
||||||
|
| UX | Расширенный экспорт/импорт | Низкий |
|
||||||
|
| Инфра | CI: lint + test | Высокий |
|
||||||
|
| Инфра | S3 in-memory cache | Средний |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
1. Выбрать 2–3 пункта из плана для первой итерации
|
||||||
|
2. Для аутентификации — определиться со стратегией (JWT vs session, хранение пользователей)
|
||||||
|
3. Для lazy loading — создать `PageSkeleton` и обновить роуты в App.jsx
|
||||||
|
4. Для тестов — настроить Vitest, написать первые тесты для `validators` и `apiErrorHandler`
|
||||||
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
name: Publish Fast Tabler Docker image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- tabler
|
|
||||||
- v3
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push-fast:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=moby/buildkit:v0.13.2
|
|
||||||
|
|
||||||
- name: Log in to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shts.su
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
|
||||||
|
|
||||||
- name: Build and push Fast Tabler Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile.fast
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64
|
|
||||||
cache-from: |
|
|
||||||
type=registry,ref=git.shts.su/${{ gitea.repository }}:tabler-fast
|
|
||||||
cache-to: type=inline
|
|
||||||
tags: |
|
|
||||||
git.shts.su/${{ gitea.repository }}:tabler-fast
|
|
||||||
git.shts.su/${{ gitea.repository }}:tabler-fast-${{ gitea.sha }}
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.title=S3 Lists Manager with Tabler UI (Fast Build)
|
|
||||||
org.opencontainers.image.description=Modern S3 Lists Manager with Tabler UI design - Fast Alpine build
|
|
||||||
org.opencontainers.image.version=tabler-fast
|
|
||||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
|
||||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
|
||||||
provenance: false
|
|
||||||
|
|
||||||
- name: Cleanup old images
|
|
||||||
run: |
|
|
||||||
echo "Skipping container registry cleanup (inline cache in image). Keep 3 latest image tags logic can be added with regctl if needed."
|
|
||||||
|
|
||||||
- name: Trigger container update webhook
|
|
||||||
run: |
|
|
||||||
curl -X POST -sS --fail https://docker.shts.su/api/webhooks/d3fd3b6a-c670-4035-9357-2fde8aa76b6e
|
|
||||||
@@ -4,6 +4,10 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- v4
|
||||||
|
- tabler
|
||||||
|
- v3
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -29,7 +33,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile.fast
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
@@ -38,8 +42,8 @@ jobs:
|
|||||||
git.shts.su/${{ gitea.repository }}:latest
|
git.shts.su/${{ gitea.repository }}:latest
|
||||||
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
|
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.title=S3 Lists Manager
|
org.opencontainers.image.title=RouterOS Manager
|
||||||
org.opencontainers.image.description=S3 Lists Manager for domains and AS management
|
org.opencontainers.image.description=RouterOS Manager for domains and AS management
|
||||||
org.opencontainers.image.version=latest
|
org.opencontainers.image.version=latest
|
||||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||||
@@ -47,5 +51,6 @@ jobs:
|
|||||||
BUILDKIT_INLINE_CACHE=1
|
BUILDKIT_INLINE_CACHE=1
|
||||||
|
|
||||||
- name: Trigger container update webhook
|
- name: Trigger container update webhook
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
curl -X POST -sS --fail https://docker.shts.su/api/webhooks/d3fd3b6a-c670-4035-9357-2fde8aa76b6e
|
curl -X POST -sS --fail "${{ secrets.portainer_webhook }}"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Docker Images
|
# Docker Images
|
||||||
|
|
||||||
Этот проект предоставляет Docker образы для S3 Lists Manager с двумя различными версиями интерфейса.
|
Этот проект предоставляет Docker образы для RouterOS Manager с двумя различными версиями интерфейса.
|
||||||
|
|
||||||
## 🏷️ Доступные теги
|
## 🏷️ Доступные теги
|
||||||
|
|
||||||
@@ -75,6 +75,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
|
||||||
|
# Прокси EvoBGP для UI (Communities / Domains / …) — без CORS в браузере:
|
||||||
|
# - EVOBGP_API_URL=http://evobgp:8080
|
||||||
|
# - EVOBGP_API_TOKEN=...
|
||||||
|
|
||||||
s3-lists-manager-tabler:
|
s3-lists-manager-tabler:
|
||||||
image: git.shts.su/[repository]:tabler
|
image: git.shts.su/[repository]:tabler
|
||||||
@@ -104,6 +107,28 @@ docker-compose up -d
|
|||||||
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
|
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
|
||||||
| `PORT` | Порт для запуска сервера | `3001` | Нет |
|
| `PORT` | Порт для запуска сервера | `3001` | Нет |
|
||||||
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
|
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
|
||||||
|
| `EVOBGP_API_URL` | Базовый URL API EvoBGP для серверного прокси `/api/evobgp` (например `http://evobgp:8080` или `http://10.0.0.5:8080`; при необходимости к пути добавляется `/v1`) | - | Да, если используете интеграцию EvoBGP в UI |
|
||||||
|
| `EVOBGP_API_TOKEN` | Bearer-токен к API EvoBGP (только на сервере, не в браузере) | - | Да, если используете интеграцию EvoBGP в UI |
|
||||||
|
|
||||||
|
### EvoBGP и reverse proxy (nginx)
|
||||||
|
|
||||||
|
Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI (иначе в upstream попадёт не тот путь и будет 404).
|
||||||
|
|
||||||
|
**Рекомендуемый вариант** (путь `/api/…` сохраняется):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3001/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Типичная ошибка**: внутри `location /api` указать `proxy_pass http://127.0.0.1:3001/;` — nginx **срезает** `/api`, в Node приходит `/evobgp/router-lists/catalog` вместо `/api/evobgp/router-lists/catalog`. В коде добавлен запасной маршрут на `/evobgp`, но лучше поправить nginx.
|
||||||
|
|
||||||
|
**Проверка**: `GET http://<хост>:<порт>/api/evobgp` (без хвоста) должен вернуть JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}` — если вместо этого HTML «404» или страница SPA, запрос **не доходит** до Node (только статика, другой порт или неверный `proxy_pass`).
|
||||||
|
|
||||||
### ENCRYPTION_KEY
|
### ENCRYPTION_KEY
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ COPY backend/services ./services
|
|||||||
COPY backend/utils ./utils
|
COPY backend/utils ./utils
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||||
|
|
||||||
|
# EvoBGP (справочник community / router-lists через прокси /api/evobgp, без CORS в браузере):
|
||||||
|
# EVOBGP_API_URL — например http://evobgp:8080 или http://192.168.1.10:8080 (к /v1 приведётся автоматически при необходимости)
|
||||||
|
# EVOBGP_API_TOKEN — Bearer-токен API EvoBGP
|
||||||
|
#
|
||||||
# ENCRYPTION_KEY должен быть установлен через переменные окружения при запуске контейнера
|
# ENCRYPTION_KEY должен быть установлен через переменные окружения при запуске контейнера
|
||||||
# Это обязательный параметр для работы с IPSec паролями
|
# Это обязательный параметр для работы с IPSec паролями
|
||||||
# Пример: docker run -e ENCRYPTION_KEY=your-64-hex-character-key ...
|
# Пример: docker run -e ENCRYPTION_KEY=your-64-hex-character-key ...
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=d
|
|||||||
- GET `/api/servers/availability?ttlSeconds=60` — быстрый TCP‑чек доступности нод.
|
- GET `/api/servers/availability?ttlSeconds=60` — быстрый TCP‑чек доступности нод.
|
||||||
- GET `/api/s3/last-modified` — метаданные S3 (etag/lastModified/contentLength) по ключевым файлам.
|
- GET `/api/s3/last-modified` — метаданные S3 (etag/lastModified/contentLength) по ключевым файлам.
|
||||||
- POST `/api/update-bgp/background` — прокси к фоновой задаче обновления BGP. Требует `BGP_BACKGROUND_URL` в `.env`.
|
- POST `/api/update-bgp/background` — прокси к фоновой задаче обновления BGP. Требует `BGP_BACKGROUND_URL` в `.env`.
|
||||||
- Locks: GET `/api/locks/:resource`, POST `/api/locks/:resource`, DELETE `/api/locks/:resource`.
|
- Locks: GET `/api/locks/:resource`, POST `/api/locks/:resource`, DELETE `/api/locks/:resource` (опциональные soft-locks для редактирования; доступ к S3 и долгие операции вроде speed-test блокировок не используют).
|
||||||
- History: GET `/api/history/:resource`, POST `/api/history/:resource/rollback`.
|
- History: GET `/api/history/:resource`, POST `/api/history/:resource/rollback`.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
@@ -138,7 +138,7 @@ frontend/ # Vite React UI
|
|||||||
## Лицензия
|
## Лицензия
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
# 📂 S3 Lists Manager
|
# 📂 RouterOS Manager
|
||||||
|
|
||||||
|
|
||||||
Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах:
|
Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Краткое описание
|
## Краткое описание
|
||||||
|
|
||||||
Проведена комплексная модернизация пользовательского интерфейса S3 Lists Manager с внедрением современных UX/UI практик, улучшением доступности и мобильного опыта.
|
Проведена комплексная модернизация пользовательского интерфейса RouterOS Manager с внедрением современных UX/UI практик, улучшением доступности и мобильного опыта.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,22 @@ function sanitizeString(str) {
|
|||||||
return cleaned.trim();
|
return cleaned.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация идентификатора сервера для использования в S3-ключах и путях.
|
||||||
|
* Запрещает path traversal (.., /, \) и небезопасные символы.
|
||||||
|
* @param {string} serverId - идентификатор сервера
|
||||||
|
* @param {object} opts - { maxLength: number } (по умолчанию 128)
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isValidServerId(serverId, opts = {}) {
|
||||||
|
if (!serverId || typeof serverId !== 'string') return false;
|
||||||
|
const maxLen = opts.maxLength != null ? opts.maxLength : 128;
|
||||||
|
const trimmed = serverId.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.length > maxLen) return false;
|
||||||
|
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) return false;
|
||||||
|
return /^[a-zA-Z0-9_.-]+$/.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверка на SQL injection паттерны
|
* Проверка на SQL injection паттерны
|
||||||
* @param {string} str - строка для проверки
|
* @param {string} str - строка для проверки
|
||||||
@@ -328,6 +344,7 @@ module.exports = {
|
|||||||
isValidASN,
|
isValidASN,
|
||||||
isValidCommunity,
|
isValidCommunity,
|
||||||
isValidGateway,
|
isValidGateway,
|
||||||
|
isValidServerId,
|
||||||
sanitizeString,
|
sanitizeString,
|
||||||
isSafeSQLString,
|
isSafeSQLString,
|
||||||
isSafeXSSString,
|
isSafeXSSString,
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/**
|
||||||
|
* Система оповещений: агрегирует проблемы из availability и resources/stats.
|
||||||
|
* Критерии и пороги задаются в ui-settings.alertSettings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sendError } = require('../middleware/errorHandler');
|
||||||
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
|
const { checkOneServerFast, loadUiSettingsSync } = require('./miscRoutes');
|
||||||
|
const { getResourceStatsData } = require('./resourceStatsRoutes');
|
||||||
|
const { getUptimeCacheData } = require('./mikrotikConfigRoutes');
|
||||||
|
const { buildConnections } = require('../services/networkMapScheduler');
|
||||||
|
const { readS3TextObject } = require('../services/s3Service');
|
||||||
|
|
||||||
|
/** Значения по умолчанию для настроек оповещений. */
|
||||||
|
const DEFAULT_ALERT_SETTINGS = {
|
||||||
|
serverOffline: { enabled: true, offlineMinutes: 5 },
|
||||||
|
mikrotikUnreachable: { enabled: true, durationMinutes: 5 },
|
||||||
|
highCpu: { enabled: true, thresholdPercent: 85, durationMinutes: 10 },
|
||||||
|
highRam: { enabled: true, thresholdPercent: 85, durationMinutes: 10 },
|
||||||
|
highHdd: { enabled: true, thresholdPercent: 90, durationMinutes: 10 },
|
||||||
|
/** Глобальные пороги карты сети для UI-аналитики */
|
||||||
|
networkMapNodeThresholds: {
|
||||||
|
lowSpeedMbps: 40,
|
||||||
|
belowNormSpeedMbps: 80,
|
||||||
|
normalSpeedMbps: 120,
|
||||||
|
},
|
||||||
|
/** Индивидуальные пороги по туннелям (карта сети) */
|
||||||
|
tunnelThresholds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAlertSettings(uiSettings) {
|
||||||
|
const raw = uiSettings?.alertSettings && typeof uiSettings.alertSettings === 'object' ? uiSettings.alertSettings : {};
|
||||||
|
return {
|
||||||
|
serverOffline: { ...DEFAULT_ALERT_SETTINGS.serverOffline, ...raw.serverOffline },
|
||||||
|
mikrotikUnreachable: { ...DEFAULT_ALERT_SETTINGS.mikrotikUnreachable, ...raw.mikrotikUnreachable },
|
||||||
|
highCpu: { ...DEFAULT_ALERT_SETTINGS.highCpu, ...raw.highCpu },
|
||||||
|
highRam: { ...DEFAULT_ALERT_SETTINGS.highRam, ...raw.highRam },
|
||||||
|
highHdd: { ...DEFAULT_ALERT_SETTINGS.highHdd, ...raw.highHdd },
|
||||||
|
networkMapNodeThresholds: {
|
||||||
|
...DEFAULT_ALERT_SETTINGS.networkMapNodeThresholds,
|
||||||
|
...(raw.networkMapNodeThresholds && typeof raw.networkMapNodeThresholds === 'object'
|
||||||
|
? raw.networkMapNodeThresholds
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
tunnelThresholds: Array.isArray(raw.tunnelThresholds) ? raw.tunnelThresholds : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgePingKey(a, b) {
|
||||||
|
return [String(a), String(b)].sort().join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function speedKey(key1, key2) {
|
||||||
|
return [String(key1), String(key2)].sort().join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/alerts
|
||||||
|
* Возвращает список активных оповещений (учёт настроек из ui-settings.alertSettings).
|
||||||
|
*/
|
||||||
|
async function getAlerts(req, res) {
|
||||||
|
try {
|
||||||
|
const uiSettings = await loadUiSettingsSync();
|
||||||
|
const settings = getAlertSettings(uiSettings);
|
||||||
|
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const alerts = [];
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// 1) Доступность серверов: jumphost/home — по правилам Uptime Monitor (кеш), остальные — TCP 80/443
|
||||||
|
if (settings.serverOffline.enabled) {
|
||||||
|
const routerTypes = ['jumphost', 'home'];
|
||||||
|
const { results: uptimeResults } = await getUptimeCacheData();
|
||||||
|
|
||||||
|
const nonRouterServers = servers.filter(
|
||||||
|
(s) => !routerTypes.includes(String(s.type || '').toLowerCase())
|
||||||
|
);
|
||||||
|
const availabilityChecks = await Promise.allSettled(
|
||||||
|
nonRouterServers.map((s) => checkOneServerFast(s))
|
||||||
|
);
|
||||||
|
|
||||||
|
servers.forEach((server) => {
|
||||||
|
const serverId = server.id || server.dns || server.ip;
|
||||||
|
const name = server.name || server.dns || server.ip || server.id || 'Сервер';
|
||||||
|
const isRouter = routerTypes.includes(String(server.type || '').toLowerCase());
|
||||||
|
|
||||||
|
let offline = false;
|
||||||
|
let description = '';
|
||||||
|
|
||||||
|
if (isRouter) {
|
||||||
|
const entry = uptimeResults[serverId];
|
||||||
|
const offlineMinutes = Math.max(1, Math.min(1440, Number(settings.serverOffline.offlineMinutes) || 5));
|
||||||
|
const offlineSinceMs = entry && entry.lastCheckTs ? (Date.now() - entry.lastCheckTs) : 0;
|
||||||
|
offline = entry && entry.ok === false && offlineSinceMs >= offlineMinutes * 60 * 1000;
|
||||||
|
description = offline
|
||||||
|
? `${name} недоступен более ${offlineMinutes} мин (по проверке Uptime Monitor).`
|
||||||
|
: '';
|
||||||
|
} else {
|
||||||
|
const idx = nonRouterServers.findIndex(
|
||||||
|
(s) => (s.id || s.dns || s.ip) === serverId
|
||||||
|
);
|
||||||
|
const online = idx >= 0 && availabilityChecks[idx].status === 'fulfilled' && availabilityChecks[idx].value;
|
||||||
|
offline = !online;
|
||||||
|
description = offline ? `${name} не отвечает на TCP (80/443).` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offline && description) {
|
||||||
|
alerts.push({
|
||||||
|
id: `server-offline-${serverId}`,
|
||||||
|
type: 'server_offline',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'Сервер недоступен',
|
||||||
|
description,
|
||||||
|
entity: name,
|
||||||
|
entityId: serverId,
|
||||||
|
link: isRouter ? '/uptime-monitor' : '/servers',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Ресурсы роутеров (MikroTik): ошибка доступа, высокое CPU/RAM/HDD
|
||||||
|
let resourceData;
|
||||||
|
try {
|
||||||
|
resourceData = await getResourceStatsData();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('alerts: getResourceStatsData failed', err?.message);
|
||||||
|
resourceData = { routers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const thCpu = Math.max(1, Math.min(100, Number(settings.highCpu.thresholdPercent) || 85));
|
||||||
|
const thRam = Math.max(1, Math.min(100, Number(settings.highRam.thresholdPercent) || 85));
|
||||||
|
const thHdd = Math.max(1, Math.min(100, Number(settings.highHdd.thresholdPercent) || 90));
|
||||||
|
|
||||||
|
const routers = resourceData.routers || [];
|
||||||
|
routers.forEach((router) => {
|
||||||
|
const { serverId, name, error, resource } = router;
|
||||||
|
const entityName = name || serverId;
|
||||||
|
|
||||||
|
if (error && settings.mikrotikUnreachable.enabled) {
|
||||||
|
alerts.push({
|
||||||
|
id: `mikrotik-error-${serverId}`,
|
||||||
|
type: 'mikrotik_unreachable',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'MikroTik недоступен',
|
||||||
|
description: `${entityName}: ${error}`,
|
||||||
|
entity: entityName,
|
||||||
|
entityId: serverId,
|
||||||
|
link: '/resource-stats',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource) return;
|
||||||
|
|
||||||
|
if (settings.highCpu.enabled && resource.cpuLoad != null && resource.cpuLoad >= thCpu) {
|
||||||
|
alerts.push({
|
||||||
|
id: `cpu-high-${serverId}`,
|
||||||
|
type: 'high_cpu',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Высокое использование CPU',
|
||||||
|
description: `${entityName}: ${resource.cpuLoad}% (порог ${thCpu}%).`,
|
||||||
|
entity: entityName,
|
||||||
|
entityId: serverId,
|
||||||
|
value: resource.cpuLoad,
|
||||||
|
threshold: thCpu,
|
||||||
|
link: '/resource-stats',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (settings.highRam.enabled && resource.memoryUsagePercent != null && resource.memoryUsagePercent >= thRam) {
|
||||||
|
alerts.push({
|
||||||
|
id: `ram-high-${serverId}`,
|
||||||
|
type: 'high_ram',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Высокое использование RAM',
|
||||||
|
description: `${entityName}: ${resource.memoryUsagePercent}% (порог ${thRam}%).`,
|
||||||
|
entity: entityName,
|
||||||
|
entityId: serverId,
|
||||||
|
value: resource.memoryUsagePercent,
|
||||||
|
threshold: thRam,
|
||||||
|
link: '/resource-stats',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (settings.highHdd.enabled && resource.hddUsagePercent != null && resource.hddUsagePercent >= thHdd) {
|
||||||
|
alerts.push({
|
||||||
|
id: `hdd-high-${serverId}`,
|
||||||
|
type: 'high_hdd',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Высокое использование диска',
|
||||||
|
description: `${entityName}: ${resource.hddUsagePercent}% (порог ${thHdd}%).`,
|
||||||
|
entity: entityName,
|
||||||
|
entityId: serverId,
|
||||||
|
value: resource.hddUsagePercent,
|
||||||
|
threshold: thHdd,
|
||||||
|
link: '/resource-stats',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Туннели (карта сети): индивидуальные пороги по пингу и скорости
|
||||||
|
const rawTunnelThresholds = Array.isArray(settings.tunnelThresholds)
|
||||||
|
? settings.tunnelThresholds
|
||||||
|
: [];
|
||||||
|
const tunnelThresholds = rawTunnelThresholds.filter(
|
||||||
|
(t) => t && (t.fromKey || t.toKey) && t.enabled !== false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tunnelThresholds.length > 0) {
|
||||||
|
let cache = null;
|
||||||
|
let connectionsData = null;
|
||||||
|
try {
|
||||||
|
const [rawCache, built] = await Promise.all([
|
||||||
|
readS3TextObject('network-map-cache/latest.json').catch(() => null),
|
||||||
|
buildConnections().catch(() => null),
|
||||||
|
]);
|
||||||
|
if (rawCache?.body) {
|
||||||
|
const parsed = JSON.parse(rawCache.body);
|
||||||
|
if (parsed && typeof parsed === 'object') cache = parsed;
|
||||||
|
}
|
||||||
|
if (built && typeof built === 'object') connectionsData = built;
|
||||||
|
} catch (e) {
|
||||||
|
// Ошибки при чтении кеша/конфигурации не должны ломать остальные оповещения
|
||||||
|
console.warn('alerts: network-map-cache read failed', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache && connectionsData && Array.isArray(connectionsData.connections)) {
|
||||||
|
const pingMap = cache.pingMap && typeof cache.pingMap === 'object' ? cache.pingMap : {};
|
||||||
|
const speedMap = cache.speedMap && typeof cache.speedMap === 'object' ? cache.speedMap : {};
|
||||||
|
|
||||||
|
const serversByIp = new Map();
|
||||||
|
servers.forEach((s) => {
|
||||||
|
if (s && s.ip) {
|
||||||
|
serversByIp.set(String(s.ip), s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const thresholdsByKey = new Map();
|
||||||
|
const makeKey = (fromKey, toKey, iface) => {
|
||||||
|
const a = String(fromKey || '');
|
||||||
|
const b = String(toKey || '');
|
||||||
|
const pair = [a, b].sort().join('__');
|
||||||
|
return `${pair}::${iface || ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
tunnelThresholds.forEach((t) => {
|
||||||
|
const key = makeKey(t.fromKey, t.toKey, t.interfaceName || '');
|
||||||
|
const maxPingMs =
|
||||||
|
typeof t.maxPingMs === 'number'
|
||||||
|
? t.maxPingMs
|
||||||
|
: t.thresholdMs != null
|
||||||
|
? Number(t.thresholdMs) || 0
|
||||||
|
: 0;
|
||||||
|
const minDownloadMbps =
|
||||||
|
typeof t.minDownloadMbps === 'number'
|
||||||
|
? t.minDownloadMbps
|
||||||
|
: t.minDownMbps != null
|
||||||
|
? Number(t.minDownMbps) || 0
|
||||||
|
: 0;
|
||||||
|
const minUploadMbps =
|
||||||
|
typeof t.minUploadMbps === 'number'
|
||||||
|
? t.minUploadMbps
|
||||||
|
: t.minUpMbps != null
|
||||||
|
? Number(t.minUpMbps) || 0
|
||||||
|
: 0;
|
||||||
|
thresholdsByKey.set(key, {
|
||||||
|
maxPingMs,
|
||||||
|
minDownloadMbps,
|
||||||
|
minUploadMbps,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
connectionsData.connections.forEach((c) => {
|
||||||
|
const fromIp = String(c.from);
|
||||||
|
const toIp = String(c.to);
|
||||||
|
if (!fromIp || !toIp || fromIp === toIp) return;
|
||||||
|
|
||||||
|
const fromServer = serversByIp.get(fromIp) || null;
|
||||||
|
const toServer = serversByIp.get(toIp) || null;
|
||||||
|
const fromName = fromServer?.name || fromServer?.dns || fromIp;
|
||||||
|
const toName = toServer?.name || toServer?.dns || toIp;
|
||||||
|
|
||||||
|
const fromKey = c.fromKey || fromIp;
|
||||||
|
const toKey = c.toKey || toIp;
|
||||||
|
const key = makeKey(fromKey, toKey, c.interfaceName || '');
|
||||||
|
const th = thresholdsByKey.get(key);
|
||||||
|
if (!th) return;
|
||||||
|
|
||||||
|
const maxPingMs = Math.max(0, Number(th.maxPingMs) || 0);
|
||||||
|
const minDownMbps = Math.max(0, Number(th.minDownloadMbps) || 0);
|
||||||
|
const minUpMbps = Math.max(0, Number(th.minUploadMbps) || 0);
|
||||||
|
|
||||||
|
const pingMapKey = edgePingKey(fromIp, toIp);
|
||||||
|
const pingMs = typeof pingMap[pingMapKey] === 'number' ? pingMap[pingMapKey] : null;
|
||||||
|
|
||||||
|
if (maxPingMs > 0 && pingMs != null && pingMs >= maxPingMs) {
|
||||||
|
alerts.push({
|
||||||
|
id: `tunnel-ping-${pingMapKey}-${maxPingMs}`,
|
||||||
|
type: 'tunnel_high_ping',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Высокий пинг по туннелю',
|
||||||
|
description: `Пинг между ${fromName} и ${toName}: ${pingMs} ms (порог ${maxPingMs} ms).`,
|
||||||
|
entity: `${fromName} ⇄ ${toName}`,
|
||||||
|
entityId: `${c.fromKey || fromIp}__${c.toKey || toIp}__${c.interfaceName || ''}`,
|
||||||
|
link: '/network-map',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((minDownMbps > 0 || minUpMbps > 0) && c.fromKey && c.toKey) {
|
||||||
|
const sk = speedKey(c.fromKey, c.toKey);
|
||||||
|
const speed = speedMap[sk];
|
||||||
|
if (speed && typeof speed === 'object') {
|
||||||
|
const downMbps =
|
||||||
|
typeof speed.tcpDownloadBps === 'number' ? speed.tcpDownloadBps / 1e6 : null;
|
||||||
|
const upMbps =
|
||||||
|
typeof speed.tcpUploadBps === 'number' ? speed.tcpUploadBps / 1e6 : null;
|
||||||
|
|
||||||
|
const downTooLow = minDownMbps > 0 && (downMbps == null || downMbps < minDownMbps);
|
||||||
|
const upTooLow = minUpMbps > 0 && (upMbps == null || upMbps < minUpMbps);
|
||||||
|
|
||||||
|
if (downTooLow || upTooLow) {
|
||||||
|
const parts = [];
|
||||||
|
if (downTooLow) {
|
||||||
|
parts.push(
|
||||||
|
`↓ ${downMbps != null ? downMbps.toFixed(1) : '—'} Мбит/с (порог ${minDownMbps} Мбит/с)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (upTooLow) {
|
||||||
|
parts.push(
|
||||||
|
`↑ ${upMbps != null ? upMbps.toFixed(1) : '—'} Мбит/с (порог ${minUpMbps} Мбит/с)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ifaceLabel = c.interfaceName ? `, интерфейс ${c.interfaceName}` : '';
|
||||||
|
alerts.push({
|
||||||
|
id: `tunnel-speed-${sk}-${minDownMbps}-${minUpMbps}`,
|
||||||
|
type: 'tunnel_low_speed',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Низкая скорость по туннелю',
|
||||||
|
description: `Туннель между ${fromName} и ${toName}${ifaceLabel}: ${parts.join(
|
||||||
|
'; '
|
||||||
|
)}.`,
|
||||||
|
entity: `${fromName} ⇄ ${toName}`,
|
||||||
|
entityId: `${c.fromKey || fromIp}__${c.toKey || toIp}__${c.interfaceName || ''}`,
|
||||||
|
link: '/network-map',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
alerts,
|
||||||
|
total: alerts.length,
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getAlerts:', error);
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
500,
|
||||||
|
error.message || 'Ошибка загрузки оповещений',
|
||||||
|
'E_ALERTS'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAlerts,
|
||||||
|
};
|
||||||
@@ -23,8 +23,8 @@ async function getCommunities(req, res) {
|
|||||||
name: c.name ? String(c.name) : '',
|
name: c.name ? String(c.name) : '',
|
||||||
description: c.description ? String(c.description) : '',
|
description: c.description ? String(c.description) : '',
|
||||||
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
||||||
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
|
color: c.color ? String(c.color) : '',
|
||||||
color: c.color ? String(c.color) : ''
|
icon: c.icon ? String(c.icon) : ''
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
@@ -68,8 +68,8 @@ async function postCommunities(req, res) {
|
|||||||
name: entry.name ? String(entry.name) : '',
|
name: entry.name ? String(entry.name) : '',
|
||||||
description: entry.description ? String(entry.description) : '',
|
description: entry.description ? String(entry.description) : '',
|
||||||
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
|
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
|
||||||
gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '',
|
|
||||||
color: entry.color ? String(entry.color) : '',
|
color: entry.color ? String(entry.color) : '',
|
||||||
|
icon: entry.icon ? String(entry.icon) : '',
|
||||||
category: entry.category ? String(entry.category) : '',
|
category: entry.category ? String(entry.category) : '',
|
||||||
priority: typeof entry.priority === 'number' ? entry.priority : 0,
|
priority: typeof entry.priority === 'number' ? entry.priority : 0,
|
||||||
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
|
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализует базовый URL API EvoBGP до вида …/v1 (без завершающего слэша).
|
||||||
|
*/
|
||||||
|
function normalizeEvobgpBaseUrl(raw) {
|
||||||
|
if (!raw || !String(raw).trim()) return '';
|
||||||
|
const input = String(raw).trim();
|
||||||
|
try {
|
||||||
|
const u = new URL(input);
|
||||||
|
let path = (u.pathname || '').replace(/\/+$/, '');
|
||||||
|
if (!path || path === '/') {
|
||||||
|
u.pathname = '/v1';
|
||||||
|
} else if (!/\/v1$/i.test(path)) {
|
||||||
|
u.pathname = `${path}/v1`;
|
||||||
|
} else {
|
||||||
|
u.pathname = path;
|
||||||
|
}
|
||||||
|
return `${u.origin}${u.pathname}`.replace(/\/+$/, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прокси EvoBGP: браузер бьёт в тот же origin (/api/evobgp/…), сервер добавляет Bearer и ходит в EvoBGP по сети Docker (без CORS).
|
||||||
|
*/
|
||||||
|
function createEvobgpProxy({ logger = console } = {}) {
|
||||||
|
return function evobgpProxy(req, res) {
|
||||||
|
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
|
||||||
|
const token = String(process.env.EVOBGP_API_TOKEN || '').trim();
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
return res.status(503).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_NOT_CONFIGURED',
|
||||||
|
message:
|
||||||
|
'Прокси EvoBGP не настроен: задайте EVOBGP_API_URL (и при необходимости EVOBGP_API_TOKEN) в окружении контейнера.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
return res.status(503).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_NO_TOKEN',
|
||||||
|
message: 'Прокси EvoBGP: не задан EVOBGP_API_TOKEN в окружении сервера.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = req.url && req.url.length ? req.url : '/';
|
||||||
|
const targetUrl = `${base}${suffix.startsWith('/') ? suffix : `/${suffix}`}`;
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(targetUrl);
|
||||||
|
} catch {
|
||||||
|
return res.status(500).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_BAD_URL',
|
||||||
|
message: 'Некорректный EVOBGP_API_URL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lib = parsed.protocol === 'https:' ? https : http;
|
||||||
|
const headers = { ...req.headers };
|
||||||
|
delete headers.host;
|
||||||
|
delete headers.connection;
|
||||||
|
delete headers['content-length'];
|
||||||
|
delete headers.cookie;
|
||||||
|
headers.authorization = `Bearer ${token}`;
|
||||||
|
headers.host = parsed.host;
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
timeout: Math.min(120000, Number(process.env.EVOBGP_PROXY_TIMEOUT_MS) || 60000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientReq = lib.request(opts, (clientRes) => {
|
||||||
|
res.status(clientRes.statusCode);
|
||||||
|
// Не отбрасываем content-encoding: иначе upstream может отдать gzip-тело,
|
||||||
|
// а клиент не узнает, что его нужно распаковать (в браузере будет «мусор»).
|
||||||
|
const skip = new Set(['connection', 'transfer-encoding']);
|
||||||
|
Object.keys(clientRes.headers).forEach((k) => {
|
||||||
|
if (!skip.has(k.toLowerCase())) {
|
||||||
|
res.setHeader(k, clientRes.headers[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clientRes.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
clientReq.on('error', (err) => {
|
||||||
|
try {
|
||||||
|
(req.log || logger).error({ err: err.message, target: targetUrl }, 'evobgp proxy');
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(502).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_UPSTREAM',
|
||||||
|
message: err.message || 'Не удалось связаться с EvoBGP',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientReq.on('timeout', () => {
|
||||||
|
clientReq.destroy();
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(504).json({
|
||||||
|
code: 'E_EVOBGP_PROXY_TIMEOUT',
|
||||||
|
message: 'Таймаут запроса к EvoBGP',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS' || req.method === 'GET' || req.method === 'HEAD') {
|
||||||
|
clientReq.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
clientReq.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body =
|
||||||
|
req.body !== undefined && req.body !== null && typeof req.body === 'object'
|
||||||
|
? JSON.stringify(req.body)
|
||||||
|
: typeof req.body === 'string'
|
||||||
|
? req.body
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
const buf = Buffer.from(body, 'utf8');
|
||||||
|
clientReq.setHeader('Content-Type', req.headers['content-type'] || 'application/json');
|
||||||
|
clientReq.setHeader('Content-Length', buf.length);
|
||||||
|
clientReq.end(buf);
|
||||||
|
} else {
|
||||||
|
clientReq.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createEvobgpProxy,
|
||||||
|
normalizeEvobgpBaseUrl,
|
||||||
|
};
|
||||||
@@ -298,7 +298,7 @@ async function runBackupNow(req, res) {
|
|||||||
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
|
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
|
||||||
});
|
});
|
||||||
|
|
||||||
return sendOk(res, {
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
key,
|
key,
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
+1479
-105
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
|
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3TextObject, headMeta, streamToString } = require('../services/s3Service');
|
const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, streamToString } = require('../services/s3Service');
|
||||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
const { resourceToKey, toIso } = require('../utils/helpers');
|
const { resourceToKey, toIso } = require('../utils/helpers');
|
||||||
const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
|
const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
@@ -309,6 +309,255 @@ function tcpCheck(host, port, timeoutMs) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Измерить RTT (мс) до host:port по TCP. Возвращает число мс или null при ошибке/таймауте. */
|
||||||
|
function measureTcpRtt(host, port = 443, timeoutMs = 6000) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let settled = false;
|
||||||
|
const settle = (ms) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
try { socket.destroy(); } catch (_) {}
|
||||||
|
resolve(ms);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.setTimeout(timeoutMs, () => settle(null));
|
||||||
|
socket.once('error', () => settle(null));
|
||||||
|
socket.connect(port, host, () => {
|
||||||
|
const ms = Math.round(Date.now() - start);
|
||||||
|
settle(ms);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список целей для пинга по умолчанию (id, name, host, port, icon, color) */
|
||||||
|
const PING_SERVICES_DEFAULT = [
|
||||||
|
{ id: 'google', name: 'Google', host: '8.8.8.8', port: 443, icon: 'IconBrandGoogle', color: 'blue' },
|
||||||
|
{ id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', port: 443, icon: 'IconBrandCloudflare', color: 'orange' },
|
||||||
|
{ id: 'yandex', name: 'Yandex', host: 'ya.ru', port: 443, icon: 'IconBrandYandex', color: 'red' },
|
||||||
|
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443, icon: 'IconBrandInstagram', color: 'pink' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getPingServicesListFromSettings(uiSettings) {
|
||||||
|
const raw = uiSettings.pingServicesList;
|
||||||
|
if (Array.isArray(raw) && raw.length > 0) {
|
||||||
|
return raw.map((s) => ({
|
||||||
|
id: String(s?.id ?? '').trim() || `svc-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
name: String(s?.name ?? '').trim() || 'Сервис',
|
||||||
|
host: String(s?.host ?? '').trim() || '0.0.0.0',
|
||||||
|
port: Math.max(1, parseInt(s?.port, 10) || 443),
|
||||||
|
icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld',
|
||||||
|
color: typeof s?.color === 'string' ? s.color : 'primary',
|
||||||
|
})).filter((s) => s.host !== '0.0.0.0');
|
||||||
|
}
|
||||||
|
return PING_SERVICES_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
|
||||||
|
async function loadUiSettingsSync() {
|
||||||
|
try {
|
||||||
|
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' }));
|
||||||
|
const jsonText = await streamToString(data.Body);
|
||||||
|
const parsed = JSON.parse(jsonText || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PING_SERVICES_CACHE_KEY_PREFIX = 'ping-services/cache_';
|
||||||
|
const PING_SERVICES_HISTORY_KEY_PREFIX = 'ping-services/history_';
|
||||||
|
const PING_HISTORY_MAX = 30;
|
||||||
|
|
||||||
|
function pingServicesStorageSuffix(viaRouter, serverId, gatewayIp) {
|
||||||
|
return (viaRouter ? 'router' : 'web') + '_' + String(serverId || '').replace(/[^a-zA-Z0-9.-]/g, '_') + '_' + String(gatewayIp || '').replace(/[^a-zA-Z0-9.]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать историю замеров пингов из S3. Формат: { byId: { [serviceId]: number[] } }.
|
||||||
|
*/
|
||||||
|
async function readPingServicesHistory(suffix) {
|
||||||
|
const key = PING_SERVICES_HISTORY_KEY_PREFIX + suffix;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(key).catch(() => null);
|
||||||
|
if (!raw?.body) return {};
|
||||||
|
const parsed = JSON.parse(raw.body);
|
||||||
|
return parsed?.byId && typeof parsed.byId === 'object' ? parsed.byId : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записать историю замеров пингов в S3.
|
||||||
|
*/
|
||||||
|
async function writePingServicesHistory(suffix, byId) {
|
||||||
|
const key = PING_SERVICES_HISTORY_KEY_PREFIX + suffix;
|
||||||
|
await writeS3JsonObject(key, { byId }).catch((err) => console.warn('[ping-services] history write failed:', err?.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить кеш пинг-сервисов: выполнить пинги по списку из настроек и записать результат в S3.
|
||||||
|
* Используется планировщиком и при промахе кеша в getPingServices.
|
||||||
|
* @returns {Promise<Record<string, { id: string, name: string, host: string, ms: number|null }>>} byId
|
||||||
|
*/
|
||||||
|
async function refreshPingServicesCache() {
|
||||||
|
const uiSettings = await loadUiSettingsSync();
|
||||||
|
const servicesList = getPingServicesListFromSettings(uiSettings);
|
||||||
|
const fallbackPayload = {};
|
||||||
|
servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
|
||||||
|
|
||||||
|
const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router';
|
||||||
|
const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0);
|
||||||
|
|
||||||
|
let serverId = null;
|
||||||
|
let gatewayIp = null;
|
||||||
|
if (viaRouter) {
|
||||||
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const routerServers = servers.filter(
|
||||||
|
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
|
||||||
|
);
|
||||||
|
serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip));
|
||||||
|
gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = pingServicesStorageSuffix(viaRouter, serverId, gatewayIp);
|
||||||
|
const cacheKey = cacheSeconds > 0 ? PING_SERVICES_CACHE_KEY_PREFIX + suffix : null;
|
||||||
|
|
||||||
|
if (viaRouter) {
|
||||||
|
const { runPingViaRouter } = require('./mikrotikConfigRoutes');
|
||||||
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const routerServers = servers.filter(
|
||||||
|
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
|
||||||
|
);
|
||||||
|
let gatewayIpResolved = gatewayIp;
|
||||||
|
if (!gatewayIpResolved && serverId && routerServers.length > 0) {
|
||||||
|
const server = routerServers.find((s) => (s.id || s.dns || s.ip) === serverId) || routerServers[0];
|
||||||
|
const gateways = Array.isArray(server.gateways) ? server.gateways : [];
|
||||||
|
const primary = gateways.find((g) => g && g.primary) || gateways[0];
|
||||||
|
gatewayIpResolved = primary && (primary.ip || primary.remoteIp) ? (primary.ip || primary.remoteIp) : null;
|
||||||
|
}
|
||||||
|
if (!serverId) {
|
||||||
|
console.warn('[ping-services] router mode: no serverId (no jumphost/home in settings or in servers list)');
|
||||||
|
return fallbackPayload;
|
||||||
|
}
|
||||||
|
const results = await Promise.all(
|
||||||
|
servicesList.map(async (svc) => {
|
||||||
|
try {
|
||||||
|
const result = await runPingViaRouter(serverId, gatewayIpResolved || null, svc.host, 3);
|
||||||
|
const ms = typeof result.avgMs === 'number' ? Math.round(result.avgMs) : null;
|
||||||
|
return { id: svc.id, name: svc.name, host: svc.host, ms };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ping-services] runPingViaRouter failed for', svc.host, err?.message || err);
|
||||||
|
return { id: svc.id, name: svc.name, host: svc.host, ms: null };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const byId = {};
|
||||||
|
results.forEach((r) => { byId[r.id] = r; });
|
||||||
|
if (cacheKey) {
|
||||||
|
writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message));
|
||||||
|
}
|
||||||
|
const history = await readPingServicesHistory(suffix);
|
||||||
|
Object.keys(byId).forEach((id) => {
|
||||||
|
const ms = byId[id]?.ms;
|
||||||
|
if (typeof ms !== 'number') return;
|
||||||
|
const list = Array.isArray(history[id]) ? history[id] : [];
|
||||||
|
history[id] = [...list, ms].slice(-PING_HISTORY_MAX);
|
||||||
|
});
|
||||||
|
await writePingServicesHistory(suffix, history);
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
servicesList.map(async (svc) => {
|
||||||
|
const ms = await measureTcpRtt(svc.host, svc.port, 6000);
|
||||||
|
return { id: svc.id, name: svc.name, host: svc.host, ms };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const byId = {};
|
||||||
|
results.forEach((r) => { byId[r.id] = r; });
|
||||||
|
if (cacheKey) {
|
||||||
|
writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message));
|
||||||
|
}
|
||||||
|
const history = await readPingServicesHistory(suffix);
|
||||||
|
Object.keys(byId).forEach((id) => {
|
||||||
|
const ms = byId[id]?.ms;
|
||||||
|
if (typeof ms !== 'number') return;
|
||||||
|
const list = Array.isArray(history[id]) ? history[id] : [];
|
||||||
|
history[id] = [...list, ms].slice(-PING_HISTORY_MAX);
|
||||||
|
});
|
||||||
|
await writePingServicesHistory(suffix, history);
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/ping-services-list — список сервисов для пинга из настроек (для дашборда и редактора)
|
||||||
|
async function getPingServicesList(req, res) {
|
||||||
|
try {
|
||||||
|
const uiSettings = await loadUiSettingsSync();
|
||||||
|
const list = getPingServicesListFromSettings(uiSettings);
|
||||||
|
return res.json({ list });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ping-services-list error', e);
|
||||||
|
return res.json({ list: PING_SERVICES_DEFAULT });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/ping-services — RTT до сервисов из списка (веб или через RouterOS по настройке)
|
||||||
|
async function getPingServices(req, res) {
|
||||||
|
try {
|
||||||
|
const uiSettings = await loadUiSettingsSync();
|
||||||
|
const servicesList = getPingServicesListFromSettings(uiSettings);
|
||||||
|
const fallbackPayload = {};
|
||||||
|
servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
|
||||||
|
|
||||||
|
const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router';
|
||||||
|
const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0);
|
||||||
|
|
||||||
|
let serverId = null;
|
||||||
|
let gatewayIp = null;
|
||||||
|
if (viaRouter) {
|
||||||
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const routerServers = servers.filter(
|
||||||
|
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
|
||||||
|
);
|
||||||
|
serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip));
|
||||||
|
gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = pingServicesStorageSuffix(viaRouter, serverId, gatewayIp);
|
||||||
|
const cacheKey = cacheSeconds > 0 ? PING_SERVICES_CACHE_KEY_PREFIX + suffix : null;
|
||||||
|
const skipCache = ['1', 'true', 'yes'].includes(String(req.query?.refresh || req.query?.nocache || '').toLowerCase());
|
||||||
|
if (cacheKey && !skipCache) {
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(cacheKey).catch(() => null);
|
||||||
|
if (raw?.body) {
|
||||||
|
const cached = JSON.parse(raw.body);
|
||||||
|
const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0;
|
||||||
|
const ttlMs = cacheSeconds * 1000;
|
||||||
|
if (cachedAt && Date.now() - cachedAt < ttlMs && cached.byId && typeof cached.byId === 'object') {
|
||||||
|
const history = await readPingServicesHistory(suffix);
|
||||||
|
return res.json({ byId: cached.byId, history });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = await refreshPingServicesCache();
|
||||||
|
const history = await readPingServicesHistory(suffix);
|
||||||
|
return res.json({ byId, history });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ping-services error', e);
|
||||||
|
const fallback = {};
|
||||||
|
PING_SERVICES_DEFAULT.forEach((s) => { fallback[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
|
||||||
|
res.status(500).json({ byId: fallback, history: {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function anyTrue(promises) {
|
function anyTrue(promises) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!Array.isArray(promises) || promises.length === 0) return resolve(false);
|
if (!Array.isArray(promises) || promises.length === 0) return resolve(false);
|
||||||
@@ -524,5 +773,10 @@ module.exports = {
|
|||||||
getWsUrl,
|
getWsUrl,
|
||||||
getUiSettings,
|
getUiSettings,
|
||||||
postUiSettings,
|
postUiSettings,
|
||||||
|
getPingServicesList,
|
||||||
|
getPingServices,
|
||||||
|
refreshPingServicesCache,
|
||||||
|
checkOneServerFast,
|
||||||
|
loadUiSettingsSync,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Роуты для сбора статистики ресурсов (RAM, CPU, HDD) с MikroTik RouterOS.
|
||||||
|
* GET /api/resources/stats — system/resource по каждому jumphost/home с учётами MikroTik.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sendError } = require('../middleware/errorHandler');
|
||||||
|
const { decrypt } = require('../utils/encryption');
|
||||||
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
|
const { createRosClient } = require('../services/mikrotikApplyService');
|
||||||
|
|
||||||
|
function getMikrotikCredentials(server) {
|
||||||
|
if (!server || (server.type !== 'jumphost' && server.type !== 'home')) return null;
|
||||||
|
const host = server.mikrotikHost || server.ip || server.dns;
|
||||||
|
if (!host) return null;
|
||||||
|
const port = server.mikrotikPort;
|
||||||
|
const restPort = (port === 80 || port === 443 || port === 8443) ? port : 80;
|
||||||
|
const user = server.mikrotikUser || 'admin';
|
||||||
|
let password = '';
|
||||||
|
if (server.encryptedMikrotikPassword) {
|
||||||
|
try {
|
||||||
|
password = decrypt(server.encryptedMikrotikPassword);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { host, port: restPort, user, password, secure: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсинг system/resource в нормализованный объект для UI.
|
||||||
|
* MikroTik REST возвращает: total-memory, free-memory (bytes), cpu-load (%),
|
||||||
|
* total-hdd-space, free-hdd-space (bytes), uptime, version, board-name и др.
|
||||||
|
*/
|
||||||
|
function parseResource(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const num = (v) => (v != null && v !== '' ? parseInt(String(v), 10) : null);
|
||||||
|
const totalMemory = num(raw['total-memory']);
|
||||||
|
const freeMemory = num(raw['free-memory']);
|
||||||
|
const usedMemory = totalMemory != null && freeMemory != null ? totalMemory - freeMemory : null;
|
||||||
|
const totalHdd = num(raw['total-hdd-space']);
|
||||||
|
const freeHdd = num(raw['free-hdd-space']);
|
||||||
|
const usedHdd = totalHdd != null && freeHdd != null ? totalHdd - freeHdd : null;
|
||||||
|
const cpuLoad = raw['cpu-load'] != null ? parseFloat(String(raw['cpu-load'])) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// RAM (bytes и проценты)
|
||||||
|
totalMemory: totalMemory,
|
||||||
|
freeMemory: freeMemory,
|
||||||
|
usedMemory: usedMemory,
|
||||||
|
memoryUsagePercent:
|
||||||
|
totalMemory != null && totalMemory > 0 && usedMemory != null
|
||||||
|
? Math.round((usedMemory / totalMemory) * 100)
|
||||||
|
: null,
|
||||||
|
// CPU (%)
|
||||||
|
cpuLoad: cpuLoad != null ? Math.round(cpuLoad) : null,
|
||||||
|
cpuCount: num(raw['cpu-count']) ?? null,
|
||||||
|
cpuFrequency: raw['cpu-frequency'] != null ? String(raw['cpu-frequency']) : null,
|
||||||
|
cpu: raw.cpu != null ? String(raw.cpu) : null,
|
||||||
|
// HDD (bytes и проценты)
|
||||||
|
totalHddSpace: totalHdd,
|
||||||
|
freeHddSpace: freeHdd,
|
||||||
|
usedHddSpace: usedHdd,
|
||||||
|
hddUsagePercent:
|
||||||
|
totalHdd != null && totalHdd > 0 && usedHdd != null
|
||||||
|
? Math.round((usedHdd / totalHdd) * 100)
|
||||||
|
: null,
|
||||||
|
// прочее
|
||||||
|
uptime: raw.uptime != null ? String(raw.uptime) : null,
|
||||||
|
version: raw.version != null ? String(raw.version) : null,
|
||||||
|
boardName: raw['board-name'] != null ? String(raw['board-name']) : null,
|
||||||
|
architectureName: raw['architecture-name'] != null ? String(raw['architecture-name']) : null,
|
||||||
|
platform: raw.platform != null ? String(raw.platform) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Внутренняя функция: возвращает данные по ресурсам роутеров (без HTTP).
|
||||||
|
*/
|
||||||
|
async function getResourceStatsData() {
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const routers = (Array.isArray(servers) ? servers : []).filter(
|
||||||
|
(s) =>
|
||||||
|
s &&
|
||||||
|
(String(s.type || '').toLowerCase() === 'jumphost' ||
|
||||||
|
String(s.type || '').toLowerCase() === 'home')
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
routers.map(async (server) => {
|
||||||
|
const serverId = server.id || server.dns || server.ip;
|
||||||
|
const name = server.name || server.dns || server.ip || serverId;
|
||||||
|
const host = server.mikrotikHost || server.ip || server.dns;
|
||||||
|
|
||||||
|
const creds = getMikrotikCredentials(server);
|
||||||
|
if (!creds) {
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
groupKey: server.groupKey || null,
|
||||||
|
error: 'MikroTik API не настроен или нет пароля',
|
||||||
|
resource: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const client = createRosClient(creds);
|
||||||
|
const apiRes = await client.print('system/resource');
|
||||||
|
const raw = apiRes?.data;
|
||||||
|
const single = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
const resource = parseResource(single);
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
groupKey: server.groupKey || null,
|
||||||
|
error: null,
|
||||||
|
resource,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS';
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
groupKey: server.groupKey || null,
|
||||||
|
error: msg,
|
||||||
|
resource: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { routers: results };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/resources/stats
|
||||||
|
* По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам.
|
||||||
|
*/
|
||||||
|
async function getResourceStats(req, res) {
|
||||||
|
try {
|
||||||
|
const data = await getResourceStatsData();
|
||||||
|
return res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getResourceStats:', error);
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
500,
|
||||||
|
error.message || 'Ошибка сбора статистики ресурсов',
|
||||||
|
'E_RESOURCE_STATS'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getResourceStats,
|
||||||
|
getResourceStatsData,
|
||||||
|
};
|
||||||
@@ -0,0 +1,859 @@
|
|||||||
|
/**
|
||||||
|
* Rule-based "local AI" optimizer for route selection.
|
||||||
|
* Returns best paths and probability of optimality for all candidates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sendError } = require('../middleware/errorHandler');
|
||||||
|
const { readS3TextObject, listS3Objects } = require('../services/s3Service');
|
||||||
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
|
|
||||||
|
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
||||||
|
const NETWORK_CONFIG_KEY = 'network-config.json';
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
|
||||||
|
const DEFAULT_AI_SETTINGS = {
|
||||||
|
latencyWeight: 0.55,
|
||||||
|
bandwidthWeight: 0.35,
|
||||||
|
freshnessWeight: 0.1,
|
||||||
|
downloadWeight: 0.5,
|
||||||
|
uploadWeight: 0.5,
|
||||||
|
combineHomeToJumphostWeight: 0.45,
|
||||||
|
combineJumphostToExitWeight: 0.55,
|
||||||
|
probabilityScale: 5,
|
||||||
|
minProbabilityGainForSwitch: 10,
|
||||||
|
noPingScore: 0.2,
|
||||||
|
noSpeedScore: 0.15,
|
||||||
|
staleScore: 0.35,
|
||||||
|
freshnessExcellentSeconds: 120,
|
||||||
|
freshnessGoodSeconds: 600,
|
||||||
|
freshnessFairSeconds: 1800,
|
||||||
|
distancePenaltyPerStep: 0.03,
|
||||||
|
pinnedCommunityGateways: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function asObject(v) {
|
||||||
|
return v && typeof v === 'object' ? v : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveNumber(v, fallback) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeNumber(v, fallback) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeServerKey(server) {
|
||||||
|
if (!server || typeof server !== 'object') return '';
|
||||||
|
return String(server.id || server.dns || server.ip || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveServerRef(ref, byRef) {
|
||||||
|
const key = String(ref || '').trim();
|
||||||
|
if (!key) return null;
|
||||||
|
return byRef.get(key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairKey(a, b) {
|
||||||
|
return [String(a || ''), String(b || '')].sort().join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairProbability(items, scoreGetter) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) return [];
|
||||||
|
const values = items.map((it) => Number(scoreGetter(it) || 0));
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const exps = values.map((v) => Math.exp(v - max));
|
||||||
|
const sum = exps.reduce((acc, x) => acc + x, 0) || 1;
|
||||||
|
return exps.map((x) => (x / sum) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyScore(pingMs, aiSettings) {
|
||||||
|
if (typeof pingMs !== 'number') return aiSettings.noPingScore;
|
||||||
|
const normalized = 1 / (1 + pingMs / 35);
|
||||||
|
return clamp(normalized, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBandwidthValue(speedMbps, aiSettings) {
|
||||||
|
if (typeof speedMbps !== 'number' || speedMbps <= 0) return aiSettings.noSpeedScore;
|
||||||
|
// log-scale to avoid dominance by very high channels
|
||||||
|
const normalized = Math.log10(1 + speedMbps) / Math.log10(1001);
|
||||||
|
return clamp(normalized, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshnessScore(cacheUpdatedAt, aiSettings) {
|
||||||
|
if (typeof cacheUpdatedAt !== 'number') return aiSettings.staleScore;
|
||||||
|
const ageMs = Math.max(0, Date.now() - cacheUpdatedAt);
|
||||||
|
const excellentMs = aiSettings.freshnessExcellentSeconds * 1000;
|
||||||
|
const goodMs = aiSettings.freshnessGoodSeconds * 1000;
|
||||||
|
const fairMs = aiSettings.freshnessFairSeconds * 1000;
|
||||||
|
if (ageMs <= excellentMs) return 1;
|
||||||
|
if (ageMs <= goodMs) return 0.8;
|
||||||
|
if (ageMs <= fairMs) return 0.6;
|
||||||
|
return aiSettings.staleScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandwidthScore({ speedDownloadMbps, speedUploadMbps, aiSettings }) {
|
||||||
|
// DL/UL considered separately with configurable weights.
|
||||||
|
const dl = normalizeBandwidthValue(speedDownloadMbps, aiSettings);
|
||||||
|
const ul = normalizeBandwidthValue(speedUploadMbps, aiSettings);
|
||||||
|
return clamp(dl * aiSettings.downloadWeight + ul * aiSettings.uploadWeight, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSegmentScore({
|
||||||
|
pingMs,
|
||||||
|
speedMbps,
|
||||||
|
speedDownloadMbps,
|
||||||
|
speedUploadMbps,
|
||||||
|
cacheUpdatedAt,
|
||||||
|
aiSettings,
|
||||||
|
}) {
|
||||||
|
const l = latencyScore(pingMs, aiSettings);
|
||||||
|
const b = bandwidthScore({ speedDownloadMbps, speedUploadMbps, aiSettings });
|
||||||
|
const f = freshnessScore(cacheUpdatedAt, aiSettings);
|
||||||
|
const hasAnySpeed =
|
||||||
|
typeof speedMbps === 'number' ||
|
||||||
|
typeof speedDownloadMbps === 'number' ||
|
||||||
|
typeof speedUploadMbps === 'number';
|
||||||
|
const metricPresence = (typeof pingMs === 'number' ? 1 : 0) + (hasAnySpeed ? 1 : 0);
|
||||||
|
const confidence = metricPresence === 2 ? 1 : metricPresence === 1 ? 0.65 : 0.35;
|
||||||
|
const score =
|
||||||
|
l * aiSettings.latencyWeight +
|
||||||
|
b * aiSettings.bandwidthWeight +
|
||||||
|
f * aiSettings.freshnessWeight;
|
||||||
|
return { score: clamp(score, 0, 1), confidence };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactServer(server) {
|
||||||
|
return {
|
||||||
|
id: server?.id || null,
|
||||||
|
ip: server?.ip || null,
|
||||||
|
dns: server?.dns || null,
|
||||||
|
type: server?.type || null,
|
||||||
|
country: server?.country || null,
|
||||||
|
label: server?.dns || server?.ip || server?.id || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRef(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServerRefSet(server) {
|
||||||
|
return new Set(collectServerRefs(server).map(normalizeRef).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPinnedCommunityGateway(pinnedCommunityGateways, jumphost, community) {
|
||||||
|
const targetCommunity = normalizeRef(community);
|
||||||
|
if (!targetCommunity || !Array.isArray(pinnedCommunityGateways)) return null;
|
||||||
|
const jumphostRefs = buildServerRefSet(jumphost);
|
||||||
|
for (const entry of pinnedCommunityGateways) {
|
||||||
|
const entryCommunity = normalizeRef(entry?.community);
|
||||||
|
if (!entryCommunity || entryCommunity !== targetCommunity) continue;
|
||||||
|
const pinnedGateway = normalizeRef(entry?.gateway);
|
||||||
|
if (!pinnedGateway) continue;
|
||||||
|
const pinnedJumphost = normalizeRef(entry?.jumphost);
|
||||||
|
if (!pinnedJumphost) return { community: entryCommunity, gateway: pinnedGateway, jumphost: null };
|
||||||
|
if (jumphostRefs.has(pinnedJumphost)) {
|
||||||
|
return { community: entryCommunity, gateway: pinnedGateway, jumphost: pinnedJumphost };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommunitiesIndex() {
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject('bgp_data/communities.json').catch(() => null);
|
||||||
|
if (!raw?.body) return new Map();
|
||||||
|
const arr = JSON.parse(raw.body || '[]');
|
||||||
|
const m = new Map();
|
||||||
|
if (!Array.isArray(arr)) return m;
|
||||||
|
for (const c of arr) {
|
||||||
|
const value = String(c?.value || '').trim();
|
||||||
|
if (!value) continue;
|
||||||
|
m.set(value, {
|
||||||
|
value,
|
||||||
|
name: c?.name ? String(c.name) : '',
|
||||||
|
description: c?.description ? String(c.description) : '',
|
||||||
|
tags: Array.isArray(c?.tags) ? c.tags.map(String) : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
} catch (_) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServerIdFromFilterKey(key) {
|
||||||
|
const m = String(key || '').match(/^filter-manager\/server-filters-(.+)\.json$/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServerFiltersByServerId() {
|
||||||
|
const out = new Map();
|
||||||
|
try {
|
||||||
|
const objects = await listS3Objects('filter-manager/server-filters-', { maxKeys: 500 });
|
||||||
|
for (const obj of objects) {
|
||||||
|
const key = obj?.key;
|
||||||
|
const serverId = parseServerIdFromFilterKey(key);
|
||||||
|
if (!serverId) continue;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(key).catch(() => null);
|
||||||
|
const parsed = raw?.body ? JSON.parse(raw.body || '[]') : [];
|
||||||
|
const list = Array.isArray(parsed) ? parsed : [];
|
||||||
|
out.set(serverId, list.filter((f) => f && f.community && f.gateway).map((f) => ({
|
||||||
|
community: String(f.community),
|
||||||
|
gateway: String(f.gateway),
|
||||||
|
description: f.description ? String(f.description) : '',
|
||||||
|
})));
|
||||||
|
} catch (_) {
|
||||||
|
out.set(serverId, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNetworkMapCache() {
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
|
||||||
|
if (!raw?.body) return { pingMap: {}, speedMap: {}, updatedAt: null };
|
||||||
|
const parsed = JSON.parse(raw.body || '{}');
|
||||||
|
return {
|
||||||
|
pingMap: asObject(parsed.pingMap),
|
||||||
|
speedMap: asObject(parsed.speedMap),
|
||||||
|
updatedAt: toNumber(parsed.updatedAt),
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return { pingMap: {}, speedMap: {}, updatedAt: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNetworkConfig() {
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(NETWORK_CONFIG_KEY).catch(() => null);
|
||||||
|
if (!raw?.body) return {};
|
||||||
|
const parsed = JSON.parse(raw.body || '{}');
|
||||||
|
return asObject(parsed);
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAiSettings() {
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => null);
|
||||||
|
const parsed = raw?.body ? JSON.parse(raw.body || '{}') : {};
|
||||||
|
const src = asObject(parsed?.aiRouteOptimizer);
|
||||||
|
const latencyWeight = positiveNumber(src.latencyWeight, DEFAULT_AI_SETTINGS.latencyWeight);
|
||||||
|
const bandwidthWeight = positiveNumber(src.bandwidthWeight, DEFAULT_AI_SETTINGS.bandwidthWeight);
|
||||||
|
const freshnessWeight = positiveNumber(src.freshnessWeight, DEFAULT_AI_SETTINGS.freshnessWeight);
|
||||||
|
const sum = latencyWeight + bandwidthWeight + freshnessWeight || 1;
|
||||||
|
const downloadWeight = nonNegativeNumber(src.downloadWeight, DEFAULT_AI_SETTINGS.downloadWeight);
|
||||||
|
const uploadWeight = nonNegativeNumber(src.uploadWeight, DEFAULT_AI_SETTINGS.uploadWeight);
|
||||||
|
const sumDu = downloadWeight + uploadWeight;
|
||||||
|
const hjW = positiveNumber(
|
||||||
|
src.combineHomeToJumphostWeight,
|
||||||
|
DEFAULT_AI_SETTINGS.combineHomeToJumphostWeight
|
||||||
|
);
|
||||||
|
const jeW = positiveNumber(
|
||||||
|
src.combineJumphostToExitWeight,
|
||||||
|
DEFAULT_AI_SETTINGS.combineJumphostToExitWeight
|
||||||
|
);
|
||||||
|
const sum2 = hjW + jeW || 1;
|
||||||
|
|
||||||
|
const pinnedCommunityGateways = Array.isArray(src.pinnedCommunityGateways)
|
||||||
|
? src.pinnedCommunityGateways
|
||||||
|
.map((x) => ({
|
||||||
|
community: normalizeRef(x?.community),
|
||||||
|
gateway: normalizeRef(x?.gateway),
|
||||||
|
jumphost: normalizeRef(x?.jumphost),
|
||||||
|
}))
|
||||||
|
.filter((x) => x.community && x.gateway)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
latencyWeight: latencyWeight / sum,
|
||||||
|
bandwidthWeight: bandwidthWeight / sum,
|
||||||
|
freshnessWeight: freshnessWeight / sum,
|
||||||
|
downloadWeight: sumDu > 0 ? downloadWeight / sumDu : DEFAULT_AI_SETTINGS.downloadWeight,
|
||||||
|
uploadWeight: sumDu > 0 ? uploadWeight / sumDu : DEFAULT_AI_SETTINGS.uploadWeight,
|
||||||
|
combineHomeToJumphostWeight: hjW / sum2,
|
||||||
|
combineJumphostToExitWeight: jeW / sum2,
|
||||||
|
probabilityScale: clamp(
|
||||||
|
positiveNumber(src.probabilityScale, DEFAULT_AI_SETTINGS.probabilityScale),
|
||||||
|
0.5,
|
||||||
|
20
|
||||||
|
),
|
||||||
|
minProbabilityGainForSwitch: clamp(
|
||||||
|
positiveNumber(
|
||||||
|
src.minProbabilityGainForSwitch,
|
||||||
|
DEFAULT_AI_SETTINGS.minProbabilityGainForSwitch
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
),
|
||||||
|
noPingScore: clamp(positiveNumber(src.noPingScore, DEFAULT_AI_SETTINGS.noPingScore), 0, 1),
|
||||||
|
noSpeedScore: clamp(positiveNumber(src.noSpeedScore, DEFAULT_AI_SETTINGS.noSpeedScore), 0, 1),
|
||||||
|
staleScore: clamp(positiveNumber(src.staleScore, DEFAULT_AI_SETTINGS.staleScore), 0, 1),
|
||||||
|
freshnessExcellentSeconds: clamp(
|
||||||
|
positiveNumber(src.freshnessExcellentSeconds, DEFAULT_AI_SETTINGS.freshnessExcellentSeconds),
|
||||||
|
10,
|
||||||
|
86400
|
||||||
|
),
|
||||||
|
freshnessGoodSeconds: clamp(
|
||||||
|
positiveNumber(src.freshnessGoodSeconds, DEFAULT_AI_SETTINGS.freshnessGoodSeconds),
|
||||||
|
10,
|
||||||
|
86400
|
||||||
|
),
|
||||||
|
freshnessFairSeconds: clamp(
|
||||||
|
positiveNumber(src.freshnessFairSeconds, DEFAULT_AI_SETTINGS.freshnessFairSeconds),
|
||||||
|
10,
|
||||||
|
86400
|
||||||
|
),
|
||||||
|
distancePenaltyPerStep: clamp(
|
||||||
|
positiveNumber(src.distancePenaltyPerStep, DEFAULT_AI_SETTINGS.distancePenaltyPerStep),
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
pinnedCommunityGateways,
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return { ...DEFAULT_AI_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap, cacheUpdatedAt, aiSettings }) {
|
||||||
|
const byRef = new Map();
|
||||||
|
servers.forEach((s) => {
|
||||||
|
const refs = [s.id, s.ip, s.dns].filter(Boolean).map((v) => String(v));
|
||||||
|
refs.forEach((ref) => byRef.set(ref, s));
|
||||||
|
});
|
||||||
|
|
||||||
|
const homeToJh = [];
|
||||||
|
const jhToExit = [];
|
||||||
|
|
||||||
|
for (const iface of tunnelInterfaces) {
|
||||||
|
if (!iface || !iface.serverId || !iface.serverId2) continue;
|
||||||
|
const s1 = resolveServerRef(iface.serverId, byRef);
|
||||||
|
const s2 = resolveServerRef(iface.serverId2, byRef);
|
||||||
|
if (!s1 || !s2) continue;
|
||||||
|
|
||||||
|
const t1 = String(s1.type || '').toLowerCase();
|
||||||
|
const t2 = String(s2.type || '').toLowerCase();
|
||||||
|
const k1 = makeServerKey(s1);
|
||||||
|
const k2 = makeServerKey(s2);
|
||||||
|
const pkey = pairKey(s1.ip, s2.ip);
|
||||||
|
const skey = pairKey(k1, k2);
|
||||||
|
const pingMs = toNumber(pingMap[pkey]);
|
||||||
|
const speedEntry = asObject(speedMap[skey]);
|
||||||
|
const downBps = toNumber(speedEntry.tcpDownloadBps);
|
||||||
|
const upBps = toNumber(speedEntry.tcpUploadBps);
|
||||||
|
const speedDownloadMbps = downBps != null ? downBps / 1e6 : null;
|
||||||
|
const speedUploadMbps = upBps != null ? upBps / 1e6 : null;
|
||||||
|
const speedMbps =
|
||||||
|
speedDownloadMbps != null && speedUploadMbps != null
|
||||||
|
? (speedDownloadMbps + speedUploadMbps) / 2
|
||||||
|
: speedDownloadMbps != null
|
||||||
|
? speedDownloadMbps
|
||||||
|
: speedUploadMbps != null
|
||||||
|
? speedUploadMbps
|
||||||
|
: null;
|
||||||
|
const scoring = buildSegmentScore({
|
||||||
|
pingMs,
|
||||||
|
speedMbps,
|
||||||
|
speedDownloadMbps,
|
||||||
|
speedUploadMbps,
|
||||||
|
cacheUpdatedAt,
|
||||||
|
aiSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
interfaceName: iface.name || null,
|
||||||
|
localIp: iface.localIp || null,
|
||||||
|
remoteIp: iface.remoteIp || null,
|
||||||
|
pingMs,
|
||||||
|
speedMbps: speedMbps != null ? Number(speedMbps.toFixed(2)) : null,
|
||||||
|
speedDownloadMbps: speedDownloadMbps != null ? Number(speedDownloadMbps.toFixed(2)) : null,
|
||||||
|
speedUploadMbps: speedUploadMbps != null ? Number(speedUploadMbps.toFixed(2)) : null,
|
||||||
|
score: Number(scoring.score.toFixed(4)),
|
||||||
|
confidence: Number(scoring.confidence.toFixed(4)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ((t1 === 'home' && t2 === 'jumphost') || (t1 === 'jumphost' && t2 === 'home')) {
|
||||||
|
const home = t1 === 'home' ? s1 : s2;
|
||||||
|
const jumphost = t1 === 'jumphost' ? s1 : s2;
|
||||||
|
homeToJh.push({
|
||||||
|
id: `${makeServerKey(home)}->${makeServerKey(jumphost)}::${base.interfaceName || 'iface'}`,
|
||||||
|
homeKey: makeServerKey(home),
|
||||||
|
jumphostKey: makeServerKey(jumphost),
|
||||||
|
home: compactServer(home),
|
||||||
|
jumphost: compactServer(jumphost),
|
||||||
|
...base,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((t1 === 'jumphost' && t2 === 'exit') || (t1 === 'exit' && t2 === 'jumphost')) {
|
||||||
|
const jumphost = t1 === 'jumphost' ? s1 : s2;
|
||||||
|
const exit = t1 === 'exit' ? s1 : s2;
|
||||||
|
const gatewayIpForJumphost = t1 === 'jumphost' ? (iface.remoteIp || null) : (iface.localIp || null);
|
||||||
|
jhToExit.push({
|
||||||
|
id: `${makeServerKey(jumphost)}->${makeServerKey(exit)}::${base.interfaceName || 'iface'}`,
|
||||||
|
jumphostKey: makeServerKey(jumphost),
|
||||||
|
exitKey: makeServerKey(exit),
|
||||||
|
gatewayIpForJumphost,
|
||||||
|
jumphost: compactServer(jumphost),
|
||||||
|
exit: compactServer(exit),
|
||||||
|
...base,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { homeToJh, jhToExit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichProbabilities(candidates, scoreField = 'score', aiSettings = DEFAULT_AI_SETTINGS) {
|
||||||
|
if (!Array.isArray(candidates) || candidates.length === 0) return [];
|
||||||
|
const probs = pairProbability(candidates, (c) => c[scoreField] * aiSettings.probabilityScale);
|
||||||
|
return candidates.map((c, i) => ({
|
||||||
|
...c,
|
||||||
|
probabilityOptimal: Number(probs[i].toFixed(2)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBy(items, keyGetter) {
|
||||||
|
const m = new Map();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = keyGetter(item);
|
||||||
|
if (!m.has(key)) m.set(key, []);
|
||||||
|
m.get(key).push(item);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectServerRefs(server) {
|
||||||
|
return [server?.id, server?.ip, server?.dns]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((x) => String(x).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServerIndex(servers) {
|
||||||
|
const byRef = new Map();
|
||||||
|
(Array.isArray(servers) ? servers : []).forEach((s) => {
|
||||||
|
collectServerRefs(s).forEach((ref) => byRef.set(ref, s));
|
||||||
|
});
|
||||||
|
return byRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerByAnyRef(ref, byRef) {
|
||||||
|
const r = String(ref || '').trim();
|
||||||
|
if (!r) return null;
|
||||||
|
return byRef.get(r) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatewayBelongsToJumphost(gw, jumphost, byRef) {
|
||||||
|
if (!gw || !jumphost) return false;
|
||||||
|
const gwServer = getServerByAnyRef(gw.serverId, byRef);
|
||||||
|
if (!gwServer) return false;
|
||||||
|
const jhRefs = new Set(collectServerRefs(jumphost));
|
||||||
|
return collectServerRefs(gwServer).some((r) => jhRefs.has(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParentGatewayRef(parentId, networkConfig) {
|
||||||
|
const gateways = Array.isArray(networkConfig?.gateways) ? networkConfig.gateways : [];
|
||||||
|
const interfaces = Array.isArray(networkConfig?.tunnelInterfaces)
|
||||||
|
? networkConfig.tunnelInterfaces
|
||||||
|
: [];
|
||||||
|
const gw = gateways.find((x) => x && x.id === parentId);
|
||||||
|
if (gw) return { type: 'gateway', value: gw };
|
||||||
|
const iface = interfaces.find((x) => x && x.id === parentId);
|
||||||
|
if (iface) return { type: 'interface', value: iface };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInterfaceGatewayIpForJumphost(iface, jumphost, byRef) {
|
||||||
|
const s1 = getServerByAnyRef(iface?.serverId, byRef);
|
||||||
|
const s2 = getServerByAnyRef(iface?.serverId2, byRef);
|
||||||
|
if (!s1 || !s2 || !jumphost) return null;
|
||||||
|
const jhRefs = new Set(collectServerRefs(jumphost));
|
||||||
|
const s1isJh = collectServerRefs(s1).some((r) => jhRefs.has(r));
|
||||||
|
const s2isJh = collectServerRefs(s2).some((r) => jhRefs.has(r));
|
||||||
|
if (s1isJh) return iface.remoteIp || null;
|
||||||
|
if (s2isJh) return iface.localIp || null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDistanceAdjustedScore(score, distance, aiSettings) {
|
||||||
|
const d = Number.isFinite(Number(distance)) ? Number(distance) : 1;
|
||||||
|
const penaltySteps = Math.max(0, d - 1);
|
||||||
|
return clamp(Number(score || 0) - penaltySteps * aiSettings.distancePenaltyPerStep, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecursiveGatewayOptionsForJumphost({
|
||||||
|
jumphost,
|
||||||
|
jumphostCandidates,
|
||||||
|
networkConfig,
|
||||||
|
servers,
|
||||||
|
aiSettings,
|
||||||
|
}) {
|
||||||
|
const byRef = buildServerIndex(servers);
|
||||||
|
const gateways = Array.isArray(networkConfig?.gateways) ? networkConfig.gateways : [];
|
||||||
|
const recursive = gateways.filter(
|
||||||
|
(gw) =>
|
||||||
|
gw &&
|
||||||
|
String(gw.type || '').toLowerCase() === 'recursive' &&
|
||||||
|
gatewayBelongsToJumphost(gw, jumphost, byRef)
|
||||||
|
);
|
||||||
|
if (recursive.length === 0) return [];
|
||||||
|
|
||||||
|
const options = [];
|
||||||
|
for (const rgw of recursive) {
|
||||||
|
const parentRefs =
|
||||||
|
Array.isArray(rgw.parentGateways) && rgw.parentGateways.length > 0
|
||||||
|
? rgw.parentGateways
|
||||||
|
: rgw.parentGatewayId
|
||||||
|
? [{ id: rgw.parentGatewayId, distance: undefined }]
|
||||||
|
: [];
|
||||||
|
const mapped = [];
|
||||||
|
|
||||||
|
for (const pref of parentRefs) {
|
||||||
|
const resolved = resolveParentGatewayRef(pref?.id, networkConfig);
|
||||||
|
if (!resolved) continue;
|
||||||
|
const distance = Number.isFinite(Number(pref?.distance)) ? Number(pref.distance) : 1;
|
||||||
|
let physicalGatewayIp = null;
|
||||||
|
if (resolved.type === 'gateway') {
|
||||||
|
physicalGatewayIp = resolved.value?.ip || null;
|
||||||
|
} else if (resolved.type === 'interface') {
|
||||||
|
physicalGatewayIp = getInterfaceGatewayIpForJumphost(
|
||||||
|
resolved.value,
|
||||||
|
jumphost,
|
||||||
|
byRef
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!physicalGatewayIp) continue;
|
||||||
|
|
||||||
|
const candidate = (Array.isArray(jumphostCandidates) ? jumphostCandidates : []).find(
|
||||||
|
(c) => String(c.gatewayIpForJumphost || '') === String(physicalGatewayIp)
|
||||||
|
);
|
||||||
|
if (!candidate) continue;
|
||||||
|
|
||||||
|
mapped.push({
|
||||||
|
distance,
|
||||||
|
physicalGatewayIp,
|
||||||
|
candidate,
|
||||||
|
scoreWithDistance: computeDistanceAdjustedScore(candidate.score, distance, aiSettings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapped.length === 0) continue;
|
||||||
|
mapped.sort((a, b) => b.scoreWithDistance - a.scoreWithDistance);
|
||||||
|
const best = mapped[0];
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
recursiveGateway: rgw.ip || null,
|
||||||
|
recursiveGatewayId: rgw.id || null,
|
||||||
|
recursiveDescription: rgw.description || '',
|
||||||
|
tunnelGatewayIp: best.physicalGatewayIp,
|
||||||
|
interfaceName: best.candidate.interfaceName || null,
|
||||||
|
distance: best.distance,
|
||||||
|
exit: best.candidate.exit,
|
||||||
|
baseScore: best.candidate.score,
|
||||||
|
score: Number(best.scoreWithDistance.toFixed(4)),
|
||||||
|
pingMs: best.candidate.pingMs,
|
||||||
|
speedMbps: best.candidate.speedMbps,
|
||||||
|
sourceCandidateId: best.candidate.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommunityOptimization({
|
||||||
|
exitsByJh,
|
||||||
|
serverFiltersByServerId,
|
||||||
|
communitiesIndex,
|
||||||
|
networkConfig,
|
||||||
|
servers,
|
||||||
|
aiSettings,
|
||||||
|
}) {
|
||||||
|
const jumphostByCommunity = [];
|
||||||
|
|
||||||
|
for (const [jumphostKey, rawCandidates] of exitsByJh.entries()) {
|
||||||
|
const candidates = enrichProbabilities([...rawCandidates].sort((a, b) => b.score - a.score), 'score', aiSettings);
|
||||||
|
const jumphost = candidates[0]?.jumphost || null;
|
||||||
|
const recursiveOptionsRaw = buildRecursiveGatewayOptionsForJumphost({
|
||||||
|
jumphost,
|
||||||
|
jumphostCandidates: candidates,
|
||||||
|
networkConfig,
|
||||||
|
servers,
|
||||||
|
aiSettings,
|
||||||
|
});
|
||||||
|
const recursiveOptions = enrichProbabilities(recursiveOptionsRaw, 'score', aiSettings);
|
||||||
|
const gatewayBestMap = new Map();
|
||||||
|
for (const c of candidates) {
|
||||||
|
const gw = String(c.gatewayIpForJumphost || '').trim();
|
||||||
|
if (!gw) continue;
|
||||||
|
if (!gatewayBestMap.has(gw) || gatewayBestMap.get(gw).score < c.score) {
|
||||||
|
gatewayBestMap.set(gw, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recursiveByIp = new Map();
|
||||||
|
const recursiveById = new Map();
|
||||||
|
recursiveOptions.forEach((o) => {
|
||||||
|
if (o.recursiveGateway) recursiveByIp.set(String(o.recursiveGateway), o);
|
||||||
|
if (o.recursiveGatewayId) recursiveById.set(String(o.recursiveGatewayId), o);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters = serverFiltersByServerId.get(jumphostKey) || [];
|
||||||
|
if (filters.length === 0) {
|
||||||
|
jumphostByCommunity.push({
|
||||||
|
jumphost,
|
||||||
|
candidates,
|
||||||
|
recursiveGatewayOptions: recursiveOptions,
|
||||||
|
recommendations: [],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendations = filters.map((f) => {
|
||||||
|
const currentGateway = String(f.gateway || '').trim();
|
||||||
|
const pinned = findPinnedCommunityGateway(
|
||||||
|
aiSettings.pinnedCommunityGateways,
|
||||||
|
jumphost,
|
||||||
|
f.community
|
||||||
|
);
|
||||||
|
const currentCandidate =
|
||||||
|
recursiveByIp.get(currentGateway) ||
|
||||||
|
recursiveById.get(currentGateway) ||
|
||||||
|
gatewayBestMap.get(currentGateway) ||
|
||||||
|
null;
|
||||||
|
const pinnedCandidate = pinned
|
||||||
|
? recursiveByIp.get(pinned.gateway) ||
|
||||||
|
recursiveById.get(pinned.gateway) ||
|
||||||
|
gatewayBestMap.get(pinned.gateway) ||
|
||||||
|
null
|
||||||
|
: null;
|
||||||
|
const recommendedCandidate = pinned
|
||||||
|
? pinnedCandidate
|
||||||
|
: (recursiveOptions.length > 0 ? recursiveOptions[0] : null) || candidates[0] || null;
|
||||||
|
const communityInfo = communitiesIndex.get(String(f.community)) || null;
|
||||||
|
const recommendedGatewayRef = normalizeRef(
|
||||||
|
recommendedCandidate?.recursiveGateway || recommendedCandidate?.gatewayIpForJumphost
|
||||||
|
);
|
||||||
|
const currentGatewayRef = normalizeRef(
|
||||||
|
currentCandidate?.recursiveGateway || currentCandidate?.gatewayIpForJumphost || currentGateway
|
||||||
|
);
|
||||||
|
const shouldSwitch = pinned
|
||||||
|
? Boolean(recommendedGatewayRef && recommendedGatewayRef !== currentGatewayRef)
|
||||||
|
: Boolean(
|
||||||
|
recommendedCandidate &&
|
||||||
|
currentCandidate &&
|
||||||
|
recommendedGatewayRef !== currentGatewayRef &&
|
||||||
|
(recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >=
|
||||||
|
aiSettings.minProbabilityGainForSwitch
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
community: String(f.community),
|
||||||
|
communityInfo: communityInfo || {
|
||||||
|
value: String(f.community),
|
||||||
|
name: '',
|
||||||
|
description: f.description || '',
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
currentGateway: currentGateway || null,
|
||||||
|
current: currentCandidate ? {
|
||||||
|
gateway: currentCandidate.recursiveGateway || currentCandidate.gatewayIpForJumphost || null,
|
||||||
|
tunnelGateway: currentCandidate.tunnelGatewayIp || currentCandidate.gatewayIpForJumphost || null,
|
||||||
|
interfaceName: currentCandidate.interfaceName || null,
|
||||||
|
distance: currentCandidate.distance != null ? currentCandidate.distance : null,
|
||||||
|
exit: currentCandidate.exit,
|
||||||
|
score: currentCandidate.score,
|
||||||
|
probabilityOptimal: currentCandidate.probabilityOptimal,
|
||||||
|
pingMs: currentCandidate.pingMs,
|
||||||
|
speedMbps: currentCandidate.speedMbps,
|
||||||
|
speedDownloadMbps: currentCandidate.speedDownloadMbps,
|
||||||
|
speedUploadMbps: currentCandidate.speedUploadMbps,
|
||||||
|
} : null,
|
||||||
|
recommended: recommendedCandidate ? {
|
||||||
|
gateway: recommendedCandidate.recursiveGateway || recommendedCandidate.gatewayIpForJumphost || null,
|
||||||
|
tunnelGateway: recommendedCandidate.tunnelGatewayIp || recommendedCandidate.gatewayIpForJumphost || null,
|
||||||
|
interfaceName: recommendedCandidate.interfaceName || null,
|
||||||
|
distance: recommendedCandidate.distance != null ? recommendedCandidate.distance : null,
|
||||||
|
exit: recommendedCandidate.exit,
|
||||||
|
score: recommendedCandidate.score,
|
||||||
|
probabilityOptimal: recommendedCandidate.probabilityOptimal,
|
||||||
|
pingMs: recommendedCandidate.pingMs,
|
||||||
|
speedMbps: recommendedCandidate.speedMbps,
|
||||||
|
speedDownloadMbps: recommendedCandidate.speedDownloadMbps,
|
||||||
|
speedUploadMbps: recommendedCandidate.speedUploadMbps,
|
||||||
|
} : null,
|
||||||
|
pinnedGateway: pinned?.gateway || null,
|
||||||
|
pinnedBySettings: Boolean(pinned),
|
||||||
|
pinnedGatewayResolved: Boolean(pinned && recommendedCandidate),
|
||||||
|
shouldSwitch,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jumphostByCommunity.push({
|
||||||
|
jumphost,
|
||||||
|
candidates,
|
||||||
|
recursiveGatewayOptions: recursiveOptions,
|
||||||
|
recommendations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jumphostByCommunity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRouteOptimizer(req, res) {
|
||||||
|
try {
|
||||||
|
const [servers, networkConfig, networkMapCache, communitiesIndex, serverFiltersByServerId, aiSettings] = await Promise.all([
|
||||||
|
readServersFromS3(),
|
||||||
|
loadNetworkConfig(),
|
||||||
|
loadNetworkMapCache(),
|
||||||
|
loadCommunitiesIndex(),
|
||||||
|
loadServerFiltersByServerId(),
|
||||||
|
loadAiSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tunnelInterfaces = Array.isArray(networkConfig.tunnelInterfaces)
|
||||||
|
? networkConfig.tunnelInterfaces
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const { homeToJh, jhToExit } = buildInterfaceCandidates({
|
||||||
|
servers: Array.isArray(servers) ? servers : [],
|
||||||
|
tunnelInterfaces,
|
||||||
|
pingMap: networkMapCache.pingMap,
|
||||||
|
speedMap: networkMapCache.speedMap,
|
||||||
|
cacheUpdatedAt: networkMapCache.updatedAt,
|
||||||
|
aiSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const homeGroups = groupBy(homeToJh, (x) => x.homeKey);
|
||||||
|
const exitsByJh = groupBy(jhToExit, (x) => x.jumphostKey);
|
||||||
|
|
||||||
|
const homes = [];
|
||||||
|
|
||||||
|
for (const [homeKey, hjListRaw] of homeGroups.entries()) {
|
||||||
|
const hjList = enrichProbabilities(hjListRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
|
||||||
|
const bestHomeToJumphost = hjList[0] || null;
|
||||||
|
|
||||||
|
const fullRoutesRaw = [];
|
||||||
|
for (const hj of hjList) {
|
||||||
|
const exitCandidates = exitsByJh.get(hj.jumphostKey) || [];
|
||||||
|
for (const jhExit of exitCandidates) {
|
||||||
|
const combinedScore = clamp(
|
||||||
|
hj.score * aiSettings.combineHomeToJumphostWeight +
|
||||||
|
jhExit.score * aiSettings.combineJumphostToExitWeight,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
fullRoutesRaw.push({
|
||||||
|
id: `${hj.id}>>>${jhExit.id}`,
|
||||||
|
home: hj.home,
|
||||||
|
jumphost: hj.jumphost,
|
||||||
|
exit: jhExit.exit,
|
||||||
|
homeToJumphost: {
|
||||||
|
interfaceName: hj.interfaceName,
|
||||||
|
pingMs: hj.pingMs,
|
||||||
|
speedMbps: hj.speedMbps,
|
||||||
|
speedDownloadMbps: hj.speedDownloadMbps,
|
||||||
|
speedUploadMbps: hj.speedUploadMbps,
|
||||||
|
score: hj.score,
|
||||||
|
confidence: hj.confidence,
|
||||||
|
},
|
||||||
|
jumphostToExit: {
|
||||||
|
interfaceName: jhExit.interfaceName,
|
||||||
|
pingMs: jhExit.pingMs,
|
||||||
|
speedMbps: jhExit.speedMbps,
|
||||||
|
speedDownloadMbps: jhExit.speedDownloadMbps,
|
||||||
|
speedUploadMbps: jhExit.speedUploadMbps,
|
||||||
|
score: jhExit.score,
|
||||||
|
confidence: jhExit.confidence,
|
||||||
|
},
|
||||||
|
score: Number(combinedScore.toFixed(4)),
|
||||||
|
confidence: Number(((hj.confidence + jhExit.confidence) / 2).toFixed(4)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullRoutes = enrichProbabilities(fullRoutesRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
|
||||||
|
const bestFullRoute = fullRoutes[0] || null;
|
||||||
|
|
||||||
|
homes.push({
|
||||||
|
home: bestHomeToJumphost?.home || null,
|
||||||
|
bestHomeToJumphost,
|
||||||
|
bestFullRoute,
|
||||||
|
homeToJumphostCandidates: hjList,
|
||||||
|
fullRouteCandidates: fullRoutes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also expose jumphost->exit view for transparency
|
||||||
|
const jumphostSummaries = [];
|
||||||
|
for (const [jumphostKey, exitListRaw] of exitsByJh.entries()) {
|
||||||
|
const candidates = enrichProbabilities(exitListRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
|
||||||
|
jumphostSummaries.push({
|
||||||
|
jumphost: candidates[0]?.jumphost || null,
|
||||||
|
bestCandidate: candidates[0] || null,
|
||||||
|
candidates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const communityOptimization = buildCommunityOptimization({
|
||||||
|
exitsByJh,
|
||||||
|
serverFiltersByServerId,
|
||||||
|
communitiesIndex,
|
||||||
|
networkConfig,
|
||||||
|
servers,
|
||||||
|
aiSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
homes.sort((a, b) => {
|
||||||
|
const pa = a.bestFullRoute?.probabilityOptimal ?? 0;
|
||||||
|
const pb = b.bestFullRoute?.probabilityOptimal ?? 0;
|
||||||
|
return pb - pa;
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
metricsUpdatedAt: networkMapCache.updatedAt || null,
|
||||||
|
aiSettingsUsed: aiSettings,
|
||||||
|
homes,
|
||||||
|
jumphostToExitByJumphost: jumphostSummaries,
|
||||||
|
communityOptimizationByJumphost: communityOptimization,
|
||||||
|
totals: {
|
||||||
|
homes: homes.length,
|
||||||
|
homeToJumphostCandidates: homeToJh.length,
|
||||||
|
jumphostToExitCandidates: jhToExit.length,
|
||||||
|
jumphostsWithCommunityFilters: communityOptimization.filter((x) => (x.recommendations || []).length > 0).length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[route-optimizer] getRouteOptimizer:', error);
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
500,
|
||||||
|
error?.message || 'Ошибка расчёта оптимального маршрута',
|
||||||
|
'E_ROUTE_OPTIMIZER'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getRouteOptimizer,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Роуты планировщика карты сети: настройки, логи, ручной запуск, кеш.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
|
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
||||||
|
const networkMapScheduler = require('../services/networkMapScheduler');
|
||||||
|
const pingServicesScheduler = require('../services/pingServicesScheduler');
|
||||||
|
const uptimeMonitorScheduler = require('../services/uptimeMonitorScheduler');
|
||||||
|
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
||||||
|
|
||||||
|
const SCHEDULER_KEYS = [
|
||||||
|
'networkMapSchedulerEnabled',
|
||||||
|
'networkMapSchedulerIntervalMinutes',
|
||||||
|
'networkMapSchedulerRunPing',
|
||||||
|
'networkMapSchedulerRunSpeedTest',
|
||||||
|
'networkMapSchedulerLogMaxLines',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PING_SERVICES_SCHEDULER_KEYS = [
|
||||||
|
'pingServicesSchedulerEnabled',
|
||||||
|
'pingServicesSchedulerIntervalMinutes',
|
||||||
|
];
|
||||||
|
|
||||||
|
const UPTIME_MONITOR_SCHEDULER_KEYS = [
|
||||||
|
'uptimeMonitorSchedulerEnabled',
|
||||||
|
'uptimeMonitorSchedulerIntervalMinutes',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function readUiSettings() {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
const parsed = JSON.parse(data?.body || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/scheduler/network-map/settings */
|
||||||
|
async function getNetworkMapSchedulerSettings(req, res) {
|
||||||
|
try {
|
||||||
|
const ui = await readUiSettings();
|
||||||
|
const settings = networkMapScheduler.getSettingsFromUiSettings(ui);
|
||||||
|
const nextRunAt = networkMapScheduler.getNextRunAt(settings);
|
||||||
|
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] getSettings', e);
|
||||||
|
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/scheduler/network-map/settings - обновить только ключи планировщика в ui-settings */
|
||||||
|
async function patchNetworkMapSchedulerSettings(req, res) {
|
||||||
|
try {
|
||||||
|
const body = req.body || {};
|
||||||
|
const current = await readUiSettings();
|
||||||
|
for (const key of SCHEDULER_KEYS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
||||||
|
current[key] = body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, 'networkMapSchedulerLogMaxLines')) {
|
||||||
|
networkMapScheduler.setLogMaxLines(body.networkMapSchedulerLogMaxLines);
|
||||||
|
}
|
||||||
|
await writeS3JsonObject(UI_SETTINGS_KEY, current);
|
||||||
|
const settings = networkMapScheduler.getSettingsFromUiSettings(current);
|
||||||
|
return res.json(settings);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] patchSettings', e);
|
||||||
|
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/scheduler/network-map/logs */
|
||||||
|
async function getNetworkMapSchedulerLogs(req, res) {
|
||||||
|
try {
|
||||||
|
const lines = networkMapScheduler.getLogs();
|
||||||
|
return res.json({ lines });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] getLogs', e);
|
||||||
|
return sendError(res, 500, 'Не удалось прочитать логи', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/scheduler/network-map/run-now - один запуск по текущим настройкам */
|
||||||
|
async function runNetworkMapSchedulerNow(req, res) {
|
||||||
|
try {
|
||||||
|
const port = Number(process.env.PORT) || 3001;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const ui = await readUiSettings();
|
||||||
|
const settings = networkMapScheduler.getSettingsFromUiSettings(ui);
|
||||||
|
|
||||||
|
networkMapScheduler.runJob({
|
||||||
|
baseUrl,
|
||||||
|
runPing: settings.runPing,
|
||||||
|
runSpeedTest: settings.runSpeedTest,
|
||||||
|
trigger: 'manual',
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[scheduler] run-now failed', err);
|
||||||
|
const msg = err?.message || String(err);
|
||||||
|
try {
|
||||||
|
networkMapScheduler.pushLog(`Ошибка выполнения: ${msg}`);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ ok: true, message: 'Запуск выполняется в фоне' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] run-now', e);
|
||||||
|
const msg = e?.message || String(e);
|
||||||
|
try {
|
||||||
|
networkMapScheduler.pushLog(`Ошибка запуска: ${msg}`);
|
||||||
|
} catch (_) {}
|
||||||
|
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/network-map-cache - кеш пингов и скоростей для карты сети */
|
||||||
|
async function getNetworkMapCache(req, res) {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
|
||||||
|
if (!data?.body) {
|
||||||
|
return res.json({ pingMap: {}, speedMap: {}, updatedAt: null });
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(data.body);
|
||||||
|
return res.json({
|
||||||
|
pingMap: parsed.pingMap && typeof parsed.pingMap === 'object' ? parsed.pingMap : {},
|
||||||
|
speedMap: parsed.speedMap && typeof parsed.speedMap === 'object' ? parsed.speedMap : {},
|
||||||
|
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[network-map-cache] get', e);
|
||||||
|
return res.json({ pingMap: {}, speedMap: {}, updatedAt: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Ping services scheduler ===
|
||||||
|
|
||||||
|
/** GET /api/scheduler/ping-services/settings */
|
||||||
|
async function getPingServicesSchedulerSettings(req, res) {
|
||||||
|
try {
|
||||||
|
const ui = await readUiSettings();
|
||||||
|
const settings = pingServicesScheduler.getSettingsFromUiSettings(ui);
|
||||||
|
const nextRunAt = pingServicesScheduler.getNextRunAt(settings);
|
||||||
|
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] getPingServicesSettings', e);
|
||||||
|
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/scheduler/ping-services/settings */
|
||||||
|
async function patchPingServicesSchedulerSettings(req, res) {
|
||||||
|
try {
|
||||||
|
const body = req.body || {};
|
||||||
|
const current = await readUiSettings();
|
||||||
|
for (const key of PING_SERVICES_SCHEDULER_KEYS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
||||||
|
current[key] = body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeS3JsonObject(UI_SETTINGS_KEY, current);
|
||||||
|
const settings = pingServicesScheduler.getSettingsFromUiSettings(current);
|
||||||
|
return res.json(settings);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] patchPingServicesSettings', e);
|
||||||
|
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/scheduler/ping-services/run-now */
|
||||||
|
async function runPingServicesSchedulerNow(req, res) {
|
||||||
|
try {
|
||||||
|
const { refreshPingServicesCache } = require('./miscRoutes');
|
||||||
|
refreshPingServicesCache().catch((err) => {
|
||||||
|
console.error('[scheduler] ping-services run-now failed', err);
|
||||||
|
});
|
||||||
|
return res.json({ ok: true, message: 'Обновление кеша пинг-сервисов запущено в фоне' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] ping-services run-now', e);
|
||||||
|
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Uptime monitor scheduler ===
|
||||||
|
|
||||||
|
/** GET /api/scheduler/uptime-monitor/settings */
|
||||||
|
async function getUptimeMonitorSchedulerSettings(req, res) {
|
||||||
|
try {
|
||||||
|
const ui = await readUiSettings();
|
||||||
|
const settings = uptimeMonitorScheduler.getSettingsFromUiSettings(ui);
|
||||||
|
const nextRunAt = uptimeMonitorScheduler.getNextRunAt(settings);
|
||||||
|
return res.json({ ...settings, nextRunAt: nextRunAt || undefined });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] getUptimeMonitorSettings', e);
|
||||||
|
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/scheduler/uptime-monitor/settings */
|
||||||
|
async function patchUptimeMonitorSchedulerSettings(req, res) {
|
||||||
|
try {
|
||||||
|
const body = req.body || {};
|
||||||
|
const current = await readUiSettings();
|
||||||
|
for (const key of UPTIME_MONITOR_SCHEDULER_KEYS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
||||||
|
current[key] = body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeS3JsonObject(UI_SETTINGS_KEY, current);
|
||||||
|
const settings = uptimeMonitorScheduler.getSettingsFromUiSettings(current);
|
||||||
|
return res.json(settings);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] patchUptimeMonitorSettings', e);
|
||||||
|
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/scheduler/uptime-monitor/run-now */
|
||||||
|
async function runUptimeMonitorSchedulerNow(req, res) {
|
||||||
|
try {
|
||||||
|
const port = Number(process.env.PORT) || 3001;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const resServers = await fetch(`${baseUrl}/api/servers`).catch(() => null);
|
||||||
|
const data = resServers?.ok ? await resServers.json() : [];
|
||||||
|
const servers = Array.isArray(data) ? data : [];
|
||||||
|
const jumphosts = servers.filter(
|
||||||
|
(s) => s && ['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
|
||||||
|
);
|
||||||
|
const serverIds = jumphosts.map((s) => s.id || s.dns || s.ip).filter(Boolean);
|
||||||
|
for (const serverId of serverIds) {
|
||||||
|
fetch(`${baseUrl}/api/uptime/check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serverId }),
|
||||||
|
}).catch((err) => console.warn('[scheduler] uptime check', serverId, err?.message));
|
||||||
|
}
|
||||||
|
return res.json({ ok: true, message: 'Проверки Uptime Monitor запущены в фоне' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] uptime-monitor run-now', e);
|
||||||
|
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getNetworkMapSchedulerSettings,
|
||||||
|
patchNetworkMapSchedulerSettings,
|
||||||
|
getNetworkMapSchedulerLogs,
|
||||||
|
runNetworkMapSchedulerNow,
|
||||||
|
getNetworkMapCache,
|
||||||
|
getPingServicesSchedulerSettings,
|
||||||
|
patchPingServicesSchedulerSettings,
|
||||||
|
runPingServicesSchedulerNow,
|
||||||
|
getUptimeMonitorSchedulerSettings,
|
||||||
|
patchUptimeMonitorSchedulerSettings,
|
||||||
|
runUptimeMonitorSchedulerNow,
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHand
|
|||||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { streamToString } = require('../services/s3Service');
|
const { streamToString } = require('../services/s3Service');
|
||||||
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||||
|
const { isValidServerId } = require('../lib/validators');
|
||||||
|
|
||||||
const S3_KEY = 'servers.json';
|
const S3_KEY = 'servers.json';
|
||||||
|
|
||||||
@@ -143,9 +144,19 @@ const serverConfigsListRoutes = {
|
|||||||
post: postServerConfigsList
|
post: postServerConfigsList
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function validateServerIdParam(req, res) {
|
||||||
|
const serverId = req.params?.serverId;
|
||||||
|
if (!serverId || !isValidServerId(serverId)) {
|
||||||
|
sendError(res, 400, 'Invalid serverId: only alphanumeric, dot, dash, underscore allowed', 'E_BAD_REQUEST');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/server-configs/:serverId (конкретная конфигурация)
|
// GET /api/server-configs/:serverId (конкретная конфигурация)
|
||||||
async function getServerConfig(req, res) {
|
async function getServerConfig(req, res) {
|
||||||
const { serverId } = req.params;
|
const serverId = validateServerIdParam(req, res);
|
||||||
|
if (serverId === null) return;
|
||||||
await getS3TextWithHeaders(
|
await getS3TextWithHeaders(
|
||||||
`filter-manager/config-${serverId}.txt`,
|
`filter-manager/config-${serverId}.txt`,
|
||||||
req,
|
req,
|
||||||
@@ -159,7 +170,8 @@ async function getServerConfig(req, res) {
|
|||||||
|
|
||||||
// POST /api/server-configs/:serverId (сохранить конфигурацию)
|
// POST /api/server-configs/:serverId (сохранить конфигурацию)
|
||||||
async function postServerConfig(req, res) {
|
async function postServerConfig(req, res) {
|
||||||
const { serverId } = req.params;
|
const serverId = validateServerIdParam(req, res);
|
||||||
|
if (serverId === null) return;
|
||||||
const { config } = req.body;
|
const { config } = req.body;
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -177,7 +189,8 @@ async function postServerConfig(req, res) {
|
|||||||
|
|
||||||
// DELETE /api/server-configs/:serverId (удалить только конфигурацию)
|
// DELETE /api/server-configs/:serverId (удалить только конфигурацию)
|
||||||
async function deleteServerConfig(req, res) {
|
async function deleteServerConfig(req, res) {
|
||||||
const { serverId } = req.params;
|
const serverId = validateServerIdParam(req, res);
|
||||||
|
if (serverId === null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteS3Object(`filter-manager/config-${serverId}.txt`);
|
await deleteS3Object(`filter-manager/config-${serverId}.txt`);
|
||||||
@@ -191,7 +204,8 @@ async function deleteServerConfig(req, res) {
|
|||||||
|
|
||||||
// DELETE /api/server-configs/:serverId/complete (удалить конфигурацию и фильтры)
|
// DELETE /api/server-configs/:serverId/complete (удалить конфигурацию и фильтры)
|
||||||
async function deleteServerComplete(req, res) {
|
async function deleteServerComplete(req, res) {
|
||||||
const { serverId } = req.params;
|
const serverId = validateServerIdParam(req, res);
|
||||||
|
if (serverId === null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
@@ -208,7 +222,8 @@ async function deleteServerComplete(req, res) {
|
|||||||
|
|
||||||
// GET /api/server-filters/:serverId
|
// GET /api/server-filters/:serverId
|
||||||
async function getServerFilters(req, res) {
|
async function getServerFilters(req, res) {
|
||||||
const { serverId } = req.params;
|
const serverId = validateServerIdParam(req, res);
|
||||||
|
if (serverId === null) return;
|
||||||
await getS3JsonWithHeaders(
|
await getS3JsonWithHeaders(
|
||||||
`filter-manager/server-filters-${serverId}.json`,
|
`filter-manager/server-filters-${serverId}.json`,
|
||||||
req,
|
req,
|
||||||
@@ -219,7 +234,8 @@ async function getServerFilters(req, res) {
|
|||||||
|
|
||||||
// POST /api/server-filters/:serverId
|
// POST /api/server-filters/:serverId
|
||||||
async function postServerFilters(req, res) {
|
async function postServerFilters(req, res) {
|
||||||
const { serverId } = req.params;
|
const serverId = validateServerIdParam(req, res);
|
||||||
|
if (serverId === null) return;
|
||||||
const { filters } = req.body;
|
const { filters } = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(filters)) {
|
if (!Array.isArray(filters)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Кастомные роуты для серверов с поддержкой зашифрованных MikroTik учётных данных
|
* Кастомные роуты для серверов с поддержкой зашифрованных MikroTik учётных данных
|
||||||
* Для jumphost: mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
|
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
@@ -80,10 +80,11 @@ async function getServers(req, res) {
|
|||||||
const servers = await readServersFromS3();
|
const servers = await readServersFromS3();
|
||||||
const sanitized = servers.map(s => {
|
const sanitized = servers.map(s => {
|
||||||
const out = { ...s };
|
const out = { ...s };
|
||||||
if (s.type === 'jumphost' && out.encryptedMikrotikPassword) {
|
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
|
||||||
|
if (hasMikrotikApi && out.encryptedMikrotikPassword) {
|
||||||
out.hasMikrotikPassword = true;
|
out.hasMikrotikPassword = true;
|
||||||
delete out.encryptedMikrotikPassword;
|
delete out.encryptedMikrotikPassword;
|
||||||
} else if (s.type === 'jumphost') {
|
} else if (hasMikrotikApi) {
|
||||||
out.hasMikrotikPassword = false;
|
out.hasMikrotikPassword = false;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -121,7 +122,7 @@ async function postServers(req, res) {
|
|||||||
const toSave = items.map((srv) => {
|
const toSave = items.map((srv) => {
|
||||||
const current = findCurrent(srv);
|
const current = findCurrent(srv);
|
||||||
|
|
||||||
if (srv.type === 'jumphost') {
|
if (srv.type === 'jumphost' || srv.type === 'home') {
|
||||||
const out = { ...srv };
|
const out = { ...srv };
|
||||||
if (srv.mikrotikPassword !== undefined && srv.mikrotikPassword !== null && String(srv.mikrotikPassword).trim() !== '') {
|
if (srv.mikrotikPassword !== undefined && srv.mikrotikPassword !== null && String(srv.mikrotikPassword).trim() !== '') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Роуты для сбора статистики трафика с MikroTik RouterOS (интерфейсы по jumphost).
|
||||||
|
* GET /api/traffic/interface-stats — статистика rx/tx по каждому интерфейсу для всех jumphost.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sendError } = require('../middleware/errorHandler');
|
||||||
|
const { decrypt } = require('../utils/encryption');
|
||||||
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
|
const { createRosClient } = require('../services/mikrotikApplyService');
|
||||||
|
|
||||||
|
/** Получить MikroTik credentials из сервера (jumphost или home) */
|
||||||
|
function getMikrotikCredentials(server) {
|
||||||
|
if (!server || (server.type !== 'jumphost' && server.type !== 'home')) return null;
|
||||||
|
const host = server.mikrotikHost || server.ip || server.dns;
|
||||||
|
if (!host) return null;
|
||||||
|
const port = server.mikrotikPort;
|
||||||
|
const restPort = (port === 80 || port === 443 || port === 8443) ? port : 80;
|
||||||
|
const user = server.mikrotikUser || 'admin';
|
||||||
|
let password = '';
|
||||||
|
if (server.encryptedMikrotikPassword) {
|
||||||
|
try {
|
||||||
|
password = decrypt(server.encryptedMikrotikPassword);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { host, port: restPort, user, password, secure: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI-настройки (общие с остальным UI)
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
const INTERFACE_SPEED_CACHE_PREFIX = 'interface-speed-cache/';
|
||||||
|
|
||||||
|
async function loadUiSettings() {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
const parsed = JSON.parse(data?.body || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interfaceSpeedCacheKey(serverId, interfaceName) {
|
||||||
|
const key = `${serverId || ''}|${interfaceName || ''}`;
|
||||||
|
// Хэш не нужен: ключи короткие, но на всякий случай экранируем странные символы
|
||||||
|
return (
|
||||||
|
INTERFACE_SPEED_CACHE_PREFIX +
|
||||||
|
String(key)
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9_.:-]+/g, '_') +
|
||||||
|
'.json'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/traffic/interface-stats
|
||||||
|
* Возвращает по каждому jumphost список интерфейсов с rx-byte, tx-byte (из RouterOS REST /interface).
|
||||||
|
*/
|
||||||
|
async function getInterfaceStats(req, res) {
|
||||||
|
try {
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const jumphosts = (Array.isArray(servers) ? servers : []).filter(
|
||||||
|
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
jumphosts.map(async (server) => {
|
||||||
|
const creds = getMikrotikCredentials(server);
|
||||||
|
if (!creds) {
|
||||||
|
return {
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name || server.dns || server.ip || server.id,
|
||||||
|
host: server.mikrotikHost || server.ip || server.dns,
|
||||||
|
error: 'MikroTik API не настроен или нет пароля',
|
||||||
|
interfaces: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const client = createRosClient(creds);
|
||||||
|
const apiRes = await client.print('interface');
|
||||||
|
const raw = apiRes?.data;
|
||||||
|
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||||
|
const interfaces = list.map((iface) => {
|
||||||
|
const rx = iface['rx-byte'] != null ? parseInt(String(iface['rx-byte']), 10) : 0;
|
||||||
|
const tx = iface['tx-byte'] != null ? parseInt(String(iface['tx-byte']), 10) : 0;
|
||||||
|
const total = rx + tx;
|
||||||
|
return {
|
||||||
|
name: iface.name != null ? String(iface.name) : '',
|
||||||
|
type: iface.type != null ? String(iface.type) : '',
|
||||||
|
rxBytes: Number.isNaN(rx) ? 0 : rx,
|
||||||
|
txBytes: Number.isNaN(tx) ? 0 : tx,
|
||||||
|
totalBytes: Number.isNaN(total) ? 0 : total,
|
||||||
|
};
|
||||||
|
}).filter((i) => i.name && i.totalBytes >= 0);
|
||||||
|
return {
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name || server.dns || server.ip || server.id,
|
||||||
|
host: server.mikrotikHost || server.ip || server.dns,
|
||||||
|
interfaces,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS';
|
||||||
|
return {
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name || server.dns || server.ip || server.id,
|
||||||
|
host: server.mikrotikHost || server.ip || server.dns,
|
||||||
|
error: msg,
|
||||||
|
interfaces: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ jumphosts: results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getInterfaceStats:', error);
|
||||||
|
return sendError(res, 500, error.message || 'Ошибка сбора статистики трафика', 'E_TRAFFIC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getInterfaceStats,
|
||||||
|
};
|
||||||
+91
-7
@@ -29,7 +29,16 @@ const miscRoutes = require('./routes/miscRoutes');
|
|||||||
const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes');
|
const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes');
|
||||||
const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
|
const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
|
||||||
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
||||||
|
const trafficRoutes = require('./routes/trafficRoutes');
|
||||||
|
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
|
||||||
|
const alertsRoutes = require('./routes/alertsRoutes');
|
||||||
|
const routeOptimizerRoutes = require('./routes/routeOptimizerRoutes');
|
||||||
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
|
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
|
||||||
|
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
|
||||||
|
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
||||||
|
const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler');
|
||||||
|
const schedulerRoutes = require('./routes/schedulerRoutes');
|
||||||
|
const { createEvobgpProxy, normalizeEvobgpBaseUrl } = require('./routes/evobgpProxyRoutes');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT) || 3001;
|
const port = Number(process.env.PORT) || 3001;
|
||||||
@@ -148,6 +157,23 @@ app.get('/api/version', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === EvoBGP reverse proxy (токен и URL только на сервере; без CORS в браузере) ===
|
||||||
|
function evobgpProxyHealth(req, res) {
|
||||||
|
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
|
||||||
|
const hasToken = Boolean(String(process.env.EVOBGP_API_TOKEN || '').trim());
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
proxy: true,
|
||||||
|
evobgpConfigured: Boolean(base && hasToken),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Точное совпадение до app.use: GET /api/evobgp и GET /evobgp — проверка, что запрос доходит до Node
|
||||||
|
app.get('/api/evobgp', evobgpProxyHealth);
|
||||||
|
app.get('/evobgp', evobgpProxyHealth);
|
||||||
|
app.use('/api/evobgp', createEvobgpProxy({ logger }));
|
||||||
|
// Если nginx делает proxy_pass http://upstream/ и срезает префикс /api, до приложения приходит /evobgp/…
|
||||||
|
app.use('/evobgp', createEvobgpProxy({ logger }));
|
||||||
|
|
||||||
// === Cache TTL ===
|
// === Cache TTL ===
|
||||||
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
|
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
@@ -439,6 +465,8 @@ app.post('/api/update-bgp/background', bgpUpdateLimiter, miscRoutes.updateBgpBac
|
|||||||
app.get('/api/ws/url', miscRoutes.getWsUrl);
|
app.get('/api/ws/url', miscRoutes.getWsUrl);
|
||||||
app.get('/api/ui-settings', miscRoutes.getUiSettings);
|
app.get('/api/ui-settings', miscRoutes.getUiSettings);
|
||||||
app.post('/api/ui-settings', miscRoutes.postUiSettings);
|
app.post('/api/ui-settings', miscRoutes.postUiSettings);
|
||||||
|
app.get('/api/ping-services-list', miscRoutes.getPingServicesList);
|
||||||
|
app.get('/api/ping-services', miscRoutes.getPingServices);
|
||||||
|
|
||||||
// === IPSEC PASSWORDS ===
|
// === IPSEC PASSWORDS ===
|
||||||
app.get('/api/ipsec-passwords', ipsecPasswordsRoutes.getIpsecPasswords);
|
app.get('/api/ipsec-passwords', ipsecPasswordsRoutes.getIpsecPasswords);
|
||||||
@@ -456,8 +484,29 @@ app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConne
|
|||||||
app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() }));
|
app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() }));
|
||||||
// Реальный ping через RouterOS по интерфейсу/шлюзу
|
// Реальный ping через RouterOS по интерфейсу/шлюзу
|
||||||
app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface);
|
app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface);
|
||||||
|
// Traceroute через RouterOS с выбором сервера и шлюза
|
||||||
|
app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway);
|
||||||
|
// Speed-test (TCP upload/download) между MikroTik по туннельному интерфейсу
|
||||||
|
app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTestViaTunnel);
|
||||||
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
||||||
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
||||||
|
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
|
||||||
|
app.get('/api/mikrotik/ospf-interface-templates', mikrotikConfigRoutes.getOspfInterfaceTemplates);
|
||||||
|
app.post('/api/mikrotik/ospf-interface-templates/apply', writeLimiter, mikrotikConfigRoutes.applyOspfInterfaceTemplates);
|
||||||
|
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
|
||||||
|
|
||||||
|
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
|
||||||
|
app.get('/api/uptime/cache', mikrotikConfigRoutes.getUptimeCache);
|
||||||
|
app.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck);
|
||||||
|
|
||||||
|
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
|
||||||
|
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
||||||
|
|
||||||
|
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
|
||||||
|
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
|
||||||
|
|
||||||
|
// === ALERTS (агрегация проблем: серверы, ресурсы роутеров) ===
|
||||||
|
app.get('/api/alerts', alertsRoutes.getAlerts);
|
||||||
|
|
||||||
// === MIKROTIK BACKUPS (S3) ===
|
// === MIKROTIK BACKUPS (S3) ===
|
||||||
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
||||||
@@ -466,6 +515,28 @@ app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
|
|||||||
app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups);
|
app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups);
|
||||||
app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBackupNow);
|
app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBackupNow);
|
||||||
|
|
||||||
|
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
|
||||||
|
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
|
||||||
|
|
||||||
|
// === LOCAL AI ROUTE OPTIMIZER (rule-based) ===
|
||||||
|
app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer);
|
||||||
|
|
||||||
|
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
|
||||||
|
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
|
||||||
|
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
|
||||||
|
app.get('/api/scheduler/network-map/logs', schedulerRoutes.getNetworkMapSchedulerLogs);
|
||||||
|
app.post('/api/scheduler/network-map/run-now', writeLimiter, schedulerRoutes.runNetworkMapSchedulerNow);
|
||||||
|
|
||||||
|
// === SCHEDULER (пинг сервисов: обновление кеша по расписанию, по умолчанию каждые 2 мин) ===
|
||||||
|
app.get('/api/scheduler/ping-services/settings', schedulerRoutes.getPingServicesSchedulerSettings);
|
||||||
|
app.patch('/api/scheduler/ping-services/settings', writeLimiter, schedulerRoutes.patchPingServicesSchedulerSettings);
|
||||||
|
app.post('/api/scheduler/ping-services/run-now', writeLimiter, schedulerRoutes.runPingServicesSchedulerNow);
|
||||||
|
|
||||||
|
// === SCHEDULER (Uptime Monitor: проверки по расписанию, по умолчанию каждые 2 мин) ===
|
||||||
|
app.get('/api/scheduler/uptime-monitor/settings', schedulerRoutes.getUptimeMonitorSchedulerSettings);
|
||||||
|
app.patch('/api/scheduler/uptime-monitor/settings', writeLimiter, schedulerRoutes.patchUptimeMonitorSchedulerSettings);
|
||||||
|
app.post('/api/scheduler/uptime-monitor/run-now', writeLimiter, schedulerRoutes.runUptimeMonitorSchedulerNow);
|
||||||
|
|
||||||
// === MIKROTIK VALIDATION ===
|
// === MIKROTIK VALIDATION ===
|
||||||
app.post('/api/mikrotik/validate', async (req, res) => {
|
app.post('/api/mikrotik/validate', async (req, res) => {
|
||||||
const { config } = req.body;
|
const { config } = req.body;
|
||||||
@@ -493,12 +564,25 @@ app.use(errorHandler);
|
|||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server is running on http://localhost:${port}`);
|
console.log(`Server is running on http://localhost:${port}`);
|
||||||
|
try {
|
||||||
|
initMikrotikBackupScheduler(logger);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
initNetworkMapScheduler(logger, port);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ component: 'network-map-scheduler', err: e && e.message }, 'Failed to start network map scheduler');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
initPingServicesScheduler(logger);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ component: 'ping-services-scheduler', err: e && e.message }, 'Failed to start ping services scheduler');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
initUptimeMonitorScheduler(logger, port);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ component: 'uptime-monitor-scheduler', err: e && e.message }, 'Failed to start uptime monitor scheduler');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Запускаем планировщик автоматического бэкапа MikroTik (если включен через ENV)
|
|
||||||
try {
|
|
||||||
initMikrotikBackupScheduler(logger);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -160,21 +160,19 @@ function initMikrotikBackupScheduler(logger) {
|
|||||||
Number(process.env.MIKROTIK_BACKUP_INTERVAL_MINUTES || DEFAULT_INTERVAL_MIN) || DEFAULT_INTERVAL_MIN;
|
Number(process.env.MIKROTIK_BACKUP_INTERVAL_MINUTES || DEFAULT_INTERVAL_MIN) || DEFAULT_INTERVAL_MIN;
|
||||||
const intervalMs = Math.max(5, intervalMin) * 60 * 1000;
|
const intervalMs = Math.max(5, intervalMin) * 60 * 1000;
|
||||||
|
|
||||||
|
const intervalMinutes = intervalMs / 60000;
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
component: 'mikrotik-backup',
|
component: 'mikrotik-backup',
|
||||||
intervalMinutes: intervalMs / 60000,
|
intervalMinutes,
|
||||||
|
nextRunInMinutes: intervalMinutes,
|
||||||
servers: process.env.MIKROTIK_BACKUP_SERVERS || 'all jumphost',
|
servers: process.env.MIKROTIK_BACKUP_SERVERS || 'all jumphost',
|
||||||
},
|
},
|
||||||
'Starting automatic MikroTik backup scheduler',
|
'Automatic MikroTik backup scheduler started (no backup on startup, first run in %d min)',
|
||||||
|
intervalMinutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Первый запуск с небольшой задержкой, чтобы сервер успел подняться
|
// Только по расписанию — без бэкапа при перезапуске контейнера
|
||||||
setTimeout(() => {
|
|
||||||
runBackupOnce(log);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
// Периодический запуск
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
runBackupOnce(log);
|
runBackupOnce(log);
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
/**
|
||||||
|
* Планировщик пингов и замеров скорости для карты сети.
|
||||||
|
* Периодически вызывает пинг и speed-test по связям из network-config,
|
||||||
|
* сохраняет результаты в S3 (кеш по рёбрам + агрегат network-map-cache/latest.json).
|
||||||
|
* Логи пишет в кольцевой буфер для просмотра в UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { readServersFromS3 } = require('../routes/serversRoutes');
|
||||||
|
const { loadNetworkConfig } = require('../routes/mikrotikConfigRoutes');
|
||||||
|
const { readS3TextObject, writeS3JsonObject } = require('./s3Service');
|
||||||
|
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
||||||
|
const NEXT_RUN_AT_KEY = 'networkMapSchedulerNextRunAt';
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MINUTES = 5;
|
||||||
|
const DEFAULT_LOG_MAX_LINES = 500;
|
||||||
|
const RESTART_RECOVERY_DELAY_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Кольцевой буфер логов */
|
||||||
|
class LogBuffer {
|
||||||
|
constructor(maxLines = 500) {
|
||||||
|
this.maxLines = Math.max(100, maxLines);
|
||||||
|
this.lines = [];
|
||||||
|
this.index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
push(line) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const entry = `${ts} ${line}`;
|
||||||
|
if (this.lines.length < this.maxLines) {
|
||||||
|
this.lines.push(entry);
|
||||||
|
} else {
|
||||||
|
this.lines[this.index] = entry;
|
||||||
|
this.index = (this.index + 1) % this.maxLines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLines() {
|
||||||
|
if (this.lines.length < this.maxLines) return [...this.lines];
|
||||||
|
return [...this.lines.slice(this.index), ...this.lines.slice(0, this.index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaxLines(n) {
|
||||||
|
this.maxLines = Math.max(100, n);
|
||||||
|
if (this.lines.length > this.maxLines) {
|
||||||
|
this.lines = this.getLines().slice(-this.maxLines);
|
||||||
|
this.index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logBuffer = new LogBuffer(DEFAULT_LOG_MAX_LINES);
|
||||||
|
|
||||||
|
async function loadUiSettings() {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
const parsed = JSON.parse(data?.body || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Построить список связей как в NetworkMapDashboard (tunnelInterfaces → connections) */
|
||||||
|
async function buildConnections() {
|
||||||
|
const [serversList, config, uiSettings] = await Promise.all([
|
||||||
|
readServersFromS3(),
|
||||||
|
loadNetworkConfig(),
|
||||||
|
loadUiSettings(),
|
||||||
|
]);
|
||||||
|
const servers = Array.isArray(serversList) ? serversList : [];
|
||||||
|
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||||||
|
|
||||||
|
const getServer = (serverId) =>
|
||||||
|
servers.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
|
||||||
|
|
||||||
|
const connList = [];
|
||||||
|
for (const iface of tunnelInterfaces) {
|
||||||
|
if (!iface.serverId || !iface.serverId2) continue;
|
||||||
|
const s1 = getServer(iface.serverId);
|
||||||
|
const s2 = getServer(iface.serverId2);
|
||||||
|
if (!s1 || !s2 || s1.ip === s2.ip) continue;
|
||||||
|
const s1Key = s1.id || s1.dns || s1.ip;
|
||||||
|
const s2Key = s2.id || s2.dns || s2.ip;
|
||||||
|
if (!s1Key || !s2Key) continue;
|
||||||
|
const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase());
|
||||||
|
const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase());
|
||||||
|
connList.push({
|
||||||
|
from: s1.ip,
|
||||||
|
to: s2.ip,
|
||||||
|
fromKey: s1Key,
|
||||||
|
toKey: s2Key,
|
||||||
|
internalFromTo: iface.remoteIp || null,
|
||||||
|
internalToFrom: iface.localIp || null,
|
||||||
|
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
|
||||||
|
interfaceName: iface.name || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { connections: connList, servers, uiSettings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgePingKey(a, b) {
|
||||||
|
return [String(a), String(b)].sort().join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function speedKey(key1, key2) {
|
||||||
|
return [String(key1), String(key2)].sort().join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Параллельно с лимитом */
|
||||||
|
async function runWithLimit(tasks, limit = 4) {
|
||||||
|
const results = [];
|
||||||
|
let index = 0;
|
||||||
|
async function runNext() {
|
||||||
|
const i = index++;
|
||||||
|
if (i >= tasks.length) return;
|
||||||
|
try {
|
||||||
|
results[i] = { value: await tasks[i]() };
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = { error: err };
|
||||||
|
}
|
||||||
|
await runNext();
|
||||||
|
}
|
||||||
|
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один тест на ноду в момент времени */
|
||||||
|
async function runWithOnePerKey(tasks, getKey) {
|
||||||
|
const n = tasks.length;
|
||||||
|
const keyToIndices = new Map();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const k = String(getKey(i));
|
||||||
|
if (!keyToIndices.has(k)) keyToIndices.set(k, []);
|
||||||
|
keyToIndices.get(k).push(i);
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < n; i++) results[i] = null;
|
||||||
|
const keys = Array.from(keyToIndices.keys());
|
||||||
|
let round = 0;
|
||||||
|
while (true) {
|
||||||
|
const batch = [];
|
||||||
|
for (const k of keys) {
|
||||||
|
const indices = keyToIndices.get(k);
|
||||||
|
if (round < indices.length) batch.push(indices[round]);
|
||||||
|
}
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async (i) => {
|
||||||
|
try {
|
||||||
|
results[i] = { value: await tasks[i]() };
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = { error: err };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
round++;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполнить одну итерацию: пинг и speed-test по связям, записать кеш и логи.
|
||||||
|
* @param {object} opts - { baseUrl, runPing, runSpeedTest, log }
|
||||||
|
*/
|
||||||
|
async function runJob(opts) {
|
||||||
|
const { baseUrl, runPing = true, runSpeedTest = true, log = (msg) => logBuffer.push(msg), trigger = 'scheduled' } = opts;
|
||||||
|
const pingMap = {};
|
||||||
|
const speedMap = {};
|
||||||
|
|
||||||
|
log(trigger === 'manual' ? '--- Ручной запуск ---' : '--- Запуск по расписанию ---');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connections, servers } = await buildConnections();
|
||||||
|
if (connections.length === 0) {
|
||||||
|
log('Нет связей в network-config (tunnelInterfaces).');
|
||||||
|
await writeCache(pingMap, speedMap);
|
||||||
|
return { pingMap, speedMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Связей: ${connections.length}. Пинг: ${runPing ? 'да' : 'нет'}, Скорость: ${runSpeedTest ? 'да' : 'нет'}`);
|
||||||
|
|
||||||
|
if (runPing) {
|
||||||
|
const pingTasks = [];
|
||||||
|
for (const c of connections) {
|
||||||
|
const from = String(c.from);
|
||||||
|
const to = String(c.to);
|
||||||
|
if (!from || !to || from === to) continue;
|
||||||
|
const srcFrom = servers.find((s) => s.ip === from);
|
||||||
|
const srcTo = servers.find((s) => s.ip === to);
|
||||||
|
const canPingFrom = srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo;
|
||||||
|
const canPingTo = srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
|
||||||
|
|
||||||
|
if (canPingFrom) {
|
||||||
|
pingTasks.push(async () => {
|
||||||
|
const ekey = edgePingKey(from, to);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/mikrotik/ping`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: c.fromKey,
|
||||||
|
target: c.internalFromTo,
|
||||||
|
gatewayIp: c.internalFromTo,
|
||||||
|
count: 3,
|
||||||
|
forceRefresh: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
||||||
|
pingMap[ekey] = ms;
|
||||||
|
const errMsg = !res.ok ? (data?.message || `HTTP ${res.status}`) : null;
|
||||||
|
log(`Пинг ${from} → ${to}: ${ms != null ? ms + ' ms' : errMsg || 'ошибка'}`);
|
||||||
|
return ms;
|
||||||
|
} catch (e) {
|
||||||
|
pingMap[ekey] = null;
|
||||||
|
log(`Пинг ${from} → ${to}: ошибка ${e?.message || e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (canPingTo) {
|
||||||
|
pingTasks.push(async () => {
|
||||||
|
const ekey = edgePingKey(from, to);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/mikrotik/ping`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: c.toKey,
|
||||||
|
target: c.internalToFrom,
|
||||||
|
gatewayIp: c.internalToFrom,
|
||||||
|
count: 3,
|
||||||
|
forceRefresh: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
||||||
|
pingMap[ekey] = ms;
|
||||||
|
const errMsg2 = !res.ok ? (data?.message || `HTTP ${res.status}`) : null;
|
||||||
|
log(`Пинг ${to} → ${from}: ${ms != null ? ms + ' ms' : errMsg2 || 'ошибка'}`);
|
||||||
|
return ms;
|
||||||
|
} catch (e) {
|
||||||
|
pingMap[ekey] = null;
|
||||||
|
log(`Пинг ${to} → ${from}: ошибка ${e?.message || e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runWithLimit(pingTasks, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runSpeedTest) {
|
||||||
|
// Таймаут запроса: замер может долго идти на медленных каналах (download + upload по durationSeconds каждый)
|
||||||
|
const SPEED_TEST_REQUEST_MS = 300000; // 5 мин
|
||||||
|
const withSpeed = connections.filter((c) => c.speedTestServerId && c.interfaceName);
|
||||||
|
const speedTasks = withSpeed.map((c) => async () => {
|
||||||
|
const key = speedKey(c.fromKey, c.toKey);
|
||||||
|
const server = servers.find((s) => (s.id || s.dns || s.ip) === c.speedTestServerId);
|
||||||
|
const serverLabel = server ? (server.dns || server.ip || server.id) : c.speedTestServerId;
|
||||||
|
const speedLabel = `${serverLabel} / ${c.interfaceName}`;
|
||||||
|
const ac = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => ac.abort(), SPEED_TEST_REQUEST_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/mikrotik/speed-test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: c.speedTestServerId,
|
||||||
|
interfaceName: c.interfaceName,
|
||||||
|
forceRefresh: true,
|
||||||
|
}),
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
|
||||||
|
speedMap[key] = {
|
||||||
|
tcpDownloadBps: data.tcpDownloadBps,
|
||||||
|
tcpUploadBps: data.tcpUploadBps,
|
||||||
|
cached: data.cached === true,
|
||||||
|
durationSeconds: data.durationSeconds,
|
||||||
|
};
|
||||||
|
const down = data.tcpDownloadBps != null ? (data.tcpDownloadBps / 1e6).toFixed(1) : '—';
|
||||||
|
const up = data.tcpUploadBps != null ? (data.tcpUploadBps / 1e6).toFixed(1) : '—';
|
||||||
|
log(`Скорость ${speedLabel}: ↓${down} ↑${up} Mbps`);
|
||||||
|
return speedMap[key];
|
||||||
|
}
|
||||||
|
speedMap[key] = null;
|
||||||
|
const speedErr = !res.ok ? (data?.message || `HTTP ${res.status}`) : 'нет данных';
|
||||||
|
log(`Скорость ${speedLabel}: ${speedErr}`);
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
speedMap[key] = null;
|
||||||
|
const msg = e?.message || e;
|
||||||
|
const isAbort = String(msg).toLowerCase().includes('abort');
|
||||||
|
log(`Скорость ${speedLabel}: ошибка ${isAbort ? 'таймаут или отмена запроса' : msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// В один момент — только один тест на один сервер назначения (удалённый конец туннеля),
|
||||||
|
// иначе несколько замеров к одному хостингу делят канал и дают заниженные результаты
|
||||||
|
await runWithOnePerKey(speedTasks, (i) => {
|
||||||
|
const c = withSpeed[i];
|
||||||
|
return c.speedTestServerId === c.fromKey ? c.toKey : c.fromKey;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeCache(pingMap, speedMap);
|
||||||
|
log('Кеш карты сети обновлён.');
|
||||||
|
} catch (err) {
|
||||||
|
log(`Ошибка: ${err?.message || err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pingMap, speedMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCache(pingMap, speedMap) {
|
||||||
|
try {
|
||||||
|
await writeS3JsonObject(NETWORK_MAP_CACHE_KEY, {
|
||||||
|
pingMap: pingMap || {},
|
||||||
|
speedMap: speedMap || {},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logBuffer.push(`Запись кеша в S3 не удалась: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let schedulerTimer = null;
|
||||||
|
let schedulerStartTimeout = null;
|
||||||
|
/** Время последнего запуска по расписанию (для расчёта следующего) */
|
||||||
|
let lastScheduledRunAt = null;
|
||||||
|
/** Реально запланированное время следующего запуска */
|
||||||
|
let nextScheduledRunAt = null;
|
||||||
|
/** Интервал активного таймера (мс) */
|
||||||
|
let activeIntervalMs = null;
|
||||||
|
|
||||||
|
async function persistNextRunAt(nextRunAt) {
|
||||||
|
try {
|
||||||
|
const ui = await loadUiSettings();
|
||||||
|
ui[NEXT_RUN_AT_KEY] = typeof nextRunAt === 'number' ? Math.round(nextRunAt) : null;
|
||||||
|
await writeS3JsonObject(UI_SETTINGS_KEY, ui);
|
||||||
|
} catch (e) {
|
||||||
|
logBuffer.push(`Не удалось сохранить nextRunAt: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettingsFromUiSettings(uiSettings) {
|
||||||
|
const persistedNextRunAtRaw = uiSettings?.[NEXT_RUN_AT_KEY];
|
||||||
|
const persistedNextRunAt = Number(persistedNextRunAtRaw);
|
||||||
|
return {
|
||||||
|
enabled: Boolean(uiSettings.networkMapSchedulerEnabled),
|
||||||
|
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
|
||||||
|
runPing: uiSettings.networkMapSchedulerRunPing !== false,
|
||||||
|
runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false,
|
||||||
|
logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)),
|
||||||
|
persistedNextRunAt: Number.isFinite(persistedNextRunAt) ? persistedNextRunAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить планировщик (setInterval).
|
||||||
|
* @param {object} logger - pino logger
|
||||||
|
* @param {number} port - порт сервера для вызова API
|
||||||
|
*/
|
||||||
|
function initNetworkMapScheduler(logger, port) {
|
||||||
|
const log = logger || console;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
async function tick(source = 'interval') {
|
||||||
|
if (source === 'interval') {
|
||||||
|
lastScheduledRunAt = Date.now();
|
||||||
|
if (activeIntervalMs) {
|
||||||
|
nextScheduledRunAt = lastScheduledRunAt + activeIntervalMs;
|
||||||
|
persistNextRunAt(nextScheduledRunAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let uiSettings;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
uiSettings = JSON.parse(raw?.body || '{}') || {};
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
|
if (!settings.enabled) return;
|
||||||
|
|
||||||
|
logBuffer.setMaxLines(settings.logMaxLines);
|
||||||
|
logBuffer.push('--- Запуск по расписанию ---');
|
||||||
|
runJob({
|
||||||
|
baseUrl,
|
||||||
|
runPing: settings.runPing,
|
||||||
|
runSpeedTest: settings.runSpeedTest,
|
||||||
|
trigger: source === 'startup-delay' ? 'startup-delay' : 'scheduled',
|
||||||
|
log: (msg) => {
|
||||||
|
logBuffer.push(msg);
|
||||||
|
log.info({ component: 'network-map-scheduler' }, msg);
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
logBuffer.push(`Ошибка: ${err?.message || err}`);
|
||||||
|
log.error({ component: 'network-map-scheduler', err: err?.message }, 'Scheduler run failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedule() {
|
||||||
|
let uiSettings;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
uiSettings = JSON.parse(raw?.body || '{}') || {};
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
|
if (!settings.enabled) {
|
||||||
|
activeIntervalMs = null;
|
||||||
|
nextScheduledRunAt = null;
|
||||||
|
persistNextRunAt(null);
|
||||||
|
log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logBuffer.setMaxLines(settings.logMaxLines);
|
||||||
|
const intervalMs = settings.intervalMinutes * 60 * 1000;
|
||||||
|
activeIntervalMs = intervalMs;
|
||||||
|
const now = Date.now();
|
||||||
|
const persisted = settings.persistedNextRunAt;
|
||||||
|
let firstDelayMs = intervalMs;
|
||||||
|
if (typeof persisted === 'number' && Number.isFinite(persisted)) {
|
||||||
|
if (persisted > now) {
|
||||||
|
// Плановая дата ещё не наступила — сохраняем исходный план.
|
||||||
|
firstDelayMs = Math.max(1000, persisted - now);
|
||||||
|
} else {
|
||||||
|
// Плановая дата уже прошла (контейнер был перезапущен/простаивал) —
|
||||||
|
// первый запуск через 10 минут от старта контейнера.
|
||||||
|
firstDelayMs = RESTART_RECOVERY_DELAY_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextScheduledRunAt = now + firstDelayMs;
|
||||||
|
persistNextRunAt(nextScheduledRunAt);
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
component: 'network-map-scheduler',
|
||||||
|
intervalMinutes: settings.intervalMinutes,
|
||||||
|
firstDelayMs,
|
||||||
|
recoveryDelayMs: RESTART_RECOVERY_DELAY_MS,
|
||||||
|
},
|
||||||
|
'Network map scheduler started (first run in %d sec, then every %d min)',
|
||||||
|
Math.round(firstDelayMs / 1000),
|
||||||
|
settings.intervalMinutes
|
||||||
|
);
|
||||||
|
schedulerStartTimeout = setTimeout(() => {
|
||||||
|
tick('interval');
|
||||||
|
schedulerTimer = setInterval(() => tick('interval'), intervalMs);
|
||||||
|
}, firstDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopNetworkMapScheduler() {
|
||||||
|
if (schedulerStartTimeout) {
|
||||||
|
clearTimeout(schedulerStartTimeout);
|
||||||
|
schedulerStartTimeout = null;
|
||||||
|
}
|
||||||
|
if (schedulerTimer) {
|
||||||
|
clearInterval(schedulerTimer);
|
||||||
|
schedulerTimer = null;
|
||||||
|
}
|
||||||
|
activeIntervalMs = null;
|
||||||
|
nextScheduledRunAt = null;
|
||||||
|
persistNextRunAt(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogs() {
|
||||||
|
return logBuffer.getLines();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogMaxLines() {
|
||||||
|
return logBuffer.maxLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLogMaxLines(n) {
|
||||||
|
const num = Math.max(100, Math.min(2000, parseInt(n, 10) || 500));
|
||||||
|
logBuffer.setMaxLines(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Добавить строку в лог (для вывода ошибок из роутов) */
|
||||||
|
function pushLog(msg) {
|
||||||
|
logBuffer.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Время следующего запуска по расписанию (timestamp или null если выключено).
|
||||||
|
* @param {object} settings - результат getSettingsFromUiSettings()
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function getNextRunAt(settings) {
|
||||||
|
if (!settings || !settings.enabled) return null;
|
||||||
|
if (typeof nextScheduledRunAt === 'number' && Number.isFinite(nextScheduledRunAt)) {
|
||||||
|
return nextScheduledRunAt;
|
||||||
|
}
|
||||||
|
if (typeof lastScheduledRunAt === 'number' && Number.isFinite(lastScheduledRunAt)) {
|
||||||
|
const intervalMs = activeIntervalMs || (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
|
||||||
|
return lastScheduledRunAt + intervalMs;
|
||||||
|
}
|
||||||
|
if (typeof settings?.persistedNextRunAt === 'number' && Number.isFinite(settings.persistedNextRunAt)) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (settings.persistedNextRunAt > now) return settings.persistedNextRunAt;
|
||||||
|
return now + RESTART_RECOVERY_DELAY_MS;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
runJob,
|
||||||
|
initNetworkMapScheduler,
|
||||||
|
stopNetworkMapScheduler,
|
||||||
|
getLogs,
|
||||||
|
getLogMaxLines,
|
||||||
|
setLogMaxLines,
|
||||||
|
pushLog,
|
||||||
|
getSettingsFromUiSettings,
|
||||||
|
getNextRunAt,
|
||||||
|
loadUiSettings: loadUiSettings,
|
||||||
|
buildConnections,
|
||||||
|
LogBuffer,
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Планировщик пинга сервисов из списка настроек.
|
||||||
|
* Периодически вызывает refreshPingServicesCache и обновляет кеш для GET /api/ping-services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { readS3TextObject } = require('./s3Service');
|
||||||
|
const { refreshPingServicesCache } = require('../routes/miscRoutes');
|
||||||
|
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MINUTES = 2;
|
||||||
|
|
||||||
|
let schedulerTimer = null;
|
||||||
|
let lastScheduledRunAt = null;
|
||||||
|
|
||||||
|
function getSettingsFromUiSettings(uiSettings) {
|
||||||
|
return {
|
||||||
|
enabled: uiSettings.pingServicesSchedulerEnabled !== false,
|
||||||
|
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.pingServicesSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Время следующего запуска по расписанию (timestamp или null если выключено).
|
||||||
|
*/
|
||||||
|
function getNextRunAt(settings) {
|
||||||
|
if (!settings || !settings.enabled) return null;
|
||||||
|
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
|
||||||
|
const from = lastScheduledRunAt || Date.now();
|
||||||
|
return from + intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить планировщик пинг-сервисов.
|
||||||
|
* @param {object} logger - pino logger
|
||||||
|
*/
|
||||||
|
function initPingServicesScheduler(logger) {
|
||||||
|
const log = logger || console;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
lastScheduledRunAt = Date.now();
|
||||||
|
let uiSettings;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
uiSettings = JSON.parse(raw?.body || '{}') || {};
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
|
if (!settings.enabled) return;
|
||||||
|
|
||||||
|
log.info({ component: 'ping-services-scheduler' }, 'Running ping services refresh');
|
||||||
|
refreshPingServicesCache().catch((err) => {
|
||||||
|
log.error({ component: 'ping-services-scheduler', err: err?.message }, 'Ping services refresh failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedule() {
|
||||||
|
let uiSettings;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
uiSettings = JSON.parse(raw?.body || '{}') || {};
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
|
if (!settings.enabled) {
|
||||||
|
log.info({ component: 'ping-services-scheduler' }, 'Ping services scheduler disabled in settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const intervalMs = settings.intervalMinutes * 60 * 1000;
|
||||||
|
log.info(
|
||||||
|
{ component: 'ping-services-scheduler', intervalMinutes: settings.intervalMinutes },
|
||||||
|
'Ping services scheduler started (first run in 15 s, then every %d min)',
|
||||||
|
settings.intervalMinutes
|
||||||
|
);
|
||||||
|
schedulerTimer = setInterval(tick, intervalMs);
|
||||||
|
setTimeout(() => tick(), 15 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPingServicesScheduler() {
|
||||||
|
if (schedulerTimer) {
|
||||||
|
clearInterval(schedulerTimer);
|
||||||
|
schedulerTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initPingServicesScheduler,
|
||||||
|
stopPingServicesScheduler,
|
||||||
|
getSettingsFromUiSettings,
|
||||||
|
getNextRunAt,
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Планировщик проверок Uptime Monitor.
|
||||||
|
* Периодически вызывает POST /api/uptime/check для каждого jumphost/home и обновляет кеш в S3.
|
||||||
|
* После перезапуска контейнера кеш обновляется по расписанию без необходимости открывать страницу.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { readS3TextObject } = require('./s3Service');
|
||||||
|
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MINUTES = 2;
|
||||||
|
const PARALLEL_CHECKS = 2;
|
||||||
|
|
||||||
|
let schedulerTimer = null;
|
||||||
|
let lastScheduledRunAt = null;
|
||||||
|
|
||||||
|
function getSettingsFromUiSettings(uiSettings) {
|
||||||
|
return {
|
||||||
|
enabled: uiSettings.uptimeMonitorSchedulerEnabled !== false,
|
||||||
|
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.uptimeMonitorSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextRunAt(settings) {
|
||||||
|
if (!settings || !settings.enabled) return null;
|
||||||
|
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
|
||||||
|
const from = lastScheduledRunAt || Date.now();
|
||||||
|
return from + intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить планировщик Uptime Monitor.
|
||||||
|
* @param {object} logger - pino logger
|
||||||
|
* @param {number} port - порт приложения для вызова API
|
||||||
|
*/
|
||||||
|
function initUptimeMonitorScheduler(logger, port) {
|
||||||
|
const log = logger || console;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
async function runChecks(serverIds) {
|
||||||
|
const runOne = async (serverId) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/uptime/check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serverId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
log.warn({ component: 'uptime-monitor-scheduler', serverId, status: res.status }, 'Uptime check failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ component: 'uptime-monitor-scheduler', serverId, err: err?.message }, 'Uptime check error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < serverIds.length; i += PARALLEL_CHECKS) {
|
||||||
|
const chunk = serverIds.slice(i, i + PARALLEL_CHECKS);
|
||||||
|
await Promise.all(chunk.map(runOne));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
lastScheduledRunAt = Date.now();
|
||||||
|
let uiSettings;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
uiSettings = JSON.parse(raw?.body || '{}') || {};
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
|
if (!settings.enabled) return;
|
||||||
|
|
||||||
|
let servers = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/servers`);
|
||||||
|
const data = res.ok ? await res.json() : [];
|
||||||
|
servers = Array.isArray(data) ? data : [];
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ component: 'uptime-monitor-scheduler', err: err?.message }, 'Failed to fetch servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumphosts = servers.filter(
|
||||||
|
(s) => s && ['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
|
||||||
|
);
|
||||||
|
const serverIds = jumphosts
|
||||||
|
.map((s) => s.id || s.dns || s.ip)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (serverIds.length === 0) {
|
||||||
|
log.info({ component: 'uptime-monitor-scheduler' }, 'No jumphost/home servers to check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ component: 'uptime-monitor-scheduler', count: serverIds.length },
|
||||||
|
'Running uptime monitor checks for %d server(s)',
|
||||||
|
serverIds.length
|
||||||
|
);
|
||||||
|
await runChecks(serverIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedule() {
|
||||||
|
let uiSettings;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
uiSettings = JSON.parse(raw?.body || '{}') || {};
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
|
if (!settings.enabled) {
|
||||||
|
log.info({ component: 'uptime-monitor-scheduler' }, 'Uptime monitor scheduler disabled in settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const intervalMs = settings.intervalMinutes * 60 * 1000;
|
||||||
|
log.info(
|
||||||
|
{ component: 'uptime-monitor-scheduler', intervalMinutes: settings.intervalMinutes },
|
||||||
|
'Uptime monitor scheduler started (first run in 20 s, then every %d min)',
|
||||||
|
settings.intervalMinutes
|
||||||
|
);
|
||||||
|
schedulerTimer = setInterval(tick, intervalMs);
|
||||||
|
setTimeout(() => tick(), 20 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopUptimeMonitorScheduler() {
|
||||||
|
if (schedulerTimer) {
|
||||||
|
clearInterval(schedulerTimer);
|
||||||
|
schedulerTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initUptimeMonitorScheduler,
|
||||||
|
stopUptimeMonitorScheduler,
|
||||||
|
getSettingsFromUiSettings,
|
||||||
|
getNextRunAt,
|
||||||
|
};
|
||||||
@@ -120,9 +120,7 @@ function decrypt(encryptedText) {
|
|||||||
}
|
}
|
||||||
console.error('Decryption error details:', {
|
console.error('Decryption error details:', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
encryptedTextLength: encryptedText.length
|
||||||
encryptedTextLength: encryptedText.length,
|
|
||||||
encryptedTextPreview: encryptedText.substring(0, 100)
|
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# S3 Lists Manager - Frontend
|
# RouterOS Manager - Frontend
|
||||||
|
|
||||||
Современный веб-интерфейс для управления списками доменов и AS с использованием Tabler UI.
|
Современный веб-интерфейс для управления списками доменов и AS с использованием Tabler UI.
|
||||||
|
|
||||||
@@ -172,4 +172,4 @@ import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**S3 Lists Manager** - Современный интерфейс для управления списками с использованием Tabler UI.
|
**RouterOS Manager** - Современный интерфейс для управления списками с использованием Tabler UI.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 🎨 UX/UI Улучшения - Документация
|
# 🎨 UX/UI Улучшения - Документация
|
||||||
|
|
||||||
Этот документ описывает все внедренные UX/UI улучшения в проект S3 Lists Manager.
|
Этот документ описывает все внедренные UX/UI улучшения в проект RouterOS Manager.
|
||||||
|
|
||||||
## 📋 Содержание
|
## 📋 Содержание
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Таблицы: UX/UI по Tabler и проекту
|
||||||
|
|
||||||
|
Рекомендации по формированию таблиц в приложении — [Tabler Tables](https://docs.tabler.io/ui/components/tables/), [Preview](https://preview.tabler.io/tables.html).
|
||||||
|
|
||||||
|
## Классы (Tabler)
|
||||||
|
|
||||||
|
- **Базовые:** `table card-table table-vcenter table-nowrap mb-0`
|
||||||
|
- **Чередование строк:** `table-striped` — улучшает читаемость
|
||||||
|
- **Компактный режим:** `table-sm` — меньше отступы в ячейках
|
||||||
|
- **Обёртка:** `table-responsive` вокруг `<table>`
|
||||||
|
- **Обрезка длинного текста:** для ячейки использовать `td-truncate` (Tabler) или `text-truncate` + `max-width`
|
||||||
|
|
||||||
|
## Компонент DataTable
|
||||||
|
|
||||||
|
Используйте `DataTable` там, где подходит декларативное описание колонок и общие действия (сортировка, выбор, пагинация):
|
||||||
|
|
||||||
|
- `columns`: `{ key, title, sortable?, icon?, render?, tooltip? }` — для подсказки по заголовку задайте `tooltip`
|
||||||
|
- `striped={true}` по умолчанию
|
||||||
|
- `compact={true}` — компактные ячейки
|
||||||
|
- Колонка «Действия» получает класс `td-actions` (фиксированная минимальная ширина в проекте)
|
||||||
|
|
||||||
|
## Информативность и лаконичность
|
||||||
|
|
||||||
|
1. **Заголовки колонок** — короткие, при необходимости уточнение через `tooltip` (например, значение иконки замка).
|
||||||
|
2. **Действия в строке** — иконки с `Tooltip` и `aria-label`; колонка «Действия» справа, без лишнего текста.
|
||||||
|
3. **Копирование** — кнопка/иконка копирования рядом с ячейкой (IP, домен и т.д.) вместо дублирования в «Действия», если это основной сценарий.
|
||||||
|
4. **Пустое состояние** — через `emptyState` в DataTable или свой блок с призывом к действию (импорт, добавление).
|
||||||
|
|
||||||
|
## Доступность
|
||||||
|
|
||||||
|
- У заголовков: `scope="col"`.
|
||||||
|
- У таблицы: `role="grid"` при семантической сетке данных.
|
||||||
|
- Кнопки действий: `aria-label` и/или Tooltip.
|
||||||
|
|
||||||
|
## Где таблица своя, а не DataTable
|
||||||
|
|
||||||
|
В IPRangesManager, DomainsNewManager, ServerManager и др. таблицы рендерятся вручную. Для единообразия:
|
||||||
|
|
||||||
|
- Те же классы: `table card-table table-vcenter table-nowrap mb-0`, при желании `table-striped`.
|
||||||
|
- Заголовки: `th` с `scope="col"`, короткий текст, при необходимости иконка и тултип.
|
||||||
|
- Колонка действий: класс `td-actions` у `th` и `td`, кнопки с тултипами.
|
||||||
|
|
||||||
|
При рефакторинге страниц можно постепенно переводить такие таблицы на DataTable с описанием `columns` и общих обработчиков.
|
||||||
@@ -15,7 +15,7 @@ export default defineConfig([
|
|||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: { ...globals.browser, process: 'readonly' },
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
ecmaFeatures: { jsx: true },
|
ecmaFeatures: { jsx: true },
|
||||||
@@ -23,7 +23,8 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="S3 Lists Manager - Управление списками доменов и AS" />
|
<meta name="description" content="RouterOS Manager - Управление списками доменов и AS" />
|
||||||
<meta name="theme-color" content="#206bc4" />
|
<meta name="theme-color" content="#206bc4" />
|
||||||
|
|
||||||
<!-- Inter Font -->
|
<!-- Inter Font -->
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<title>S3 Lists Manager</title>
|
<title>RouterOS Manager</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+773
-1
@@ -17,9 +17,12 @@
|
|||||||
"@tanstack/react-virtual": "^3.10.8",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"@xyflow/react": "^12.3.4",
|
"@xyflow/react": "^12.3.4",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
|
"diff": "^8.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1"
|
"react-icons": "^5.5.0",
|
||||||
|
"react-router-dom": "^6.30.1",
|
||||||
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
@@ -346,6 +349,414 @@
|
|||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
|
||||||
|
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.25.5",
|
"version": "0.25.5",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
@@ -601,6 +1012,42 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||||
@@ -628,6 +1075,18 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tabler/core": {
|
"node_modules/@tabler/core": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.3.2.tgz",
|
||||||
@@ -780,6 +1239,12 @@
|
|||||||
"@babel/types": "^7.20.7"
|
"@babel/types": "^7.20.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-color": {
|
"node_modules/@types/d3-color": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
@@ -795,6 +1260,12 @@
|
|||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-interpolate": {
|
"node_modules/@types/d3-interpolate": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
@@ -804,12 +1275,48 @@
|
|||||||
"@types/d3-color": "*"
|
"@types/d3-color": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-selection": {
|
"node_modules/@types/d3-selection": {
|
||||||
"version": "3.0.11",
|
"version": "3.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-transition": {
|
"node_modules/@types/d3-transition": {
|
||||||
"version": "3.0.9",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
@@ -855,6 +1362,12 @@
|
|||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1076,6 +1589,15 @@
|
|||||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1130,6 +1652,18 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-color": {
|
"node_modules/d3-color": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
@@ -1170,6 +1704,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-interpolate": {
|
"node_modules/d3-interpolate": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
@@ -1182,6 +1725,31 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-selection": {
|
"node_modules/d3-selection": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
@@ -1191,6 +1759,42 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-timer": {
|
"node_modules/d3-timer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
@@ -1251,6 +1855,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1263,6 +1873,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/diff": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1317,6 +1936,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.5",
|
"version": "0.25.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1533,6 +2162,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1780,6 +2415,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1803,6 +2448,15 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2145,6 +2799,45 @@
|
|||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2185,6 +2878,57 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2254,6 +2998,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.14",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2346,6 +3096,28 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -21,9 +21,12 @@
|
|||||||
"@tanstack/react-virtual": "^3.10.8",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"@xyflow/react": "^12.3.4",
|
"@xyflow/react": "^12.3.4",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
|
"diff": "^8.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1"
|
"react-icons": "^5.5.0",
|
||||||
|
"react-router-dom": "^6.30.1",
|
||||||
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
|
|||||||
+429
-418
File diff suppressed because it is too large
Load Diff
+412
-5
@@ -1,5 +1,166 @@
|
|||||||
/* Custom styles for S3 Lists Manager */
|
/* Custom styles for RouterOS Manager */
|
||||||
/* Most styles are now provided by @tabler/core */
|
/* Layout: Tabler Fluid Vertical (https://preview.tabler.io/layout-fluid-vertical.html) */
|
||||||
|
|
||||||
|
/* --- Fluid Vertical: page structure --- */
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сайдбар: всегда тёмный (как на референсе Tabler) */
|
||||||
|
.navbar-vertical {
|
||||||
|
--tblr-body-color: rgba(255, 255, 255, 0.9);
|
||||||
|
--tblr-body-color-rgb: 255, 255, 255;
|
||||||
|
--tblr-border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
--tblr-bg-surface: #1e293b;
|
||||||
|
--tblr-bg-surface-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
--tblr-navbar-active-bg: rgba(255, 255, 255, 0.08);
|
||||||
|
--tblr-navbar-active-color: #fff;
|
||||||
|
--tblr-primary: #6ea8fe;
|
||||||
|
--tblr-muted: rgba(255, 255, 255, 0.6);
|
||||||
|
background-color: #1e293b !important;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-brand,
|
||||||
|
.navbar-vertical .nav-link,
|
||||||
|
.navbar-vertical .dropdown-item {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-brand:hover,
|
||||||
|
.navbar-vertical .nav-link:hover,
|
||||||
|
.navbar-vertical .dropdown-item:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.navbar-vertical .nav-link.active,
|
||||||
|
.navbar-vertical .dropdown-item.active {
|
||||||
|
background-color: var(--tblr-navbar-active-bg);
|
||||||
|
color: var(--tblr-navbar-active-color);
|
||||||
|
}
|
||||||
|
.navbar-vertical .dropdown-toggle::after {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-toggler {
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сайдбар: у бренда убираем лишний отступ и стрелку (Tabler не должен добавлять caret к brand) */
|
||||||
|
.navbar-vertical .navbar-brand {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-brand::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.navbar-vertical > .container-fluid {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.navbar-vertical .collapse.navbar-collapse {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.navbar-vertical .collapse .navbar-nav.pt-lg-3 {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-sidebar-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon {
|
||||||
|
padding: 0.35rem;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon .badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-sidebar-footer .dropdown-toggle::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Все выпадающие меню в футере сайдбара — поверх контента и без обрезки */
|
||||||
|
.navbar-vertical .navbar-sidebar-footer .dropdown-menu {
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card {
|
||||||
|
overflow-x: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card .card-footer {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-sidebar-footer .dropdown-menu-card .card-footer .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
/* Стрелка только у пунктов с подменю (dropdown-toggle), не у обычных ссылок и не у бренда */
|
||||||
|
.navbar-vertical .nav-link:not(.dropdown-toggle)::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar: nav-link with icon (Tabler vertical) */
|
||||||
|
.navbar-vertical .nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.navbar-vertical .nav-link-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.navbar-vertical .nav-link-icon .icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
/* Sidebar dropdown: inline submenu (no popup) — только для основного списка меню,
|
||||||
|
не затрагиваем иконки в нижнем футере сайдбара (язык, тема, колокол и т.д.) */
|
||||||
|
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-menu {
|
||||||
|
position: static;
|
||||||
|
float: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 0 0.5rem 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom avatar background */
|
/* Custom avatar background */
|
||||||
.avatar {
|
.avatar {
|
||||||
@@ -151,6 +312,150 @@
|
|||||||
/* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler
|
/* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler
|
||||||
(например, badge bg-blue-lt text-blue) */
|
(например, badge bg-blue-lt text-blue) */
|
||||||
|
|
||||||
|
/* --- Server sidebar (фильтры на /servers) — Tabler card + list-group --- */
|
||||||
|
.server-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
max-height: calc(100vh - 180px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.server-sidebar .card-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.server-sidebar .card-body.border-bottom,
|
||||||
|
.server-sidebar .card-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.server-sidebar-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
.server-sidebar .filter-section .list-group-header {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
color: var(--tblr-body-color);
|
||||||
|
}
|
||||||
|
.server-sidebar .filter-section .list-group-header:hover {
|
||||||
|
background-color: var(--tblr-bg-surface-tertiary);
|
||||||
|
}
|
||||||
|
.server-sidebar .list-group-item {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding-top: 0.375rem;
|
||||||
|
padding-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
/* Полные названия в фильтрах (без обрезания), перенос по словам */
|
||||||
|
.server-sidebar .list-group-item .text-break,
|
||||||
|
#server-filters-offcanvas .list-group-item span:first-of-type span {
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabler-style subheader (как на preview.tabler.io/settings.html) */
|
||||||
|
.subheader {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--tblr-muted-color, #6c757d);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Мобильный сайдбар: верхняя полоса + оверлей меню (Tabler-style) ===== */
|
||||||
|
.sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1035;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
body.sidebar-overlay-open {
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
/* На мобильных: колоночный layout — сайдбар сверху как полоса, контент на всю ширину снизу */
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.page .navbar-vertical {
|
||||||
|
position: relative;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1041; /* выше бэкдропа (1035), чтобы меню и верхняя полоса были кликабельны */
|
||||||
|
}
|
||||||
|
.page .navbar-vertical > .container-fluid {
|
||||||
|
flex-direction: row !important;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.page .navbar-vertical .navbar-brand {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.page .navbar-vertical .navbar-brand .fw-bold {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 12rem;
|
||||||
|
}
|
||||||
|
.page .navbar-vertical .navbar-nav.flex-row {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.page .navbar-vertical .navbar-toggler {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
/* Меню скрыто по умолчанию, при открытии — оверлей слева (z-index внутри контекста навбара) */
|
||||||
|
.page .navbar-vertical .navbar-collapse {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(100vw, 280px);
|
||||||
|
max-width: 280px;
|
||||||
|
z-index: 1042;
|
||||||
|
background: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
||||||
|
flex-direction: column;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.page .navbar-vertical .navbar-collapse.show {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
.page-wrapper {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.navbar-vertical .nav-link,
|
||||||
|
.navbar-vertical .dropdown-item {
|
||||||
|
min-height: 44px;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive navbar */
|
/* Responsive navbar */
|
||||||
@media (max-width: 991.98px) {
|
@media (max-width: 991.98px) {
|
||||||
.navbar-collapse {
|
.navbar-collapse {
|
||||||
@@ -251,10 +556,12 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.header-actions .btn svg { width: 18px; height: 18px; }
|
.header-actions .btn svg { width: 18px; height: 18px; }
|
||||||
|
.header-actions .header-actions-right { flex-shrink: 0; }
|
||||||
|
|
||||||
/* Ensure dropdowns in header-actions overlay tables/cards correctly but stay below modals */
|
/* Ensure dropdowns in main content (and header) are above the sidebar, below modals */
|
||||||
.header-actions .dropdown-menu,
|
.header-actions .dropdown-menu,
|
||||||
.page-header .dropdown-menu {
|
.page-header .dropdown-menu,
|
||||||
|
.page-wrapper .dropdown-menu {
|
||||||
z-index: 1035; /* above navbar(1030), below modal-backdrop(1040) and modal(1050) */
|
z-index: 1035; /* above navbar(1030), below modal-backdrop(1040) and modal(1050) */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +570,7 @@
|
|||||||
|
|
||||||
/* Responsive sizing for small screens */
|
/* Responsive sizing for small screens */
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
.header-actions { gap: 0.4rem; }
|
.header-actions { gap: 0.4rem; flex-wrap: wrap; }
|
||||||
.header-actions .btn {
|
.header-actions .btn {
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -276,6 +583,30 @@
|
|||||||
.header-actions { width: 100%; }
|
.header-actions { width: 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Мобильные отступы контента */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.page-body .container-fluid,
|
||||||
|
.page-body .container-xl {
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
.card .card-body {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.offcanvas-start {
|
||||||
|
width: min(320px, 100vw) !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
}
|
||||||
|
.modal-dialog {
|
||||||
|
margin: 0.5rem;
|
||||||
|
max-width: calc(100% - 1rem);
|
||||||
|
}
|
||||||
|
.modal-fullscreen-sm-down .modal-dialog {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom responsive adjustments */
|
/* Custom responsive adjustments */
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
@@ -366,6 +697,82 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Easy Switch: bring visuals in line with Tabler pages */
|
||||||
|
.easy-switch-page .card-tabs .nav-link {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-server-header {
|
||||||
|
background-color: rgba(32, 107, 196, 0.05);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-flag {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-gateway-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
border: 1px solid var(--tblr-border-color);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-gateway-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0.125rem 0.375rem rgba(32, 41, 57, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-gateway-card-active {
|
||||||
|
border: 1px solid var(--tblr-primary) !important;
|
||||||
|
box-shadow: 0 0.25rem 0.75rem rgba(32, 107, 196, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-active-check {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-fast-slot {
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-fast-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-gateway-flag {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-gateway-flag-active {
|
||||||
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-gateway-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-gateway-ip {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-switch-gateway-ping {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ripple effect для кнопок */
|
/* Ripple effect для кнопок */
|
||||||
@keyframes ripple {
|
@keyframes ripple {
|
||||||
to {
|
to {
|
||||||
|
|||||||
+447
-194
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, createContext, useContext } from 'react';
|
import { useState, useEffect, createContext, useContext, useRef } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
BrowserRouter as Router,
|
BrowserRouter as Router,
|
||||||
@@ -20,7 +20,15 @@ import {
|
|||||||
IconDownload,
|
IconDownload,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconMenu2,
|
IconMenu2,
|
||||||
IconArrowsExchange
|
IconArrowsExchange,
|
||||||
|
IconLayoutSidebarLeftExpand,
|
||||||
|
IconLayoutNavbar,
|
||||||
|
IconChartPie,
|
||||||
|
IconChartBar,
|
||||||
|
IconClock,
|
||||||
|
IconClockPlay,
|
||||||
|
IconShield,
|
||||||
|
IconRoute2
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import ServerManager from './ServerManager';
|
import ServerManager from './ServerManager';
|
||||||
import FilterManager from './FilterManager';
|
import FilterManager from './FilterManager';
|
||||||
@@ -32,15 +40,29 @@ import AutoUrlManager from './AutoUrlManager';
|
|||||||
import BillingManager from './BillingManager';
|
import BillingManager from './BillingManager';
|
||||||
import CommunitiesManager from './CommunitiesManager';
|
import CommunitiesManager from './CommunitiesManager';
|
||||||
import NetworkConfigManager from './NetworkConfigManager';
|
import NetworkConfigManager from './NetworkConfigManager';
|
||||||
|
import MikrotikTools from './MikrotikTools.jsx';
|
||||||
|
import InterfaceSpeedTest from './InterfaceSpeedTest.jsx';
|
||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
|
import TrafficDashboard from './TrafficDashboard.jsx';
|
||||||
|
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
|
||||||
|
import NetworkMapSchedulerPage from './NetworkMapSchedulerPage.jsx';
|
||||||
|
import UptimeMonitorPage from './UptimeMonitorPage.jsx';
|
||||||
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
||||||
|
import PingServicesManager from './PingServicesManager.jsx';
|
||||||
|
import FirewallPage from './FirewallPage.jsx';
|
||||||
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
|
import ResourceStatsPage from './ResourceStatsPage.jsx';
|
||||||
|
import RouteOptimizerPage from './RouteOptimizerPage.jsx';
|
||||||
|
import OspfToolsPage from './OspfToolsPage.jsx';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||||
import SettingsModal from './components/SettingsModal.jsx';
|
|
||||||
import ToastContainer from './components/ToastContainer.jsx';
|
import ToastContainer from './components/ToastContainer.jsx';
|
||||||
import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx';
|
import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx';
|
||||||
import ErrorBoundary from './components/ErrorBoundary.jsx';
|
import ErrorBoundary from './components/ErrorBoundary.jsx';
|
||||||
import NetworkErrorHandler from './components/NetworkErrorHandler.jsx';
|
import NetworkErrorHandler from './components/NetworkErrorHandler.jsx';
|
||||||
|
import { PingProvider } from './contexts/PingContext.jsx';
|
||||||
|
import { AlertsProvider } from './contexts/AlertsContext.jsx';
|
||||||
|
import AlertsBell from './components/AlertsBell.jsx';
|
||||||
|
|
||||||
// --- Simple i18n (RU/EN) ---
|
// --- Simple i18n (RU/EN) ---
|
||||||
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
|
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
|
||||||
@@ -51,17 +73,21 @@ function LanguageProvider({ children }) {
|
|||||||
const dict = {
|
const dict = {
|
||||||
ru: {
|
ru: {
|
||||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||||
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||||
|
routeOptimizer: 'Оптимизация маршрутов ИИ',
|
||||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||||
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы',
|
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', ospfTools: 'Инструменты OSPF',
|
||||||
light: 'Светлая', dark: 'Тёмная'
|
light: 'Светлая', dark: 'Тёмная',
|
||||||
|
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||||
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||||
|
routeOptimizer: 'AI Route Optimizer',
|
||||||
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
||||||
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups',
|
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall', ospfTools: 'OSPF Tools',
|
||||||
light: 'Light', dark: 'Dark'
|
light: 'Light', dark: 'Dark',
|
||||||
|
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const t = (key) => (dict[lang] && key in dict[lang] ? dict[lang][key] : key);
|
const t = (key) => (dict[lang] && key in dict[lang] ? dict[lang][key] : key);
|
||||||
@@ -104,12 +130,16 @@ function App() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ToastContainer>
|
<PingProvider>
|
||||||
<NotifyProvider>
|
<AlertsProvider>
|
||||||
<NetworkErrorHandler />
|
<ToastContainer>
|
||||||
<MainLayout />
|
<NotifyProvider>
|
||||||
</NotifyProvider>
|
<NetworkErrorHandler />
|
||||||
</ToastContainer>
|
<MainLayout />
|
||||||
|
</NotifyProvider>
|
||||||
|
</ToastContainer>
|
||||||
|
</AlertsProvider>
|
||||||
|
</PingProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
@@ -118,12 +148,60 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LAYOUT_FLUID = 'fluid';
|
||||||
|
const LAYOUT_HORIZONTAL = 'horizontal';
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSE_ID = 'sidebar-menu';
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
function MainLayout() {
|
function MainLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { lang, setLang, t } = useContext(LanguageContext);
|
const { lang, setLang, t } = useContext(LanguageContext);
|
||||||
const { theme, setTheme } = useContext(ThemeContext);
|
const { theme, setTheme } = useContext(ThemeContext);
|
||||||
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
|
const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [sidebarBackdrop, setSidebarBackdrop] = useState(false);
|
||||||
|
const sidebarTogglerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('layout', layout);
|
||||||
|
}, [layout]);
|
||||||
|
|
||||||
|
// Мобильный сайдбар: бэкдроп при открытом меню и закрытие при навигации
|
||||||
|
useEffect(() => {
|
||||||
|
if (layout !== LAYOUT_FLUID) return;
|
||||||
|
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
|
||||||
|
const onShow = () => {
|
||||||
|
if (window.innerWidth < MOBILE_BREAKPOINT) {
|
||||||
|
setSidebarBackdrop(true);
|
||||||
|
document.body.classList.add('sidebar-overlay-open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onHide = () => {
|
||||||
|
setSidebarBackdrop(false);
|
||||||
|
document.body.classList.remove('sidebar-overlay-open');
|
||||||
|
};
|
||||||
|
if (collapseEl) {
|
||||||
|
collapseEl.addEventListener('show.bs.collapse', onShow);
|
||||||
|
collapseEl.addEventListener('hide.bs.collapse', onHide);
|
||||||
|
return () => {
|
||||||
|
collapseEl.removeEventListener('show.bs.collapse', onShow);
|
||||||
|
collapseEl.removeEventListener('hide.bs.collapse', onHide);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [layout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (layout !== LAYOUT_FLUID || window.innerWidth >= MOBILE_BREAKPOINT) return;
|
||||||
|
setSidebarBackdrop(false);
|
||||||
|
document.body.classList.remove('sidebar-overlay-open');
|
||||||
|
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
|
||||||
|
if (collapseEl?.classList.contains('show')) {
|
||||||
|
sidebarTogglerRef.current?.click?.();
|
||||||
|
}
|
||||||
|
}, [location.pathname, layout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
}, [location.pathname, layout]);
|
||||||
|
|
||||||
// Состояние для управления выпадающими меню
|
// Состояние для управления выпадающими меню
|
||||||
const [dropdownStates, setDropdownStates] = useState({
|
const [dropdownStates, setDropdownStates] = useState({
|
||||||
@@ -132,10 +210,12 @@ function MainLayout() {
|
|||||||
tools: false
|
tools: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Закрытие всех выпадающих меню при клике вне их области
|
// Закрытие выпадающих меню только при клике в сайдбаре вне открытого меню (клики по странице не закрывают)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (!event.target.closest('.nav-item.dropdown')) {
|
const inSidebar = event.target.closest('.navbar-vertical');
|
||||||
|
const inDropdown = event.target.closest('.nav-item.dropdown');
|
||||||
|
if (inSidebar && !inDropdown) {
|
||||||
setDropdownStates({
|
setDropdownStates({
|
||||||
data: false,
|
data: false,
|
||||||
management: false,
|
management: false,
|
||||||
@@ -171,8 +251,14 @@ function MainLayout() {
|
|||||||
id: 'home',
|
id: 'home',
|
||||||
title: t('home'),
|
title: t('home'),
|
||||||
icon: IconHome,
|
icon: IconHome,
|
||||||
path: '/dashboard',
|
items: [
|
||||||
single: true
|
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
|
||||||
|
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
|
||||||
|
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
|
||||||
|
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
|
||||||
|
{ id: 'route-optimizer', title: t('routeOptimizer'), path: '/route-optimizer', icon: IconRoute2 },
|
||||||
|
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'data',
|
id: 'data',
|
||||||
@@ -203,10 +289,23 @@ function MainLayout() {
|
|||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
|
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
|
||||||
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase }
|
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
|
||||||
|
{ id: 'ospf-tools', title: t('ospfTools'), path: '/ospf-tools', icon: IconRoute2 },
|
||||||
|
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
|
||||||
|
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
|
||||||
|
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
|
||||||
|
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork },
|
||||||
|
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock },
|
||||||
|
{ id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Убрали неиспользуемые/неработающие разделы
|
{
|
||||||
|
id: 'settings',
|
||||||
|
title: 'Настройки',
|
||||||
|
icon: IconSettings,
|
||||||
|
single: true,
|
||||||
|
path: '/settings'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Определяем активную вкладку по адресу
|
// Определяем активную вкладку по адресу
|
||||||
@@ -228,191 +327,345 @@ function MainLayout() {
|
|||||||
|
|
||||||
const activeTab = getActiveTab();
|
const activeTab = getActiveTab();
|
||||||
|
|
||||||
|
// Старый вид: один верхний навбар с горизонтальным меню
|
||||||
|
if (layout === LAYOUT_HORIZONTAL) {
|
||||||
|
return (
|
||||||
|
<div className="page d-flex flex-column min-vh-100">
|
||||||
|
<a href="#main-content" className="skip-link">Перейти к основному содержанию</a>
|
||||||
|
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{ boxShadow: '0 1px 0 0 #e9ecef' }}>
|
||||||
|
<div className="container-xl">
|
||||||
|
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
||||||
|
<Link to="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<IconBrandTabler className="navbar-brand-icon me-2" />
|
||||||
|
<span className="fw-bold">RouterOS Manager</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler d-md-none"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbar-menu-horizontal"
|
||||||
|
aria-controls="navbar-menu-horizontal"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<IconMenu2 />
|
||||||
|
</button>
|
||||||
|
<div className="collapse navbar-collapse" id="navbar-menu-horizontal">
|
||||||
|
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
||||||
|
<ul className="navbar-nav">
|
||||||
|
{navCategories.map(category => (
|
||||||
|
<li key={category.id} className="nav-item dropdown">
|
||||||
|
{category.single ? (
|
||||||
|
<Link
|
||||||
|
to={category.path}
|
||||||
|
className={`nav-link${activeTab === category.id ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
<category.icon className="icon icon-tabler" />
|
||||||
|
<span className="nav-link-title">{category.title}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
className={`nav-link dropdown-toggle${activeTab.startsWith(category.id) ? ' active' : ''}`}
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleDropdown(category.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<category.icon className="icon icon-tabler" />
|
||||||
|
<span className="nav-link-title">{category.title}</span>
|
||||||
|
</a>
|
||||||
|
{dropdownStates[category.id] && (
|
||||||
|
<div className="dropdown-menu show" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dropdown-menu-columns">
|
||||||
|
<div className="dropdown-menu-column">
|
||||||
|
{category.items.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
to={item.path}
|
||||||
|
className={`dropdown-item${activeTab === item.id ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
<item.icon className="icon icon-tabler" />
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="navbar-nav flex-row order-md-last">
|
||||||
|
<div className="nav-item dropdown me-1">
|
||||||
|
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
|
||||||
|
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
|
||||||
|
</a>
|
||||||
|
<div className="dropdown-menu dropdown-menu-end">
|
||||||
|
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
|
||||||
|
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
|
||||||
|
</a>
|
||||||
|
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_HORIZONTAL ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_HORIZONTAL); }}>
|
||||||
|
<IconLayoutNavbar className="me-2" size={18} /> {t('layoutHorizontal')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="nav-item dropdown me-1">
|
||||||
|
<a href="#" className="nav-link px-2 dropdown-toggle d-flex align-items-center" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
|
||||||
|
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
|
||||||
|
</a>
|
||||||
|
<div className="dropdown-menu dropdown-menu-end">
|
||||||
|
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}><span className="me-2" aria-hidden>🇷🇺</span>Русский</a>
|
||||||
|
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}><span className="me-2" aria-hidden>🇺🇸</span>English</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="nav-item dropdown me-1">
|
||||||
|
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
|
||||||
|
{theme === 'dark' ? '🌙' : '☀️'}
|
||||||
|
</a>
|
||||||
|
<div className="dropdown-menu dropdown-menu-end">
|
||||||
|
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}>☀️ {t('light')}</a>
|
||||||
|
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="nav-item me-1">
|
||||||
|
<AlertsBell />
|
||||||
|
</div>
|
||||||
|
<div className="nav-item me-1">
|
||||||
|
<KeyboardShortcutsButton className="nav-link px-2" />
|
||||||
|
</div>
|
||||||
|
<div className="nav-item">
|
||||||
|
<Link to="/settings" className="nav-link px-2" title="Настройки">
|
||||||
|
<IconSettings size={20} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||||
|
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||||
|
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||||
|
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
|
||||||
|
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||||
|
<Route path="/domains" element={<DomainsNewManager />} />
|
||||||
|
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||||
|
<Route path="/asns" element={<ASNsNewManager />} />
|
||||||
|
<Route path="/communities" element={<CommunitiesManager />} />
|
||||||
|
<Route path="/auto-urls" element={<AutoUrlManager />} />
|
||||||
|
<Route path="/servers" element={<ServerManager />} />
|
||||||
|
<Route path="/billing" element={<BillingManager />} />
|
||||||
|
<Route path="/filters" element={<FilterManager />} />
|
||||||
|
<Route path="/network-config" element={<NetworkConfigManager />} />
|
||||||
|
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||||
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
|
<Route path="/ospf-tools" element={<OspfToolsPage />} />
|
||||||
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
|
<Route path="/firewall" element={<FirewallPage />} />
|
||||||
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
|
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||||
|
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<footer className="footer mt-auto py-2">
|
||||||
|
<div className="container-xl text-muted small">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</div>
|
||||||
|
</footer>
|
||||||
|
<CommandPalette />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fluid Vertical: вертикальный сайдбар + верхний навбар + page-wrapper
|
||||||
return (
|
return (
|
||||||
<div className="page d-flex flex-column min-vh-100">
|
<div className="page">
|
||||||
{/* Skip link для доступности */}
|
{/* Skip link для доступности */}
|
||||||
<a href="#main-content" className="skip-link">
|
<a href="#main-content" className="skip-link">
|
||||||
Перейти к основному содержанию
|
Перейти к основному содержанию
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Верхнее меню Tabler Admin */}
|
|
||||||
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{boxShadow: '0 1px 0 0 #e9ecef'}}>
|
|
||||||
<div className="container-xl">
|
|
||||||
{/* Логотип */}
|
|
||||||
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
|
||||||
<Link to="/" className="navbar-brand d-flex align-items-center">
|
|
||||||
<IconBrandTabler className="navbar-brand-icon me-2" />
|
|
||||||
<span className="fw-bold">S3 Lists Manager</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопка бургера для мобильных */}
|
{/* Левая вертикальная навигация — всегда тёмная (Tabler navbar-vertical) */}
|
||||||
<button
|
<header className="navbar navbar-vertical navbar-expand-md d-print-none" data-bs-theme="dark">
|
||||||
className="navbar-toggler d-md-none"
|
<div className="container-fluid">
|
||||||
type="button"
|
<Link to="/" className="navbar-brand navbar-brand-autodark">
|
||||||
data-bs-toggle="collapse"
|
<IconBrandTabler className="navbar-brand-icon" />
|
||||||
data-bs-target="#navbar-menu"
|
<span className="fw-bold">RouterOS Manager</span>
|
||||||
aria-controls="navbar-menu"
|
</Link>
|
||||||
aria-expanded="false"
|
<div className="navbar-nav flex-row d-md-none">
|
||||||
aria-label="Toggle navigation"
|
<button
|
||||||
>
|
ref={sidebarTogglerRef}
|
||||||
<IconMenu2 />
|
className="navbar-toggler"
|
||||||
</button>
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
{/* Основное меню */}
|
data-bs-target={`#${SIDEBAR_COLLAPSE_ID}`}
|
||||||
<div className="collapse navbar-collapse" id="navbar-menu">
|
aria-controls={SIDEBAR_COLLAPSE_ID}
|
||||||
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
aria-expanded="false"
|
||||||
<ul className="navbar-nav">
|
aria-label="Открыть меню"
|
||||||
{navCategories.map(category => (
|
>
|
||||||
<li key={category.id} className="nav-item dropdown">
|
<IconMenu2 />
|
||||||
{category.single ? (
|
</button>
|
||||||
<Link
|
</div>
|
||||||
to={category.path}
|
<div className="collapse navbar-collapse" id={SIDEBAR_COLLAPSE_ID}>
|
||||||
className={`nav-link${activeTab === category.id ? ' active' : ''}`}
|
<ul className="navbar-nav pt-lg-3">
|
||||||
|
{navCategories.map(category => (
|
||||||
|
<li key={category.id} className={`nav-item${category.items ? ' dropdown' : ''}${(category.single && activeTab === category.id) || (category.items && category.items.some(i => activeTab === i.id)) ? ' active' : ''}`}>
|
||||||
|
{category.single ? (
|
||||||
|
<Link
|
||||||
|
to={category.path}
|
||||||
|
className="nav-link"
|
||||||
|
>
|
||||||
|
<span className="nav-link-icon">
|
||||||
|
<category.icon className="icon" />
|
||||||
|
</span>
|
||||||
|
<span className="nav-link-title">{category.title}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
className="nav-link dropdown-toggle"
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDropdown(category.id);
|
||||||
|
}}
|
||||||
|
aria-expanded={dropdownStates[category.id]}
|
||||||
>
|
>
|
||||||
<category.icon className="icon icon-tabler" />
|
<span className="nav-link-icon">
|
||||||
|
<category.icon className="icon" />
|
||||||
|
</span>
|
||||||
<span className="nav-link-title">{category.title}</span>
|
<span className="nav-link-title">{category.title}</span>
|
||||||
</Link>
|
</a>
|
||||||
) : (
|
<div className={`dropdown-menu${dropdownStates[category.id] ? ' show' : ''}`}>
|
||||||
<>
|
{category.items.map(item => (
|
||||||
<a
|
<Link
|
||||||
className={`nav-link dropdown-toggle${activeTab.startsWith(category.id) ? ' active' : ''}`}
|
key={item.id}
|
||||||
href="#"
|
to={item.path}
|
||||||
onClick={(e) => {
|
className={`dropdown-item d-flex align-items-center${activeTab === item.id ? ' active' : ''}`}
|
||||||
e.preventDefault();
|
>
|
||||||
e.stopPropagation();
|
<span className="nav-link-icon me-2">
|
||||||
toggleDropdown(category.id);
|
<item.icon className="icon" />
|
||||||
}}
|
</span>
|
||||||
>
|
{item.title}
|
||||||
<category.icon className="icon icon-tabler" />
|
</Link>
|
||||||
<span className="nav-link-title">{category.title}</span>
|
))}
|
||||||
</a>
|
</div>
|
||||||
{dropdownStates[category.id] && (
|
</>
|
||||||
<div className="dropdown-menu show" onClick={(e) => e.stopPropagation()}>
|
)}
|
||||||
<div className="dropdown-menu-columns">
|
</li>
|
||||||
<div className="dropdown-menu-column">
|
))}
|
||||||
{category.items.map(item => (
|
</ul>
|
||||||
<Link
|
{/* Горизонтальный ряд значков в самом низу сайдбара */}
|
||||||
key={item.id}
|
<div className="navbar-sidebar-footer border-top mt-auto">
|
||||||
to={item.path}
|
<div className="d-flex align-items-center justify-content-center gap-1 py-2 px-2">
|
||||||
className={`dropdown-item${activeTab === item.id ? ' active' : ''}`}
|
<div className="nav-item dropdown dropup">
|
||||||
>
|
<a href="#" className="nav-link btn-icon rounded d-inline-flex align-items-center justify-content-center" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
|
||||||
<item.icon className="icon icon-tabler" />
|
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
|
||||||
{item.title}
|
</a>
|
||||||
</Link>
|
<div className="dropdown-menu dropdown-menu-start">
|
||||||
))}
|
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}><span className="me-2" aria-hidden>🇷🇺</span>Русский</a>
|
||||||
</div>
|
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}><span className="me-2" aria-hidden>🇺🇸</span>English</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="nav-item dropdown dropup">
|
||||||
</>
|
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('layoutSidebar')}>
|
||||||
)}
|
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
|
||||||
</li>
|
</a>
|
||||||
))}
|
<div className="dropdown-menu dropdown-menu-start">
|
||||||
</ul>
|
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
|
||||||
</div>
|
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
|
||||||
</div>
|
</a>
|
||||||
|
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_HORIZONTAL ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_HORIZONTAL); }}>
|
||||||
{/* Правая часть навбара: язык, тема и действия */}
|
<IconLayoutNavbar className="me-2" size={18} /> {t('layoutHorizontal')}
|
||||||
<div className="navbar-nav flex-row order-md-last">
|
</a>
|
||||||
{/* Язык */}
|
</div>
|
||||||
<div className="nav-item dropdown me-1">
|
</div>
|
||||||
<a
|
<div className="nav-item dropdown dropup">
|
||||||
href="#"
|
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('light') + ' / ' + t('dark')}>
|
||||||
className="nav-link px-2 dropdown-toggle"
|
{theme === 'dark' ? '🌙' : '☀️'}
|
||||||
data-bs-toggle="dropdown"
|
</a>
|
||||||
onClick={(e) => e.preventDefault()}
|
<div className="dropdown-menu dropdown-menu-start">
|
||||||
>
|
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}>☀️ {t('light')}</a>
|
||||||
<span className="badge">{lang.toUpperCase()}</span>
|
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
|
||||||
</a>
|
</div>
|
||||||
<div className="dropdown-menu dropdown-menu-end">
|
</div>
|
||||||
<a
|
<div className="nav-item">
|
||||||
href="#"
|
<AlertsBell dropup />
|
||||||
className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`}
|
</div>
|
||||||
onClick={(e) => { e.preventDefault(); setLang('ru'); }}
|
<div className="nav-item">
|
||||||
>
|
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
|
||||||
Русский
|
</div>
|
||||||
</a>
|
<div className="nav-item">
|
||||||
<a
|
<Link to="/settings" className="nav-link btn-icon rounded" title="Настройки">
|
||||||
href="#"
|
<IconSettings size={20} />
|
||||||
className={`dropdown-item ${lang === 'en' ? 'active' : ''}`}
|
</Link>
|
||||||
onClick={(e) => { e.preventDefault(); setLang('en'); }}
|
</div>
|
||||||
>
|
|
||||||
English
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Тема */}
|
|
||||||
<div className="nav-item dropdown me-1">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="nav-link px-2 dropdown-toggle"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? '🌙' : '☀️'}
|
|
||||||
</a>
|
|
||||||
<div className="dropdown-menu dropdown-menu-end">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className={`dropdown-item ${theme === 'light' ? 'active' : ''}`}
|
|
||||||
onClick={(e) => { e.preventDefault(); setTheme('light'); }}
|
|
||||||
>
|
|
||||||
☀️ {t('light')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`}
|
|
||||||
onClick={(e) => { e.preventDefault(); setTheme('dark'); }}
|
|
||||||
>
|
|
||||||
🌙 {t('dark')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Горячие клавиши */}
|
|
||||||
<div className="nav-item me-1">
|
|
||||||
<KeyboardShortcutsButton className="nav-link px-2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Настройки */}
|
|
||||||
<div className="nav-item">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="nav-link px-2"
|
|
||||||
onClick={(e) => { e.preventDefault(); setSettingsOpen(true); }}
|
|
||||||
title="Настройки"
|
|
||||||
>
|
|
||||||
<IconSettings size={20} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
|
|
||||||
<Routes>
|
{/* Бэкдроп мобильного меню: закрытие по клику */}
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
{sidebarBackdrop && (
|
||||||
<Route path="/domains" element={<DomainsNewManager />} />
|
<div
|
||||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
className="sidebar-backdrop d-md-none"
|
||||||
<Route path="/asns" element={<ASNsNewManager />} />
|
aria-hidden="true"
|
||||||
<Route path="/communities" element={<CommunitiesManager />} />
|
onClick={() => sidebarTogglerRef.current?.click?.()}
|
||||||
<Route path="/auto-urls" element={<AutoUrlManager />} />
|
/>
|
||||||
<Route path="/servers" element={<ServerManager />} />
|
)}
|
||||||
<Route path="/billing" element={<BillingManager />} />
|
|
||||||
<Route path="/filters" element={<FilterManager />} />
|
<div className="page-wrapper">
|
||||||
<Route path="/network-config" element={<NetworkConfigManager />} />
|
<div className="page-body">
|
||||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
<div className="container-fluid">
|
||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<main id="main-content" tabIndex="-1">
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Routes>
|
||||||
</Routes>
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
</main>
|
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||||
<footer className="footer mt-auto py-2">
|
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||||
<div className="container-xl text-muted small">
|
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||||
Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}
|
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
|
||||||
|
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||||
|
<Route path="/domains" element={<DomainsNewManager />} />
|
||||||
|
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||||
|
<Route path="/asns" element={<ASNsNewManager />} />
|
||||||
|
<Route path="/communities" element={<CommunitiesManager />} />
|
||||||
|
<Route path="/auto-urls" element={<AutoUrlManager />} />
|
||||||
|
<Route path="/servers" element={<ServerManager />} />
|
||||||
|
<Route path="/billing" element={<BillingManager />} />
|
||||||
|
<Route path="/filters" element={<FilterManager />} />
|
||||||
|
<Route path="/network-config" element={<NetworkConfigManager />} />
|
||||||
|
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||||
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
|
<Route path="/ospf-tools" element={<OspfToolsPage />} />
|
||||||
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
|
<Route path="/firewall" element={<FirewallPage />} />
|
||||||
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
|
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||||
|
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
<footer className="footer footer-transparent d-print-none">
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<div className="container-fluid">
|
||||||
|
<ul className="list-inline list-inline-dots mb-0 text-muted small">
|
||||||
|
<li className="list-inline-item">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -101,7 +101,7 @@ function AutoUrlManager() {
|
|||||||
|
|
||||||
const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community));
|
const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community));
|
||||||
|
|
||||||
const saveUrls = async () => {
|
const saveUrls = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -124,7 +124,7 @@ function AutoUrlManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}, [urls]);
|
||||||
|
|
||||||
const processUrls = async () => {
|
const processUrls = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -158,7 +158,7 @@ function AutoUrlManager() {
|
|||||||
}
|
}
|
||||||
setSuccess('Пример формата скопирован');
|
setSuccess('Пример формата скопирован');
|
||||||
setTimeout(() => setSuccess(''), 2000);
|
setTimeout(() => setSuccess(''), 2000);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setError('Не удалось скопировать пример');
|
setError('Не удалось скопировать пример');
|
||||||
setTimeout(() => setError(''), 2000);
|
setTimeout(() => setError(''), 2000);
|
||||||
}
|
}
|
||||||
@@ -276,7 +276,7 @@ function AutoUrlManager() {
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Автоматические URL"
|
title="Автоматические URL"
|
||||||
pretitle="Управление автоматическими URL-адресами для загрузки списков IP и доменов"
|
icon={<IconLink size={24} />}
|
||||||
actions={(
|
actions={(
|
||||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<div className="btn-list">
|
<div className="btn-list">
|
||||||
@@ -619,7 +619,7 @@ function AutoUrlManager() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredAndSortedUrls.map((url, displayIndex) => {
|
{filteredAndSortedUrls.map((url, _displayIndex) => {
|
||||||
const originalIndex = urls.findIndex(u => u === url);
|
const originalIndex = urls.findIndex(u => u === url);
|
||||||
const v = getRowValidity(url);
|
const v = getRowValidity(url);
|
||||||
const isValid = v.url && v.community;
|
const isValid = v.url && v.community;
|
||||||
|
|||||||
+438
-391
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
@@ -41,8 +41,43 @@ import {
|
|||||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
import { BillingCard } from './components/billing/index.js';
|
import { BillingCard } from './components/billing/index.js';
|
||||||
|
|
||||||
|
function normalizeBillingItems(rawItems, rates) {
|
||||||
|
const toRUB = (amt, ccy) => {
|
||||||
|
if (!amt) return 0;
|
||||||
|
const rub = Number(rates?.RUB) || 1;
|
||||||
|
if (!ccy || ccy === 'RUB') return amt;
|
||||||
|
if (ccy === 'USD') return amt * rub;
|
||||||
|
const c = Number(rates?.[ccy]);
|
||||||
|
return c ? (amt / c) * rub : amt * rub;
|
||||||
|
};
|
||||||
|
return (rawItems || []).map((item, idx) => {
|
||||||
|
const payments = Array.isArray(item.payments)
|
||||||
|
? item.payments
|
||||||
|
: (item.lastPaymentDate && item.lastPaymentAmount
|
||||||
|
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
|
||||||
|
: []);
|
||||||
|
const merged = { id: item.id ?? String(idx + 1), payments, ...item };
|
||||||
|
if (payments.length > 0) {
|
||||||
|
const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0];
|
||||||
|
if (last?.date) {
|
||||||
|
const current = new Date(item.nextPaymentDate || 0);
|
||||||
|
const now = new Date();
|
||||||
|
if (current < now || !item.nextPaymentDate) {
|
||||||
|
const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
|
||||||
|
const amtRUB = toRUB(last.amount, last.currency || 'USD');
|
||||||
|
const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1;
|
||||||
|
const d = new Date(last.date);
|
||||||
|
d.setMonth(d.getMonth() + months);
|
||||||
|
merged.nextPaymentDate = d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function BillingManager() {
|
function BillingManager() {
|
||||||
const [billingData, setBillingData] = useState([]);
|
const [rawBillingData, setRawBillingData] = useState([]);
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -51,9 +86,10 @@ function BillingManager() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [sortField, setSortField] = useState('nextPaymentDate');
|
const [sortField, setSortField] = useState('nextPaymentDate');
|
||||||
const [sortOrder, setSortOrder] = useState('asc');
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
const [filterStatus, setFilterStatus] = useState('');
|
const [filterStatus, _setFilterStatus] = useState('');
|
||||||
const [filterUrgency, setFilterUrgency] = useState('');
|
const [filterUrgency, setFilterUrgency] = useState('');
|
||||||
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
|
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
|
||||||
|
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
|
||||||
const pageSize = 15;
|
const pageSize = 15;
|
||||||
|
|
||||||
// Модальные окна
|
// Модальные окна
|
||||||
@@ -75,14 +111,14 @@ function BillingManager() {
|
|||||||
const [editingPayment, setEditingPayment] = useState(null);
|
const [editingPayment, setEditingPayment] = useState(null);
|
||||||
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
|
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
|
||||||
const [paymentToDelete, setPaymentToDelete] = useState(null);
|
const [paymentToDelete, setPaymentToDelete] = useState(null);
|
||||||
const [futureDateConfirm, setFutureDateConfirm] = useState(false);
|
const [_futureDateConfirm, _setFutureDateConfirm] = useState(false);
|
||||||
|
|
||||||
// История платежей
|
// История платежей
|
||||||
const [paymentSearchTerm, setPaymentSearchTerm] = useState('');
|
const [paymentSearchTerm, _setPaymentSearchTerm] = useState('');
|
||||||
const [paymentSortField, setPaymentSortField] = useState('date');
|
const [_paymentSortField, _setPaymentSortField] = useState('date');
|
||||||
const [paymentSortOrder, setPaymentSortOrder] = useState('desc');
|
const [_paymentSortOrder, _setPaymentSortOrder] = useState('desc');
|
||||||
const [selectedPayments, setSelectedPayments] = useState(new Set());
|
const [_selectedPayments, _setSelectedPayments] = useState(new Set());
|
||||||
const [paymentPage, setPaymentPage] = useState(1);
|
const [_paymentPage, _setPaymentPage] = useState(1);
|
||||||
const paymentPageSize = 10;
|
const paymentPageSize = 10;
|
||||||
|
|
||||||
// Курсы валют
|
// Курсы валют
|
||||||
@@ -91,7 +127,7 @@ function BillingManager() {
|
|||||||
EUR: 1,
|
EUR: 1,
|
||||||
RUB: 1
|
RUB: 1
|
||||||
});
|
});
|
||||||
const [ratesLoading, setRatesLoading] = useState(false);
|
const [_ratesLoading, setRatesLoading] = useState(false);
|
||||||
|
|
||||||
// Новый элемент
|
// Новый элемент
|
||||||
const [newItem, setNewItem] = useState({
|
const [newItem, setNewItem] = useState({
|
||||||
@@ -111,17 +147,93 @@ function BillingManager() {
|
|||||||
serverId: '',
|
serverId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchExchangeRates = useCallback(async (abortSignal) => {
|
||||||
fetchBillingData();
|
setRatesLoading(true);
|
||||||
fetchExchangeRates();
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
fetchServers();
|
try {
|
||||||
|
const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js', opts);
|
||||||
|
const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null;
|
||||||
|
const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null;
|
||||||
|
|
||||||
|
if (rubPerUsd) {
|
||||||
|
const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1;
|
||||||
|
setExchangeRates({
|
||||||
|
USD: 1,
|
||||||
|
EUR: eurPerUsd,
|
||||||
|
RUB: rubPerUsd
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
|
console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts);
|
||||||
|
const rates = response.data?.rates || {};
|
||||||
|
setExchangeRates({
|
||||||
|
USD: 1,
|
||||||
|
EUR: rates.EUR || 1,
|
||||||
|
RUB: rates.RUB || 1
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('Ошибка при загрузке курсов валют:', error);
|
||||||
|
setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 });
|
||||||
|
} finally {
|
||||||
|
setRatesLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchServers = useCallback(async (abortSignal) => {
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/servers`, opts);
|
||||||
|
setServers(Array.isArray(res.data) ? res.data : []);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('Ошибка при загрузке серверов:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchBillingData = useCallback(async (abortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/billing`, opts);
|
||||||
|
setRawBillingData(Array.isArray(response.data) ? response.data : []);
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('Ошибка при загрузке данных биллинга:', err);
|
||||||
|
setError('Ошибка при загрузке данных');
|
||||||
|
setRawBillingData([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
fetchBillingData(signal);
|
||||||
|
fetchExchangeRates(signal);
|
||||||
|
fetchServers(signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [fetchBillingData, fetchExchangeRates, fetchServers]);
|
||||||
|
|
||||||
|
// Нормализация с учётом курсов валют (без повторных запросов)
|
||||||
|
const billingData = useMemo(
|
||||||
|
() => normalizeBillingItems(rawBillingData, exchangeRates),
|
||||||
|
[rawBillingData, exchangeRates]
|
||||||
|
);
|
||||||
|
|
||||||
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
|
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (servers.length === 0 || loading) return;
|
if (servers.length === 0 || loading) return;
|
||||||
|
|
||||||
setBillingData((prev) => {
|
setRawBillingData((prev) => {
|
||||||
const existingServerIds = new Set(
|
const existingServerIds = new Set(
|
||||||
prev.map(b => b.serverId).filter(Boolean)
|
prev.map(b => b.serverId).filter(Boolean)
|
||||||
);
|
);
|
||||||
@@ -161,111 +273,14 @@ function BillingManager() {
|
|||||||
}, [servers, loading]);
|
}, [servers, loading]);
|
||||||
|
|
||||||
|
|
||||||
const fetchExchangeRates = async () => {
|
|
||||||
setRatesLoading(true);
|
|
||||||
try {
|
|
||||||
// Основной источник: ЦБ РФ (base = RUB)
|
|
||||||
const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js');
|
|
||||||
const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null;
|
|
||||||
const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null;
|
|
||||||
|
|
||||||
if (rubPerUsd) {
|
|
||||||
const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1;
|
|
||||||
setExchangeRates({
|
|
||||||
USD: 1,
|
|
||||||
EUR: eurPerUsd, // сколько EUR за 1 USD
|
|
||||||
RUB: rubPerUsd // сколько RUB за 1 USD
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Резервный источник: open.er-api
|
|
||||||
const response = await axios.get('https://open.er-api.com/v6/latest/USD');
|
|
||||||
const rates = response.data?.rates || {};
|
|
||||||
setExchangeRates({
|
|
||||||
USD: 1,
|
|
||||||
EUR: rates.EUR || 1,
|
|
||||||
RUB: rates.RUB || 1
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при загрузке курсов валют:', error);
|
|
||||||
// Фолбек на разумные значения
|
|
||||||
setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 });
|
|
||||||
} finally {
|
|
||||||
setRatesLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchServers = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get(`/servers`);
|
|
||||||
setServers(Array.isArray(res.data) ? res.data : []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при загрузке серверов:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchBillingData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/billing`);
|
|
||||||
const rates = exchangeRates; // из closure, на первой загрузке может быть {1,1,1} — для USD достаточно
|
|
||||||
const toRUB = (amt, ccy) => {
|
|
||||||
if (!amt) return 0;
|
|
||||||
const rub = Number(rates.RUB) || 1;
|
|
||||||
if (!ccy || ccy === 'RUB') return amt;
|
|
||||||
if (ccy === 'USD') return amt * rub;
|
|
||||||
const c = Number(rates[ccy]);
|
|
||||||
return c ? (amt / c) * rub : amt * rub;
|
|
||||||
};
|
|
||||||
const normalized = (response.data || []).map((item, idx) => {
|
|
||||||
const payments = Array.isArray(item.payments)
|
|
||||||
? item.payments
|
|
||||||
: (item.lastPaymentDate && item.lastPaymentAmount
|
|
||||||
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
const merged = { id: item.id ?? String(idx + 1), payments, ...item };
|
|
||||||
if (payments.length > 0) {
|
|
||||||
const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0];
|
|
||||||
if (last?.date) {
|
|
||||||
const current = new Date(item.nextPaymentDate || 0);
|
|
||||||
const now = new Date();
|
|
||||||
if (current < now || !item.nextPaymentDate) {
|
|
||||||
const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
|
|
||||||
const amtRUB = toRUB(last.amount, last.currency || 'USD');
|
|
||||||
const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1;
|
|
||||||
const d = new Date(last.date);
|
|
||||||
d.setMonth(d.getMonth() + months);
|
|
||||||
merged.nextPaymentDate = d.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
});
|
|
||||||
setBillingData(normalized);
|
|
||||||
setError('');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при загрузке данных биллинга:', err);
|
|
||||||
setError('Ошибка при загрузке данных');
|
|
||||||
setBillingData([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Убираем служебное поле _external перед сохранением
|
// Убираем служебное поле _external перед сохранением
|
||||||
const payload = billingData.map(({ _external, ...rest }) => rest);
|
const payload = billingData.map(({ _external: _ext, ...rest }) => rest);
|
||||||
await api.post(`/billing`, { domains: payload });
|
await api.post(`/billing`, { domains: payload });
|
||||||
// После сохранения снимаем флаг _external со всех записей
|
// После сохранения снимаем флаг _external со всех записей
|
||||||
setBillingData(prev => prev.map(item => ({ ...item, _external: false })));
|
setRawBillingData(prev => prev.map(item => ({ ...item, _external: false })));
|
||||||
setSuccess('Данные успешно сохранены');
|
setSuccess('Данные успешно сохранены');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -298,6 +313,10 @@ function BillingManager() {
|
|||||||
return sum + convertToRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
|
return sum + convertToRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
const totalPaymentsCount = billingData.reduce((sum, item) => {
|
||||||
|
return sum + ((item.payments && Array.isArray(item.payments) ? item.payments.length : 0));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const urgentPayments = billingData.filter(item => {
|
const urgentPayments = billingData.filter(item => {
|
||||||
const nextPayment = new Date(item.nextPaymentDate);
|
const nextPayment = new Date(item.nextPaymentDate);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -451,7 +470,7 @@ function BillingManager() {
|
|||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
setBillingData(prev => prev.filter(item => item.id !== selectedItem.id));
|
setRawBillingData(prev => prev.filter(item => item.id !== selectedItem.id));
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false);
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
}
|
}
|
||||||
@@ -464,7 +483,7 @@ function BillingManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newId = Math.max(...billingData.map(item => parseInt(item.id) || 0), 0) + 1;
|
const newId = Math.max(...billingData.map(item => parseInt(item.id) || 0), 0) + 1;
|
||||||
setBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]);
|
setRawBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]);
|
||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
setSuccess('Элемент добавлен');
|
setSuccess('Элемент добавлен');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
@@ -477,7 +496,7 @@ function BillingManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Снимаем флаг _external при редактировании (запись теперь заполнена)
|
// Снимаем флаг _external при редактировании (запись теперь заполнена)
|
||||||
setBillingData(prev => prev.map(item =>
|
setRawBillingData(prev => prev.map(item =>
|
||||||
item.id === editingItem.id ? { ...editingItem, _external: false } : item
|
item.id === editingItem.id ? { ...editingItem, _external: false } : item
|
||||||
));
|
));
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
@@ -489,7 +508,7 @@ function BillingManager() {
|
|||||||
const handleDeletePayment = () => {
|
const handleDeletePayment = () => {
|
||||||
if (!paymentToDelete) return;
|
if (!paymentToDelete) return;
|
||||||
const { serverId, index } = paymentToDelete;
|
const { serverId, index } = paymentToDelete;
|
||||||
setBillingData(prev => prev.map(s => {
|
setRawBillingData(prev => prev.map(s => {
|
||||||
if (s.id !== serverId) return s;
|
if (s.id !== serverId) return s;
|
||||||
const payments = [...(s.payments || [])];
|
const payments = [...(s.payments || [])];
|
||||||
payments.splice(index, 1);
|
payments.splice(index, 1);
|
||||||
@@ -576,17 +595,41 @@ function BillingManager() {
|
|||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Биллинг"
|
title="Биллинг"
|
||||||
pretitle="Финансы / Инфраструктура"
|
icon={<IconCreditCard size={24} />}
|
||||||
actions={
|
actions={
|
||||||
<PageHeaderActions
|
<div className="d-flex flex-column flex-sm-row align-items-stretch align-items-sm-center gap-2">
|
||||||
loading={loading}
|
<div className="btn-group me-sm-2" role="group">
|
||||||
onRefresh={() => { fetchBillingData(); fetchServers(); }}
|
<button
|
||||||
disableRefresh={loading}
|
className={`btn ${activeTab === 'subscriptions' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
onExport={exportData}
|
onClick={() => setActiveTab('subscriptions')}
|
||||||
disableExport={loading}
|
>
|
||||||
onSave={handleSaveChanges}
|
<IconServer size={16} className="me-1 d-none d-sm-inline" />
|
||||||
disableSave={loading}
|
Подписки
|
||||||
/>
|
<span className={`badge ms-1 ${activeTab === 'subscriptions' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
||||||
|
{billingData.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${activeTab === 'payments' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setActiveTab('payments')}
|
||||||
|
>
|
||||||
|
<IconHistory size={16} className="me-1 d-none d-sm-inline" />
|
||||||
|
История платежей
|
||||||
|
<span className={`badge ms-1 ${activeTab === 'payments' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
||||||
|
{totalPaymentsCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<PageHeaderActions
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => { fetchBillingData(); fetchServers(); }}
|
||||||
|
disableRefresh={loading}
|
||||||
|
onExport={exportData}
|
||||||
|
disableExport={loading}
|
||||||
|
onSave={handleSaveChanges}
|
||||||
|
disableSave={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -656,279 +699,282 @@ function BillingManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Основная секция */}
|
{/* Основная секция: Подписки */}
|
||||||
<div className="card">
|
{activeTab === 'subscriptions' && (
|
||||||
<div className="card-header">
|
<div className="card">
|
||||||
<div className="d-flex align-items-center gap-3 flex-grow-1">
|
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
<h3 className="card-title mb-0">
|
<h3 className="card-title mb-0">
|
||||||
Подписки
|
Подписки
|
||||||
<span className="badge bg-blue-lt text-blue ms-2">{filteredData.length}</span>
|
<span className="badge bg-blue-lt text-blue ms-2">{filteredData.length}</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Поиск */}
|
|
||||||
<div className="input-icon flex-grow-1" style={{ maxWidth: '280px' }}>
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconSearch size={16} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control form-control-sm"
|
|
||||||
placeholder="Поиск..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Быстрые фильтры */}
|
<div className="d-flex flex-wrap align-items-center gap-2 flex-grow-1 justify-content-end">
|
||||||
<div className="d-flex gap-1">
|
{/* Поиск */}
|
||||||
<button
|
<div className="input-icon" style={{ minWidth: '220px', maxWidth: '280px' }}>
|
||||||
className={`btn btn-sm ${filterUrgency === '' ? 'btn-primary' : 'btn-outline-secondary'}`}
|
<span className="input-icon-addon">
|
||||||
onClick={() => { setFilterUrgency(''); setCurrentPage(1); }}
|
<IconSearch size={16} />
|
||||||
>
|
</span>
|
||||||
Все
|
<input
|
||||||
</button>
|
type="text"
|
||||||
<button
|
className="form-control form-control-sm"
|
||||||
className={`btn btn-sm ${filterUrgency === 'overdue' ? 'btn-red' : 'btn-outline-secondary'}`}
|
placeholder="Поиск..."
|
||||||
onClick={() => { setFilterUrgency('overdue'); setCurrentPage(1); }}
|
value={searchTerm}
|
||||||
>
|
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||||
Просрочено
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${filterUrgency === 'urgent' ? 'btn-yellow' : 'btn-outline-secondary'}`}
|
|
||||||
onClick={() => { setFilterUrgency('urgent'); setCurrentPage(1); }}
|
|
||||||
>
|
|
||||||
Срочно
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Переключатель вида */}
|
|
||||||
<div className="btn-group btn-group-sm" role="group">
|
|
||||||
<button
|
|
||||||
className={`btn ${viewMode === 'cards' ? 'btn-primary' : 'btn-outline-secondary'}`}
|
|
||||||
onClick={() => setViewMode('cards')}
|
|
||||||
title="Карточки"
|
|
||||||
>
|
|
||||||
<IconLayoutGrid size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn ${viewMode === 'table' ? 'btn-primary' : 'btn-outline-secondary'}`}
|
|
||||||
onClick={() => setViewMode('table')}
|
|
||||||
title="Таблица"
|
|
||||||
>
|
|
||||||
<IconList size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-actions">
|
|
||||||
<button className="btn btn-primary" onClick={handleAddItem}>
|
|
||||||
<IconPlus size={18} className="me-1" />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Контент: карточки или таблица */}
|
|
||||||
{paginatedData.length === 0 ? (
|
|
||||||
<div className="card-body text-center text-muted py-5">
|
|
||||||
<IconCreditCard size={48} className="mb-3 text-muted" />
|
|
||||||
<h3>{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}</h3>
|
|
||||||
<p className="text-muted">Добавьте первую подписку для отслеживания платежей</p>
|
|
||||||
<button className="btn btn-primary" onClick={handleAddItem}>
|
|
||||||
<IconPlus size={18} className="me-1" />
|
|
||||||
Добавить подписку
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : viewMode === 'cards' ? (
|
|
||||||
/* Карточный вид */
|
|
||||||
<div className="card-body">
|
|
||||||
{paginatedData.map(item => {
|
|
||||||
const days = getDaysUntilPayment(item.nextPaymentDate);
|
|
||||||
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BillingCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
linkedServer={linkedServer}
|
|
||||||
daysUntilPayment={days}
|
|
||||||
onEdit={handleEditItem}
|
|
||||||
onDelete={handleDeleteItem}
|
|
||||||
onAddPayment={(item) => {
|
|
||||||
setPaymentDraft({
|
|
||||||
serverId: item.id,
|
|
||||||
date: new Date().toISOString().slice(0,10),
|
|
||||||
amount: item.monthlyCost || 0,
|
|
||||||
currency: item.monthlyCostCurrency || 'USD',
|
|
||||||
note: ''
|
|
||||||
});
|
|
||||||
setShowPaymentModal(true);
|
|
||||||
}}
|
|
||||||
formatCurrencyInRUB={formatCurrencyInRUB}
|
|
||||||
/>
|
/>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
|
{/* Быстрые фильтры */}
|
||||||
|
<div className="btn-group" role="group">
|
||||||
|
<button
|
||||||
|
className={`btn ${filterUrgency === '' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => { setFilterUrgency(''); setCurrentPage(1); }}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${filterUrgency === 'overdue' ? 'btn-outline-danger active' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => { setFilterUrgency('overdue'); setCurrentPage(1); }}
|
||||||
|
>
|
||||||
|
Просрочено
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${filterUrgency === 'urgent' ? 'btn-outline-warning active' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => { setFilterUrgency('urgent'); setCurrentPage(1); }}
|
||||||
|
>
|
||||||
|
Срочно
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Переключатель вида */}
|
||||||
|
<div className="btn-group" role="group">
|
||||||
|
<button
|
||||||
|
className={`btn ${viewMode === 'cards' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => setViewMode('cards')}
|
||||||
|
title="Карточки"
|
||||||
|
>
|
||||||
|
<IconLayoutGrid size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${viewMode === 'table' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
title="Таблица"
|
||||||
|
>
|
||||||
|
<IconList size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-actions">
|
||||||
|
<button className="btn btn-primary" onClick={handleAddItem}>
|
||||||
|
<IconPlus size={18} className="me-1" />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
/* Табличный вид */
|
{/* Контент: карточки или таблица */}
|
||||||
<div className="table-responsive">
|
{paginatedData.length === 0 ? (
|
||||||
<table className="table card-table table-vcenter mb-0">
|
<div className="card-body text-center text-muted py-5">
|
||||||
<thead>
|
<IconCreditCard size={48} className="mb-3 text-muted" />
|
||||||
<tr>
|
<h3>{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}</h3>
|
||||||
<th
|
<p className="text-muted">Добавьте первую подписку для отслеживания платежей</p>
|
||||||
className="cursor-pointer"
|
<button className="btn btn-primary" onClick={handleAddItem}>
|
||||||
onClick={() => handleSort('hostName')}
|
<IconPlus size={18} className="me-1" />
|
||||||
style={{ minWidth: '200px' }}
|
Добавить подписку
|
||||||
>
|
</button>
|
||||||
Сервис
|
</div>
|
||||||
{sortField === 'hostName' && (
|
) : viewMode === 'cards' ? (
|
||||||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
/* Карточный вид */
|
||||||
)}
|
<div className="card-body">
|
||||||
</th>
|
{paginatedData.map(item => {
|
||||||
<th
|
const days = getDaysUntilPayment(item.nextPaymentDate);
|
||||||
className="cursor-pointer text-end"
|
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
|
||||||
onClick={() => handleSort('monthlyCost')}
|
|
||||||
style={{ width: '140px' }}
|
return (
|
||||||
>
|
<BillingCard
|
||||||
Стоимость
|
key={item.id}
|
||||||
{sortField === 'monthlyCost' && (
|
item={item}
|
||||||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
linkedServer={linkedServer}
|
||||||
)}
|
daysUntilPayment={days}
|
||||||
</th>
|
onEdit={handleEditItem}
|
||||||
<th
|
onDelete={handleDeleteItem}
|
||||||
className="cursor-pointer text-center"
|
onAddPayment={(item) => {
|
||||||
onClick={() => handleSort('nextPaymentDate')}
|
setPaymentDraft({
|
||||||
style={{ width: '120px' }}
|
serverId: item.id,
|
||||||
>
|
date: new Date().toISOString().slice(0,10),
|
||||||
Платёж
|
amount: item.monthlyCost || 0,
|
||||||
{sortField === 'nextPaymentDate' && (
|
currency: item.monthlyCostCurrency || 'USD',
|
||||||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
note: ''
|
||||||
)}
|
});
|
||||||
</th>
|
setShowPaymentModal(true);
|
||||||
<th className="text-center" style={{ width: '100px' }}>Срок</th>
|
}}
|
||||||
<th className="text-center" style={{ width: '80px' }}>Статус</th>
|
formatCurrencyInRUB={formatCurrencyInRUB}
|
||||||
<th style={{ width: '140px' }}></th>
|
/>
|
||||||
</tr>
|
);
|
||||||
</thead>
|
})}
|
||||||
<tbody>
|
</div>
|
||||||
{paginatedData.map(item => {
|
) : (
|
||||||
const days = getDaysUntilPayment(item.nextPaymentDate);
|
/* Табличный вид */
|
||||||
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter mb-0">
|
||||||
return (
|
<thead>
|
||||||
<tr key={item.id}>
|
<tr>
|
||||||
<td>
|
<th
|
||||||
<div className="d-flex align-items-center">
|
className="cursor-pointer"
|
||||||
<div>
|
onClick={() => handleSort('hostName')}
|
||||||
<div className="fw-semibold">
|
style={{ minWidth: '200px' }}
|
||||||
{item.hostName}
|
>
|
||||||
{item._external && (
|
Сервис
|
||||||
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения">
|
{sortField === 'hostName' && (
|
||||||
не заполнен
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</th>
|
||||||
</div>
|
<th
|
||||||
<div className="text-muted small d-flex align-items-center gap-2">
|
className="cursor-pointer text-end"
|
||||||
<span>{item.provider}</span>
|
onClick={() => handleSort('monthlyCost')}
|
||||||
{linkedServer && (
|
style={{ width: '140px' }}
|
||||||
<>
|
>
|
||||||
<span>•</span>
|
Стоимость
|
||||||
<span className="text-blue">{linkedServer.ip}</span>
|
{sortField === 'monthlyCost' && (
|
||||||
</>
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||||||
)}
|
)}
|
||||||
{item.loginUrl && (
|
</th>
|
||||||
<a
|
<th
|
||||||
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
|
className="cursor-pointer text-center"
|
||||||
target="_blank"
|
onClick={() => handleSort('nextPaymentDate')}
|
||||||
rel="noopener noreferrer"
|
style={{ width: '120px' }}
|
||||||
className="text-muted"
|
>
|
||||||
title="Открыть панель"
|
Платёж
|
||||||
>
|
{sortField === 'nextPaymentDate' && (
|
||||||
<IconExternalLink size={12} />
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||||||
</a>
|
)}
|
||||||
)}
|
</th>
|
||||||
|
<th className="text-center" style={{ width: '100px' }}>Срок</th>
|
||||||
|
<th className="text-center" style={{ width: '80px' }}>Статус</th>
|
||||||
|
<th style={{ width: '140px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedData.map(item => {
|
||||||
|
const days = getDaysUntilPayment(item.nextPaymentDate);
|
||||||
|
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<div>
|
||||||
|
<div className="fw-semibold">
|
||||||
|
{item.hostName}
|
||||||
|
{item._external && (
|
||||||
|
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения">
|
||||||
|
не заполнен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small d-flex align-items-center gap-2">
|
||||||
|
<span>{item.provider}</span>
|
||||||
|
{linkedServer && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-blue">{linkedServer.ip}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.loginUrl && (
|
||||||
|
<a
|
||||||
|
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted"
|
||||||
|
title="Открыть панель"
|
||||||
|
>
|
||||||
|
<IconExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className="text-end">
|
||||||
<td className="text-end">
|
<div className="fw-semibold">
|
||||||
<div className="fw-semibold">
|
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
|
||||||
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
|
|
||||||
</div>
|
|
||||||
{item.monthlyCostCurrency !== 'RUB' && (
|
|
||||||
<div className="text-muted small">
|
|
||||||
≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{item.monthlyCostCurrency !== 'RUB' && (
|
||||||
</td>
|
<div className="text-muted small">
|
||||||
<td className="text-center">
|
≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
|
||||||
{formatDate(item.nextPaymentDate)}
|
</div>
|
||||||
</td>
|
)}
|
||||||
<td className="text-center">
|
</td>
|
||||||
<UrgencyBadge days={days} />
|
<td className="text-center">
|
||||||
</td>
|
{formatDate(item.nextPaymentDate)}
|
||||||
<td className="text-center">
|
</td>
|
||||||
{item.status === 'active' ? (
|
<td className="text-center">
|
||||||
<span className="badge bg-green-lt text-green">Активен</span>
|
<UrgencyBadge days={days} />
|
||||||
) : (
|
</td>
|
||||||
<span className="badge bg-secondary-lt text-secondary">Неактивен</span>
|
<td className="text-center">
|
||||||
)}
|
{item.status === 'active' ? (
|
||||||
</td>
|
<span className="badge bg-green-lt text-green">Активен</span>
|
||||||
<td>
|
) : (
|
||||||
<div className="btn-list gap-1 justify-content-end flex-nowrap">
|
<span className="badge bg-secondary-lt text-secondary">Неактивен</span>
|
||||||
<button
|
)}
|
||||||
className="btn btn-outline-success btn-sm"
|
</td>
|
||||||
onClick={() => {
|
<td className="text-end">
|
||||||
setPaymentDraft({
|
<div className="btn-group" role="group">
|
||||||
serverId: item.id,
|
<button
|
||||||
date: new Date().toISOString().slice(0,10),
|
className="btn btn-outline-success"
|
||||||
amount: item.monthlyCost || 0,
|
onClick={() => {
|
||||||
currency: item.monthlyCostCurrency || 'USD',
|
setPaymentDraft({
|
||||||
note: ''
|
serverId: item.id,
|
||||||
});
|
date: new Date().toISOString().slice(0,10),
|
||||||
setShowPaymentModal(true);
|
amount: item.monthlyCost || 0,
|
||||||
}}
|
currency: item.monthlyCostCurrency || 'USD',
|
||||||
title="Записать платёж"
|
note: ''
|
||||||
>
|
});
|
||||||
<IconCreditCard size={14} className="me-1" />
|
setShowPaymentModal(true);
|
||||||
Оплата
|
}}
|
||||||
</button>
|
title="Записать платёж"
|
||||||
<button
|
>
|
||||||
className="btn btn-outline-secondary btn-sm"
|
<IconCreditCard size={14} className="me-1" />
|
||||||
onClick={() => handleEditItem(item)}
|
Оплата
|
||||||
title="Редактировать"
|
</button>
|
||||||
>
|
<button
|
||||||
<IconEdit size={14} />
|
className="btn btn-outline-secondary"
|
||||||
</button>
|
onClick={() => handleEditItem(item)}
|
||||||
<button
|
title="Редактировать"
|
||||||
className="btn btn-outline-danger btn-sm"
|
>
|
||||||
onClick={() => handleDeleteItem(item)}
|
<IconEdit size={14} />
|
||||||
title="Удалить"
|
</button>
|
||||||
>
|
<button
|
||||||
<IconTrash size={14} />
|
className="btn btn-outline-danger"
|
||||||
</button>
|
onClick={() => handleDeleteItem(item)}
|
||||||
</div>
|
title="Удалить"
|
||||||
</td>
|
>
|
||||||
</tr>
|
<IconTrash size={14} />
|
||||||
);
|
</button>
|
||||||
})}
|
</div>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</div>
|
);
|
||||||
)}
|
})}
|
||||||
|
</tbody>
|
||||||
{totalPages > 1 && (
|
</table>
|
||||||
<Pagination
|
</div>
|
||||||
currentPage={currentPage}
|
)}
|
||||||
totalPages={totalPages}
|
|
||||||
totalItems={filteredData.length}
|
{totalPages > 1 && (
|
||||||
pageSize={pageSize}
|
<Pagination
|
||||||
onPageChange={setCurrentPage}
|
currentPage={currentPage}
|
||||||
/>
|
totalPages={totalPages}
|
||||||
)}
|
totalItems={filteredData.length}
|
||||||
</div>
|
pageSize={pageSize}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* История платежей — компактная версия */}
|
{/* История платежей — компактная версия */}
|
||||||
|
{activeTab === 'payments' && (
|
||||||
<div className="card mt-4">
|
<div className="card mt-4">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title d-flex align-items-center gap-2">
|
<h3 className="card-title d-flex align-items-center gap-2">
|
||||||
@@ -1046,6 +1092,7 @@ function BillingManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Модальные окна */}
|
{/* Модальные окна */}
|
||||||
<FormModal
|
<FormModal
|
||||||
@@ -1108,7 +1155,7 @@ function BillingManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (editingPayment) {
|
if (editingPayment) {
|
||||||
setBillingData(prev => prev.map(s => {
|
setRawBillingData(prev => prev.map(s => {
|
||||||
if (s.id !== editingPayment.serverId) return s;
|
if (s.id !== editingPayment.serverId) return s;
|
||||||
const payments = [...(s.payments || [])];
|
const payments = [...(s.payments || [])];
|
||||||
payments[editingPayment.index] = {
|
payments[editingPayment.index] = {
|
||||||
@@ -1123,7 +1170,7 @@ function BillingManager() {
|
|||||||
}));
|
}));
|
||||||
setEditingPayment(null);
|
setEditingPayment(null);
|
||||||
} else {
|
} else {
|
||||||
setBillingData(prev => prev.map(s => {
|
setRawBillingData(prev => prev.map(s => {
|
||||||
if (s.id !== paymentDraft.serverId) return s;
|
if (s.id !== paymentDraft.serverId) return s;
|
||||||
const payments = [...(s.payments || []), {
|
const payments = [...(s.payments || []), {
|
||||||
date: paymentDraft.date,
|
date: paymentDraft.date,
|
||||||
|
|||||||
+467
-382
@@ -11,14 +11,17 @@ import {
|
|||||||
IconFilter,
|
IconFilter,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconList,
|
IconList,
|
||||||
IconLink,
|
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconSortAscending,
|
IconSortAscending,
|
||||||
IconSortDescending
|
IconSortDescending,
|
||||||
|
IconUpload,
|
||||||
|
IconDownload,
|
||||||
|
IconDeviceFloppy,
|
||||||
|
IconPlugConnected
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import PortalDropdown from './components/PortalDropdown.jsx';
|
||||||
import EmptyState from './components/EmptyState.jsx';
|
import EmptyState from './components/EmptyState.jsx';
|
||||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
|
||||||
import CommunityStats from './components/CommunityStats.jsx';
|
import CommunityStats from './components/CommunityStats.jsx';
|
||||||
import SavedFilters from './components/SavedFilters.jsx';
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
@@ -26,8 +29,9 @@ import ConfirmModal from './components/ConfirmModal.jsx';
|
|||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||||
import Pagination from './components/Pagination.jsx';
|
import Pagination from './components/Pagination.jsx';
|
||||||
|
import IconPicker from './components/IconPicker.jsx';
|
||||||
const API_URL = '/api';
|
import { getIconById } from './lib/brandIcons.js';
|
||||||
|
import { listCommunities, saveCommunitiesData, normalizeEvobgpApiBaseUrl } from './lib/evobgpData.js';
|
||||||
|
|
||||||
const normalizeCommunityValue = (value) => {
|
const normalizeCommunityValue = (value) => {
|
||||||
const trimmed = String(value || '').trim();
|
const trimmed = String(value || '').trim();
|
||||||
@@ -50,14 +54,25 @@ function CommunitiesManager() {
|
|||||||
// Состояние для переключения между списком и статистикой
|
// Состояние для переключения между списком и статистикой
|
||||||
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
|
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
|
||||||
|
|
||||||
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true });
|
||||||
const [editingValue, setEditingValue] = useState(null);
|
const [editingValue, setEditingValue] = useState(null);
|
||||||
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true });
|
||||||
const [filterUsage, setFilterUsage] = useState({});
|
const [filterUsage, setFilterUsage] = useState({});
|
||||||
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
|
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
|
||||||
const [filterUsageError, setFilterUsageError] = useState('');
|
const [filterUsageError, setFilterUsageError] = useState('');
|
||||||
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
|
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
|
||||||
|
|
||||||
|
const [evobgpUrlDraft, setEvobgpUrlDraft] = useState('');
|
||||||
|
const [evobgpTokenDraft, setEvobgpTokenDraft] = useState('');
|
||||||
|
const [evobgpSettingsOpen, setEvobgpSettingsOpen] = useState(() => {
|
||||||
|
if (import.meta.env?.VITE_EVOBGP_API_URL) return false;
|
||||||
|
try {
|
||||||
|
return Boolean(window.localStorage?.getItem('evobgp_api_url'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Модальные окна
|
// Модальные окна
|
||||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -69,6 +84,15 @@ function CommunitiesManager() {
|
|||||||
fetchFilterUsage();
|
fetchFilterUsage();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setEvobgpUrlDraft(String(window.localStorage?.getItem('evobgp_api_url') || ''));
|
||||||
|
setEvobgpTokenDraft(String(window.localStorage?.getItem('evobgp_api_token') || ''));
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filterUsage) return;
|
if (!filterUsage) return;
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
@@ -76,7 +100,7 @@ function CommunitiesManager() {
|
|||||||
Object.keys(filterUsage).forEach((value) => {
|
Object.keys(filterUsage).forEach((value) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
if (!map.has(value)) {
|
if (!map.has(value)) {
|
||||||
map.set(value, { value, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
|
map.set(value, { value, name: '', description: '', tags: [], color: '', icon: '', _external: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
@@ -86,57 +110,12 @@ function CommunitiesManager() {
|
|||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [dictRes, asnsRes, ipRes, domRes] = await Promise.all([
|
const rows = await listCommunities();
|
||||||
api.get(`/communities`),
|
const normalized = rows.map((item) => ({
|
||||||
api.get(`/asns`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
...item,
|
||||||
api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
value: normalizeCommunityValue(item.value),
|
||||||
api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
}));
|
||||||
]);
|
setItems(normalized);
|
||||||
const dict = Array.isArray(dictRes.data) ? dictRes.data : [];
|
|
||||||
const asnsPayload = Array.isArray(asnsRes.data?.items) ? asnsRes.data.items : (Array.isArray(asnsRes.data) ? asnsRes.data : []);
|
|
||||||
const ipPayload = Array.isArray(ipRes.data?.items) ? ipRes.data.items : (Array.isArray(ipRes.data) ? ipRes.data : []);
|
|
||||||
const domPayload = Array.isArray(domRes.data?.items) ? domRes.data.items : (Array.isArray(domRes.data) ? domRes.data : []);
|
|
||||||
|
|
||||||
const usedCommunities = new Set();
|
|
||||||
// ASNs: fields domain/type
|
|
||||||
for (const it of asnsPayload) {
|
|
||||||
const v = normalizeCommunityValue(it.type);
|
|
||||||
if (v) usedCommunities.add(v);
|
|
||||||
}
|
|
||||||
// IP ranges: fields ipRange/community
|
|
||||||
for (const it of ipPayload) {
|
|
||||||
const v = normalizeCommunityValue(it.community);
|
|
||||||
if (v) usedCommunities.add(v);
|
|
||||||
}
|
|
||||||
// Domains: fields domain/community
|
|
||||||
for (const it of domPayload) {
|
|
||||||
const v = normalizeCommunityValue(it.community);
|
|
||||||
if (v) usedCommunities.add(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = new Map();
|
|
||||||
// put dictionary items first
|
|
||||||
for (const d of dict) {
|
|
||||||
const raw = String(d.value || '').trim();
|
|
||||||
if (!raw) continue;
|
|
||||||
const value = normalizeCommunityValue(raw);
|
|
||||||
map.set(value, {
|
|
||||||
value,
|
|
||||||
name: String(d.name || ''),
|
|
||||||
description: String(d.description || ''),
|
|
||||||
tags: Array.isArray(d.tags) ? d.tags : [],
|
|
||||||
gatewayDefault: String(d.gatewayDefault || ''),
|
|
||||||
color: String(d.color || ''),
|
|
||||||
_external: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// add unknown ones from usage
|
|
||||||
for (const v of usedCommunities) {
|
|
||||||
if (!map.has(v)) {
|
|
||||||
map.set(v, { value: v, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setItems(Array.from(map.values()));
|
|
||||||
setError('');
|
setError('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching communities:', e);
|
console.error('Error fetching communities:', e);
|
||||||
@@ -185,9 +164,7 @@ function CommunitiesManager() {
|
|||||||
const saveAll = async (data) => {
|
const saveAll = async (data) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// не отправляем служебное поле _external
|
await saveCommunitiesData(data);
|
||||||
const payload = data.map(({ _external, ...rest }) => rest);
|
|
||||||
await api.post(`/communities`, { communities: payload });
|
|
||||||
setSuccess('Справочник сохранён!');
|
setSuccess('Справочник сохранён!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -212,10 +189,10 @@ function CommunitiesManager() {
|
|||||||
name: String(newItem.name || ''),
|
name: String(newItem.name || ''),
|
||||||
description: String(newItem.description || ''),
|
description: String(newItem.description || ''),
|
||||||
tags: toTagsArray(newItem.tags),
|
tags: toTagsArray(newItem.tags),
|
||||||
gatewayDefault: String(newItem.gatewayDefault || ''),
|
|
||||||
color: String(newItem.color || ''),
|
color: String(newItem.color || ''),
|
||||||
|
icon: String(newItem.icon || ''),
|
||||||
}]);
|
}]);
|
||||||
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
|
||||||
setAddModalOpen(false);
|
setAddModalOpen(false);
|
||||||
setSuccess('Community успешно добавлен!');
|
setSuccess('Community успешно добавлен!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
@@ -228,8 +205,8 @@ function CommunitiesManager() {
|
|||||||
name: item.name || '',
|
name: item.name || '',
|
||||||
description: item.description || '',
|
description: item.description || '',
|
||||||
tags: fromTagsArray(item.tags),
|
tags: fromTagsArray(item.tags),
|
||||||
gatewayDefault: item.gatewayDefault || '',
|
|
||||||
color: item.color || '',
|
color: item.color || '',
|
||||||
|
icon: item.icon || '',
|
||||||
});
|
});
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -244,8 +221,8 @@ function CommunitiesManager() {
|
|||||||
name: String(editingDraft.name || ''),
|
name: String(editingDraft.name || ''),
|
||||||
description: String(editingDraft.description || ''),
|
description: String(editingDraft.description || ''),
|
||||||
tags: toTagsArray(editingDraft.tags),
|
tags: toTagsArray(editingDraft.tags),
|
||||||
gatewayDefault: String(editingDraft.gatewayDefault || ''),
|
|
||||||
color: String(editingDraft.color || ''),
|
color: String(editingDraft.color || ''),
|
||||||
|
icon: String(editingDraft.icon || ''),
|
||||||
} : i);
|
} : i);
|
||||||
setItems(next);
|
setItems(next);
|
||||||
setEditingValue(null);
|
setEditingValue(null);
|
||||||
@@ -275,7 +252,7 @@ function CommunitiesManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)');
|
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, color, icon)');
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
const arr = JSON.parse(text);
|
const arr = JSON.parse(text);
|
||||||
@@ -292,13 +269,13 @@ function CommunitiesManager() {
|
|||||||
name: String(x.name || ''),
|
name: String(x.name || ''),
|
||||||
description: String(x.description || ''),
|
description: String(x.description || ''),
|
||||||
tags: Array.isArray(x.tags) ? x.tags : [],
|
tags: Array.isArray(x.tags) ? x.tags : [],
|
||||||
gatewayDefault: String(x.gatewayDefault || ''),
|
|
||||||
color: String(x.color || ''),
|
color: String(x.color || ''),
|
||||||
|
icon: String(x.icon || ''),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setError('Неверный JSON.');
|
setError('Неверный JSON.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -315,6 +292,43 @@ function CommunitiesManager() {
|
|||||||
|
|
||||||
const handleSave = () => saveAll(items);
|
const handleSave = () => saveAll(items);
|
||||||
|
|
||||||
|
const saveEvobgpConnection = () => {
|
||||||
|
const trimmedUrl = String(evobgpUrlDraft || '').trim();
|
||||||
|
if (!trimmedUrl) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem('evobgp_api_url');
|
||||||
|
window.localStorage.removeItem('evobgp_api_token');
|
||||||
|
} catch (_e) {
|
||||||
|
setError('Не удалось очистить настройки в localStorage.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEvobgpUrlDraft('');
|
||||||
|
setEvobgpTokenDraft('');
|
||||||
|
setError('');
|
||||||
|
setSuccess('Включён серверный прокси /api/evobgp (EVOBGP_API_URL и EVOBGP_API_TOKEN в контейнере).');
|
||||||
|
fetchItems();
|
||||||
|
setTimeout(() => setSuccess(''), 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const norm = normalizeEvobgpApiBaseUrl(trimmedUrl);
|
||||||
|
if (!norm) {
|
||||||
|
setError('Укажите корректный URL EvoBGP (например http://192.168.0.1:8080). Порт — у API EvoBGP, не у этого интерфейса.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('evobgp_api_url', norm);
|
||||||
|
window.localStorage.setItem('evobgp_api_token', String(evobgpTokenDraft || '').trim());
|
||||||
|
} catch (_e) {
|
||||||
|
setError('Не удалось сохранить настройки в localStorage.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEvobgpUrlDraft(norm);
|
||||||
|
setError('');
|
||||||
|
setSuccess('Прямое подключение к EvoBGP из браузера сохранено. Загружаем данные…');
|
||||||
|
fetchItems();
|
||||||
|
setTimeout(() => setSuccess(''), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const arr = [...items];
|
const arr = [...items];
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
@@ -350,315 +364,390 @@ function CommunitiesManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-wrapper">
|
<div>
|
||||||
<div className="page-header d-print-none">
|
<PageHeader
|
||||||
<div className="container-xl">
|
title="Справочник Community"
|
||||||
<div className="row g-2 align-items-center">
|
icon={<IconFilter size={24} />}
|
||||||
<div className="col">
|
/>
|
||||||
<h2 className="page-title">
|
|
||||||
<IconFilter className="icon me-2" />
|
<div className="card mb-3">
|
||||||
Справочник Community
|
<div className="card-header py-2 d-flex align-items-center justify-content-between">
|
||||||
</h2>
|
<div className="d-flex align-items-center gap-2">
|
||||||
<div className="page-pretitle">
|
<IconPlugConnected size={18} />
|
||||||
<Breadcrumbs items={[
|
<span className="fw-semibold">Подключение к EvoBGP API</span>
|
||||||
{ label: 'Главная', to: '/dashboard' },
|
</div>
|
||||||
{ label: 'Данные' },
|
<button
|
||||||
{ label: 'Community' },
|
type="button"
|
||||||
]} />
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => setEvobgpSettingsOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{evobgpSettingsOpen ? 'Скрыть' : 'Настроить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{evobgpSettingsOpen && (
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary small mb-3">
|
||||||
|
<strong>По умолчанию</strong> запросы идут на внутренний прокси <code>/api/evobgp</code>: URL и токен задаются в{' '}
|
||||||
|
<strong>переменных окружения контейнера</strong> — <code>EVOBGP_API_URL</code> и <code>EVOBGP_API_TOKEN</code>{' '}
|
||||||
|
(без CORS). Ниже — только <strong>опциональное</strong> прямое подключение из браузера к EvoBGP; оставьте URL пустым и
|
||||||
|
нажмите «Сохранить», чтобы снова использовать прокси.
|
||||||
|
</p>
|
||||||
|
<div className="row g-2 align-items-end">
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label">Прямой Base URL EvoBGP (необязательно)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="оставьте пустым для прокси"
|
||||||
|
autoComplete="off"
|
||||||
|
value={evobgpUrlDraft}
|
||||||
|
onChange={(e) => setEvobgpUrlDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<label className="form-label">Токен (только для прямого режима)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Bearer token"
|
||||||
|
autoComplete="off"
|
||||||
|
value={evobgpTokenDraft}
|
||||||
|
onChange={(e) => setEvobgpTokenDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-2">
|
||||||
|
<button type="button" className="btn btn-primary w-100" onClick={saveEvobgpConnection}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto ms-auto d-print-none">
|
</div>
|
||||||
<PageHeaderActions
|
)}
|
||||||
loading={loading}
|
</div>
|
||||||
onRefresh={fetchItems}
|
|
||||||
disableRefresh={loading}
|
{/* Уведомления */}
|
||||||
onImport={handleImport}
|
{error && (
|
||||||
onExport={handleExport}
|
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
|
||||||
disableExport={items.length === 0}
|
)}
|
||||||
onSave={handleSave}
|
{success && (
|
||||||
disableSave={loading}
|
<div className="alert alert-success alert-dismissible" role="alert">
|
||||||
|
<div className="d-flex">
|
||||||
|
<div>
|
||||||
|
<IconCheck className="icon alert-icon" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Табы и кнопки в одной строке, единый стиль btn-primary */}
|
||||||
|
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||||
|
<div className="btn-group" role="tablist" aria-label="Переключение режимов страницы">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${activeTab === 'list' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setActiveTab('list')}
|
||||||
|
role="tab"
|
||||||
|
aria-pressed={activeTab === 'list'}
|
||||||
|
>
|
||||||
|
<IconList size={16} className="me-1 d-none d-sm-inline" />
|
||||||
|
Список
|
||||||
|
<span
|
||||||
|
className={`badge ms-1 ${
|
||||||
|
activeTab === 'list'
|
||||||
|
? 'bg-white text-primary'
|
||||||
|
: 'bg-primary-lt text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filtered.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${activeTab === 'stats' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setActiveTab('stats')}
|
||||||
|
role="tab"
|
||||||
|
aria-pressed={activeTab === 'stats'}
|
||||||
|
>
|
||||||
|
<IconChartBar size={16} className="me-1 d-none d-sm-inline" />
|
||||||
|
Статистика
|
||||||
|
<span
|
||||||
|
className={`badge ms-1 ${
|
||||||
|
activeTab === 'stats'
|
||||||
|
? 'bg-white text-primary'
|
||||||
|
: 'bg-primary-lt text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={fetchItems}
|
||||||
|
disabled={loading}
|
||||||
|
title="Обновить данные"
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} className={loading ? 'spin me-1' : 'me-1'} />
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<PortalDropdown
|
||||||
|
buttonClassName="btn btn-primary dropdown-toggle"
|
||||||
|
buttonProps={{ title: 'Импорт/Экспорт' }}
|
||||||
|
align="left"
|
||||||
|
buttonContent={<><IconDownload className="me-1" /> Импорт/Экспорт</>}
|
||||||
|
>
|
||||||
|
<button type="button" className="dropdown-item" onClick={handleImport}>
|
||||||
|
<IconUpload className="me-1" /> Импорт
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dropdown-item"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
<IconDownload className="me-1" /> Экспорт
|
||||||
|
</button>
|
||||||
|
</PortalDropdown>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
title="Сохранить"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<IconDeviceFloppy className="me-1" />
|
||||||
|
)}
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Отображение списка communities */}
|
||||||
|
{activeTab === 'list' && (
|
||||||
|
<div className="card card-flat">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="row align-items-center w-100 g-2">
|
||||||
|
<div className="col">
|
||||||
|
<h3 className="card-title mb-0">
|
||||||
|
<IconFilter className="icon me-2" size={20} />
|
||||||
|
Список community
|
||||||
|
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setAddModalOpen(true)}
|
||||||
|
>
|
||||||
|
<IconPlus className="icon me-1" />
|
||||||
|
Добавить community
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
|
||||||
|
<SavedFilters
|
||||||
|
pageKey="communities"
|
||||||
|
currentFilters={{ searchTerm, sortField, sortOrder }}
|
||||||
|
onApplyFilter={(filters) => {
|
||||||
|
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
||||||
|
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
||||||
|
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
||||||
</div>
|
<span className="input-icon-addon"><IconSearch size={18} /></span>
|
||||||
</div>
|
<input
|
||||||
</div>
|
className="form-control"
|
||||||
|
placeholder="Поиск по value, названию, описанию и тегам"
|
||||||
<div className="page-body">
|
value={searchTerm}
|
||||||
<div className="container-xl">
|
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
|
||||||
{/* Уведомления */}
|
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
||||||
{error && (
|
/>
|
||||||
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
|
{!!searchTerm && (
|
||||||
)}
|
<button
|
||||||
{success && (
|
type="button"
|
||||||
<div className="alert alert-success alert-dismissible" role="alert">
|
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||||
<div className="d-flex">
|
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
|
||||||
<div>
|
aria-label="Очистить поиск"
|
||||||
<IconCheck className="icon alert-icon" />
|
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
|
||||||
</div>
|
>
|
||||||
<div>
|
<IconX size={16} />
|
||||||
{success}
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||||
</div>
|
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
|
||||||
)}
|
<input
|
||||||
|
className="form-check-input"
|
||||||
{/* Вкладки: Список / Статистика в стиле Tabler */}
|
type="checkbox"
|
||||||
<div className="card mb-3">
|
checked={showOnlyUsedInFilters}
|
||||||
<div className="card-header border-0 pb-0">
|
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
|
||||||
<ul className="nav nav-tabs card-header-tabs" role="tablist">
|
/>
|
||||||
<li className="nav-item" role="presentation">
|
<span className="form-check-label">Только используемые в фильтрах</span>
|
||||||
<button
|
{filterUsageLoading && (
|
||||||
className={`nav-link ${activeTab === 'list' ? 'active' : ''}`}
|
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
|
||||||
onClick={() => setActiveTab('list')}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'list'}
|
|
||||||
>
|
|
||||||
<IconList className="me-2" size={18} />
|
|
||||||
Список communities
|
|
||||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" role="presentation">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === 'stats' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('stats')}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'stats'}
|
|
||||||
>
|
|
||||||
<IconChartBar className="me-2" size={18} />
|
|
||||||
Статистика использования
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Отображение списка communities */}
|
|
||||||
{activeTab === 'list' && (
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-12">
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<div className="row align-items-center w-100 g-2">
|
|
||||||
<div className="col">
|
|
||||||
<h3 className="card-title mb-0">
|
|
||||||
<IconFilter className="icon me-2" size={20} />
|
|
||||||
Список community
|
|
||||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="col-auto">
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => setAddModalOpen(true)}
|
|
||||||
>
|
|
||||||
<IconPlus className="icon me-1" />
|
|
||||||
Добавить community
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
|
|
||||||
<SavedFilters
|
|
||||||
pageKey="communities"
|
|
||||||
currentFilters={{ searchTerm, sortField, sortOrder }}
|
|
||||||
onApplyFilter={(filters) => {
|
|
||||||
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
|
||||||
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
|
||||||
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
|
||||||
<span className="input-icon-addon"><IconSearch size={18} /></span>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Поиск по value, названию, описанию и тегам"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
|
|
||||||
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
|
||||||
/>
|
|
||||||
{!!searchTerm && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
|
||||||
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
|
|
||||||
aria-label="Очистить поиск"
|
|
||||||
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
|
|
||||||
>
|
|
||||||
<IconX size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
|
||||||
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
|
|
||||||
<input
|
|
||||||
className="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
checked={showOnlyUsedInFilters}
|
|
||||||
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
|
|
||||||
/>
|
|
||||||
<span className="form-check-label">Только используемые в фильтрах</span>
|
|
||||||
{filterUsageLoading && (
|
|
||||||
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-secondary btn-sm"
|
|
||||||
type="button"
|
|
||||||
onClick={fetchFilterUsage}
|
|
||||||
disabled={filterUsageLoading}
|
|
||||||
title="Обновить использование из /filters"
|
|
||||||
>
|
|
||||||
<IconRefresh className="icon me-1" size={16} />
|
|
||||||
Обновить использование
|
|
||||||
</button>
|
|
||||||
{filterUsageError && (
|
|
||||||
<span className="text-danger small">{filterUsageError}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<IconHash size={16} className="me-1 text-muted" />
|
|
||||||
Value
|
|
||||||
{sortField==='value' && (
|
|
||||||
<span className="ms-1">
|
|
||||||
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<IconFilter size={16} className="me-1 text-muted" />
|
|
||||||
Название
|
|
||||||
{sortField==='name' && (
|
|
||||||
<span className="ms-1">
|
|
||||||
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th>Описание</th>
|
|
||||||
<th>Теги</th>
|
|
||||||
<th>Gateway</th>
|
|
||||||
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
|
||||||
<th className="text-end">Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{loading ? (
|
|
||||||
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
|
|
||||||
) : paginated.length === 0 ? (
|
|
||||||
<tr><td colSpan={7} className="text-center text-muted py-4">
|
|
||||||
<EmptyState
|
|
||||||
title="Нет community"
|
|
||||||
description="Добавьте запись, чтобы начать."
|
|
||||||
action={
|
|
||||||
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
|
|
||||||
<IconPlus className="me-2" />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td></tr>
|
|
||||||
) : paginated.map((item) => {
|
|
||||||
const usageCount = filterUsage[item.value] || 0;
|
|
||||||
return (
|
|
||||||
<tr key={item.value}>
|
|
||||||
<td>
|
|
||||||
<code className="text-blue">{item.value}</code>
|
|
||||||
{item._external && (
|
|
||||||
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{item.name || <span className="text-muted">—</span>}</td>
|
|
||||||
<td>
|
|
||||||
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{(item.tags || []).map(t => (
|
|
||||||
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
|
||||||
))}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="text-muted">{item.gatewayDefault || '—'}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{usageCount > 0 ? (
|
|
||||||
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted">0</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="text-end">
|
|
||||||
<div className="btn-list">
|
|
||||||
<a
|
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
|
||||||
href={`/filters?community=${encodeURIComponent(item.value)}`}
|
|
||||||
title="Открыть в /filters с поиском по community"
|
|
||||||
>
|
|
||||||
<IconLink size={16} />
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
|
||||||
onClick={()=>startEdit(item)}
|
|
||||||
title="Редактировать"
|
|
||||||
>
|
|
||||||
<IconEdit size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-danger btn-icon btn-sm"
|
|
||||||
onClick={()=>handleDeleteClick(item)}
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<Pagination
|
|
||||||
currentPage={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
totalItems={filtered.length}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onPageChange={setCurrentPage}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
onClick={fetchFilterUsage}
|
||||||
|
disabled={filterUsageLoading}
|
||||||
|
title="Обновить использование из /filters"
|
||||||
|
>
|
||||||
|
<IconRefresh className="icon me-1" size={16} />
|
||||||
|
Обновить использование
|
||||||
|
</button>
|
||||||
|
{filterUsageError && (
|
||||||
|
<span className="text-danger small">{filterUsageError}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '8%' }}>Иконка</th>
|
||||||
|
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
|
Value
|
||||||
|
{sortField==='value' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconFilter size={16} className="me-1 text-muted" />
|
||||||
|
Название
|
||||||
|
{sortField==='name' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Теги</th>
|
||||||
|
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
||||||
|
<th className="text-end">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
|
||||||
|
) : paginated.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="text-center text-muted py-4">
|
||||||
|
<EmptyState
|
||||||
|
title="Нет community"
|
||||||
|
description="Добавьте запись, чтобы начать."
|
||||||
|
action={
|
||||||
|
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
|
||||||
|
<IconPlus className="me-2" />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td></tr>
|
||||||
|
) : paginated.map((item) => {
|
||||||
|
const usageCount = filterUsage[item.value] || 0;
|
||||||
|
const iconInfo = item.icon ? getIconById(item.icon) : null;
|
||||||
|
return (
|
||||||
|
<tr key={item.value}>
|
||||||
|
<td>
|
||||||
|
{iconInfo ? (
|
||||||
|
<span className="avatar avatar-sm bg-blue-lt text-blue border">
|
||||||
|
<iconInfo.Icon size={20} stroke={1.5} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code className="text-blue">{item.value}</code>
|
||||||
|
{item._external && (
|
||||||
|
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{item.name || <span className="text-muted">—</span>}</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{(item.tags || []).map(t => (
|
||||||
|
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{usageCount > 0 ? (
|
||||||
|
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
|
onClick={()=>startEdit(item)}
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-icon btn-sm"
|
||||||
|
onClick={()=>handleDeleteClick(item)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={filtered.length}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Отображение статистики communities */}
|
|
||||||
{activeTab === 'stats' && (
|
|
||||||
<CommunityStats communities={items} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Отображение статистики communities */}
|
||||||
|
{activeTab === 'stats' && (
|
||||||
|
<CommunityStats communities={items} />
|
||||||
|
)}
|
||||||
{/* Модальное окно добавления */}
|
{/* Модальное окно добавления */}
|
||||||
<FormModal
|
<FormModal
|
||||||
show={addModalOpen}
|
show={addModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAddModalOpen(false);
|
setAddModalOpen(false);
|
||||||
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
|
||||||
setError('');
|
setError('');
|
||||||
}}
|
}}
|
||||||
onSubmit={addItem}
|
onSubmit={addItem}
|
||||||
@@ -719,14 +808,6 @@ function CommunitiesManager() {
|
|||||||
helpText="Разделяйте теги запятыми"
|
helpText="Разделяйте теги запятыми"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Gateway по умолчанию"
|
|
||||||
name="gatewayDefault"
|
|
||||||
value={newItem.gatewayDefault}
|
|
||||||
onChange={(val) => setNewItem({...newItem, gatewayDefault: val})}
|
|
||||||
placeholder="SWE-HIPHOST"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Цвет бейджа (опционально)"
|
label="Цвет бейджа (опционально)"
|
||||||
name="color"
|
name="color"
|
||||||
@@ -735,6 +816,12 @@ function CommunitiesManager() {
|
|||||||
placeholder="blue, green, red ..."
|
placeholder="blue, green, red ..."
|
||||||
helpText="Цвет для отображения бейджа в интерфейсе"
|
helpText="Цвет для отображения бейджа в интерфейсе"
|
||||||
/>
|
/>
|
||||||
|
<IconPicker
|
||||||
|
value={newItem.icon}
|
||||||
|
onChange={(id) => setNewItem({...newItem, icon: id})}
|
||||||
|
label="Иконка бренда (опционально)"
|
||||||
|
allowNone
|
||||||
|
/>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|
||||||
{/* Модальное окно редактирования */}
|
{/* Модальное окно редактирования */}
|
||||||
@@ -801,14 +888,6 @@ function CommunitiesManager() {
|
|||||||
helpText="Разделяйте теги запятыми"
|
helpText="Разделяйте теги запятыми"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Gateway по умолчанию"
|
|
||||||
name="gatewayDefault"
|
|
||||||
value={editingDraft.gatewayDefault}
|
|
||||||
onChange={(val) => setEditingDraft({...editingDraft, gatewayDefault: val})}
|
|
||||||
placeholder="SWE-HIPHOST"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Цвет бейджа (опционально)"
|
label="Цвет бейджа (опционально)"
|
||||||
name="color"
|
name="color"
|
||||||
@@ -817,6 +896,12 @@ function CommunitiesManager() {
|
|||||||
placeholder="blue, green, red ..."
|
placeholder="blue, green, red ..."
|
||||||
helpText="Цвет для отображения бейджа в интерфейсе"
|
helpText="Цвет для отображения бейджа в интерфейсе"
|
||||||
/>
|
/>
|
||||||
|
<IconPicker
|
||||||
|
value={editingDraft.icon}
|
||||||
|
onChange={(id) => setEditingDraft({...editingDraft, icon: id})}
|
||||||
|
label="Иконка бренда (опционально)"
|
||||||
|
allowNone
|
||||||
|
/>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|
||||||
{/* Модальное окно подтверждения удаления */}
|
{/* Модальное окно подтверждения удаления */}
|
||||||
|
|||||||
+281
-31
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import { formatDateTime } from './lib/datetime.js';
|
import { formatDateTime } from './lib/datetime.js';
|
||||||
@@ -15,21 +15,30 @@ import {
|
|||||||
IconFilter,
|
IconFilter,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconSearch
|
IconSearch,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronUp,
|
||||||
|
IconBell,
|
||||||
|
IconCpu,
|
||||||
|
IconDeviceDesktop,
|
||||||
|
IconDatabase,
|
||||||
|
IconServerOff,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { getIconById } from './lib/brandIcons.js';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
|
||||||
import TopNStats from './components/TopNStats.jsx';
|
import TopNStats from './components/TopNStats.jsx';
|
||||||
import TrendIndicator from './components/TrendIndicator.jsx';
|
import TrendIndicator from './components/TrendIndicator.jsx';
|
||||||
import LastSaved from './components/LastSaved.jsx';
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
import Sparkline from './components/Sparkline.jsx';
|
||||||
|
import { useAlerts } from './contexts/AlertsContext.jsx';
|
||||||
|
|
||||||
function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) {
|
function StatCard({ icon: _Icon, color, value, title, subtitle, to, trend, previousValue }) {
|
||||||
return (
|
return (
|
||||||
<div className="card h-100 position-relative card-hover">
|
<div className="card h-100 position-relative card-hover">
|
||||||
<div className="card-body d-flex align-items-center">
|
<div className="card-body d-flex align-items-center">
|
||||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
||||||
<Icon size={32} />
|
<_Icon size={32} />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
<div className="d-flex align-items-baseline gap-2 mb-1">
|
<div className="d-flex align-items-baseline gap-2 mb-1">
|
||||||
@@ -55,12 +64,12 @@ function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ title, value, icon: Icon, color, description }) {
|
function MetricCard({ title, value, icon: _Icon, color, description }) {
|
||||||
return (
|
return (
|
||||||
<div className="card h-100 position-relative">
|
<div className="card h-100 position-relative">
|
||||||
<div className="card-body d-flex align-items-center">
|
<div className="card-body d-flex align-items-center">
|
||||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
||||||
<Icon size={32} />
|
<_Icon size={32} />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
<div className="h3 mb-0 fw-bold">
|
<div className="h3 mb-0 fw-bold">
|
||||||
@@ -75,7 +84,115 @@ function MetricCard({ title, value, icon: Icon, color, description }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Обёртка для Sparkline по ширине контейнера */
|
||||||
|
function PingSparklineWrap({ history, color }) {
|
||||||
|
const wrapRef = useRef(null);
|
||||||
|
const [width, setWidth] = useState(280);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => setWidth(el.offsetWidth || 280));
|
||||||
|
ro.observe(el);
|
||||||
|
setWidth(el.offsetWidth || 280);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="mt-2" style={{ height: 56, width: '100%', minWidth: 0 }}>
|
||||||
|
<Sparkline
|
||||||
|
data={history}
|
||||||
|
width={width}
|
||||||
|
height={56}
|
||||||
|
strokeColor={color ? `var(--tblr-${color})` : 'var(--tblr-primary)'}
|
||||||
|
fillColor={color ? `var(--tblr-${color})` : 'var(--tblr-primary)'}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разворачиваемая карточка сервиса: свёрнутый вид — иконка и пинг; развёрнутый — тренд и мини-график (как Revenue в Tabler) */
|
||||||
|
function PingServiceCard({ config, ms, previousMs, history = [], loading, isExpanded, onToggle }) {
|
||||||
|
const { name, host, color } = config;
|
||||||
|
const { Icon } = getIconById(config.icon);
|
||||||
|
const value = loading ? '...' : (ms != null ? `${ms} мс` : '—');
|
||||||
|
const currentNum = typeof ms === 'number' ? ms : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`card h-100 position-relative ${isExpanded ? '' : 'cursor-pointer'}`}
|
||||||
|
style={isExpanded ? { minHeight: '200px' } : undefined}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onToggle}
|
||||||
|
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle()}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={isExpanded ? `Свернуть ${name}` : `Развернуть ${name}, пинг ${value}`}
|
||||||
|
>
|
||||||
|
{!isExpanded ? (
|
||||||
|
<div className="card-body d-flex align-items-center">
|
||||||
|
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0 rounded`}>
|
||||||
|
<Icon size={28} stroke={1.5} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div className="h4 mb-0 fw-bold">{value}</div>
|
||||||
|
<div className="text-muted small text-truncate" title={`${name} (${host})`}>
|
||||||
|
{name} <span className="opacity-75">({host})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconChevronDown size={18} className="text-muted ms-1 flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<span className="text-muted text-uppercase small fw-semibold">{name}</span>
|
||||||
|
<span className="text-muted small d-flex align-items-center">
|
||||||
|
Последние замеры
|
||||||
|
<IconChevronUp size={14} className="ms-1" title="Свернуть" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-baseline flex-wrap gap-2 mb-3">
|
||||||
|
<div className="h3 mb-0 fw-bold">
|
||||||
|
{loading ? '...' : currentNum != null ? `${currentNum} мс` : '—'}
|
||||||
|
</div>
|
||||||
|
{!loading && currentNum != null && previousMs != null && (
|
||||||
|
<TrendIndicator
|
||||||
|
value={currentNum}
|
||||||
|
previousValue={previousMs}
|
||||||
|
format="percent"
|
||||||
|
inverse
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{history.length > 0 && (() => {
|
||||||
|
const sum = history.reduce((a, b) => a + b, 0);
|
||||||
|
const avg = Math.round(sum / history.length);
|
||||||
|
return (
|
||||||
|
<div className="text-muted small mb-2">
|
||||||
|
Среднее за период: <span className="fw-semibold text-body">{avg} мс</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{history.length > 0 ? (
|
||||||
|
<PingSparklineWrap history={history} color={color} />
|
||||||
|
) : (
|
||||||
|
<div className="text-muted small mt-2">Нет истории замеров. Данные накапливаются при обновлении.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALERT_ICONS = {
|
||||||
|
server_offline: IconServerOff,
|
||||||
|
mikrotik_unreachable: IconServerOff,
|
||||||
|
high_cpu: IconCpu,
|
||||||
|
high_ram: IconDeviceDesktop,
|
||||||
|
high_hdd: IconDatabase,
|
||||||
|
};
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
|
const { alerts, loading: alertsLoading, refresh: refreshAlerts } = useAlerts();
|
||||||
// Глобальный поиск удалён по требованию UX
|
// Глобальный поиск удалён по требованию UX
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
domainsCount: null,
|
domainsCount: null,
|
||||||
@@ -93,20 +210,28 @@ function Dashboard() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [lastFetchTime, setLastFetchTime] = useState(null);
|
const [lastFetchTime, setLastFetchTime] = useState(null);
|
||||||
|
const [pingServices, setPingServices] = useState(null);
|
||||||
|
const [pingServicesConfig, setPingServicesConfig] = useState([]);
|
||||||
|
const [pingLoading, setPingLoading] = useState(true);
|
||||||
|
const [pingHistory, setPingHistory] = useState(() => ({}));
|
||||||
|
const [expandedPingId, setExpandedPingId] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Загружаем данные с обработкой ошибок для каждого endpoint
|
const opts = { signal };
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
api.get('/domains-new', { params: { countOnly: true } }),
|
api.get('/domains-new', { params: { countOnly: true }, ...opts }),
|
||||||
api.get('/ip-ranges', { params: { countOnly: true } }),
|
api.get('/ip-ranges', { params: { countOnly: true }, ...opts }),
|
||||||
api.get('/asns', { params: { countOnly: true } }),
|
api.get('/asns', { params: { countOnly: true }, ...opts }),
|
||||||
api.get('/servers'),
|
api.get('/servers', opts),
|
||||||
api.get('/servers/availability', { params: { ttlSeconds: 60 } }),
|
api.get('/servers/availability', { params: { ttlSeconds: 60 }, ...opts }),
|
||||||
api.get('/s3/last-modified')
|
api.get('/s3/last-modified', opts)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Обрабатываем результаты
|
// Обрабатываем результаты
|
||||||
@@ -171,6 +296,7 @@ function Dashboard() {
|
|||||||
console.warn('Некоторые API недоступны:', errors.map(e => e.reason?.message));
|
console.warn('Некоторые API недоступны:', errors.map(e => e.reason?.message));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
console.error('Критическая ошибка загрузки статистики:', e);
|
console.error('Критическая ошибка загрузки статистики:', e);
|
||||||
setError('Не удалось загрузить статистику');
|
setError('Не удалось загрузить статистику');
|
||||||
setStats({
|
setStats({
|
||||||
@@ -189,6 +315,44 @@ function Dashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchStats();
|
fetchStats();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
api.get('/ping-services-list', { signal: controller.signal })
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (Array.isArray(data?.list)) setPingServicesConfig(data.list);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
setPingServicesConfig([]);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function fetchPingServices(forceRefresh = false, abortSignal) {
|
||||||
|
setPingLoading(true);
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
api.get('/ping-services', { params: forceRefresh ? { refresh: 1 } : {}, ...opts })
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (!data || typeof data !== 'object') return;
|
||||||
|
const byId = data.byId ?? data;
|
||||||
|
const history = data.history ?? {};
|
||||||
|
setPingServices(byId);
|
||||||
|
setPingHistory(typeof history === 'object' ? history : {});
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
setPingServices(null);
|
||||||
|
})
|
||||||
|
.finally(() => setPingLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchPingServices(false, controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -206,18 +370,14 @@ function Dashboard() {
|
|||||||
{/* Заголовок страницы */}
|
{/* Заголовок страницы */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Панель"
|
title="Панель"
|
||||||
pretitle={(
|
icon={<IconCloud size={24} />}
|
||||||
<Breadcrumbs items={[
|
|
||||||
{ label: 'Главная' },
|
|
||||||
]} />
|
|
||||||
)}
|
|
||||||
actions={(
|
actions={(
|
||||||
<div className="d-flex align-items-center gap-3">
|
<div className="header-actions d-flex align-items-center flex-wrap gap-2 gap-sm-3">
|
||||||
{lastFetchTime && (
|
{lastFetchTime && (
|
||||||
<LastSaved timestamp={lastFetchTime} variant="compact" />
|
<LastSaved timestamp={lastFetchTime} variant="compact" />
|
||||||
)}
|
)}
|
||||||
<Tooltip content="Обновить данные" shortcut="⌘R">
|
<Tooltip content="Обновить данные" shortcut="⌘R">
|
||||||
<button className="btn btn-outline-primary" onClick={() => window.location.reload()}>
|
<button className="btn btn-outline-primary btn-sm" onClick={() => window.location.reload()}>
|
||||||
<IconRefresh className="me-2" /> Обновить
|
<IconRefresh className="me-2" /> Обновить
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -225,9 +385,99 @@ function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Основные метрики */}
|
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
|
||||||
|
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
|
||||||
|
<h3 className="mb-0">Пинг сервисов</h3>
|
||||||
|
<Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm w-100 w-sm-auto"
|
||||||
|
onClick={() => fetchPingServices(true)}
|
||||||
|
disabled={pingLoading}
|
||||||
|
>
|
||||||
|
<IconRefresh className={pingLoading ? 'spin me-1' : 'me-1'} size={16} />
|
||||||
|
Обновить пинг
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<div className="row g-3 mb-4">
|
<div className="row g-3 mb-4">
|
||||||
<div className="col-md-3">
|
{pingServicesConfig.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className={expandedPingId === config.id ? 'col-12' : 'col-12 col-sm-6 col-md-3'}
|
||||||
|
>
|
||||||
|
<PingServiceCard
|
||||||
|
config={config}
|
||||||
|
ms={pingServices?.[config.id]?.ms ?? null}
|
||||||
|
previousMs={(() => {
|
||||||
|
const hist = pingHistory[config.id] || [];
|
||||||
|
return hist.length >= 2 ? hist[hist.length - 2] : null;
|
||||||
|
})()}
|
||||||
|
history={pingHistory[config.id] || []}
|
||||||
|
loading={pingLoading}
|
||||||
|
isExpanded={expandedPingId === config.id}
|
||||||
|
onToggle={() => setExpandedPingId((id) => (id === config.id ? null : config.id))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Активные оповещения */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
|
||||||
|
<h3 className="mb-0 d-flex align-items-center gap-2">
|
||||||
|
<IconBell size={24} />
|
||||||
|
Активные оповещения
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<span className="badge bg-danger rounded-pill">{alerts.length}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
onClick={() => refreshAlerts()}
|
||||||
|
disabled={alertsLoading}
|
||||||
|
>
|
||||||
|
<IconRefresh className={alertsLoading ? 'spin me-1' : 'me-1'} size={16} />
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{alerts.length === 0 && !alertsLoading && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-center text-muted py-4">
|
||||||
|
Нет активных оповещений
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{alerts.map((alert) => {
|
||||||
|
const Icon = ALERT_ICONS[alert.type] || IconAlertTriangle;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={alert.id}
|
||||||
|
to={alert.link || '/resource-stats'}
|
||||||
|
className="list-group-item list-group-item-action d-flex align-items-center gap-3"
|
||||||
|
>
|
||||||
|
<span className={`avatar avatar-sm flex-shrink-0 bg-${alert.severity === 'critical' ? 'danger' : 'warning'}-lt text-${alert.severity === 'critical' ? 'danger' : 'warning'}`}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-grow-1">
|
||||||
|
<div className="fw-semibold">{alert.title}</div>
|
||||||
|
<div className="text-muted small">{alert.description}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основные метрики */}
|
||||||
|
<div className="row g-2 g-md-3 mb-4">
|
||||||
|
<div className="col-6 col-lg-3">
|
||||||
<div className="animate-in">
|
<div className="animate-in">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconWorld}
|
icon={IconWorld}
|
||||||
@@ -241,7 +491,7 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className="col-6 col-lg-3">
|
||||||
<div className="animate-in" style={{animationDelay: '0.1s'}}>
|
<div className="animate-in" style={{animationDelay: '0.1s'}}>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconNetwork}
|
icon={IconNetwork}
|
||||||
@@ -255,7 +505,7 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className="col-6 col-lg-3">
|
||||||
<div className="animate-in" style={{animationDelay: '0.2s'}}>
|
<div className="animate-in" style={{animationDelay: '0.2s'}}>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconNetwork}
|
icon={IconNetwork}
|
||||||
@@ -269,7 +519,7 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className="col-6 col-lg-3">
|
||||||
<div className="animate-in" style={{animationDelay: '0.3s'}}>
|
<div className="animate-in" style={{animationDelay: '0.3s'}}>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconServer}
|
icon={IconServer}
|
||||||
@@ -286,8 +536,8 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Дополнительные метрики */}
|
{/* Дополнительные метрики */}
|
||||||
<div className="row g-3 mb-4 justify-content-center">
|
<div className="row g-2 g-md-3 mb-4 justify-content-center">
|
||||||
<div className="col-sm-6 col-md-3">
|
<div className="col-6 col-md-6 col-lg-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Стран"
|
title="Стран"
|
||||||
value={loading ? '...' : stats.countriesCount ?? '—'}
|
value={loading ? '...' : stats.countriesCount ?? '—'}
|
||||||
@@ -296,7 +546,7 @@ function Dashboard() {
|
|||||||
description="Географическое покрытие"
|
description="Географическое покрытие"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-6 col-md-3">
|
<div className="col-6 col-md-6 col-lg-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Провайдеров"
|
title="Провайдеров"
|
||||||
value={loading ? '...' : stats.providersCount ?? '—'}
|
value={loading ? '...' : stats.providersCount ?? '—'}
|
||||||
@@ -305,7 +555,7 @@ function Dashboard() {
|
|||||||
description="Облачные провайдеры"
|
description="Облачные провайдеры"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-6 col-md-3">
|
<div className="col-6 col-md-6 col-lg-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Онлайн серверов"
|
title="Онлайн серверов"
|
||||||
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
|
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
|
||||||
@@ -314,7 +564,7 @@ function Dashboard() {
|
|||||||
description="Активные серверы"
|
description="Активные серверы"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-6 col-md-3">
|
<div className="col-6 col-md-6 col-lg-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Последнее обновление"
|
title="Последнее обновление"
|
||||||
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
|
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -39,11 +39,7 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
|||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
|
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchItems = useCallback(async () => {
|
||||||
fetchItems();
|
|
||||||
}, [entityKey]);
|
|
||||||
|
|
||||||
const fetchItems = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/${entityKey}`);
|
const response = await api.get(`/${entityKey}`);
|
||||||
@@ -55,7 +51,11 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [entityKey, entityName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
if (newItem.domain.trim() === '') {
|
if (newItem.domain.trim() === '') {
|
||||||
@@ -212,8 +212,8 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="row">
|
<div className="row g-2 g-lg-3">
|
||||||
<div className="col-lg-4">
|
<div className="col-12 col-lg-4">
|
||||||
{/* Add New Item Card */}
|
{/* Add New Item Card */}
|
||||||
<div className="card card-md">
|
<div className="card card-md">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
@@ -333,11 +333,11 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-lg-8">
|
<div className="col-12 col-lg-8">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex flex-column flex-md-row justify-content-between align-items-stretch align-items-md-center gap-2">
|
||||||
<h3 className="card-title mb-0">Список {entityName === 'домен' ? 'доменов' : entityName}</h3>
|
<h3 className="card-title mb-0">Список {entityName === 'домен' ? 'доменов' : entityName}</h3>
|
||||||
<div className="d-flex gap-2 w-50">
|
<div className="d-flex flex-wrap gap-2 flex-grow-1 flex-md-grow-0">
|
||||||
{/* Фильтр по шлюзу */}
|
{/* Фильтр по шлюзу */}
|
||||||
<select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
|
<select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
|
||||||
<option value="">Все шлюзы</option>
|
<option value="">Все шлюзы</option>
|
||||||
|
|||||||
+314
-435
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||||
import CommunityBadge from './components/CommunityBadge.jsx';
|
import CommunityBadge from './components/CommunityBadge.jsx';
|
||||||
@@ -29,9 +29,7 @@ import {
|
|||||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import EmptyState from './components/EmptyState.jsx';
|
import EmptyState from './components/EmptyState.jsx';
|
||||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
|
||||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
|
||||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||||
import WsUpdateModal from './components/WsUpdateModal.jsx';
|
import WsUpdateModal from './components/WsUpdateModal.jsx';
|
||||||
@@ -47,35 +45,42 @@ import BulkActionsBar from './components/BulkActionsBar.jsx';
|
|||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
import Pagination from './components/Pagination.jsx';
|
import Pagination from './components/Pagination.jsx';
|
||||||
|
import AddItemModal from './components/AddItemModal.jsx';
|
||||||
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
|
import FormModal from './components/FormModal.jsx';
|
||||||
import { useToast } from './components/ToastContainer.jsx';
|
import { useToast } from './components/ToastContainer.jsx';
|
||||||
|
import {
|
||||||
const API_URL = '/api';
|
listCommunities,
|
||||||
|
fetchDomainsData,
|
||||||
|
saveDomainsData,
|
||||||
|
} from './lib/evobgpData.js';
|
||||||
|
|
||||||
function DomainsNewManager() {
|
function DomainsNewManager() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [originalItems, setOriginalItems] = useState([]);
|
const [originalItems, setOriginalItems] = useState([]);
|
||||||
const [etag, setEtag] = useState('');
|
const [, setEtag] = useState('');
|
||||||
const [lastModified, setLastModified] = useState('');
|
const [lastModified, setLastModified] = useState('');
|
||||||
const [contentLength, setContentLength] = useState(null);
|
const [, setContentLength] = useState(null);
|
||||||
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
||||||
const [newInvalid, setNewInvalid] = useState({ domain: false, community: false });
|
const [, setNewInvalid] = useState({ domain: false, community: false });
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const searchTimer = useRef(null);
|
const _searchTimer = useRef(null);
|
||||||
const didInit = useRef(false);
|
const didInit = useRef(false);
|
||||||
const [editingDomain, setEditingDomain] = useState(null);
|
|
||||||
const [editingValue, setEditingValue] = useState('');
|
const [editingValue, setEditingValue] = useState('');
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [itemBeingEdited, setItemBeingEdited] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [sortField, setSortField] = useState('domain');
|
const [sortField, setSortField] = useState('domain');
|
||||||
const [sortOrder, setSortOrder] = useState('asc');
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
const [filterCommunity, setFilterCommunity] = useState('');
|
const [filterCommunity, setFilterCommunity] = useState('');
|
||||||
const editInputRef = useRef(null);
|
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
// Bulk operations
|
// Bulk operations
|
||||||
@@ -84,14 +89,25 @@ function DomainsNewManager() {
|
|||||||
// Справочник community для подсказок
|
// Справочник community для подсказок
|
||||||
const [communities, setCommunities] = useState([]);
|
const [communities, setCommunities] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`);
|
const list = await listCommunities();
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
setCommunities(list);
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
// тихо игнорируем
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.get('/ws/url', { signal });
|
||||||
|
setWsUrl(String(r.data?.url || ''));
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||||||
@@ -108,68 +124,79 @@ function DomainsNewManager() {
|
|||||||
const [wsOpen, setWsOpen] = useState(false);
|
const [wsOpen, setWsOpen] = useState(false);
|
||||||
const [wsUrl, setWsUrl] = useState('');
|
const [wsUrl, setWsUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchItems = useCallback(async (_abortSignal) => {
|
||||||
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchItems = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
|
const payload = await fetchDomainsData();
|
||||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
|
||||||
const total = payload.length;
|
const total = payload.length;
|
||||||
setItems(payload);
|
setItems(payload);
|
||||||
setOriginalItems(payload);
|
setOriginalItems(payload);
|
||||||
setTotalItems(Number.isFinite(total) ? total : 0);
|
setTotalItems(Number.isFinite(total) ? total : 0);
|
||||||
setEtag(response.headers?.etag || '');
|
setEtag('');
|
||||||
setLastModified(response.headers?.['last-modified'] || '');
|
setLastModified(new Date().toISOString());
|
||||||
const lengthHeader = response.headers?.['content-length-source'];
|
setContentLength(null);
|
||||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
|
||||||
setError('');
|
setError('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
console.error('Error fetching domains-new:', error);
|
console.error('Error fetching domains-new:', error);
|
||||||
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
|
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Подгрузка при изменении страницы/поиска/фильтра
|
useEffect(() => {
|
||||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
if (!didInit.current) {
|
||||||
|
didInit.current = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchItems(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = (item) => {
|
||||||
const domainOk = isValidDomain(newItem.domain);
|
// Если item передан (из модалки), используем его, иначе из state (старый способ)
|
||||||
const communityOk = isValidCommunity(newItem.community);
|
const domain = item?.domain || newItem.domain;
|
||||||
setNewInvalid({ domain: !domainOk, community: !communityOk });
|
const community = item?.community || newItem.community;
|
||||||
|
|
||||||
|
const domainOk = isValidDomain(domain);
|
||||||
|
const communityOk = isValidCommunity(community);
|
||||||
|
|
||||||
if (!domainOk || !communityOk) {
|
if (!domainOk || !communityOk) {
|
||||||
setError('Заполните корректный домен (example.com) и числовой community.');
|
setError('Заполните корректный домен (example.com) и числовой community.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setItems([...items, { domain: newItem.domain.trim().toLowerCase(), community: String(newItem.community).trim() }]);
|
const newDomain = { domain: domain.trim().toLowerCase(), community: String(community).trim() };
|
||||||
setNewItem({ domain: '', community: '' });
|
setItems([...items, newDomain]);
|
||||||
|
if (!item) setNewItem({ domain: '', community: '' }); // Сброс только если не из модалки
|
||||||
setNewInvalid({ domain: false, community: false });
|
setNewInvalid({ domain: false, community: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (item) => {
|
const handleEdit = (item) => {
|
||||||
setEditingDomain(item.domain);
|
setItemBeingEdited(item);
|
||||||
setEditingValue(item.community);
|
setEditingValue(item.community);
|
||||||
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = (domainName) => {
|
const handleSaveEdit = () => {
|
||||||
|
if (!itemBeingEdited) return;
|
||||||
if (!isValidCommunity(editingValue)) {
|
if (!isValidCommunity(editingValue)) {
|
||||||
setError('Community должен быть числом.');
|
setError('Community должен быть числом.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updatedItems = items.map(i =>
|
const updatedItems = items.map(i =>
|
||||||
i.domain === domainName ? { ...i, community: String(editingValue).trim() } : i
|
i.domain === itemBeingEdited.domain ? { ...i, community: String(editingValue).trim() } : i
|
||||||
);
|
);
|
||||||
setItems(updatedItems);
|
setItems(updatedItems);
|
||||||
setEditingDomain(null);
|
setEditModalOpen(false);
|
||||||
|
setItemBeingEdited(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setEditingDomain(null);
|
setEditModalOpen(false);
|
||||||
|
setItemBeingEdited(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteItem = (domainNameToDelete) => {
|
const handleDeleteItem = (domainNameToDelete) => {
|
||||||
@@ -246,27 +273,7 @@ function DomainsNewManager() {
|
|||||||
setShowDiff(true);
|
setShowDiff(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const performSave = useCallback(async () => {
|
||||||
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
|
||||||
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
|
|
||||||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
|
||||||
const unique = deduplicate(valid);
|
|
||||||
const diffData = computeDiff(originalItems, unique);
|
|
||||||
setDiff(diffData);
|
|
||||||
|
|
||||||
// Умное подтверждение: показываем модалку только при массовых изменениях
|
|
||||||
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
|
|
||||||
|
|
||||||
if (totalChanges > 10) {
|
|
||||||
// Массовое изменение - требуется подтверждение
|
|
||||||
setConfirmSaveOpen(true);
|
|
||||||
} else {
|
|
||||||
// Малое изменение - сохраняем сразу
|
|
||||||
performSave();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const performSave = async () => {
|
|
||||||
setConfirmSaveOpen(false);
|
setConfirmSaveOpen(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -274,30 +281,8 @@ function DomainsNewManager() {
|
|||||||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
|
|
||||||
// Подгружаем весь список, применяем изменения текущей страницы и сохраняем полные данные
|
await saveDomainsData(unique, originalItems);
|
||||||
const fullRes = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
|
setEtag('');
|
||||||
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
|
|
||||||
const fullMap = new Map(full.filter(i => i != null).map(i => [String(i.domain || '').trim().toLowerCase(), { domain: String(i.domain || '').trim().toLowerCase(), community: String(i.community || '').trim() }]));
|
|
||||||
const originalPageMap = new Map(originalItems.map(i => [String(i.domain).trim().toLowerCase(), true]));
|
|
||||||
const uniqueMap = new Map(unique.map(i => [String(i.domain).trim().toLowerCase(), { domain: String(i.domain).trim().toLowerCase(), community: String(i.community).trim() }]));
|
|
||||||
// Удаления: всё, что было на странице, но отсутствует в изменённом наборе
|
|
||||||
for (const key of originalPageMap.keys()) {
|
|
||||||
if (!uniqueMap.has(key)) fullMap.delete(key);
|
|
||||||
}
|
|
||||||
// Добавления/изменения
|
|
||||||
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
|
|
||||||
const fullToSave = Array.from(fullMap.values());
|
|
||||||
|
|
||||||
const et = fullRes?.headers?.etag || etag;
|
|
||||||
const response = await api.post(`/domains-new`, { domains: fullToSave, etag: et }, { validateStatus: () => true });
|
|
||||||
if (response.status === 412) {
|
|
||||||
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status >= 400) {
|
|
||||||
throw new Error(`Save failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
setEtag(response.headers?.etag || et);
|
|
||||||
// После сохранения перезагрузим текущую страницу для консистентности
|
// После сохранения перезагрузим текущую страницу для консистентности
|
||||||
await fetchItems();
|
await fetchItems();
|
||||||
setSuccess('Изменения успешно сохранены!');
|
setSuccess('Изменения успешно сохранены!');
|
||||||
@@ -309,10 +294,24 @@ function DomainsNewManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [items, originalItems, fetchItems]);
|
||||||
|
|
||||||
|
const handleSaveChanges = useCallback(async () => {
|
||||||
|
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||||
|
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||||
|
const unique = deduplicate(valid);
|
||||||
|
const diffData = computeDiff(originalItems, unique);
|
||||||
|
setDiff(diffData);
|
||||||
|
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
|
||||||
|
if (totalChanges > 10) {
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
} else {
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
|
}, [items, originalItems, performSave]);
|
||||||
|
|
||||||
// Drag&Drop импорт
|
// Drag&Drop импорт
|
||||||
const onDropImport = async (e) => {
|
const _onDropImport = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer?.files?.[0];
|
const file = e.dataTransfer?.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -339,9 +338,7 @@ function DomainsNewManager() {
|
|||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
|
const all = filtered;
|
||||||
const response = await api.get(`/domains-new`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
|
|
||||||
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
|
|
||||||
const header = ['domain', 'community'];
|
const header = ['domain', 'community'];
|
||||||
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.community || ''])]
|
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.community || ''])]
|
||||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
@@ -419,13 +416,6 @@ function DomainsNewManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Улучшенный инлайн-редакт
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingDomain && editInputRef.current) {
|
|
||||||
editInputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [editingDomain]);
|
|
||||||
|
|
||||||
// Глобальные хоткеи: Ctrl+S — сохранить изменения
|
// Глобальные хоткеи: Ctrl+S — сохранить изменения
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
@@ -436,11 +426,17 @@ function DomainsNewManager() {
|
|||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
|
}, [loading, handleSaveChanges]);
|
||||||
|
|
||||||
const handleEditKeyDown = (e, domainName) => {
|
const handleEditKeyDown = (e) => {
|
||||||
if (e.key === 'Enter') handleSaveEdit(domainName);
|
if (e.key === 'Enter') {
|
||||||
if (e.key === 'Escape') handleCancelEdit();
|
e.preventDefault();
|
||||||
|
handleSaveEdit();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCancelEdit();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bulk operations handlers
|
// Bulk operations handlers
|
||||||
@@ -507,180 +503,54 @@ function DomainsNewManager() {
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Домены"
|
title="Домены"
|
||||||
pretitle={(
|
icon={<IconWorld size={24} />}
|
||||||
<Breadcrumbs
|
meta={<LastSaved timestamp={lastModified} variant="compact" />}
|
||||||
items={[
|
|
||||||
{ label: 'Главная', to: '/dashboard' },
|
|
||||||
{ label: 'Данные' },
|
|
||||||
{ label: 'Домены', count: filtered.length },
|
|
||||||
]}
|
|
||||||
showBack={false}
|
|
||||||
filterInfo={filterCommunity ? `Community: ${filterCommunity}` : (searchTerm ? `Поиск: "${searchTerm}"` : null)}
|
|
||||||
count={filtered.length}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
actions={(
|
actions={(
|
||||||
<PageHeaderActions
|
<PageHeaderActions
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onPreview={handlePreviewDiff}
|
onPreview={handlePreviewDiff}
|
||||||
disablePreview={loading || items.length === 0}
|
disablePreview={loading || items.length === 0}
|
||||||
onRefresh={fetchItems}
|
onRefresh={fetchItems}
|
||||||
disableRefresh={loading}
|
disableRefresh={loading}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
disableExport={items.length === 0}
|
disableExport={items.length === 0}
|
||||||
onClear={clearInvalid}
|
onClear={clearInvalid}
|
||||||
disableClear={items.length === 0}
|
disableClear={items.length === 0}
|
||||||
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
|
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
|
||||||
disableClearCommunities={items.length === 0 || !filterCommunity}
|
disableClearCommunities={items.length === 0 || !filterCommunity}
|
||||||
onSave={handleSaveChanges}
|
onSave={handleSaveChanges}
|
||||||
disableSave={loading}
|
disableSave={loading}
|
||||||
onHistory={() => setHistoryOpen(true)}
|
onHistory={() => setHistoryOpen(true)}
|
||||||
onOnlineUpdate={() => setWsOpen(true)}
|
lastModified={lastModified}
|
||||||
onBackgroundUpdate={async () => {
|
onOnlineUpdate={() => setWsOpen(true)}
|
||||||
try {
|
onBackgroundUpdate={async () => {
|
||||||
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
|
try {
|
||||||
const data = await res.json().catch(() => ({}));
|
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
|
||||||
if (!res.ok || data.ok === false) {
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data?.message || `HTTP ${res.status}`);
|
if (!res.ok || data.ok === false) {
|
||||||
}
|
throw new Error(data?.message || `HTTP ${res.status}`);
|
||||||
notifyMutationSuccess('Фоновое обновление запущено', data);
|
|
||||||
} catch (e) {
|
|
||||||
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
|
||||||
}
|
}
|
||||||
}}
|
notifyMutationSuccess('Фоновое обновление запущено', data);
|
||||||
|
} catch (e) {
|
||||||
|
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddItemModal
|
||||||
|
open={addModalOpen}
|
||||||
|
onClose={() => setAddModalOpen(false)}
|
||||||
|
onSubmit={handleAddItem}
|
||||||
|
type="domain"
|
||||||
|
communities={communities}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-12">
|
||||||
{/* Classic Add New Domain Card (improved) */}
|
|
||||||
<div className="card card-md animate-in">
|
|
||||||
<div className="card-header bg-primary-lt">
|
|
||||||
<h3 className="card-title text-primary">
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить новый домен
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label required">Домен</label>
|
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconWorld size={18} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`form-control${newInvalid.domain ? ' is-invalid' : isValidDomain(newItem.domain) ? ' is-valid' : ''}`}
|
|
||||||
placeholder="example.com"
|
|
||||||
value={newItem.domain}
|
|
||||||
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{newInvalid.domain && <div className="invalid-feedback">Неверный формат домена</div>}
|
|
||||||
{isValidDomain(newItem.domain) && !newInvalid.domain && (
|
|
||||||
<div className="valid-feedback">Корректный формат домена</div>
|
|
||||||
)}
|
|
||||||
<div className="form-text text-muted small">
|
|
||||||
Формат: sub.domain.tld (например foo.example.org)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label required">Community</label>
|
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconHash size={18} />
|
|
||||||
</span>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={newItem.community}
|
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
|
||||||
communities={communities}
|
|
||||||
placeholder="Начните вводить номер или имя"
|
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
|
|
||||||
useShortValue={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
|
|
||||||
{isValidCommunity(newItem.community) && !newInvalid.community && (
|
|
||||||
<div className="valid-feedback">Корректный формат community</div>
|
|
||||||
)}
|
|
||||||
<div className="form-text text-muted small">
|
|
||||||
Формат: N или N:N (например 65000:100)
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
|
||||||
if (!match) return null;
|
|
||||||
return (
|
|
||||||
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
|
||||||
<div className="d-flex">
|
|
||||||
<div>
|
|
||||||
<strong>{match.name || match.value}</strong>
|
|
||||||
{match.description && <div className="text-muted small">{match.description}</div>}
|
|
||||||
{match.tags && match.tags.length > 0 && (
|
|
||||||
<div className="mt-1">
|
|
||||||
{match.tags.map((tag, idx) => (
|
|
||||||
<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="form-footer">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary w-100"
|
|
||||||
disabled={!isValidDomain(newItem.domain) || !isValidCommunity(newItem.community)}
|
|
||||||
>
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить домен
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Дополнительные варианты */}
|
|
||||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
|
||||||
<QuickAddBar
|
|
||||||
placeholder={'example.com 65000:100'}
|
|
||||||
help={'Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY'}
|
|
||||||
parseLine={(line) => { const [d, c] = String(line).split(/\s+/); return { domain: (d||'').toLowerCase(), community: (c||'').trim() }; }}
|
|
||||||
validateItem={(obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community)}
|
|
||||||
onApply={(list) => {
|
|
||||||
setItems(prev => {
|
|
||||||
const merged = [...prev, ...list];
|
|
||||||
const map = new Map();
|
|
||||||
for (const it of merged) {
|
|
||||||
const key = String(it.domain).trim().toLowerCase();
|
|
||||||
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="card card-md border-dashed mt-3 animate-in"
|
|
||||||
style={{animationDelay: '0.15s'}}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
|
|
||||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
|
|
||||||
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center text-muted py-4">
|
|
||||||
<IconUpload size={32} className="mb-2 text-muted" />
|
|
||||||
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
|
|
||||||
<div className="small">Формат: domain community</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-lg-9">
|
|
||||||
{/* Bulk Actions Bar */}
|
{/* Bulk Actions Bar */}
|
||||||
<BulkActionsBar
|
<BulkActionsBar
|
||||||
selectedCount={selectedDomains.size}
|
selectedCount={selectedDomains.size}
|
||||||
@@ -691,16 +561,22 @@ function DomainsNewManager() {
|
|||||||
onExport={handleBulkExport}
|
onExport={handleBulkExport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card animate-in" style={{animationDelay: '0.3s'}}>
|
<div className="card card-flat animate-in" style={{animationDelay: '0.3s'}}>
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="row align-items-center w-100 g-2">
|
<div className="row align-items-center w-100 g-2">
|
||||||
<div className="col-auto">
|
<div className="col">
|
||||||
<h3 className="card-title mb-0">
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<IconWorld className="icon me-2" size={20} />
|
<div className="d-flex align-items-center gap-2">
|
||||||
Список доменов
|
<IconWorld className="icon text-muted" size={18} />
|
||||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
<span className="text-muted text-uppercase small">Список</span>
|
||||||
</h3>
|
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-none d-md-block text-muted small">
|
||||||
|
Всего записей: {filtered.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-12 mt-2"></div>
|
||||||
<div className="col">
|
<div className="col">
|
||||||
<div className="d-flex gap-2 flex-wrap">
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
{/* Сохранённые фильтры */}
|
{/* Сохранённые фильтры */}
|
||||||
@@ -781,142 +657,120 @@ function DomainsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setAddModalOpen(true)}
|
||||||
|
title="Добавить новый домен"
|
||||||
|
>
|
||||||
|
<IconPlus className="me-1" size={18} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Мета-информация будет показана внизу карточки */}
|
<div className="card-body p-0">
|
||||||
{loading ? (
|
{/* Таблица с лёгкой карточной подложкой (плоский стиль) */}
|
||||||
<TableSkeleton rows={10} cols={4} hasCheckbox={true} />
|
{loading ? (
|
||||||
) : paginatedItems.length === 0 ? (
|
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
|
||||||
<TableEmpty cols={3}>
|
) : paginatedItems.length === 0 ? (
|
||||||
<EmptyState
|
<TableEmpty cols={3} flat>
|
||||||
title="Нет доменов"
|
<EmptyState
|
||||||
description="Импортируйте или добавьте записи, чтобы начать."
|
title="Нет доменов"
|
||||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
description="Импортируйте или добавьте записи, чтобы начать."
|
||||||
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||||
const header = ['domain','community'];
|
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
||||||
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
|
const header = ['domain','community'];
|
||||||
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||||
const url = URL.createObjectURL(blob);
|
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
||||||
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
const url = URL.createObjectURL(blob);
|
||||||
}}>Шаблон CSV</button>}
|
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
||||||
/>
|
}}>Шаблон CSV</button>}
|
||||||
</TableEmpty>
|
/>
|
||||||
) : (
|
</TableEmpty>
|
||||||
<div className="table-responsive">
|
) : (
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<div className="table-responsive">
|
||||||
<thead>
|
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||||
<tr>
|
<thead>
|
||||||
<th style={{ width: '40px' }}>
|
<tr>
|
||||||
<input
|
<th style={{ width: '40px' }}>
|
||||||
type="checkbox"
|
<input
|
||||||
className="form-check-input"
|
type="checkbox"
|
||||||
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
|
className="form-check-input"
|
||||||
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
|
||||||
/>
|
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
||||||
</th>
|
/>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
|
</th>
|
||||||
<div className="d-flex align-items-center">
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
|
||||||
<IconWorld size={16} className="me-1 text-muted" />
|
<div className="d-flex align-items-center">
|
||||||
Домен
|
<IconWorld size={16} className="me-1 text-muted" />
|
||||||
{sortField === 'domain' && (
|
Домен
|
||||||
<span className="ms-1">
|
{sortField === 'domain' && (
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
<span className="ms-1">
|
||||||
</span>
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
</th>
|
</div>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
</th>
|
||||||
<div className="d-flex align-items-center">
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||||||
<IconHash size={16} className="me-1 text-muted" />
|
<div className="d-flex align-items-center">
|
||||||
Community
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
{sortField === 'community' && (
|
Community
|
||||||
<span className="ms-1">
|
{sortField === 'community' && (
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
<span className="ms-1">
|
||||||
</span>
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
</th>
|
</div>
|
||||||
<th className="text-end">Действия</th>
|
</th>
|
||||||
</tr>
|
<th className="text-end">Действия</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{paginatedItems.map((item, idx) => (
|
<tbody>
|
||||||
<tr
|
{paginatedItems.map((item, idx) => (
|
||||||
key={item.domain}
|
<tr
|
||||||
className={editingDomain === item.domain ? 'table-active' : (selectedDomains.has(item.domain) ? 'table-selected' : '')}
|
key={item.domain}
|
||||||
style={{
|
className={selectedDomains.has(item.domain) ? 'table-selected' : ''}
|
||||||
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
|
style={{
|
||||||
}}
|
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
|
||||||
>
|
}}
|
||||||
<td>
|
>
|
||||||
<input
|
<td>
|
||||||
type="checkbox"
|
<input
|
||||||
className="form-check-input"
|
type="checkbox"
|
||||||
checked={selectedDomains.has(item.domain)}
|
className="form-check-input"
|
||||||
onChange={() => handleToggleSelect(item.domain)}
|
checked={selectedDomains.has(item.domain)}
|
||||||
aria-label={`Выбрать ${item.domain}`}
|
onChange={() => handleToggleSelect(item.domain)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
aria-label={`Выбрать ${item.domain}`}
|
||||||
/>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<IconWorld size={16} className="me-2 text-muted" />
|
|
||||||
<span className="font-monospace">{item.domain}</span>
|
|
||||||
<Tooltip content="Копировать домен" position="top">
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
|
||||||
onClick={() => copyToClipboard(item.domain)}
|
|
||||||
aria-label="Копировать"
|
|
||||||
title="Копировать домен"
|
|
||||||
>
|
|
||||||
<IconCopy size={14} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
{renderCommunityBadge(item.community)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="text-end">
|
|
||||||
<div className="btn-group">
|
|
||||||
{editingDomain === item.domain ? (
|
|
||||||
<>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={editingValue}
|
|
||||||
onChange={setEditingValue}
|
|
||||||
communities={communities}
|
|
||||||
onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
|
|
||||||
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
|
||||||
style={{ width: '150px' }}
|
|
||||||
useShortValue={true}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip content="Сохранить (Enter)">
|
</td>
|
||||||
<button
|
<td>
|
||||||
className="btn btn-success btn-icon btn-sm"
|
<div className="d-flex align-items-center">
|
||||||
onClick={() => handleSaveEdit(item.domain)}
|
<IconWorld size={16} className="me-2 text-muted" />
|
||||||
disabled={!isValidCommunity(editingValue)}
|
<span className="font-monospace">{item.domain}</span>
|
||||||
aria-label="Сохранить"
|
<Tooltip content="Копировать домен" position="top">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||||
|
onClick={() => copyToClipboard(item.domain)}
|
||||||
|
aria-label="Копировать"
|
||||||
|
title="Копировать домен"
|
||||||
>
|
>
|
||||||
<IconCheck size={16} />
|
<IconCopy size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Отмена (Esc)">
|
</div>
|
||||||
<button
|
</td>
|
||||||
className="btn btn-secondary btn-icon btn-sm"
|
<td>
|
||||||
onClick={handleCancelEdit}
|
<div className="d-flex align-items-center">
|
||||||
aria-label="Отмена"
|
{renderCommunityBadge(item.community)}
|
||||||
>
|
</div>
|
||||||
<IconX size={16} />
|
</td>
|
||||||
</button>
|
<td className="text-end">
|
||||||
</Tooltip>
|
<div className="btn-group btn-group-sm" role="group" aria-label={`Действия с доменом ${item.domain}`}>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Tooltip content="Редактировать" position="top">
|
<Tooltip content="Редактировать" position="top">
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
className="btn btn-icon btn-outline-primary"
|
||||||
aria-label="Редактировать"
|
aria-label="Редактировать"
|
||||||
onClick={() => handleEdit(item)}
|
onClick={() => handleEdit(item)}
|
||||||
title="Редактировать"
|
title="Редактировать"
|
||||||
@@ -924,19 +778,9 @@ function DomainsNewManager() {
|
|||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Копировать" position="top">
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-secondary btn-icon btn-sm"
|
|
||||||
aria-label="Копировать"
|
|
||||||
onClick={() => copyToClipboard(`${item.domain} ${item.community}`)}
|
|
||||||
title="Копировать"
|
|
||||||
>
|
|
||||||
<IconCopy size={16} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip content="Удалить" position="top">
|
<Tooltip content="Удалить" position="top">
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-danger btn-icon btn-sm"
|
className="btn btn-icon btn-outline-danger"
|
||||||
aria-label="Удалить"
|
aria-label="Удалить"
|
||||||
onClick={() => confirmDelete(item)}
|
onClick={() => confirmDelete(item)}
|
||||||
title="Удалить"
|
title="Удалить"
|
||||||
@@ -944,25 +788,24 @@ function DomainsNewManager() {
|
|||||||
<IconTrash size={16} />
|
<IconTrash size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</div>
|
||||||
)}
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</td>
|
))}
|
||||||
</tr>
|
</tbody>
|
||||||
))}
|
</table>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-footer border-top-0">
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems || filtered.length}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{/* Пагинация */}
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
totalItems={totalItems || filtered.length}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onPageChange={setCurrentPage}
|
|
||||||
/>
|
|
||||||
<S3MetaBar keys={["domainsNew"]} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1013,6 +856,42 @@ function DomainsNewManager() {
|
|||||||
url={wsUrl}
|
url={wsUrl}
|
||||||
onClose={() => setWsOpen(false)}
|
onClose={() => setWsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
{/* Модальное окно редактирования community */}
|
||||||
|
<FormModal
|
||||||
|
show={editModalOpen && !!itemBeingEdited}
|
||||||
|
onClose={handleCancelEdit}
|
||||||
|
onSubmit={handleSaveEdit}
|
||||||
|
title="Редактировать community"
|
||||||
|
submitLabel="Сохранить"
|
||||||
|
submitIcon={IconCheck}
|
||||||
|
size="md"
|
||||||
|
centered
|
||||||
|
disabled={!isValidCommunity(editingValue)}
|
||||||
|
>
|
||||||
|
{itemBeingEdited && (
|
||||||
|
<>
|
||||||
|
<p className="text-muted mb-2">
|
||||||
|
Домен: <span className="font-monospace">{itemBeingEdited.domain}</span>
|
||||||
|
</p>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Community</label>
|
||||||
|
<CommunityAutocompleteInput
|
||||||
|
value={editingValue}
|
||||||
|
onChange={setEditingValue}
|
||||||
|
communities={communities}
|
||||||
|
onKeyDown={handleEditKeyDown}
|
||||||
|
className={`form-control${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
|
useShortValue={true}
|
||||||
|
/>
|
||||||
|
{!isValidCommunity(editingValue) && (
|
||||||
|
<div className="invalid-feedback d-block">
|
||||||
|
Введите корректное значение community (число или N:N).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormModal>
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
show={clearCommunitiesOpen}
|
show={clearCommunitiesOpen}
|
||||||
onClose={() => setClearCommunitiesOpen(false)}
|
onClose={() => setClearCommunitiesOpen(false)}
|
||||||
@@ -1041,10 +920,10 @@ function DomainsNewManager() {
|
|||||||
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
|
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
|
||||||
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
|
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<div className="row g-3">
|
<div className="row g-2 g-md-3">
|
||||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
|
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
|
||||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
|
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
|
||||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
|
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
|
|||||||
+152
-280
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
|
import { usePing } from './contexts/PingContext.jsx';
|
||||||
import {
|
import {
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp
|
IconChevronUp
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
|
||||||
// Функция для получения флага по коду страны
|
// Функция для получения флага по коду страны
|
||||||
const countryToFlag = (code) => {
|
const countryToFlag = (code) => {
|
||||||
@@ -39,7 +42,7 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
// Поиск и фильтры
|
// Поиск и фильтры
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedRule, setSelectedRule] = useState('all'); // all | by-community
|
const [_selectedRule, _setSelectedRule] = useState('all'); // all | by-community
|
||||||
const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
|
const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
|
||||||
const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные
|
const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные
|
||||||
|
|
||||||
@@ -49,60 +52,100 @@ function EasySwitchManager() {
|
|||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
|
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
|
||||||
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
|
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
|
||||||
// Пинги по шлюзам: ключ `${routerId}:${gatewayIp}` → avg RTT в мс
|
// Единый источник пингов (кеш + API) — общий с страницей фильтров
|
||||||
const [pingMap, setPingMap] = useState({});
|
const { pingMap, requestPings, cancelScope } = usePing();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
const initData = async () => {
|
const initData = async () => {
|
||||||
try {
|
try {
|
||||||
// Загружаем communities
|
// Загружаем communities
|
||||||
const commRes = await api.get(`/communities`);
|
const commRes = await api.get(`/communities`, { signal });
|
||||||
const communities = Array.isArray(commRes.data) ? commRes.data : [];
|
const communities = Array.isArray(commRes.data) ? commRes.data : [];
|
||||||
setCommunitiesDirectory(communities);
|
setCommunitiesDirectory(communities);
|
||||||
|
|
||||||
// Загружаем inventory servers
|
// Загружаем inventory servers
|
||||||
const invRes = await api.get(`/servers`);
|
const invRes = await api.get(`/servers`, { signal });
|
||||||
const inventory = Array.isArray(invRes.data) ? invRes.data : [];
|
const inventory = Array.isArray(invRes.data) ? invRes.data : [];
|
||||||
setInventoryServers(inventory);
|
setInventoryServers(inventory);
|
||||||
|
|
||||||
// Загружаем network-config (gateways и туннели)
|
// Загружаем network-config (gateways и туннели)
|
||||||
let ncGateways = [];
|
let ncGateways = [];
|
||||||
try {
|
try {
|
||||||
const ncRes = await api.get(`/network-config`);
|
const ncRes = await api.get(`/network-config`, { signal });
|
||||||
const ncData = ncRes.data || {};
|
const ncData = ncRes.data || {};
|
||||||
ncGateways = Array.isArray(ncData.gateways) ? ncData.gateways : [];
|
ncGateways = Array.isArray(ncData.gateways) ? ncData.gateways : [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
// Если файла нет или ошибка - используем пустой массив (fallback на старую логику)
|
// Если файла нет или ошибка - используем пустой массив (fallback на старую логику)
|
||||||
console.warn('EasySwitch: не удалось загрузить /network-config, используется fallback:', e?.message || e);
|
console.warn('EasySwitch: не удалось загрузить /network-config, используется fallback:', e?.message || e);
|
||||||
ncGateways = [];
|
ncGateways = [];
|
||||||
}
|
}
|
||||||
setNetworkConfigGateways(ncGateways);
|
setNetworkConfigGateways(ncGateways);
|
||||||
|
|
||||||
// Загружаем данные серверов и фильтров
|
// Загружаем данные серверов и фильтров
|
||||||
await loadDataWithParams(communities, inventory, ncGateways);
|
await loadDataWithParams(communities, inventory, ncGateways, signal);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
console.error('Error initializing data:', e);
|
console.error('Error initializing data:', e);
|
||||||
setError('Ошибка загрузки данных');
|
setError('Ошибка загрузки данных');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
initData();
|
initData();
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadDataWithParams = async (communities, inventory, ncGatewaysParam) => {
|
return () => controller.abort();
|
||||||
|
}, [loadDataWithParams]);
|
||||||
|
|
||||||
|
// При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся)
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname !== '/easy-switch' || !servers.length || !inventoryServers.length) return;
|
||||||
|
const pingTasks = [];
|
||||||
|
servers.forEach((server) => {
|
||||||
|
const inv = inventoryServers.find(srv =>
|
||||||
|
String(srv.dns || '').trim() === server.name ||
|
||||||
|
String(srv.hostName || '').trim() === server.name ||
|
||||||
|
String(srv.ip || '').trim() === server.name
|
||||||
|
);
|
||||||
|
const routerId = inv?.id || inv?.dns || inv?.ip;
|
||||||
|
if (!routerId) return;
|
||||||
|
(server.gateways || []).forEach((gw) => {
|
||||||
|
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
requestPings(pingTasks, { scope: 'easy-switch' });
|
||||||
|
}, [location.pathname, servers, inventoryServers, requestPings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname === '/easy-switch') return;
|
||||||
|
cancelScope('easy-switch');
|
||||||
|
}, [location.pathname, cancelScope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancelScope('easy-switch');
|
||||||
|
};
|
||||||
|
}, [cancelScope]);
|
||||||
|
|
||||||
|
const loadDataWithParams = useCallback(async (communities, inventory, ncGatewaysParam, signal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
const requestConfig = signal ? { signal } : {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Загружаем список серверов из server-configs
|
// Загружаем список серверов из server-configs
|
||||||
const serversResponse = await api.get(`/server-configs`);
|
const serversResponse = await api.get(`/server-configs`, requestConfig);
|
||||||
const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : [];
|
const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : [];
|
||||||
|
|
||||||
// Загружаем фильтры для каждого сервера (для определения активных gateways)
|
// Загружаем фильтры для каждого сервера (для определения активных gateways)
|
||||||
const serversWithData = await Promise.all(
|
const serversWithData = await Promise.all(
|
||||||
serversList.map(async (server) => {
|
serversList.map(async (server) => {
|
||||||
try {
|
try {
|
||||||
// Загружаем filters для сервера
|
// Загружаем filters для сервера
|
||||||
const filtersResponse = await api.get(`/server-filters/${server.id}`);
|
const filtersResponse = await api.get(`/server-filters/${server.id}`, requestConfig);
|
||||||
const filters = Array.isArray(filtersResponse.data) ? filtersResponse.data : [];
|
const filters = Array.isArray(filtersResponse.data) ? filtersResponse.data : [];
|
||||||
|
|
||||||
// Получаем gateways сервера:
|
// Получаем gateways сервера:
|
||||||
@@ -212,14 +255,27 @@ function EasySwitchManager() {
|
|||||||
// Фильтруем серверы, у которых есть gateways
|
// Фильтруем серверы, у которых есть gateways
|
||||||
const serversWithGateways = serversWithData.filter(s => s.gateways.length > 0);
|
const serversWithGateways = serversWithData.filter(s => s.gateways.length > 0);
|
||||||
setServers(serversWithGateways);
|
setServers(serversWithGateways);
|
||||||
// Предзагружаем пинги для всех комбинаций server+gateway
|
// Единая точка входа: запрашиваем пинги через контекст (кеш общий с фильтрами)
|
||||||
prefetchPings(serversWithGateways, inventory);
|
const pingTasks = [];
|
||||||
|
serversWithGateways.forEach((server) => {
|
||||||
|
const inv = inventory.find(srv =>
|
||||||
|
String(srv.dns || '').trim() === server.name ||
|
||||||
|
String(srv.hostName || '').trim() === server.name ||
|
||||||
|
String(srv.ip || '').trim() === server.name
|
||||||
|
);
|
||||||
|
const routerId = inv?.id || inv?.dns || inv?.ip;
|
||||||
|
if (!routerId) return;
|
||||||
|
(server.gateways || []).forEach((gw) => {
|
||||||
|
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
requestPings(pingTasks, { scope: 'easy-switch' });
|
||||||
|
|
||||||
// Отладочная информация
|
// Отладочная информация
|
||||||
try {
|
try {
|
||||||
console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
|
console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
|
||||||
console.log('[EasySwitch] communitiesDirectory:', communities);
|
console.log('[EasySwitch] communitiesDirectory:', communities);
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
|
|
||||||
// Автоматически разворачиваем первый сервер
|
// Автоматически разворачиваем первый сервер
|
||||||
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
|
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
|
||||||
@@ -244,69 +300,17 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[EasySwitch] initialActiveGateways:', initialActive);
|
console.log('[EasySwitch] initialActiveGateways:', initialActive);
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
console.error('Error loading data:', err);
|
console.error('Error loading data:', err);
|
||||||
setError('Ошибка загрузки данных. Проверьте подключение к серверу.');
|
setError('Ошибка загрузки данных. Проверьте подключение к серверу.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, deps would cause unnecessary re-fetches
|
||||||
|
}, []);
|
||||||
/**
|
|
||||||
* Загрузить реальные пинги для комбинаций (сервер, gateway).
|
|
||||||
* Пингуем хост www.gstatic.com через соответствующий MikroTik jumphost.
|
|
||||||
*/
|
|
||||||
const prefetchPings = async (serversList, inventory) => {
|
|
||||||
try {
|
|
||||||
const tasks = [];
|
|
||||||
const seen = new Set();
|
|
||||||
|
|
||||||
serversList.forEach((server) => {
|
|
||||||
const inv = inventory.find(srv =>
|
|
||||||
String(srv.dns || '').trim() === server.name ||
|
|
||||||
String(srv.hostName || '').trim() === server.name ||
|
|
||||||
String(srv.ip || '').trim() === server.name
|
|
||||||
);
|
|
||||||
const routerId = inv?.id || inv?.dns || inv?.ip;
|
|
||||||
if (!routerId) return;
|
|
||||||
|
|
||||||
(server.gateways || []).forEach((gw) => {
|
|
||||||
const ip = gw.ip || '';
|
|
||||||
if (!ip) return;
|
|
||||||
const key = `${routerId}:${ip}`;
|
|
||||||
if (seen.has(key) || pingMap[key] !== undefined) return;
|
|
||||||
seen.add(key);
|
|
||||||
tasks.push({ routerId, ip, key });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tasks.length === 0) return;
|
|
||||||
|
|
||||||
// Запросы идут параллельно; обновляем UI по мере прихода каждого ответа,
|
|
||||||
// чтобы значения на интерфейсе отображались быстрее.
|
|
||||||
tasks.forEach(({ routerId, ip, key }) => {
|
|
||||||
api
|
|
||||||
.post('/mikrotik/ping', {
|
|
||||||
serverId: routerId,
|
|
||||||
gatewayIp: ip,
|
|
||||||
target: 'www.gstatic.com',
|
|
||||||
count: 5,
|
|
||||||
})
|
|
||||||
.then(({ data }) => {
|
|
||||||
const value = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
|
||||||
setPingMap((prev) => ({ ...prev, [key]: value }));
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.warn('Failed to fetch ping for', routerId, ip, e?.message || e);
|
|
||||||
setPingMap((prev) => ({ ...prev, [key]: null }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('prefetchPings error:', e?.message || e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGatewaySelect = (serverId, community, gateway) => {
|
const handleGatewaySelect = (serverId, community, gateway) => {
|
||||||
const key = `${serverId}:${community}`;
|
const key = `${serverId}:${community}`;
|
||||||
@@ -378,7 +382,7 @@ function EasySwitchManager() {
|
|||||||
const currentFilters = Array.isArray(response.data) ? response.data : [];
|
const currentFilters = Array.isArray(response.data) ? response.data : [];
|
||||||
|
|
||||||
// Создаем Set существующих communities в фильтрах
|
// Создаем Set существующих communities в фильтрах
|
||||||
const existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
|
const _existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
|
||||||
|
|
||||||
// Обновляем существующие фильтры и добавляем новые
|
// Обновляем существующие фильтры и добавляем новые
|
||||||
const updatedFilters = [];
|
const updatedFilters = [];
|
||||||
@@ -513,19 +517,13 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
if (loading && servers.length === 0) {
|
if (loading && servers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="container-xl">
|
<div className="easy-switch-page">
|
||||||
<div className="page-header">
|
<PageHeader
|
||||||
<div className="row align-items-center">
|
title="Easy Switch"
|
||||||
<div className="col">
|
icon={<IconWorld size={24} />}
|
||||||
<h2 className="page-title">
|
meta="Быстрое переключение шлюзов"
|
||||||
<IconWorld className="me-2" />
|
/>
|
||||||
Easy Switch
|
|
||||||
</h2>
|
|
||||||
<div className="text-muted mt-1">Быстрое переключение шлюзов</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body text-center py-5">
|
<div className="card-body text-center py-5">
|
||||||
<div className="spinner-border text-primary" role="status">
|
<div className="spinner-border text-primary" role="status">
|
||||||
@@ -539,45 +537,36 @@ function EasySwitchManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-xl">
|
<div className="easy-switch-page">
|
||||||
{/* Заголовок */}
|
<PageHeader
|
||||||
<div className="page-header mb-3">
|
title="Easy Switch"
|
||||||
<div className="row align-items-center">
|
icon={<IconWorld size={24} />}
|
||||||
<div className="col">
|
meta={`Быстрое переключение шлюзов • ${servers.length} ${servers.length === 1 ? 'сервер' : 'серверов'} • ${communitiesDirectory.length} communities`}
|
||||||
<h2 className="page-title">
|
actions={
|
||||||
<IconWorld className="me-2" />
|
<div className="btn-list">
|
||||||
Easy Switch
|
{hasChanges && (
|
||||||
</h2>
|
<button
|
||||||
<div className="text-muted mt-1">
|
className="btn btn-success"
|
||||||
Быстрое переключение шлюзов • {servers.length} {servers.length === 1 ? 'сервер' : 'серверов'} • {communitiesDirectory.length} communities
|
onClick={handleApplyChanges}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-auto">
|
|
||||||
<div className="btn-list">
|
|
||||||
{hasChanges && (
|
|
||||||
<button
|
|
||||||
className="btn btn-success"
|
|
||||||
onClick={handleApplyChanges}
|
|
||||||
>
|
|
||||||
<IconDeviceFloppy size={18} className="me-2" />
|
|
||||||
Применить изменения
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<IconRefresh size={18} className="me-2" />
|
<IconDeviceFloppy size={18} className="me-2" />
|
||||||
Обновить
|
Применить изменения
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconRefresh size={18} className="me-2" />
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Вкладки */}
|
{/* Вкладки */}
|
||||||
<div className="mb-3">
|
<div className="card card-tabs mb-3">
|
||||||
<ul className="nav nav-tabs" role="tablist">
|
<ul className="nav nav-tabs" role="tablist">
|
||||||
<li className="nav-item" role="presentation">
|
<li className="nav-item" role="presentation">
|
||||||
<button
|
<button
|
||||||
@@ -622,8 +611,9 @@ function EasySwitchManager() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Панель фильтров */}
|
{/* Панель фильтров */}
|
||||||
<div className="mb-3">
|
<div className="card mb-3">
|
||||||
<div className="row g-2 align-items-center">
|
<div className="card-body py-3">
|
||||||
|
<div className="row g-2 align-items-center">
|
||||||
{/* Переключатель "Только настроенные" */}
|
{/* Переключатель "Только настроенные" */}
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<label className="form-check form-switch mb-0">
|
<label className="form-check form-switch mb-0">
|
||||||
@@ -702,17 +692,18 @@ function EasySwitchManager() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<button className="btn btn-icon" title="Настройки">
|
<button className="btn btn-outline-secondary btn-icon" title="Настройки">
|
||||||
<IconSettings size={18} />
|
<IconSettings size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<button className="btn btn-icon" title="Быстрые действия">
|
<button className="btn btn-outline-secondary btn-icon" title="Быстрые действия">
|
||||||
<IconFilter size={18} />
|
<IconFilter size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Список серверов с communities и шлюзами */}
|
{/* Список серверов с communities и шлюзами */}
|
||||||
{filteredServers.length === 0 ? (
|
{filteredServers.length === 0 ? (
|
||||||
@@ -749,12 +740,7 @@ function EasySwitchManager() {
|
|||||||
<div key={server.id} className="card mb-3">
|
<div key={server.id} className="card mb-3">
|
||||||
{/* Заголовок сервера (кликабельный) */}
|
{/* Заголовок сервера (кликабельный) */}
|
||||||
<div
|
<div
|
||||||
className="card-header py-2"
|
className="card-header py-2 easy-switch-server-header"
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(32, 107, 196, 0.05)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none'
|
|
||||||
}}
|
|
||||||
onClick={() => toggleServerExpanded(server.id)}
|
onClick={() => toggleServerExpanded(server.id)}
|
||||||
>
|
>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
@@ -765,7 +751,7 @@ function EasySwitchManager() {
|
|||||||
<IconChevronDown size={20} className="me-2 text-muted" />
|
<IconChevronDown size={20} className="me-2 text-muted" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{flag && <span className="me-2" style={{ fontSize: '1.5rem' }}>{flag}</span>}
|
{flag && <span className="me-2 easy-switch-flag">{flag}</span>}
|
||||||
<IconServer size={20} className="me-2 text-primary" />
|
<IconServer size={20} className="me-2 text-primary" />
|
||||||
<h3 className="card-title mb-0 me-2">{server.name}</h3>
|
<h3 className="card-title mb-0 me-2">{server.name}</h3>
|
||||||
{server.description && (
|
{server.description && (
|
||||||
@@ -816,7 +802,7 @@ function EasySwitchManager() {
|
|||||||
<div key={tag} className="mb-4">
|
<div key={tag} className="mb-4">
|
||||||
{/* Заголовок группы тегов */}
|
{/* Заголовок группы тегов */}
|
||||||
<div className="mb-2 pb-2 border-bottom">
|
<div className="mb-2 pb-2 border-bottom">
|
||||||
<span className="badge bg-purple-lt text-purple me-2" style={{ fontSize: '0.85rem', padding: '6px 10px' }}>
|
<span className="badge bg-purple-lt text-purple me-2">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted small">
|
<span className="text-muted small">
|
||||||
@@ -836,12 +822,12 @@ function EasySwitchManager() {
|
|||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
{/* Заголовок community */}
|
{/* Заголовок community */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
|
<span className="badge bg-blue-lt text-blue me-2">
|
||||||
{comm.value}
|
{comm.value}
|
||||||
</span>
|
</span>
|
||||||
<span className="fw-medium">{comm.name || comm.description || ''}</span>
|
<span className="fw-medium">{comm.name || comm.description || ''}</span>
|
||||||
{hasFilterForComm && (
|
{hasFilterForComm && (
|
||||||
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
|
<span className="badge bg-green-lt text-green ms-2">
|
||||||
настроен
|
настроен
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -865,50 +851,21 @@ function EasySwitchManager() {
|
|||||||
return (
|
return (
|
||||||
<div key={gw.name} className="col-12 col-sm-4">
|
<div key={gw.name} className="col-12 col-sm-4">
|
||||||
<div
|
<div
|
||||||
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
|
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
|
|
||||||
position: 'relative'
|
|
||||||
}}
|
|
||||||
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
|
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="card-body p-2">
|
<div className="card-body p-2">
|
||||||
{/* Галочка (абсолютное позиционирование) */}
|
{/* Галочка (абсолютное позиционирование) */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div
|
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
|
||||||
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
|
|
||||||
style={{
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
position: 'absolute',
|
|
||||||
top: '8px',
|
|
||||||
right: '8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconCheck size={12} />
|
<IconCheck size={12} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Метка Fastest */}
|
{/* Метка Fastest */}
|
||||||
<div className="mb-1" style={{ minHeight: '18px' }}>
|
<div className="mb-1 easy-switch-fast-slot">
|
||||||
{isFastest && !isActive && (
|
{isFastest && !isActive && (
|
||||||
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
|
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
|
||||||
<IconBolt size={10} className="me-1" />
|
<IconBolt size={10} className="me-1" />
|
||||||
Fast
|
Fast
|
||||||
</span>
|
</span>
|
||||||
@@ -918,22 +875,14 @@ function EasySwitchManager() {
|
|||||||
{/* Флаг и название (комментарий) шлюза */}
|
{/* Флаг и название (комментарий) шлюза */}
|
||||||
<div className="d-flex align-items-center gap-2 mb-1">
|
<div className="d-flex align-items-center gap-2 mb-1">
|
||||||
<span
|
<span
|
||||||
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
|
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
|
||||||
style={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
fontSize: '1.25rem',
|
|
||||||
lineHeight: 1,
|
|
||||||
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
|
|
||||||
}}
|
|
||||||
title={gw.country || country || ''}
|
title={gw.country || country || ''}
|
||||||
>
|
>
|
||||||
{countryToFlag(gw.country || country || '') || '🌐'}
|
{countryToFlag(gw.country || country || '') || '🌐'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1 min-w-0">
|
<div className="flex-grow-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={`fw-bold ${isActive ? 'text-white' : ''}`}
|
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
|
||||||
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
|
|
||||||
>
|
>
|
||||||
{gw.comment || gw.name}
|
{gw.comment || gw.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -942,17 +891,16 @@ function EasySwitchManager() {
|
|||||||
{/* IP шлюза ниже */}
|
{/* IP шлюза ниже */}
|
||||||
{gw.ip && (
|
{gw.ip && (
|
||||||
<div
|
<div
|
||||||
className={`small text-truncate ${
|
className={`small text-truncate easy-switch-gateway-ip ${
|
||||||
isActive ? 'text-white-75' : 'text-muted'
|
isActive ? 'text-white-75' : 'text-muted'
|
||||||
}`}
|
}`}
|
||||||
style={{ fontSize: '0.75rem' }}
|
|
||||||
>
|
>
|
||||||
{gw.ip}
|
{gw.ip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Метрика (пинг) */}
|
{/* Метрика (пинг) */}
|
||||||
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
|
||||||
{ping != null ? ping : '—'}
|
{ping != null ? ping : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -975,7 +923,7 @@ function EasySwitchManager() {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
{/* Заголовок группы без тегов */}
|
{/* Заголовок группы без тегов */}
|
||||||
<div className="mb-2 pb-2 border-bottom">
|
<div className="mb-2 pb-2 border-bottom">
|
||||||
<span className="badge bg-gray-lt text-gray me-2" style={{ fontSize: '0.85rem', padding: '6px 10px' }}>
|
<span className="badge bg-gray-lt text-gray me-2">
|
||||||
Без тегов
|
Без тегов
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted small">
|
<span className="text-muted small">
|
||||||
@@ -995,12 +943,12 @@ function EasySwitchManager() {
|
|||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
{/* Заголовок community */}
|
{/* Заголовок community */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
|
<span className="badge bg-blue-lt text-blue me-2">
|
||||||
{comm.value}
|
{comm.value}
|
||||||
</span>
|
</span>
|
||||||
<span className="fw-medium">{comm.name || comm.description || ''}</span>
|
<span className="fw-medium">{comm.name || comm.description || ''}</span>
|
||||||
{hasFilterForComm && (
|
{hasFilterForComm && (
|
||||||
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
|
<span className="badge bg-green-lt text-green ms-2">
|
||||||
настроен
|
настроен
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1024,50 +972,21 @@ function EasySwitchManager() {
|
|||||||
return (
|
return (
|
||||||
<div key={gw.name} className="col-12 col-sm-4">
|
<div key={gw.name} className="col-12 col-sm-4">
|
||||||
<div
|
<div
|
||||||
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
|
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
|
|
||||||
position: 'relative'
|
|
||||||
}}
|
|
||||||
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
|
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="card-body p-2">
|
<div className="card-body p-2">
|
||||||
{/* Галочка (абсолютное позиционирование) */}
|
{/* Галочка (абсолютное позиционирование) */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div
|
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
|
||||||
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
|
|
||||||
style={{
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
position: 'absolute',
|
|
||||||
top: '8px',
|
|
||||||
right: '8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconCheck size={12} />
|
<IconCheck size={12} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Метка Fastest */}
|
{/* Метка Fastest */}
|
||||||
<div className="mb-1" style={{ minHeight: '18px' }}>
|
<div className="mb-1 easy-switch-fast-slot">
|
||||||
{isFastest && !isActive && (
|
{isFastest && !isActive && (
|
||||||
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
|
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
|
||||||
<IconBolt size={10} className="me-1" />
|
<IconBolt size={10} className="me-1" />
|
||||||
Fast
|
Fast
|
||||||
</span>
|
</span>
|
||||||
@@ -1077,22 +996,14 @@ function EasySwitchManager() {
|
|||||||
{/* Флаг и название (комментарий) шлюза */}
|
{/* Флаг и название (комментарий) шлюза */}
|
||||||
<div className="d-flex align-items-center gap-2 mb-1">
|
<div className="d-flex align-items-center gap-2 mb-1">
|
||||||
<span
|
<span
|
||||||
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
|
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
|
||||||
style={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
fontSize: '1.25rem',
|
|
||||||
lineHeight: 1,
|
|
||||||
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
|
|
||||||
}}
|
|
||||||
title={gw.country || country || ''}
|
title={gw.country || country || ''}
|
||||||
>
|
>
|
||||||
{countryToFlag(gw.country || country || '') || '🌐'}
|
{countryToFlag(gw.country || country || '') || '🌐'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1 min-w-0">
|
<div className="flex-grow-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={`fw-bold ${isActive ? 'text-white' : ''}`}
|
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
|
||||||
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
|
|
||||||
>
|
>
|
||||||
{gw.comment || gw.name}
|
{gw.comment || gw.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -1101,17 +1012,16 @@ function EasySwitchManager() {
|
|||||||
{/* IP шлюза ниже */}
|
{/* IP шлюза ниже */}
|
||||||
{gw.ip && (
|
{gw.ip && (
|
||||||
<div
|
<div
|
||||||
className={`small text-truncate ${
|
className={`small text-truncate easy-switch-gateway-ip ${
|
||||||
isActive ? 'text-white-75' : 'text-muted'
|
isActive ? 'text-white-75' : 'text-muted'
|
||||||
}`}
|
}`}
|
||||||
style={{ fontSize: '0.75rem' }}
|
|
||||||
>
|
>
|
||||||
{gw.ip}
|
{gw.ip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Метрика (пинг) */}
|
{/* Метрика (пинг) */}
|
||||||
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
|
||||||
{ping != null ? ping : '—'}
|
{ping != null ? ping : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1143,12 +1053,12 @@ function EasySwitchManager() {
|
|||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
{/* Заголовок community */}
|
{/* Заголовок community */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
|
<span className="badge bg-blue-lt text-blue me-2">
|
||||||
{comm.value}
|
{comm.value}
|
||||||
</span>
|
</span>
|
||||||
<span className="fw-medium">{comm.name || comm.description || ''}</span>
|
<span className="fw-medium">{comm.name || comm.description || ''}</span>
|
||||||
{hasFilterForComm && (
|
{hasFilterForComm && (
|
||||||
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
|
<span className="badge bg-green-lt text-green ms-2">
|
||||||
настроен
|
настроен
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1172,50 +1082,21 @@ function EasySwitchManager() {
|
|||||||
return (
|
return (
|
||||||
<div key={gw.name} className="col-12 col-sm-4">
|
<div key={gw.name} className="col-12 col-sm-4">
|
||||||
<div
|
<div
|
||||||
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
|
className={`card h-100 easy-switch-gateway-card ${isActive ? 'easy-switch-gateway-card-active bg-primary text-white' : ''}`}
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
|
|
||||||
position: 'relative'
|
|
||||||
}}
|
|
||||||
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
|
onClick={() => handleGatewaySelect(server.id, comm.value, gw.name)}
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="card-body p-2">
|
<div className="card-body p-2">
|
||||||
{/* Галочка (абсолютное позиционирование) */}
|
{/* Галочка (абсолютное позиционирование) */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div
|
<div className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center easy-switch-active-check">
|
||||||
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
|
|
||||||
style={{
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
position: 'absolute',
|
|
||||||
top: '8px',
|
|
||||||
right: '8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconCheck size={12} />
|
<IconCheck size={12} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Метка Fastest */}
|
{/* Метка Fastest */}
|
||||||
<div className="mb-1" style={{ minHeight: '18px' }}>
|
<div className="mb-1 easy-switch-fast-slot">
|
||||||
{isFastest && !isActive && (
|
{isFastest && !isActive && (
|
||||||
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
|
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center easy-switch-fast-badge">
|
||||||
<IconBolt size={10} className="me-1" />
|
<IconBolt size={10} className="me-1" />
|
||||||
Fast
|
Fast
|
||||||
</span>
|
</span>
|
||||||
@@ -1225,22 +1106,14 @@ function EasySwitchManager() {
|
|||||||
{/* Флаг и название (комментарий) шлюза */}
|
{/* Флаг и название (комментарий) шлюза */}
|
||||||
<div className="d-flex align-items-center gap-2 mb-1">
|
<div className="d-flex align-items-center gap-2 mb-1">
|
||||||
<span
|
<span
|
||||||
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
|
className={`d-flex align-items-center justify-content-center flex-shrink-0 rounded easy-switch-gateway-flag ${isActive ? 'easy-switch-gateway-flag-active' : ''}`}
|
||||||
style={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
fontSize: '1.25rem',
|
|
||||||
lineHeight: 1,
|
|
||||||
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
|
|
||||||
}}
|
|
||||||
title={gw.country || country || ''}
|
title={gw.country || country || ''}
|
||||||
>
|
>
|
||||||
{countryToFlag(gw.country || country || '') || '🌐'}
|
{countryToFlag(gw.country || country || '') || '🌐'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1 min-w-0">
|
<div className="flex-grow-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={`fw-bold ${isActive ? 'text-white' : ''}`}
|
className={`fw-bold easy-switch-gateway-title ${isActive ? 'text-white' : ''}`}
|
||||||
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
|
|
||||||
>
|
>
|
||||||
{gw.comment || gw.name}
|
{gw.comment || gw.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -1249,15 +1122,14 @@ function EasySwitchManager() {
|
|||||||
{/* IP шлюза ниже */}
|
{/* IP шлюза ниже */}
|
||||||
{gw.ip && (
|
{gw.ip && (
|
||||||
<div
|
<div
|
||||||
className={`small text-truncate mb-1 ${isActive ? 'text-white-75' : 'text-muted'}`}
|
className={`small text-truncate mb-1 easy-switch-gateway-ip ${isActive ? 'text-white-75' : 'text-muted'}`}
|
||||||
style={{ fontSize: '0.75rem' }}
|
|
||||||
>
|
>
|
||||||
{gw.ip}
|
{gw.ip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Метрика (пинг) */}
|
{/* Метрика (пинг) */}
|
||||||
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
<div className={`fw-bold mt-1 easy-switch-gateway-ping ${isActive ? 'text-white' : 'text-success'}`}>
|
||||||
{ping != null ? ping : '—'}
|
{ping != null ? ping : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+290
-326
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import Modal from './components/Modal.jsx';
|
import Modal from './components/Modal.jsx';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
@@ -13,24 +14,18 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconX,
|
IconX,
|
||||||
IconDatabase,
|
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconDownload,
|
|
||||||
IconUpload,
|
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconFilter,
|
IconFilter,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconLink,
|
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconServer,
|
IconServer,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
IconHash,
|
IconHash,
|
||||||
IconRocket,
|
|
||||||
IconSortAscending,
|
IconSortAscending,
|
||||||
IconSortDescending,
|
IconSortDescending,
|
||||||
IconWorld,
|
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconGripVertical
|
IconGripVertical
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
@@ -51,15 +46,8 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
|
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
|
||||||
import {
|
import { usePing } from './contexts/PingContext.jsx';
|
||||||
AddFilterModal,
|
import { listCommunities } from './lib/evobgpData.js';
|
||||||
EditFilterModal,
|
|
||||||
DeleteFilterModal,
|
|
||||||
PreviewConfigModal,
|
|
||||||
AddFilterServerModal
|
|
||||||
} from './components/filter/index.js';
|
|
||||||
|
|
||||||
const API_URL = '/api';
|
|
||||||
|
|
||||||
// Компонент автокомплита для выбора Community
|
// Компонент автокомплита для выбора Community
|
||||||
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...', baseAS = '65001' }) {
|
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...', baseAS = '65001' }) {
|
||||||
@@ -92,7 +80,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
|
|||||||
// 3. Если ввели "65001:120", ищем "120" в справочнике
|
// 3. Если ввели "65001:120", ищем "120" в справочнике
|
||||||
if (search.includes(':')) {
|
if (search.includes(':')) {
|
||||||
const searchParts = search.split(':');
|
const searchParts = search.split(':');
|
||||||
const searchAS = searchParts[0];
|
const _searchAS = searchParts[0];
|
||||||
const searchNum = searchParts[1];
|
const searchNum = searchParts[1];
|
||||||
|
|
||||||
// Точное совпадение числовой части
|
// Точное совпадение числовой части
|
||||||
@@ -259,7 +247,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
|
|||||||
{Object.entries(groupedCommunities).map(([category, items]) => (
|
{Object.entries(groupedCommunities).map(([category, items]) => (
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
<div className="dropdown-header small text-muted bg-light">{category}</div>
|
<div className="dropdown-header small text-muted bg-light">{category}</div>
|
||||||
{items.map((c, idx) => {
|
{items.map((c, _idx) => {
|
||||||
const globalIdx = filteredCommunities.indexOf(c);
|
const globalIdx = filteredCommunities.indexOf(c);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -331,7 +319,18 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Компонент выбора Gateway в виде карточек (вместо выпадающего списка)
|
// Компонент выбора Gateway в виде карточек (вместо выпадающего списка)
|
||||||
function GatewayAutocomplete({ label, value, onChange, gateways = [], required = false, placeholder = 'Введите или выберите gateway...' }) {
|
function GatewayAutocomplete({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
gateways = [],
|
||||||
|
required = false,
|
||||||
|
placeholder = 'Введите или выберите gateway...',
|
||||||
|
// Карта пингов, как на Easy Switch: ключ `${routerId}:${gatewayIp}` → avg RTT
|
||||||
|
pingMap = {},
|
||||||
|
// Идентификатор роутера (id/dns/ip из inventory), как на Easy Switch
|
||||||
|
routerId = null,
|
||||||
|
}) {
|
||||||
const [inputValue, setInputValue] = useState(value || '');
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
@@ -478,6 +477,10 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
|||||||
{filteredGateways.map((gw, idx) => {
|
{filteredGateways.map((gw, idx) => {
|
||||||
const isSelected = gw.name === value;
|
const isSelected = gw.name === value;
|
||||||
const isHighlighted = highlightedIndex === idx;
|
const isHighlighted = highlightedIndex === idx;
|
||||||
|
const pingKey = routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
|
||||||
|
const ping = pingKey && Object.prototype.hasOwnProperty.call(pingMap, pingKey)
|
||||||
|
? pingMap[pingKey]
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div className="col" key={gw.name || idx}>
|
<div className="col" key={gw.name || idx}>
|
||||||
<button
|
<button
|
||||||
@@ -511,12 +514,15 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
|||||||
{gw.serverDns}
|
{gw.serverDns}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(gw.country || gw.provider) && (
|
{/* Пинг шлюза (вместо строки с провайдером/страной), как на странице Easy Switch */}
|
||||||
<div className={`small text-truncate ${isSelected ? 'text-white-75' : 'text-muted'}`}>
|
<div
|
||||||
{gw.country && <span className="me-1">{countryToFlag(gw.country)}</span>}
|
className={`fw-bold mt-1 ${
|
||||||
{gw.provider}
|
isSelected ? 'text-white' : 'text-success'
|
||||||
</div>
|
}`}
|
||||||
)}
|
style={{ fontSize: '1.05rem' }}
|
||||||
|
>
|
||||||
|
{ping != null ? ping : '—'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{gw.primary && (
|
{gw.primary && (
|
||||||
<span className={`badge ${isSelected ? 'bg-white text-primary' : 'bg-green-lt text-green'}`}>
|
<span className={`badge ${isSelected ? 'bg-white text-primary' : 'bg-green-lt text-green'}`}>
|
||||||
@@ -764,17 +770,37 @@ function FilterManager() {
|
|||||||
const [inventoryServers, setInventoryServers] = useState([]);
|
const [inventoryServers, setInventoryServers] = useState([]);
|
||||||
// Network config gateways (from /network-config endpoint)
|
// Network config gateways (from /network-config endpoint)
|
||||||
const [networkConfigGateways, setNetworkConfigGateways] = useState([]);
|
const [networkConfigGateways, setNetworkConfigGateways] = useState([]);
|
||||||
|
// Единая точка входа для пингов (кеш общий с Easy Switch)
|
||||||
|
const { pingMap, requestPings } = usePing();
|
||||||
|
|
||||||
|
// Получить metadata сервера из inventory по имени (dns/hostName/ip или короткое имя типа "ihor")
|
||||||
|
const getInventoryMetaForServer = useCallback((serverName) => {
|
||||||
|
if (!serverName) return null;
|
||||||
|
const name = String(serverName).trim();
|
||||||
|
return inventoryServers.find((srv) => {
|
||||||
|
const dns = String(srv.dns || '').trim();
|
||||||
|
const hostName = String(srv.hostName || '').trim();
|
||||||
|
const ip = String(srv.ip || '').trim();
|
||||||
|
if (dns === name || hostName === name || ip === name) return true;
|
||||||
|
if (dns && dns.split('.')[0] === name) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [inventoryServers]);
|
||||||
|
|
||||||
|
// Получить routerId для MikroTik (id/dns/ip), как на странице Easy Switch
|
||||||
|
const getRouterIdForServer = useCallback((server) => {
|
||||||
|
if (!server) return null;
|
||||||
|
const meta = getInventoryMetaForServer(server.fullName || server.dns || server.name) || getInventoryMetaForServer(server.name);
|
||||||
|
if (!meta) return null;
|
||||||
|
return meta.id || meta.dns || meta.ip || null;
|
||||||
|
}, [getInventoryMetaForServer]);
|
||||||
|
|
||||||
// Собираем gateways только для выбранного сервера
|
// Собираем gateways только для выбранного сервера
|
||||||
const selectedServerGateways = (() => {
|
const selectedServerGateways = (() => {
|
||||||
if (!selectedServer) return [];
|
if (!selectedServer) return [];
|
||||||
|
|
||||||
// Ищем соответствующий сервер в inventoryServers по имени
|
// Ищем соответствующий сервер в inventory (по полному или короткому имени)
|
||||||
const matchedServer = inventoryServers.find(srv =>
|
const matchedServer = getInventoryMetaForServer(selectedServer.fullName || selectedServer.dns || selectedServer.name) || getInventoryMetaForServer(selectedServer.name);
|
||||||
String(srv.dns || '').trim() === selectedServer.name ||
|
|
||||||
String(srv.hostName || '').trim() === selectedServer.name ||
|
|
||||||
String(srv.ip || '').trim() === selectedServer.name
|
|
||||||
);
|
|
||||||
|
|
||||||
const serverDns = matchedServer?.dns || matchedServer?.hostName || selectedServer.name || '';
|
const serverDns = matchedServer?.dns || matchedServer?.hostName || selectedServer.name || '';
|
||||||
const serverId = matchedServer?.id || matchedServer?.ip || selectedServer.id || selectedServer.name || '';
|
const serverId = matchedServer?.id || matchedServer?.ip || selectedServer.id || selectedServer.name || '';
|
||||||
@@ -853,49 +879,68 @@ function FilterManager() {
|
|||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Запрашиваем пинги для gateways выбранного сервера; при переходе на раздел Фильтров обновляем (устаревшие по TTL перезапросятся)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Загружаем базовую AS из настроек
|
if (!selectedServer || !selectedServerGateways?.length) return;
|
||||||
|
if (location.pathname !== '/filters') return;
|
||||||
|
const routerId = getRouterIdForServer(selectedServer);
|
||||||
|
if (!routerId) return;
|
||||||
|
const tasks = selectedServerGateways
|
||||||
|
.filter((gw) => gw.ip)
|
||||||
|
.map((gw) => ({ routerId, gatewayIp: gw.ip }));
|
||||||
|
requestPings(tasks);
|
||||||
|
}, [location.pathname, selectedServer, selectedServerGateways, requestPings, getRouterIdForServer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
const opts = { signal };
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/ui-settings');
|
const res = await api.get('/ui-settings', opts);
|
||||||
const data = res?.data || {};
|
const data = res?.data || {};
|
||||||
const loadedBaseAS = String(data?.baseAS || '65001');
|
const loadedBaseAS = String(data?.baseAS || '65001');
|
||||||
setBaseAS(loadedBaseAS);
|
setBaseAS(loadedBaseAS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// мягко игнорируем, используем значение по умолчанию
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`);
|
const rows = await listCommunities();
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
if (signal.aborted) return;
|
||||||
|
setCommunities(Array.isArray(rows) ? rows : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// мягко игнорируем, автокомплит необязателен
|
if (signal.aborted) return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// Загружаем инвентарь серверов для отображения флагов и IP
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/servers`);
|
const res = await api.get(`/servers`, opts);
|
||||||
setInventoryServers(Array.isArray(res.data) ? res.data : []);
|
setInventoryServers(Array.isArray(res.data) ? res.data : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// мягко игнорируем, дополнительная информация необязательна
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Загружаем gateways из NetworkConfigManager
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/network-config`);
|
const res = await api.get(`/network-config`, opts);
|
||||||
const networkConfig = res.data || {};
|
const networkConfig = res.data || {};
|
||||||
setNetworkConfigGateways(Array.isArray(networkConfig.gateways) ? networkConfig.gateways : []);
|
setNetworkConfigGateways(Array.isArray(networkConfig.gateways) ? networkConfig.gateways : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Если файла нет или ошибка - используем пустой массив (fallback на старую логику)
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
setNetworkConfigGateways([]);
|
setNetworkConfigGateways([]);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filterKey = (f) => {
|
const filterKey = (f) => {
|
||||||
@@ -904,7 +949,9 @@ function FilterManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
const controller = new AbortController();
|
||||||
|
fetchServers(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Загружаем фильтры при выборе сервера
|
// Загружаем фильтры при выборе сервера
|
||||||
@@ -943,7 +990,7 @@ function FilterManager() {
|
|||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (legacyError) {
|
} catch (_legacyError) {
|
||||||
// Фильтров по legacyId тоже нет - это нормально
|
// Фильтров по legacyId тоже нет - это нормально
|
||||||
console.log('No legacy filters found');
|
console.log('No legacy filters found');
|
||||||
}
|
}
|
||||||
@@ -962,11 +1009,12 @@ function FilterManager() {
|
|||||||
// Функции для управления серверами
|
// Функции для управления серверами
|
||||||
// Загружаем jumphost'ы из раздела "Серверы" (/api/servers)
|
// Загружаем jumphost'ы из раздела "Серверы" (/api/servers)
|
||||||
// + загружаем старые server-configs для маппинга legacyId
|
// + загружаем старые server-configs для маппинга legacyId
|
||||||
const fetchServers = async () => {
|
const fetchServers = async (abortSignal) => {
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
try {
|
try {
|
||||||
const [serversRes, legacyRes] = await Promise.all([
|
const [serversRes, legacyRes] = await Promise.all([
|
||||||
api.get(`/servers`),
|
api.get(`/servers`, opts),
|
||||||
api.get(`/server-configs`).catch(() => ({ data: [] })) // Старые конфиги для маппинга
|
api.get(`/server-configs`, opts).catch(() => ({ data: [] })) // Старые конфиги для маппинга
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allServers = Array.isArray(serversRes.data) ? serversRes.data : [];
|
const allServers = Array.isArray(serversRes.data) ? serversRes.data : [];
|
||||||
@@ -1023,6 +1071,7 @@ function FilterManager() {
|
|||||||
|
|
||||||
setServers(jumphosts);
|
setServers(jumphosts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
console.error('Error fetching servers:', error);
|
console.error('Error fetching servers:', error);
|
||||||
setServers([]);
|
setServers([]);
|
||||||
}
|
}
|
||||||
@@ -1116,7 +1165,7 @@ function FilterManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadServerConfig = async (serverId, legacyId = null) => {
|
const _handleLoadServerConfig = async (serverId, legacyId = null) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/server-configs/${serverId}`);
|
const response = await api.get(`/server-configs/${serverId}`);
|
||||||
const config = response.data?.config;
|
const config = response.data?.config;
|
||||||
@@ -1143,7 +1192,7 @@ function FilterManager() {
|
|||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (legacyError) {
|
} catch (_legacyError) {
|
||||||
console.log('No legacy config found');
|
console.log('No legacy config found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1250,7 +1299,7 @@ function FilterManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeployToAllServers = async () => {
|
const _handleDeployToAllServers = async () => {
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
setError('Сначала выберите сервер с фильтрами для развертывания.');
|
setError('Сначала выберите сервер с фильтрами для развертывания.');
|
||||||
return;
|
return;
|
||||||
@@ -1472,7 +1521,7 @@ function FilterManager() {
|
|||||||
setEditServerModalOpen(false);
|
setEditServerModalOpen(false);
|
||||||
setSuccess('Сервер успешно обновлён!');
|
setSuccess('Сервер успешно обновлён!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setEditServerError('Не удалось сохранить изменения.');
|
setEditServerError('Не удалось сохранить изменения.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1527,7 +1576,7 @@ function FilterManager() {
|
|||||||
await navigator.clipboard.writeText(config);
|
await navigator.clipboard.writeText(config);
|
||||||
setSuccess('Конфигурация скопирована в буфер обмена!');
|
setSuccess('Конфигурация скопирована в буфер обмена!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
setError('Не удалось скопировать конфигурацию');
|
setError('Не удалось скопировать конфигурацию');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1541,7 +1590,7 @@ function FilterManager() {
|
|||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
setSuccess('Конфигурация скопирована в буфер обмена!');
|
setSuccess('Конфигурация скопирована в буфер обмена!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
setError('Не удалось скопировать конфигурацию');
|
setError('Не удалось скопировать конфигурацию');
|
||||||
}
|
}
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
@@ -1549,7 +1598,7 @@ function FilterManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Функция для экспорта конфигурации в S3
|
// Функция для экспорта конфигурации в S3
|
||||||
const exportConfigToS3 = async () => {
|
const _exportConfigToS3 = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/filters/export-config`);
|
const response = await api.post(`/filters/export-config`);
|
||||||
@@ -1699,168 +1748,32 @@ function FilterManager() {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-wrapper">
|
<>
|
||||||
<div className="page-header d-print-none">
|
{/* Уведомления */}
|
||||||
<div className="container-xl">
|
{error && (
|
||||||
<div className="row g-2 align-items-center">
|
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||||
<div className="col">
|
<IconAlertTriangle className="me-2" />
|
||||||
<h2 className="page-title">Управление фильтрами frouting</h2>
|
{error}
|
||||||
<div className="page-pretitle">
|
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||||
{selectedServer ? (
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<span className="text-muted">Сервер:</span>
|
|
||||||
<span className="ms-2 text-primary fw-bold">{selectedServer.name}</span>
|
|
||||||
{serverFilters.length > 0 && (
|
|
||||||
<span className="badge bg-blue-lt text-blue ms-2">{serverFilters.length} фильтров</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted">Выберите сервер для работы с фильтрами</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-auto ms-auto d-print-none">
|
|
||||||
<div className="btn-list">
|
|
||||||
{/* Dropdown выбора сервера */}
|
|
||||||
<div className="dropdown">
|
|
||||||
<button
|
|
||||||
className="btn btn-primary dropdown-toggle"
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<IconServer className="icon" />
|
|
||||||
{selectedServer ? selectedServer.name : 'Выбрать сервер'}
|
|
||||||
</button>
|
|
||||||
<div className="dropdown-menu dropdown-menu-end" style={{ minWidth: '300px' }}>
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control form-control-sm"
|
|
||||||
placeholder="Поиск серверов..."
|
|
||||||
value={serverSearch}
|
|
||||||
onChange={(e) => setServerSearch(e.target.value)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="dropdown-divider"></div>
|
|
||||||
{activeServers.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h6 className="dropdown-header">Активные серверы</h6>
|
|
||||||
{activeServers.map(server => (
|
|
||||||
<a
|
|
||||||
key={server.id}
|
|
||||||
className={`dropdown-item ${selectedServer?.id === server.id ? 'active' : ''}`}
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedServer(server);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<IconServer size={16} className="me-2" />
|
|
||||||
<span className="flex-grow-1">{server.name}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{inactiveServers.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="dropdown-divider"></div>
|
|
||||||
<h6 className="dropdown-header">Неактивные серверы</h6>
|
|
||||||
{inactiveServers.map(server => (
|
|
||||||
<a
|
|
||||||
key={server.id}
|
|
||||||
className={`dropdown-item ${selectedServer?.id === server.id ? 'active' : ''}`}
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedServer(server);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center text-muted">
|
|
||||||
<IconServer size={16} className="me-2" />
|
|
||||||
<span className="flex-grow-1">{server.name}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="dropdown-divider"></div>
|
|
||||||
<button
|
|
||||||
className="dropdown-item"
|
|
||||||
onClick={() => setServerListModalOpen(true)}
|
|
||||||
>
|
|
||||||
<IconSettings size={16} className="me-2" />
|
|
||||||
Управление серверами
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedServer && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={handlePreviewConfig}
|
|
||||||
disabled={serverFilters.length === 0}
|
|
||||||
>
|
|
||||||
<IconEye className="icon" />
|
|
||||||
Просмотр
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={copyConfigToClipboard}
|
|
||||||
disabled={serverFilters.length === 0}
|
|
||||||
>
|
|
||||||
<IconCopy className="icon" />
|
|
||||||
Копировать
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-success"
|
|
||||||
onClick={() => handleSaveServerConfig(selectedServer.id)}
|
|
||||||
disabled={loading || serverFilters.length === 0}
|
|
||||||
>
|
|
||||||
<IconDeviceFloppy className="icon" />
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={handleBgpUpdate}
|
|
||||||
disabled={bgpUpdateLoading}
|
|
||||||
title="Запустить /system script run update_bgp_filter на выбранном MikroTik"
|
|
||||||
>
|
|
||||||
<IconRefresh className={`icon ${bgpUpdateLoading ? 'spin' : ''}`} />
|
|
||||||
{bgpUpdateLoading ? 'Запуск...' : 'Обновить BGP фильтры'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="alert alert-success alert-dismissible" role="alert">
|
||||||
|
<IconCheck className="me-2" />
|
||||||
|
{success}
|
||||||
|
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="page-body">
|
<PageHeader
|
||||||
<div className="container-xl">
|
title="Управление фильтрами frouting"
|
||||||
{/* Уведомления */}
|
icon={<IconFilter size={24} />}
|
||||||
{error && (
|
/>
|
||||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
|
||||||
<IconAlertTriangle className="me-2" />
|
|
||||||
{error}
|
|
||||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div className="alert alert-success alert-dismissible" role="alert">
|
|
||||||
<IconCheck className="me-2" />
|
|
||||||
{success}
|
|
||||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Селектор серверов на одной странице (Tabler UI) */}
|
<div className="row">
|
||||||
<div className="card mb-3">
|
<div className="col-12">
|
||||||
|
{/* Селектор серверов на одной странице (Tabler UI) */}
|
||||||
|
<div className="card mb-3">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title d-flex align-items-center gap-2">
|
<h3 className="card-title d-flex align-items-center gap-2">
|
||||||
<IconServer className="icon" />
|
<IconServer className="icon" />
|
||||||
@@ -1941,6 +1854,15 @@ function FilterManager() {
|
|||||||
<IconDeviceFloppy className="icon" />
|
<IconDeviceFloppy className="icon" />
|
||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleBgpUpdate}
|
||||||
|
disabled={!selectedServer || bgpUpdateLoading}
|
||||||
|
title="Запустить /system script run update_bgp_filter на выбранном MikroTik"
|
||||||
|
>
|
||||||
|
<IconRefresh className={`icon ${bgpUpdateLoading ? 'spin' : ''}`} />
|
||||||
|
{bgpUpdateLoading ? 'Запуск...' : 'Обновить BGP фильтры'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2062,9 +1984,9 @@ function FilterManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Секция фильтров выбранного сервера */}
|
{/* Секция фильтров выбранного сервера */}
|
||||||
{selectedServer && (
|
{selectedServer && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="row align-items-center">
|
<div className="row align-items-center">
|
||||||
<div className="col">
|
<div className="col">
|
||||||
@@ -2137,113 +2059,151 @@ function FilterManager() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="alert alert-info mb-3">
|
<div className="alert alert-info mb-3">
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<IconGripVertical size={18} className="me-2" />
|
<IconGripVertical size={18} className="me-2" />
|
||||||
<span>Перетащите строки за иконку <IconGripVertical size={14} className="mx-1" /> чтобы изменить приоритет</span>
|
<span>
|
||||||
</div>
|
Перетащите строки за иконку{' '}
|
||||||
<div className="text-muted small mt-1">
|
<IconGripVertical size={14} className="mx-1" /> чтобы изменить приоритет
|
||||||
<strong>Важно:</strong> В RouterOS правила внизу списка имеют <strong>высший приоритет</strong> (переопределяют верхние)
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-muted small mt-1">
|
||||||
<DndContext
|
<strong>Важно:</strong> В RouterOS правила внизу списка имеют{' '}
|
||||||
sensors={sensors}
|
<strong>высший приоритет</strong> (переопределяют верхние)
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: '40px' }}></th>
|
|
||||||
<th className="w-1">
|
|
||||||
<input
|
|
||||||
className="form-check-input m-0 align-middle"
|
|
||||||
type="checkbox"
|
|
||||||
aria-label="Выбрать все"
|
|
||||||
checked={areAllProcessedSelected}
|
|
||||||
onChange={toggleSelectAllProcessed}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th style={{ width: '60px' }}>#</th>
|
|
||||||
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('community')}>
|
|
||||||
<span className="me-1">COMMUNITY</span>
|
|
||||||
{sortBy === 'community' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
|
||||||
</th>
|
|
||||||
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('gateway')}>
|
|
||||||
<span className="me-1">GATEWAY</span>
|
|
||||||
{sortBy === 'gateway' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
|
||||||
</th>
|
|
||||||
<th style={{ width: '30%' }} className="cursor-pointer" onClick={() => toggleSort('description')}>
|
|
||||||
<span className="me-1">ОПИСАНИЕ</span>
|
|
||||||
{sortBy === 'description' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
|
||||||
</th>
|
|
||||||
<th style={{ width: '120px' }} className="text-end">ДЕЙСТВИЯ</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<SortableContext
|
|
||||||
items={serverFilters.filter(f => f != null).map(f => filterKey(f))}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<tbody>
|
|
||||||
{serverFilters.filter(f => f != null).map((filter, index) => {
|
|
||||||
const key = filterKey(filter);
|
|
||||||
const isSelected = selectedFilterKeys.has(key);
|
|
||||||
// Показываем только если совпадает с поиском
|
|
||||||
if (filterSearch.trim()) {
|
|
||||||
const search = filterSearch.trim().toLowerCase();
|
|
||||||
const c = String(filter.community || '').toLowerCase();
|
|
||||||
const g = String(filter.gateway || '').toLowerCase();
|
|
||||||
const d = String(filter.description || '').toLowerCase();
|
|
||||||
if (!c.includes(search) && !g.includes(search) && !d.includes(search)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<SortableFilterRow
|
|
||||||
key={key}
|
|
||||||
filter={filter}
|
|
||||||
index={index}
|
|
||||||
totalCount={serverFilters.filter(f => f != null).length}
|
|
||||||
filterKey={key}
|
|
||||||
isSelected={isSelected}
|
|
||||||
onToggleSelect={() => toggleSelectFilter(key)}
|
|
||||||
onEdit={() => handleEditFilter(filter, index)}
|
|
||||||
onDelete={() => {
|
|
||||||
setFilterToDelete(filter);
|
|
||||||
setDeleteFilterModalOpen(true);
|
|
||||||
}}
|
|
||||||
communities={communities}
|
|
||||||
baseAS={baseAS}
|
|
||||||
selectedServerGateways={selectedServerGateways}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</SortableContext>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</DndContext>
|
|
||||||
{selectedFilterKeys.size > 0 && (
|
|
||||||
<div className="d-flex justify-content-between align-items-center mt-3">
|
|
||||||
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>
|
|
||||||
<div className="btn-list">
|
|
||||||
<button className="btn btn-outline-danger btn-sm" onClick={deleteSelectedFilters}>
|
|
||||||
<IconTrash className="me-2" /> Удалить выбранные
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => setSelectedFilterKeys(new Set())}>
|
|
||||||
<IconX className="me-2" /> Отменить выбор
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<DndContext
|
||||||
</>
|
sensors={sensors}
|
||||||
)}
|
collisionDetection={closestCenter}
|
||||||
</div>
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '40px' }}></th>
|
||||||
|
<th className="w-1">
|
||||||
|
<input
|
||||||
|
className="form-check-input m-0 align-middle"
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Выбрать все"
|
||||||
|
checked={areAllProcessedSelected}
|
||||||
|
onChange={toggleSelectAllProcessed}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th style={{ width: '60px' }}>#</th>
|
||||||
|
<th
|
||||||
|
style={{ width: '22%' }}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => toggleSort('community')}
|
||||||
|
>
|
||||||
|
<span className="me-1">COMMUNITY</span>
|
||||||
|
{sortBy === 'community' &&
|
||||||
|
(sortDir === 'asc' ? (
|
||||||
|
<IconSortAscending size={16} />
|
||||||
|
) : (
|
||||||
|
<IconSortDescending size={16} />
|
||||||
|
))}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ width: '22%' }}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => toggleSort('gateway')}
|
||||||
|
>
|
||||||
|
<span className="me-1">GATEWAY</span>
|
||||||
|
{sortBy === 'gateway' &&
|
||||||
|
(sortDir === 'asc' ? (
|
||||||
|
<IconSortAscending size={16} />
|
||||||
|
) : (
|
||||||
|
<IconSortDescending size={16} />
|
||||||
|
))}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ width: '30%' }}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => toggleSort('description')}
|
||||||
|
>
|
||||||
|
<span className="me-1">ОПИСАНИЕ</span>
|
||||||
|
{sortBy === 'description' &&
|
||||||
|
(sortDir === 'asc' ? (
|
||||||
|
<IconSortAscending size={16} />
|
||||||
|
) : (
|
||||||
|
<IconSortDescending size={16} />
|
||||||
|
))}
|
||||||
|
</th>
|
||||||
|
<th style={{ width: '120px' }} className="text-end">
|
||||||
|
ДЕЙСТВИЯ
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<SortableContext
|
||||||
|
items={serverFilters.filter((f) => f != null).map((f) => filterKey(f))}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{serverFilters
|
||||||
|
.filter((f) => f != null)
|
||||||
|
.map((filter, index) => {
|
||||||
|
const key = filterKey(filter);
|
||||||
|
const isSelected = selectedFilterKeys.has(key);
|
||||||
|
// Показываем только если совпадает с поиском
|
||||||
|
if (filterSearch.trim()) {
|
||||||
|
const search = filterSearch.trim().toLowerCase();
|
||||||
|
const c = String(filter.community || '').toLowerCase();
|
||||||
|
const g = String(filter.gateway || '').toLowerCase();
|
||||||
|
const d = String(filter.description || '').toLowerCase();
|
||||||
|
if (!c.includes(search) && !g.includes(search) && !d.includes(search)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SortableFilterRow
|
||||||
|
key={key}
|
||||||
|
filter={filter}
|
||||||
|
index={index}
|
||||||
|
totalCount={serverFilters.filter((f) => f != null).length}
|
||||||
|
filterKey={key}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggleSelect={() => toggleSelectFilter(key)}
|
||||||
|
onEdit={() => handleEditFilter(filter, index)}
|
||||||
|
onDelete={() => {
|
||||||
|
setFilterToDelete(filter);
|
||||||
|
setDeleteFilterModalOpen(true);
|
||||||
|
}}
|
||||||
|
communities={communities}
|
||||||
|
baseAS={baseAS}
|
||||||
|
selectedServerGateways={selectedServerGateways}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
{selectedFilterKeys.size > 0 && (
|
||||||
|
<div className="d-flex justify-content-between align-items-center mt-3">
|
||||||
|
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>
|
||||||
|
<div className="btn-list">
|
||||||
|
<button className="btn btn-outline-danger btn-sm" onClick={deleteSelectedFilters}>
|
||||||
|
<IconTrash className="me-2" /> Удалить выбранные
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={() => setSelectedFilterKeys(new Set())}
|
||||||
|
>
|
||||||
|
<IconX className="me-2" /> Отменить выбор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Модальные окна */}
|
{/* Модальные окна */}
|
||||||
<FormModal
|
<FormModal
|
||||||
@@ -2278,6 +2238,8 @@ function FilterManager() {
|
|||||||
value={newFilter.gateway}
|
value={newFilter.gateway}
|
||||||
onChange={(val) => setNewFilter({ ...newFilter, gateway: val })}
|
onChange={(val) => setNewFilter({ ...newFilter, gateway: val })}
|
||||||
gateways={selectedServerGateways}
|
gateways={selectedServerGateways}
|
||||||
|
pingMap={pingMap}
|
||||||
|
routerId={getRouterIdForServer(selectedServer)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2319,6 +2281,8 @@ function FilterManager() {
|
|||||||
value={editingFilter.gateway}
|
value={editingFilter.gateway}
|
||||||
onChange={(val) => setEditingFilter({ ...editingFilter, gateway: val })}
|
onChange={(val) => setEditingFilter({ ...editingFilter, gateway: val })}
|
||||||
gateways={selectedServerGateways}
|
gateways={selectedServerGateways}
|
||||||
|
pingMap={pingMap}
|
||||||
|
routerId={getRouterIdForServer(selectedServer)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2597,7 +2561,7 @@ function FilterManager() {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/server-configs`, { servers: updatedServers });
|
await api.post(`/server-configs`, { servers: updatedServers });
|
||||||
setServers(updatedServers);
|
setServers(updatedServers);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setError('Не удалось обновить статус сервера');
|
setError('Не удалось обновить статус сервера');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -2717,7 +2681,7 @@ function FilterManager() {
|
|||||||
onCancel={() => setConfirmState({ open: false, text: '', onConfirm: null })}
|
onCancel={() => setConfirmState({ open: false, text: '', onConfirm: null })}
|
||||||
onConfirm={() => confirmState.onConfirm?.()}
|
onConfirm={() => confirmState.onConfirm?.()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
|
import EmptyState from './components/EmptyState.jsx';
|
||||||
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||||
|
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
||||||
|
import Pagination from './components/Pagination.jsx';
|
||||||
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
import Modal from './components/Modal.jsx';
|
||||||
|
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||||
|
import {
|
||||||
|
IconShield,
|
||||||
|
IconRefresh,
|
||||||
|
IconSearch,
|
||||||
|
IconCopy,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconList,
|
||||||
|
IconChartBar,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
const DEFAULT_MIN_COUNT_N = 5;
|
||||||
|
|
||||||
|
/** Одиночный IP: без маски или /32 */
|
||||||
|
function isSingleIp(addr) {
|
||||||
|
const s = String(addr || '').trim();
|
||||||
|
if (!s) return false;
|
||||||
|
if (!s.includes('/')) return true;
|
||||||
|
const mask = s.split('/')[1];
|
||||||
|
return mask === '32' || mask === '128';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Базовая подсеть /24 для группировки */
|
||||||
|
function getBase24(addr) {
|
||||||
|
const s = String(addr || '').trim();
|
||||||
|
const ipPart = s.includes('/') ? s.split('/')[0] : s;
|
||||||
|
const parts = ipPart.split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const [a, b, c] = parts.map((x) => parseInt(x, 10));
|
||||||
|
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
|
||||||
|
return `${a}.${b}.${c}.0/24`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Анализ списка ban: группировка одиночных IP по /24, предложения суммаризации если в подсети > N.
|
||||||
|
*/
|
||||||
|
function analyzeBanList(banEntries, minCountN) {
|
||||||
|
const groupBy = new Map();
|
||||||
|
for (const entry of banEntries || []) {
|
||||||
|
if (!entry?.address || !isSingleIp(entry.address)) continue;
|
||||||
|
const base = getBase24(entry.address);
|
||||||
|
if (!base) continue;
|
||||||
|
if (!groupBy.has(base)) groupBy.set(base, { subnet: base, ids: [], entries: [] });
|
||||||
|
const g = groupBy.get(base);
|
||||||
|
g.ids.push(entry.id);
|
||||||
|
g.entries.push(entry);
|
||||||
|
}
|
||||||
|
const suggestions = [];
|
||||||
|
for (const [, g] of groupBy) {
|
||||||
|
if (g.entries.length > minCountN) {
|
||||||
|
suggestions.push({
|
||||||
|
subnet: g.subnet,
|
||||||
|
count: g.entries.length,
|
||||||
|
ids: g.ids,
|
||||||
|
addEntry: { address: g.subnet, list: 'ban', comment: `суммаризация ${g.entries.length} IP` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нормализация записи address-list из RouterOS (поля могут приходить в разном регистре) */
|
||||||
|
function normalizeEntry(entry) {
|
||||||
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
|
const addr = entry.address ?? entry.Address ?? '';
|
||||||
|
const list = entry.list ?? entry.List ?? '';
|
||||||
|
const timeout = entry.timeout ?? entry.Timeout ?? '';
|
||||||
|
const comment = entry.comment ?? entry.Comment ?? '';
|
||||||
|
const id = entry['.id'] ?? entry.id ?? '';
|
||||||
|
const disabled = entry.disabled ?? entry.Disabled ?? '';
|
||||||
|
const dynamic = entry.dynamic ?? entry.Dynamic ?? '';
|
||||||
|
return {
|
||||||
|
id: id || addr || Math.random().toString(36).slice(2),
|
||||||
|
address: String(addr).trim(),
|
||||||
|
list: String(list).trim(),
|
||||||
|
timeout: String(timeout).trim(),
|
||||||
|
comment: String(comment).trim(),
|
||||||
|
disabled: String(disabled).trim(),
|
||||||
|
dynamic: String(dynamic).trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddressListTable({ title, listKey, items, loading, emptyMessage }) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = String(search || '').toLowerCase().trim();
|
||||||
|
if (!term) return items;
|
||||||
|
return items.filter((e) => {
|
||||||
|
const a = (e.address || '').toLowerCase();
|
||||||
|
const c = (e.comment || '').toLowerCase();
|
||||||
|
return a.includes(term) || c.includes(term);
|
||||||
|
});
|
||||||
|
}, [items, search]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const paginated = useMemo(
|
||||||
|
() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
|
[filtered, page]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
window.notify?.success?.('Скопировано в буфер обмена');
|
||||||
|
}).catch(() => {
|
||||||
|
window.notify?.error?.('Не удалось скопировать');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-flat mb-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="row align-items-center w-100 g-2">
|
||||||
|
<div className="col">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<IconList className="icon text-muted" size={18} />
|
||||||
|
<span className="text-muted text-uppercase small">{title}</span>
|
||||||
|
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 mt-2" />
|
||||||
|
<div className="col">
|
||||||
|
<div className="input-icon flex-grow-1" style={{ maxWidth: '320px' }}>
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconSearch size={18} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Поиск по IP или комментарию..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
aria-label="Поиск"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
{loading ? (
|
||||||
|
<TableSkeleton rows={8} cols={4} flat />
|
||||||
|
) : paginated.length === 0 ? (
|
||||||
|
<TableEmpty cols={4} flat>
|
||||||
|
<EmptyState
|
||||||
|
title={items.length === 0 ? emptyMessage : 'Нет совпадений'}
|
||||||
|
description={items.length === 0 ? `Список ${listKey} пуст на роутере.` : 'Измените поисковый запрос.'}
|
||||||
|
/>
|
||||||
|
</TableEmpty>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '40px' }}>#</th>
|
||||||
|
<th>IP-адрес</th>
|
||||||
|
<th>Таймаут</th>
|
||||||
|
<th>Комментарий</th>
|
||||||
|
<th className="text-end" style={{ width: '50px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginated.map((entry, idx) => (
|
||||||
|
<tr key={entry.id || entry.address || idx}>
|
||||||
|
<td className="text-muted small">{(page - 1) * PAGE_SIZE + idx + 1}</td>
|
||||||
|
<td className="font-monospace">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<span>{entry.address || '—'}</span>
|
||||||
|
<Tooltip content="Копировать IP" position="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||||
|
onClick={() => copyToClipboard(entry.address)}
|
||||||
|
aria-label="Копировать"
|
||||||
|
>
|
||||||
|
<IconCopy size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted">{entry.timeout || '—'}</td>
|
||||||
|
<td className="text-muted small">{entry.comment || '—'}</td>
|
||||||
|
<td className="text-end"></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!loading && filtered.length > PAGE_SIZE && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={filtered.length}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirewallPage() {
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [serverId, setServerId] = useState('');
|
||||||
|
const [serverMeta, setServerMeta] = useState(null);
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingServers, setLoadingServers] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [analysisOpen, setAnalysisOpen] = useState(false);
|
||||||
|
const [minCountN, setMinCountN] = useState(DEFAULT_MIN_COUNT_N);
|
||||||
|
const [analysisResult, setAnalysisResult] = useState(null);
|
||||||
|
const [applyConfirmOpen, setApplyConfirmOpen] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [ipInfoMap, setIpInfoMap] = useState({});
|
||||||
|
const [ipInfoLoading, setIpInfoLoading] = useState(false);
|
||||||
|
const [applyConfirmRow, setApplyConfirmRow] = useState(null);
|
||||||
|
const [applyingRow, setApplyingRow] = useState(null);
|
||||||
|
|
||||||
|
const routerServers = useMemo(
|
||||||
|
() =>
|
||||||
|
(servers || []).filter(
|
||||||
|
(s) => String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home'
|
||||||
|
),
|
||||||
|
[servers]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoadingServers(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/servers');
|
||||||
|
const list = Array.isArray(res.data) ? res.data : [];
|
||||||
|
if (!cancelled) setServers(list);
|
||||||
|
} catch (_e) {
|
||||||
|
if (!cancelled) setError('Не удалось загрузить список серверов.');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingServers(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAddressLists = useCallback(async () => {
|
||||||
|
if (!serverId) {
|
||||||
|
setData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await api.get('/mikrotik/address-lists', { params: { serverId } });
|
||||||
|
const payload = res?.data ?? {};
|
||||||
|
setData({
|
||||||
|
serverLabel: payload.serverLabel || serverId,
|
||||||
|
ban: (payload.ban || []).map(normalizeEntry).filter(Boolean),
|
||||||
|
ban_counter: (payload.ban_counter || []).map(normalizeEntry).filter(Boolean),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Firewall fetch:', e);
|
||||||
|
setData(null);
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить address-list с роутера.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [serverId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverId) fetchAddressLists();
|
||||||
|
else setData(null);
|
||||||
|
}, [serverId, fetchAddressLists]);
|
||||||
|
|
||||||
|
const runAnalysis = () => {
|
||||||
|
if (!data?.ban?.length) return;
|
||||||
|
const n = Math.max(2, parseInt(minCountN, 10) || DEFAULT_MIN_COUNT_N);
|
||||||
|
setMinCountN(n);
|
||||||
|
const result = analyzeBanList(data.ban, n);
|
||||||
|
setAnalysisResult(result);
|
||||||
|
setIpInfoMap({});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Подгрузка AS/орг с ipinfo.io для подсетей из анализа (лимит 30, с задержкой между запросами)
|
||||||
|
useEffect(() => {
|
||||||
|
const suggestions = analysisResult?.suggestions;
|
||||||
|
if (!suggestions?.length) return;
|
||||||
|
const maxFetch = 30;
|
||||||
|
const slice = suggestions.slice(0, maxFetch);
|
||||||
|
let cancelled = false;
|
||||||
|
setIpInfoLoading(true);
|
||||||
|
(async () => {
|
||||||
|
for (let i = 0; i < slice.length && !cancelled; i++) {
|
||||||
|
const s = slice[i];
|
||||||
|
const firstIp = s.subnet.split('/')[0];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://ipinfo.io/${firstIp}/json`, { method: 'GET' });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const info = await res.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setIpInfoMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[s.subnet]: { org: info.org, country: info.country, city: info.city },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
|
||||||
|
}
|
||||||
|
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
|
||||||
|
}
|
||||||
|
if (!cancelled) setIpInfoLoading(false);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [analysisResult?.suggestions]);
|
||||||
|
|
||||||
|
const applyPayload = applyConfirmRow
|
||||||
|
? { removeIds: applyConfirmRow.ids, addEntries: [applyConfirmRow.addEntry] }
|
||||||
|
: analysisResult?.suggestions?.length
|
||||||
|
? {
|
||||||
|
removeIds: analysisResult.suggestions.flatMap((s) => s.ids),
|
||||||
|
addEntries: analysisResult.suggestions.map((s) => s.addEntry),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleApplySummary = async () => {
|
||||||
|
if (!serverId || !applyPayload) return;
|
||||||
|
const isRow = !!applyConfirmRow;
|
||||||
|
if (isRow) setApplyingRow(applyConfirmRow.subnet);
|
||||||
|
else setApplying(true);
|
||||||
|
try {
|
||||||
|
await api.post('/mikrotik/address-lists/apply-summary', {
|
||||||
|
serverId,
|
||||||
|
removeIds: applyPayload.removeIds,
|
||||||
|
addEntries: applyPayload.addEntries,
|
||||||
|
});
|
||||||
|
const removed = applyPayload.removeIds.length;
|
||||||
|
const added = applyPayload.addEntries.length;
|
||||||
|
window.notify?.success?.(`Удалено ${removed} записей, добавлено ${added} подсетей.`);
|
||||||
|
setApplyConfirmOpen(false);
|
||||||
|
setApplyConfirmRow(null);
|
||||||
|
if (isRow && analysisResult?.suggestions) {
|
||||||
|
setAnalysisResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
suggestions: prev.suggestions.filter((s) => s.subnet !== applyConfirmRow.subnet),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setAnalysisOpen(false);
|
||||||
|
setAnalysisResult(null);
|
||||||
|
}
|
||||||
|
await fetchAddressLists();
|
||||||
|
} catch (e) {
|
||||||
|
window.notify?.error?.(e?.response?.data?.message || e?.message || 'Ошибка применения');
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
setApplyingRow(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentServerLabel = serverMeta?.dns || serverMeta?.ip || serverId || 'Не выбран';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{error && (
|
||||||
|
<ErrorAlert message={error} onClose={() => setError('')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Firewall"
|
||||||
|
icon={<IconShield size={24} />}
|
||||||
|
meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`}
|
||||||
|
actions={
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setAnalysisOpen(true);
|
||||||
|
setAnalysisResult(null);
|
||||||
|
}}
|
||||||
|
disabled={!serverId || !data?.ban?.length || loading}
|
||||||
|
title="Анализ списка ban и суммаризация по /24"
|
||||||
|
>
|
||||||
|
<IconChartBar className="me-1" size={18} />
|
||||||
|
Анализ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={fetchAddressLists}
|
||||||
|
disabled={!serverId || loading}
|
||||||
|
>
|
||||||
|
<IconRefresh className={loading ? 'icon spin me-1' : 'me-1'} size={18} />
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="card card-flat mb-4">
|
||||||
|
<div className="card-header py-3">
|
||||||
|
<h3 className="card-title mb-0">Сервер (MikroTik)</h3>
|
||||||
|
<p className="text-muted small mb-0 mt-1">
|
||||||
|
Выберите jumphost или home-роутер для просмотра списков <strong>ban</strong> и <strong>ban_counter</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{loadingServers ? (
|
||||||
|
<div className="text-muted">Загрузка списка серверов...</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxWidth: '420px' }}>
|
||||||
|
<ServerAutocompleteInput
|
||||||
|
value={serverId}
|
||||||
|
onChange={setServerId}
|
||||||
|
onSelectMeta={setServerMeta}
|
||||||
|
servers={routerServers}
|
||||||
|
placeholder="Выберите сервер..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!serverId && (
|
||||||
|
<div className="card card-flat">
|
||||||
|
<div className="card-body text-center py-5">
|
||||||
|
<IconAlertCircle className="icon text-muted mb-2" size={48} />
|
||||||
|
<p className="text-muted mb-0">Выберите сервер выше, чтобы загрузить address-list (ban и ban_counter).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serverId && data && (
|
||||||
|
<>
|
||||||
|
<AddressListTable
|
||||||
|
title="Список ban"
|
||||||
|
listKey="ban"
|
||||||
|
items={data.ban}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="Список ban пуст"
|
||||||
|
/>
|
||||||
|
<AddressListTable
|
||||||
|
title="Список ban_counter"
|
||||||
|
listKey="ban_counter"
|
||||||
|
items={data.ban_counter}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="Список ban_counter пуст"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serverId && loading && !data && (
|
||||||
|
<div className="card card-flat">
|
||||||
|
<div className="card-body py-5 text-center text-muted">
|
||||||
|
<IconRefresh className="icon spin me-2" size={20} />
|
||||||
|
Загрузка списков с роутера...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
show={analysisOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setAnalysisOpen(false);
|
||||||
|
setAnalysisResult(null);
|
||||||
|
setApplyConfirmOpen(false);
|
||||||
|
setApplyConfirmRow(null);
|
||||||
|
setIpInfoMap({});
|
||||||
|
}}
|
||||||
|
title="Анализ списка ban"
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Суммаризировать подсети /24, в которых одиночных IP больше чем</label>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
min={2}
|
||||||
|
max={100}
|
||||||
|
value={minCountN}
|
||||||
|
onChange={(e) => setMinCountN(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted small">(по умолчанию {DEFAULT_MIN_COUNT_N})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={runAnalysis}>
|
||||||
|
<IconChartBar className="me-1" size={18} />
|
||||||
|
Анализировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{analysisResult && (
|
||||||
|
<>
|
||||||
|
{analysisResult.suggestions.length === 0 ? (
|
||||||
|
<p className="text-muted mb-0">Нет подсетей для суммаризации (в каждой /24 не больше {minCountN} одиночных IP).</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">
|
||||||
|
Найдено <strong>{analysisResult.suggestions.length}</strong> подсетей /24 для замены: удалить одиночные IP и добавить одну запись на подсеть.
|
||||||
|
{ipInfoLoading && (
|
||||||
|
<span className="ms-2 text-muted small">(загрузка AS/хостер с ipinfo.io…)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-vcenter mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Подсеть</th>
|
||||||
|
<th className="text-end">Кол-во IP</th>
|
||||||
|
<th>AS / Хостер</th>
|
||||||
|
<th>Ссылка</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th className="text-end" style={{ width: '120px' }}>Применить</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analysisResult.suggestions.map((s) => {
|
||||||
|
const firstIp = s.subnet.split('/')[0];
|
||||||
|
const info = ipInfoMap[s.subnet];
|
||||||
|
const rowApplying = applyingRow === s.subnet;
|
||||||
|
return (
|
||||||
|
<tr key={s.subnet}>
|
||||||
|
<td className="font-monospace">{s.subnet}</td>
|
||||||
|
<td className="text-end">{s.count}</td>
|
||||||
|
<td className="small">
|
||||||
|
{info === undefined
|
||||||
|
? (ipInfoLoading ? '…' : '—')
|
||||||
|
: info
|
||||||
|
? (info.org || info.country ? [info.org, info.country].filter(Boolean).join(' · ') : '—')
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href={`https://ipinfo.io/${firstIp}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary"
|
||||||
|
>
|
||||||
|
ipinfo.io
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted small">Удалить {s.count} записей → добавить 1</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-warning"
|
||||||
|
disabled={rowApplying || applying}
|
||||||
|
onClick={() => {
|
||||||
|
setApplyConfirmRow(s);
|
||||||
|
setApplyConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowApplying ? (
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={() => {
|
||||||
|
setApplyConfirmRow(null);
|
||||||
|
setApplyConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Применить всё на роутере
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={applyConfirmOpen}
|
||||||
|
title={applyConfirmRow ? `Применить для ${applyConfirmRow.subnet}?` : 'Применить суммаризацию на роутере?'}
|
||||||
|
message={
|
||||||
|
applyConfirmRow
|
||||||
|
? `Удалить ${applyConfirmRow.count} записей и добавить 1 запись (подсеть ${applyConfirmRow.subnet}). Продолжить?`
|
||||||
|
: applyPayload
|
||||||
|
? `Будет удалено ${applyPayload.removeIds.length} записей и добавлено ${applyPayload.addEntries.length} записей (подсети /24). Продолжить?`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmText="Применить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
destructive
|
||||||
|
size="md"
|
||||||
|
loading={applying || !!applyingRow}
|
||||||
|
onConfirm={handleApplySummary}
|
||||||
|
onCancel={() => {
|
||||||
|
setApplyConfirmOpen(false);
|
||||||
|
setApplyConfirmRow(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
-10
@@ -5,7 +5,6 @@ import {
|
|||||||
MiniMap,
|
MiniMap,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
addEdge,
|
|
||||||
MarkerType,
|
MarkerType,
|
||||||
Handle,
|
Handle,
|
||||||
Position,
|
Position,
|
||||||
@@ -14,7 +13,10 @@ import '@xyflow/react/dist/style.css';
|
|||||||
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react';
|
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
|
||||||
function GraphView({ servers, connections, onCreateConnection }) {
|
/**
|
||||||
|
* @param {Object} [pingMap] - опционально: ключ "fromIp:toIp" -> число (мс). Пинг между серверами для подписи на рёбрах.
|
||||||
|
*/
|
||||||
|
function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
||||||
const flowRef = useRef(null);
|
const flowRef = useRef(null);
|
||||||
const instanceRef = useRef(null);
|
const instanceRef = useRef(null);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
@@ -144,17 +146,32 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
}, [servers, connections]);
|
}, [servers, connections]);
|
||||||
|
|
||||||
const initialEdgesData = useMemo(() => {
|
const initialEdgesData = useMemo(() => {
|
||||||
|
const getPingLabel = (fromIp, toIp) => {
|
||||||
|
if (!pingMap || typeof pingMap !== 'object') return '';
|
||||||
|
const fwd = pingMap[`${fromIp}:${toIp}`];
|
||||||
|
const rev = pingMap[`${toIp}:${fromIp}`];
|
||||||
|
const parts = [];
|
||||||
|
if (typeof fwd === 'number') parts.push(`→ ${fwd} ms`);
|
||||||
|
if (typeof rev === 'number') parts.push(`← ${rev} ms`);
|
||||||
|
if (parts.length === 0) return '';
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
return (connections || []).map((c, idx) => {
|
return (connections || []).map((c, idx) => {
|
||||||
const style = getTunnelStyle(c.tunnelType);
|
const style = getTunnelStyle(c.tunnelType);
|
||||||
const baseLabel = c.tunnelType || 'TUNNEL';
|
const baseLabel = c.tunnelType || 'TUNNEL';
|
||||||
const ipLabel = c.ipA && c.ipB ? `${c.ipA} ⇄ ${c.ipB}` : '';
|
const ipLabel = c.ipA && c.ipB ? `${c.ipA} ⇄ ${c.ipB}` : '';
|
||||||
|
const pingLabel = getPingLabel(String(c.from), String(c.to));
|
||||||
|
const labelParts = [baseLabel];
|
||||||
|
if (ipLabel) labelParts.push(ipLabel);
|
||||||
|
if (pingLabel) labelParts.push(pingLabel);
|
||||||
|
const fullLabel = labelParts.join(' · ');
|
||||||
const edgeId = `${c.from}-${c.to}-${idx}`;
|
const edgeId = `${c.from}-${c.to}-${idx}`;
|
||||||
const isHighlighted = highlightedEdges.has(edgeId);
|
const isHighlighted = highlightedEdges.has(edgeId);
|
||||||
return {
|
return {
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: String(c.from),
|
source: String(c.from),
|
||||||
target: String(c.to),
|
target: String(c.to),
|
||||||
label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel,
|
label: fullLabel,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
style: {
|
style: {
|
||||||
stroke: style.color,
|
stroke: style.color,
|
||||||
@@ -176,7 +193,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
animated: false,
|
animated: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId]);
|
}, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId, pingMap]);
|
||||||
|
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData);
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData);
|
||||||
|
|
||||||
@@ -206,7 +223,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
});
|
});
|
||||||
setSelectedNodeId(nodeId);
|
setSelectedNodeId(nodeId);
|
||||||
setTimeout(() => setSelectedNodeId(null), 2000);
|
setTimeout(() => setSelectedNodeId(null), 2000);
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
|
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
|
||||||
@@ -214,7 +231,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
setNodes((current) => {
|
setNodes((current) => {
|
||||||
return current.map((n, i) => ({
|
return current.map((n, i) => ({
|
||||||
@@ -226,7 +243,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
if (instanceRef.current) {
|
if (instanceRef.current) {
|
||||||
try {
|
try {
|
||||||
instanceRef.current.fitView({ padding: 0.2 });
|
instanceRef.current.fitView({ padding: 0.2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [setNodes, computeGridPosition]);
|
}, [setNodes, computeGridPosition]);
|
||||||
@@ -360,7 +377,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -371,7 +388,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, 50);
|
}, 50);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [servers, connections]);
|
}, [servers, connections]);
|
||||||
@@ -385,7 +402,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
instanceRef.current.fitView({ padding: 0.2 });
|
instanceRef.current.fitView({ padding: 0.2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, 60);
|
}, 60);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+422
-636
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,506 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import { useNotify } from './components/NotifyProvider.jsx';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import TableSkeleton from './components/TableSkeleton.jsx';
|
||||||
|
import { countryToFlag } from './utils/serverUtils.js';
|
||||||
|
import {
|
||||||
|
IconTopologyRing,
|
||||||
|
IconNetwork,
|
||||||
|
IconSearch,
|
||||||
|
IconRefresh,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export default function InterfaceSpeedTest() {
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [networkConfig, setNetworkConfig] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [serverSearch, setServerSearch] = useState('');
|
||||||
|
|
||||||
|
const [serverId, setServerId] = useState('');
|
||||||
|
const [selectedInterfaceName, setSelectedInterfaceName] = useState('');
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [speedResult, setSpeedResult] = useState(null);
|
||||||
|
const [speedSettings, setSpeedSettings] = useState({
|
||||||
|
protocol: 'tcp',
|
||||||
|
durationSeconds: 10,
|
||||||
|
cacheMinutes: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [serversRes, netRes, uiRes] = await Promise.all([
|
||||||
|
api.get('/servers', { signal }),
|
||||||
|
api.get('/network-config', { signal }).catch(() => ({ data: null })),
|
||||||
|
api.get('/ui-settings', { signal }).catch(() => ({ data: {} })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setServers(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
||||||
|
setNetworkConfig(
|
||||||
|
netRes?.data && typeof netRes.data === 'object'
|
||||||
|
? netRes.data
|
||||||
|
: { gateways: [], tunnelInterfaces: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ui = uiRes?.data || {};
|
||||||
|
const proto =
|
||||||
|
String(ui.interfaceSpeedTestProtocol || 'tcp').toLowerCase() ===
|
||||||
|
'udp'
|
||||||
|
? 'udp'
|
||||||
|
: 'tcp';
|
||||||
|
const duration = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(
|
||||||
|
600,
|
||||||
|
parseInt(ui.interfaceSpeedTestDurationSeconds, 10) || 5
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const cacheMinutes = Math.max(
|
||||||
|
0,
|
||||||
|
parseInt(ui.interfaceSpeedTestCacheMinutes, 10) || 0
|
||||||
|
);
|
||||||
|
setSpeedSettings({
|
||||||
|
protocol: proto,
|
||||||
|
durationSeconds: duration,
|
||||||
|
cacheMinutes,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('[InterfaceSpeedTest] load failed', e);
|
||||||
|
notify.error('Не удалось загрузить данные для замера скорости');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
const _gateways = useMemo(
|
||||||
|
() =>
|
||||||
|
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
|
||||||
|
? networkConfig.gateways
|
||||||
|
: [],
|
||||||
|
[networkConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tunnelInterfaces = useMemo(
|
||||||
|
() =>
|
||||||
|
networkConfig?.tunnelInterfaces &&
|
||||||
|
Array.isArray(networkConfig.tunnelInterfaces)
|
||||||
|
? networkConfig.tunnelInterfaces
|
||||||
|
: [],
|
||||||
|
[networkConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jumphostServers = useMemo(
|
||||||
|
() =>
|
||||||
|
(servers || []).filter((s) =>
|
||||||
|
['jumphost', 'home'].includes(
|
||||||
|
String(s.type || '').toLowerCase()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[servers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
|
||||||
|
|
||||||
|
const jumphostInputServers = useMemo(
|
||||||
|
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
|
||||||
|
[jumphostServers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleJumphostServers = useMemo(() => {
|
||||||
|
if (!serverSearch.trim()) return jumphostInputServers;
|
||||||
|
const q = serverSearch.trim().toLowerCase();
|
||||||
|
return jumphostInputServers.filter((s) => {
|
||||||
|
const fields = [
|
||||||
|
s.id,
|
||||||
|
s.ip,
|
||||||
|
s.dns,
|
||||||
|
s.extIp,
|
||||||
|
s.internalIp,
|
||||||
|
s.country,
|
||||||
|
s.provider,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
return fields.includes(q);
|
||||||
|
});
|
||||||
|
}, [jumphostInputServers, serverSearch]);
|
||||||
|
|
||||||
|
const currentServer = useMemo(
|
||||||
|
() => jumphostServers.find((s) => makeServerId(s) === serverId) || null,
|
||||||
|
[jumphostServers, serverId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const interfacesForServer = useMemo(() => {
|
||||||
|
if (!serverId || !tunnelInterfaces.length) return [];
|
||||||
|
const ids = new Set(
|
||||||
|
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(String)
|
||||||
|
);
|
||||||
|
return tunnelInterfaces.filter(
|
||||||
|
(i) =>
|
||||||
|
i &&
|
||||||
|
i.name &&
|
||||||
|
(ids.has(String(i.serverId)) || ids.has(String(i.serverId2)))
|
||||||
|
);
|
||||||
|
}, [tunnelInterfaces, serverId, currentServer]);
|
||||||
|
|
||||||
|
const currentServerLabel =
|
||||||
|
currentServer?.dns ||
|
||||||
|
currentServer?.ip ||
|
||||||
|
makeServerId(currentServer || {}) ||
|
||||||
|
'Не выбран';
|
||||||
|
|
||||||
|
const isInitialLoading = loading && servers.length === 0;
|
||||||
|
|
||||||
|
const formatMbps = (bps) => {
|
||||||
|
if (bps == null || Number.isNaN(bps)) return '—';
|
||||||
|
const mbps = bps / 1_000_000;
|
||||||
|
if (!Number.isFinite(mbps)) return '—';
|
||||||
|
return `${mbps.toFixed(2)} Мбит/с`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Запросить только кеш (без замера). При выборе сервера/интерфейса показываем кеш, если есть. */
|
||||||
|
const fetchCachedResult = useCallback(async () => {
|
||||||
|
if (!serverId || !selectedInterfaceName) {
|
||||||
|
setSpeedResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.post('/mikrotik/speed-test', {
|
||||||
|
serverId,
|
||||||
|
interfaceName: selectedInterfaceName,
|
||||||
|
cacheOnly: true,
|
||||||
|
});
|
||||||
|
const data = res?.data || {};
|
||||||
|
if (data.ok !== false && !data.notInCache && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
|
||||||
|
setSpeedResult(data);
|
||||||
|
} else {
|
||||||
|
setSpeedResult(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSpeedResult(null);
|
||||||
|
}
|
||||||
|
}, [serverId, selectedInterfaceName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCachedResult();
|
||||||
|
}, [fetchCachedResult]);
|
||||||
|
|
||||||
|
const handleRunTest = async () => {
|
||||||
|
if (!serverId) {
|
||||||
|
notify.error('Выберите сервер (jumphost/home) для замера скорости');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedInterfaceName) {
|
||||||
|
notify.error('Выберите туннельный интерфейс для замера скорости');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
setSpeedResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
serverId,
|
||||||
|
interfaceName: selectedInterfaceName,
|
||||||
|
durationSeconds: speedSettings.durationSeconds,
|
||||||
|
forceRefresh: true,
|
||||||
|
};
|
||||||
|
const res = await api.post('/mikrotik/speed-test', body, {
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
const data = res?.data || {};
|
||||||
|
if (data.ok === false) {
|
||||||
|
notify.error(
|
||||||
|
data.error || 'Замер скорости по интерфейсу завершился с ошибкой'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSpeedResult(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[InterfaceSpeedTest] test failed', e);
|
||||||
|
notify.error(
|
||||||
|
e?.response?.data?.message || e?.message || 'Ошибка замера скорости'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setServerId('');
|
||||||
|
setSelectedInterfaceName('');
|
||||||
|
setSpeedResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Скорость интерфейсов MikroTik"
|
||||||
|
icon={<IconTopologyRing size={24} />}
|
||||||
|
meta={`Текущий сервер: ${currentServerLabel}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-md-5">
|
||||||
|
<div className="card mb-2">
|
||||||
|
<div className="card-header py-2 d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="card-title mb-0">Серверы (jumphost / home)</h3>
|
||||||
|
<p className="text-muted small mb-0 mt-0">
|
||||||
|
Выберите роутер с настроенным MikroTik API
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="input-icon input-icon-sm" style={{ minWidth: 200 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholder="Поиск серверов..."
|
||||||
|
value={serverSearch}
|
||||||
|
onChange={(e) => setServerSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconSearch size={14} className="text-muted" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body py-2">
|
||||||
|
{isInitialLoading ? (
|
||||||
|
<TableSkeleton rows={2} columns={2} />
|
||||||
|
) : jumphostServers.length === 0 ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
Нет серверов типа <code>jumphost</code> или <code>home</code>.
|
||||||
|
Добавьте их в разделе «Серверы», чтобы использовать замер
|
||||||
|
скорости.
|
||||||
|
</div>
|
||||||
|
) : visibleJumphostServers.length === 0 ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
По вашему запросу серверы не найдены. Попробуйте изменить фильтр
|
||||||
|
поиска.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row row-cards g-1">
|
||||||
|
{visibleJumphostServers.map((s) => {
|
||||||
|
const id = s.id;
|
||||||
|
const isActive = id === serverId;
|
||||||
|
const flag = s.country ? countryToFlag(s.country) : '';
|
||||||
|
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="col-12 col-sm-6 col-md-6"
|
||||||
|
key={id}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`card card-sm h-100 cursor-pointer ${
|
||||||
|
isActive ? 'border-primary card-hover' : 'card-hover'
|
||||||
|
}`}
|
||||||
|
onClick={() => setServerId(id)}
|
||||||
|
>
|
||||||
|
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="avatar avatar-sm bg-primary-lt"
|
||||||
|
title={s.country || ''}
|
||||||
|
>
|
||||||
|
{flag || s.ip?.slice(0, 2) || '?'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="fw-semibold text-truncate small"
|
||||||
|
title={s.dns || s.ip || id}
|
||||||
|
>
|
||||||
|
{s.dns || s.ip || id}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-muted small text-truncate"
|
||||||
|
title={line}
|
||||||
|
>
|
||||||
|
{line ||
|
||||||
|
[s.country, s.provider]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ') ||
|
||||||
|
'—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||||
|
<span className="badge bg-primary-lt text-primary">
|
||||||
|
Сервер: {currentServerLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header py-2">
|
||||||
|
<h3 className="card-title mb-0">Интерфейсы туннелей</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body py-2">
|
||||||
|
{loading && !networkConfig ? (
|
||||||
|
<div className="text-muted small">Загрузка сетевых настроек…</div>
|
||||||
|
) : !serverId ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
Выберите сервер слева, затем интерфейс для замера скорости.
|
||||||
|
</div>
|
||||||
|
) : interfacesForServer.length === 0 ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
Для выбранного сервера нет туннельных интерфейсов в
|
||||||
|
<code> /network-config</code>. Добавьте интерфейсы в разделе
|
||||||
|
«Сетевые настройки».
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={selectedInterfaceName}
|
||||||
|
onChange={(e) => setSelectedInterfaceName(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Не выбран</option>
|
||||||
|
{interfacesForServer.map((iface) => (
|
||||||
|
<option key={iface.name} value={iface.name}>
|
||||||
|
{iface.name} ({iface.localIp} ⇄ {iface.remoteIp})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small">
|
||||||
|
Для сопоставления используется имя интерфейса из
|
||||||
|
<code> /network-config.tunnelInterfaces</code>. В RouterOS
|
||||||
|
имя должно совпадать.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-7">
|
||||||
|
<div className="card mb-2">
|
||||||
|
<div className="card-header py-2 d-flex align-items-center justify-content-between">
|
||||||
|
<h3 className="card-title mb-0">Параметры замера</h3>
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} className="me-1" />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleRunTest}
|
||||||
|
disabled={running || !serverId || !selectedInterfaceName}
|
||||||
|
>
|
||||||
|
{running ? 'Замер…' : 'Запустить замер'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body py-2">
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label small mb-0">Протокол</label>
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
<IconNetwork size={16} className="text-muted" />
|
||||||
|
<span className="small text-uppercase">
|
||||||
|
{speedSettings.protocol === 'udp' ? 'UDP' : 'TCP'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small">
|
||||||
|
Значение берётся из настроек интерфейса.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label small mb-0">
|
||||||
|
Время замера (сек)
|
||||||
|
</label>
|
||||||
|
<div className="fw-semibold">
|
||||||
|
{speedSettings.durationSeconds}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label small mb-0">
|
||||||
|
Кеш результата (мин)
|
||||||
|
</label>
|
||||||
|
<div className="fw-semibold">
|
||||||
|
{speedSettings.cacheMinutes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header py-2">
|
||||||
|
<h3 className="card-title mb-0">
|
||||||
|
Скорость по интерфейсу MikroTik
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{!speedResult ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
Выберите сервер и интерфейс, затем запустите замер. Средние
|
||||||
|
скорости приёма и передачи появятся здесь.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<div className="card bg-blue-lt">
|
||||||
|
<div className="card-body py-2">
|
||||||
|
<div className="text-muted small mb-1">Интерфейс</div>
|
||||||
|
<div className="fw-semibold">
|
||||||
|
{speedResult.interfaceName || selectedInterfaceName}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small mt-1">
|
||||||
|
Замер за {speedResult.durationSeconds} сек
|
||||||
|
{speedResult.cached ? ' (из кеша)' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<div className="card bg-azure-lt">
|
||||||
|
<div className="card-body py-2">
|
||||||
|
<div className="text-muted small mb-1">
|
||||||
|
TCP скорость
|
||||||
|
</div>
|
||||||
|
<div className="fw-bold">
|
||||||
|
Download: {formatMbps(speedResult.tcpDownloadBps)}
|
||||||
|
</div>
|
||||||
|
<div className="fw-bold mt-1">
|
||||||
|
Upload: {formatMbps(speedResult.tcpUploadBps)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
|
import * as Diff from 'diff';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import { useNotify } from './components/NotifyProvider.jsx';
|
import { useNotify } from './components/NotifyProvider.jsx';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
@@ -6,6 +7,7 @@ import TableSkeleton from './components/TableSkeleton.jsx';
|
|||||||
import EmptyState from './components/EmptyState.jsx';
|
import EmptyState from './components/EmptyState.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
import DataTable from './components/DataTable.jsx';
|
import DataTable from './components/DataTable.jsx';
|
||||||
|
import { LargeModal, FullscreenModal } from './components/Modal.jsx';
|
||||||
import { countryToFlag } from './utils/serverUtils.js';
|
import { countryToFlag } from './utils/serverUtils.js';
|
||||||
import {
|
import {
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
@@ -17,6 +19,85 @@ import {
|
|||||||
IconSearch,
|
IconSearch,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит две колонки строк для side-by-side diff (как git diff).
|
||||||
|
* leftLines/rightLines — массивы { text, type: 'unchanged'|'removed'|'added'|'empty' }.
|
||||||
|
*/
|
||||||
|
function getDiffLines(oldStr, newStr) {
|
||||||
|
const left = [];
|
||||||
|
const right = [];
|
||||||
|
const changes = Diff.diffLines(oldStr || '', newStr || '');
|
||||||
|
for (const part of changes) {
|
||||||
|
const lines = (part.value || '').split('\n');
|
||||||
|
if (part.value && part.value.endsWith('\n') && lines.length > 0) lines.pop();
|
||||||
|
for (const line of lines) {
|
||||||
|
if (part.added) {
|
||||||
|
left.push({ text: '', type: 'empty' });
|
||||||
|
right.push({ text: line, type: 'added' });
|
||||||
|
} else if (part.removed) {
|
||||||
|
left.push({ text: line, type: 'removed' });
|
||||||
|
right.push({ text: '', type: 'empty' });
|
||||||
|
} else {
|
||||||
|
left.push({ text: line, type: 'unchanged' });
|
||||||
|
right.push({ text: line, type: 'unchanged' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { left, right };
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffPanelStyle = {
|
||||||
|
maxHeight: 360,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'ui-monospace, monospace',
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: 'var(--tblr-code-bg, #0f172a)', // как тёмный code-блок Tabler
|
||||||
|
border: '1px solid rgba(15, 23, 42, 0.9)',
|
||||||
|
};
|
||||||
|
const diffLineStyle = {
|
||||||
|
display: 'block',
|
||||||
|
minHeight: '1.25em',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
padding: '0 4px',
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--tblr-code-color, #e5e7eb)',
|
||||||
|
};
|
||||||
|
const diffRemovedBg = {
|
||||||
|
background: 'rgba(248, 81, 73, 0.2)',
|
||||||
|
borderLeft: '3px solid rgba(248, 81, 73, 0.9)',
|
||||||
|
};
|
||||||
|
const diffAddedBg = {
|
||||||
|
background: 'rgba(63, 185, 80, 0.2)',
|
||||||
|
borderLeft: '3px solid rgba(63, 185, 80, 0.9)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function DiffPanel({ lines, side, innerRef, onScroll }) {
|
||||||
|
return (
|
||||||
|
<pre style={diffPanelStyle} ref={innerRef} onScroll={onScroll}>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<span style={diffLineStyle}># пусто</span>
|
||||||
|
) : (
|
||||||
|
lines.map((item, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
...diffLineStyle,
|
||||||
|
...(item.type === 'removed' && side === 'left' ? diffRemovedBg : null),
|
||||||
|
...(item.type === 'added' && side === 'right' ? diffAddedBg : null),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.type === 'empty' ? '\u00A0' : item.text || ''}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
try {
|
try {
|
||||||
@@ -57,8 +138,13 @@ function MikrotikBackupsManager() {
|
|||||||
const [viewConfigKey, setViewConfigKey] = useState(null);
|
const [viewConfigKey, setViewConfigKey] = useState(null);
|
||||||
const [viewConfigText, setViewConfigText] = useState('');
|
const [viewConfigText, setViewConfigText] = useState('');
|
||||||
const [viewLoading, setViewLoading] = useState(false);
|
const [viewLoading, setViewLoading] = useState(false);
|
||||||
|
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||||||
const [diffResult, setDiffResult] = useState(null);
|
const [diffResult, setDiffResult] = useState(null);
|
||||||
const [diffLoading, setDiffLoading] = useState(false);
|
const [diffLoading, setDiffLoading] = useState(false);
|
||||||
|
const [diffModalOpen, setDiffModalOpen] = useState(false);
|
||||||
|
const leftDiffRef = useRef(null);
|
||||||
|
const rightDiffRef = useRef(null);
|
||||||
|
const isSyncingScrollRef = useRef(false);
|
||||||
|
|
||||||
// UI-состояние выбора сервера для просмотра бэкапов (в стиле /filters)
|
// UI-состояние выбора сервера для просмотра бэкапов (в стиле /filters)
|
||||||
const [serverSearch, setServerSearch] = useState('');
|
const [serverSearch, setServerSearch] = useState('');
|
||||||
@@ -70,27 +156,87 @@ function MikrotikBackupsManager() {
|
|||||||
const [backupPage, setBackupPage] = useState(1);
|
const [backupPage, setBackupPage] = useState(1);
|
||||||
const backupPageSize = 10;
|
const backupPageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
|
||||||
fetchServers();
|
|
||||||
fetchUiSettings();
|
const fetchServers = useCallback(async (abortSignal) => {
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
setLoadingServers(true);
|
||||||
|
const res = await api.get('/servers', opts);
|
||||||
|
const list = Array.isArray(res.data) ? res.data : [];
|
||||||
|
setServers(list);
|
||||||
|
const firstJumphost = list.find((s) => String(s.type || '').toLowerCase() === 'jumphost');
|
||||||
|
if (firstJumphost && !selectedServerId) {
|
||||||
|
setSelectedServerId(makeServerId(firstJumphost));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('Error fetching servers for backups:', err);
|
||||||
|
notify.error('Не удалось загрузить список серверов для бэкапов');
|
||||||
|
} finally {
|
||||||
|
setLoadingServers(false);
|
||||||
|
}
|
||||||
|
}, [selectedServerId, notify]);
|
||||||
|
|
||||||
|
const fetchUiSettings = useCallback(async (abortSignal) => {
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
const res = await api.get('/ui-settings', opts);
|
||||||
|
const data = res?.data || {};
|
||||||
|
setUiSettings(data);
|
||||||
|
const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : [];
|
||||||
|
setBackupServerIds(new Set(list.map((v) => String(v))));
|
||||||
|
const e = res?.headers?.etag || res?.headers?.ETag || '';
|
||||||
|
setUiSettingsEtag(e ? String(e) : '');
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('Error fetching UI settings for backups:', err);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchBackups = useCallback(async (serverId, abortSignal) => {
|
||||||
|
if (!serverId) return;
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
setLoadingBackups(true);
|
||||||
|
setSelectedKeys([]);
|
||||||
|
setDiffResult(null);
|
||||||
|
const res = await api.get('/mikrotik/backups', { params: { serverId }, ...opts });
|
||||||
|
const items = res.data?.items || [];
|
||||||
|
setBackups(items);
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('Error fetching backups:', err);
|
||||||
|
notify.error('Не удалось загрузить список бэкапов MikroTik');
|
||||||
|
} finally {
|
||||||
|
setLoadingBackups(false);
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
fetchServers(signal);
|
||||||
|
fetchUiSettings(signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [fetchServers, fetchUiSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedServerId) {
|
if (selectedServerId) {
|
||||||
fetchBackups(selectedServerId);
|
const controller = new AbortController();
|
||||||
|
fetchBackups(selectedServerId, controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
} else {
|
} else {
|
||||||
setBackups([]);
|
setBackups([]);
|
||||||
setSelectedKeys([]);
|
setSelectedKeys([]);
|
||||||
}
|
}
|
||||||
}, [selectedServerId]);
|
}, [selectedServerId, fetchBackups]);
|
||||||
|
|
||||||
const jumphostServers = useMemo(
|
const jumphostServers = useMemo(
|
||||||
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
|
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
|
||||||
[servers],
|
[servers],
|
||||||
);
|
);
|
||||||
|
|
||||||
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
|
|
||||||
|
|
||||||
const jumphostInputServers = useMemo(
|
const jumphostInputServers = useMemo(
|
||||||
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
|
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
|
||||||
[jumphostServers],
|
[jumphostServers],
|
||||||
@@ -172,6 +318,42 @@ function MikrotikBackupsManager() {
|
|||||||
return filteredBackupServers.slice(start, start + backupPageSize);
|
return filteredBackupServers.slice(start, start + backupPageSize);
|
||||||
}, [filteredBackupServers, backupPage, backupTotalPages]);
|
}, [filteredBackupServers, backupPage, backupTotalPages]);
|
||||||
|
|
||||||
|
const diffComputed = useMemo(() => {
|
||||||
|
if (diffResult?.configA == null || diffResult?.configB == null) {
|
||||||
|
return {
|
||||||
|
left: [],
|
||||||
|
right: [],
|
||||||
|
stats: { removed: 0, added: 0, total: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { left, right } = getDiffLines(diffResult.configA, diffResult.configB);
|
||||||
|
const removed = left.filter((l) => l.type === 'removed').length;
|
||||||
|
const added = right.filter((l) => l.type === 'added').length;
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
stats: { removed, added, total: removed + added },
|
||||||
|
};
|
||||||
|
}, [diffResult?.configA, diffResult?.configB]);
|
||||||
|
|
||||||
|
const syncScroll = (source) => {
|
||||||
|
const leftEl = leftDiffRef.current;
|
||||||
|
const rightEl = rightDiffRef.current;
|
||||||
|
if (!leftEl || !rightEl) return;
|
||||||
|
const current = source === 'left' ? leftEl : rightEl;
|
||||||
|
const other = source === 'left' ? rightEl : leftEl;
|
||||||
|
|
||||||
|
if (isSyncingScrollRef.current) return;
|
||||||
|
isSyncingScrollRef.current = true;
|
||||||
|
|
||||||
|
const maxCurrent = current.scrollHeight - current.clientHeight;
|
||||||
|
const ratio = maxCurrent > 0 ? current.scrollTop / maxCurrent : 0;
|
||||||
|
const maxOther = other.scrollHeight - other.clientHeight;
|
||||||
|
other.scrollTop = ratio * (maxOther > 0 ? maxOther : 0);
|
||||||
|
|
||||||
|
isSyncingScrollRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleBackupSort = (field) => {
|
const handleBackupSort = (field) => {
|
||||||
setBackupSortField((prevField) => {
|
setBackupSortField((prevField) => {
|
||||||
if (prevField === field) {
|
if (prevField === field) {
|
||||||
@@ -192,56 +374,6 @@ function MikrotikBackupsManager() {
|
|||||||
setBackupServerIds(new Set());
|
setBackupServerIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchServers = async () => {
|
|
||||||
try {
|
|
||||||
setLoadingServers(true);
|
|
||||||
const res = await api.get('/servers');
|
|
||||||
const list = Array.isArray(res.data) ? res.data : [];
|
|
||||||
setServers(list);
|
|
||||||
const firstJumphost = list.find((s) => String(s.type || '').toLowerCase() === 'jumphost');
|
|
||||||
if (firstJumphost && !selectedServerId) {
|
|
||||||
setSelectedServerId(makeServerId(firstJumphost));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching servers for backups:', err);
|
|
||||||
notify.error('Не удалось загрузить список серверов для бэкапов');
|
|
||||||
} finally {
|
|
||||||
setLoadingServers(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchUiSettings = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/ui-settings');
|
|
||||||
const data = res?.data || {};
|
|
||||||
setUiSettings(data);
|
|
||||||
const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : [];
|
|
||||||
setBackupServerIds(new Set(list.map((v) => String(v))));
|
|
||||||
const e = res?.headers?.etag || res?.headers?.ETag || '';
|
|
||||||
setUiSettingsEtag(e ? String(e) : '');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching UI settings for backups:', err);
|
|
||||||
// мягко игнорируем, просто будут дефолты
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchBackups = async (serverId) => {
|
|
||||||
if (!serverId) return;
|
|
||||||
try {
|
|
||||||
setLoadingBackups(true);
|
|
||||||
setSelectedKeys([]);
|
|
||||||
setDiffResult(null);
|
|
||||||
const res = await api.get('/mikrotik/backups', { params: { serverId } });
|
|
||||||
const items = res.data?.items || [];
|
|
||||||
setBackups(items);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching backups:', err);
|
|
||||||
notify.error('Не удалось загрузить список бэкапов MikroTik');
|
|
||||||
} finally {
|
|
||||||
setLoadingBackups(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectKey = (key) => {
|
const handleSelectKey = (key) => {
|
||||||
setSelectedKeys((prev) => {
|
setSelectedKeys((prev) => {
|
||||||
if (prev.includes(key)) {
|
if (prev.includes(key)) {
|
||||||
@@ -261,6 +393,7 @@ function MikrotikBackupsManager() {
|
|||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
setViewConfigKey(key);
|
setViewConfigKey(key);
|
||||||
setViewConfigText('');
|
setViewConfigText('');
|
||||||
|
setViewModalOpen(true);
|
||||||
const res = await api.get('/mikrotik/backups/item', { params: { key } });
|
const res = await api.get('/mikrotik/backups/item', { params: { key } });
|
||||||
setViewConfigText(res.data?.config || '');
|
setViewConfigText(res.data?.config || '');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -277,6 +410,7 @@ function MikrotikBackupsManager() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
setDiffModalOpen(true);
|
||||||
setDiffLoading(true);
|
setDiffLoading(true);
|
||||||
setDiffResult(null);
|
setDiffResult(null);
|
||||||
const res = await api.post('/mikrotik/backups/diff', {
|
const res = await api.post('/mikrotik/backups/diff', {
|
||||||
@@ -426,7 +560,7 @@ function MikrotikBackupsManager() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="MikroTik Backups"
|
title="MikroTik Backups"
|
||||||
pretitle="Бэкапы конфигурации через REST API + S3"
|
icon={<IconDownload size={24} />}
|
||||||
meta={`Текущий сервер: ${currentServerLabel}`}
|
meta={`Текущий сервер: ${currentServerLabel}`}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
@@ -730,94 +864,133 @@ function MikrotikBackupsManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(viewConfigKey || diffResult) && (
|
<LargeModal
|
||||||
<div className="row mt-4">
|
show={viewModalOpen}
|
||||||
{viewConfigKey && (
|
onClose={() => {
|
||||||
<div className={diffResult ? 'col-md-6' : 'col-12'}>
|
setViewModalOpen(false);
|
||||||
<div className="card">
|
}}
|
||||||
<div className="card-header d-flex align-items-center justify-content-between">
|
title="Содержимое бэкапа MikroTik"
|
||||||
<div>
|
footer={(
|
||||||
<div className="card-title mb-0">Содержимое бэкапа</div>
|
<div className="d-flex justify-content-between w-100">
|
||||||
<div className="text-muted small text-truncate" title={viewConfigKey}>
|
<div className="text-muted small text-truncate" title={viewConfigKey || ''}>
|
||||||
{viewConfigKey}
|
{viewConfigKey || 'Бэкап не выбран'}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="btn-list">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-outline-secondary btn-sm"
|
|
||||||
onClick={() => handleCopyToClipboard(viewConfigText)}
|
|
||||||
disabled={!viewConfigText}
|
|
||||||
>
|
|
||||||
<IconCopy size={16} className="me-1" />
|
|
||||||
Копировать
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
{viewLoading ? (
|
|
||||||
<TableSkeleton rows={6} columns={1} />
|
|
||||||
) : (
|
|
||||||
<pre
|
|
||||||
className="mb-0"
|
|
||||||
style={{ maxHeight: 420, overflow: 'auto', fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{viewConfigText || '# Нет данных для отображения'}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="btn-list mb-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={() => handleCopyToClipboard(viewConfigText)}
|
||||||
|
disabled={!viewConfigText}
|
||||||
|
>
|
||||||
|
<IconCopy size={16} className="me-1" />
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setViewModalOpen(false)}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{viewLoading ? (
|
||||||
|
<TableSkeleton rows={6} columns={1} />
|
||||||
|
) : (
|
||||||
|
<pre
|
||||||
|
className="bg-dark text-white p-3 rounded"
|
||||||
|
style={{ maxHeight: 500, overflow: 'auto', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<code>{viewConfigText || '# Нет данных для отображения'}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</LargeModal>
|
||||||
|
|
||||||
{diffResult && (
|
<FullscreenModal
|
||||||
<div className={viewConfigKey ? 'col-md-6 mt-3 mt-md-0' : 'col-12'}>
|
show={diffModalOpen}
|
||||||
<div className="card">
|
onClose={() => {
|
||||||
<div className="card-header d-flex align-items-center justify-content-between">
|
setDiffModalOpen(false);
|
||||||
<div>
|
}}
|
||||||
<div className="card-title mb-0">Сравнение бэкапов</div>
|
title="Сравнение бэкапов MikroTik"
|
||||||
<div className="text-muted small">
|
footer={(
|
||||||
{diffResult.same
|
<div className="d-flex justify-content-between w-100">
|
||||||
? 'Конфигурации идентичны'
|
<div className="text-muted small">
|
||||||
: 'Конфигурации отличаются — проверьте различия перед откатом.'}
|
{diffResult?.same
|
||||||
</div>
|
? 'Конфигурации идентичны'
|
||||||
</div>
|
: 'Конфигурации отличаются — проверьте различия перед откатом.'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setDiffModalOpen(false)}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{diffLoading ? (
|
||||||
|
<TableSkeleton rows={8} columns={2} />
|
||||||
|
) : !diffResult ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
Не удалось загрузить результат сравнения. Попробуйте ещё раз.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-info mb-3 small d-flex justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
{!diffResult.same ? (
|
||||||
|
<>
|
||||||
|
<IconAlertTriangle size={16} className="me-1 text-warning" />
|
||||||
|
Конфигурации отличаются — слева A, справа B. Проверьте различия перед откатом.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Конфигурации идентичны — изменений нет.'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Всего изменений:{' '}
|
||||||
|
<span className="fw-semibold">{diffComputed.stats.total}</span>
|
||||||
|
<span className="text-success ms-2">+{diffComputed.stats.added}</span>
|
||||||
|
<span className="text-danger ms-2">−{diffComputed.stats.removed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6 mb-3 mb-md-0">
|
||||||
|
<div className="mb-2 small text-muted text-truncate" title={diffResult.a?.key}>
|
||||||
|
A: {diffResult.a?.key}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<DiffPanel
|
||||||
{diffLoading ? (
|
lines={diffComputed.left}
|
||||||
<TableSkeleton rows={6} columns={2} />
|
side="left"
|
||||||
) : (
|
innerRef={leftDiffRef}
|
||||||
<div className="row">
|
onScroll={() => syncScroll('left')}
|
||||||
<div className="col-md-6 mb-3 mb-md-0">
|
/>
|
||||||
<div className="mb-2 small text-muted text-truncate" title={diffResult.a?.key}>
|
</div>
|
||||||
A: {diffResult.a?.key}
|
<div className="col-md-6">
|
||||||
</div>
|
<div className="mb-2 small text-muted text-truncate" title={diffResult.b?.key}>
|
||||||
<pre
|
B: {diffResult.b?.key}
|
||||||
className="mb-0"
|
|
||||||
style={{ maxHeight: 360, overflow: 'auto', fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{diffResult.configA || '# пусто'}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-2 small text-muted text-truncate" title={diffResult.b?.key}>
|
|
||||||
B: {diffResult.b?.key}
|
|
||||||
</div>
|
|
||||||
<pre
|
|
||||||
className="mb-0"
|
|
||||||
style={{ maxHeight: 360, overflow: 'auto', fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{diffResult.configB || '# пусто'}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<DiffPanel
|
||||||
|
lines={diffComputed.right}
|
||||||
|
side="right"
|
||||||
|
innerRef={rightDiffRef}
|
||||||
|
onScroll={() => syncScroll('right')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{!diffResult.same && (
|
||||||
</div>
|
<div className="mt-3 text-muted small">
|
||||||
)}
|
<span style={{ background: 'rgba(248, 81, 73, 0.35)', padding: '0 4px', borderRadius: 2 }}> удалено (A)</span>
|
||||||
|
{' · '}
|
||||||
|
<span style={{ background: 'rgba(63, 185, 80, 0.35)', padding: '0 4px', borderRadius: 2 }}> добавлено (B)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FullscreenModal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,930 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import { useNotify } from './components/NotifyProvider.jsx';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import TableSkeleton from './components/TableSkeleton.jsx';
|
||||||
|
import EmptyState from './components/EmptyState.jsx';
|
||||||
|
import { countryToFlag } from './utils/serverUtils.js';
|
||||||
|
import {
|
||||||
|
IconRoute,
|
||||||
|
IconWorld,
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconRefresh,
|
||||||
|
IconSearch,
|
||||||
|
IconBrandGoogle,
|
||||||
|
IconBrandYandex,
|
||||||
|
IconBrandCloudflare,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/** Популярные цели для ping/traceroute — карточки с логотипами */
|
||||||
|
const POPULAR_TARGETS = [
|
||||||
|
{ id: 'yandex', name: 'Yandex', target: 'yandex.ru', Icon: IconBrandYandex, color: 'red' },
|
||||||
|
{ id: 'google', name: 'Google', target: '8.8.8.8', Icon: IconBrandGoogle, color: 'blue' },
|
||||||
|
{ id: 'cloudflare', name: 'Cloudflare DNS', target: '1.1.1.1', Icon: IconBrandCloudflare, color: 'orange' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раздел "Инструменты → MikroTik": проверка маршрута (traceroute)
|
||||||
|
* с выбором сервера, gateway и домена/IP.
|
||||||
|
*/
|
||||||
|
function MikrotikTools() {
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [networkConfig, setNetworkConfig] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [serverId, setServerId] = useState('');
|
||||||
|
const [gatewayRef, setGatewayRef] = useState('');
|
||||||
|
const [gatewayMeta, setGatewayMeta] = useState(null);
|
||||||
|
const [target, setTarget] = useState('');
|
||||||
|
const [maxHops, setMaxHops] = useState(30);
|
||||||
|
|
||||||
|
const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping'
|
||||||
|
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [hops, setHops] = useState([]);
|
||||||
|
const [analysis, setAnalysis] = useState([]);
|
||||||
|
const [tracerouteTab, setTracerouteTab] = useState('table'); // 'table' | 'analysis'
|
||||||
|
const [pingResult, setPingResult] = useState(null);
|
||||||
|
const [useDns, setUseDns] = useState(true);
|
||||||
|
|
||||||
|
const [serverSearch, setServerSearch] = useState('');
|
||||||
|
|
||||||
|
// Загрузка серверов и сетевого конфига
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [serversRes, netRes] = await Promise.all([
|
||||||
|
api.get('/servers'),
|
||||||
|
api.get('/network-config'),
|
||||||
|
]);
|
||||||
|
setServers(Array.isArray(serversRes.data) ? serversRes.data : []);
|
||||||
|
setNetworkConfig(netRes.data && typeof netRes.data === 'object' ? netRes.data : { gateways: [], tunnelInterfaces: [] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MikrotikTools] failed to load initial data', error);
|
||||||
|
notify.error('Не удалось загрузить данные для инструментов MikroTik');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
const gateways = useMemo(
|
||||||
|
() => (networkConfig?.gateways && Array.isArray(networkConfig.gateways) ? networkConfig.gateways : []),
|
||||||
|
[networkConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const interfaces = useMemo(
|
||||||
|
() => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []),
|
||||||
|
[networkConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _handleSelectGatewayMeta = (meta) => {
|
||||||
|
setGatewayMeta(meta);
|
||||||
|
// Если цель не задана — подставляем IP gateway как target
|
||||||
|
if (!target && meta && meta.ip) {
|
||||||
|
setTarget(meta.ip);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunCheck = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setHops([]);
|
||||||
|
setAnalysis([]);
|
||||||
|
setPingResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'traceroute') {
|
||||||
|
if (!serverId) {
|
||||||
|
notify.error('Выберите сервер (jumphost)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedTarget = String(target || '').trim();
|
||||||
|
if (!trimmedTarget) {
|
||||||
|
notify.error('Укажите домен или IP назначения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
serverId,
|
||||||
|
target: trimmedTarget,
|
||||||
|
useDns,
|
||||||
|
};
|
||||||
|
if (gatewayMeta?.ip) {
|
||||||
|
body.gatewayIp = gatewayMeta.ip;
|
||||||
|
}
|
||||||
|
const hopsNum = Number(maxHops);
|
||||||
|
if (Number.isFinite(hopsNum) && hopsNum > 0) {
|
||||||
|
body.maxHops = hopsNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.post('/mikrotik/traceroute', body);
|
||||||
|
const data = res?.data || {};
|
||||||
|
if (!data.ok) {
|
||||||
|
notify.error(data.error || 'Трассировка завершилась с ошибкой');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hopsList = Array.isArray(data.hops) ? data.hops : [];
|
||||||
|
setHops(hopsList);
|
||||||
|
setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target));
|
||||||
|
} else if (mode === 'ping') {
|
||||||
|
if (!serverId) {
|
||||||
|
notify.error('Выберите сервер (jumphost)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedTarget = String(target || '').trim();
|
||||||
|
if (!trimmedTarget) {
|
||||||
|
notify.error('Укажите домен или IP назначения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
serverId,
|
||||||
|
target: trimmedTarget,
|
||||||
|
count: 4,
|
||||||
|
};
|
||||||
|
if (gatewayMeta?.ip) {
|
||||||
|
body.gatewayIp = gatewayMeta.ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.post('/mikrotik/ping', body);
|
||||||
|
const data = res?.data || {};
|
||||||
|
if (data.ok === false) {
|
||||||
|
notify.error(data.error || 'Ping завершился с ошибкой');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPingResult({
|
||||||
|
target: trimmedTarget,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MikrotikTools] check failed', error);
|
||||||
|
notify.error(error?.message || 'Ошибка при выполнении проверки');
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setGatewayRef('');
|
||||||
|
setGatewayMeta(null);
|
||||||
|
setTarget('');
|
||||||
|
setHops([]);
|
||||||
|
setAnalysis([]);
|
||||||
|
setPingResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const jumphostServers = useMemo(
|
||||||
|
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
|
||||||
|
[servers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
|
||||||
|
|
||||||
|
const jumphostInputServers = useMemo(
|
||||||
|
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
|
||||||
|
[jumphostServers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleJumphostServers = useMemo(() => {
|
||||||
|
if (!serverSearch.trim()) return jumphostInputServers;
|
||||||
|
const q = serverSearch.trim().toLowerCase();
|
||||||
|
return jumphostInputServers.filter((s) => {
|
||||||
|
const fields = [
|
||||||
|
s.id,
|
||||||
|
s.ip,
|
||||||
|
s.dns,
|
||||||
|
s.extIp,
|
||||||
|
s.internalIp,
|
||||||
|
s.country,
|
||||||
|
s.provider,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
return fields.includes(q);
|
||||||
|
});
|
||||||
|
}, [jumphostInputServers, serverSearch]);
|
||||||
|
|
||||||
|
const currentServer = useMemo(
|
||||||
|
() => jumphostServers.find((s) => makeServerId(s) === serverId) || null,
|
||||||
|
[jumphostServers, serverId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _interfacesForServer = useMemo(() => {
|
||||||
|
if (!serverId || !interfaces.length) return [];
|
||||||
|
const ids = new Set(
|
||||||
|
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(String)
|
||||||
|
);
|
||||||
|
return interfaces.filter(
|
||||||
|
(i) =>
|
||||||
|
i &&
|
||||||
|
i.name &&
|
||||||
|
(ids.has(String(i.serverId)) || ids.has(String(i.serverId2)))
|
||||||
|
);
|
||||||
|
}, [interfaces, serverId, currentServer]);
|
||||||
|
|
||||||
|
const gatewaysForServer = useMemo(() => {
|
||||||
|
if (!serverId || !gateways.length) return [];
|
||||||
|
const ids = currentServer
|
||||||
|
? new Set(
|
||||||
|
[serverId, currentServer.id, currentServer.ip, currentServer.dns].filter(Boolean).map(String)
|
||||||
|
)
|
||||||
|
: new Set([String(serverId)]);
|
||||||
|
return gateways.filter((g) => g && g.ip && ids.has(String(g.serverId)));
|
||||||
|
}, [serverId, currentServer, gateways]);
|
||||||
|
|
||||||
|
const currentServerLabel =
|
||||||
|
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
|
||||||
|
|
||||||
|
const isInitialLoading = loading && servers.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="MikroTik Tools"
|
||||||
|
icon={<IconRoute size={24} />}
|
||||||
|
meta={`Текущий сервер: ${currentServerLabel}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Сервер, шлюз, быстрые цели — один компактный блок */}
|
||||||
|
<div className="card mb-2">
|
||||||
|
<div className="card-header py-2">
|
||||||
|
<div className="row align-items-center g-2">
|
||||||
|
<div className="col">
|
||||||
|
<h3 className="card-title mb-0">Сервер и шлюз</h3>
|
||||||
|
<p className="text-muted small mb-0 mt-0">
|
||||||
|
{jumphostServers.length} серверов · {serverId ? 'шлюз опционален' : 'выберите сервер'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<div className="input-icon input-icon-sm">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholder="Поиск серверов..."
|
||||||
|
value={serverSearch}
|
||||||
|
onChange={(e) => setServerSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconSearch size={14} className="text-muted" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body py-2">
|
||||||
|
{isInitialLoading ? (
|
||||||
|
<TableSkeleton rows={2} columns={2} />
|
||||||
|
) : jumphostServers.length === 0 ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы», чтобы использовать инструменты MikroTik.
|
||||||
|
</div>
|
||||||
|
) : visibleJumphostServers.length === 0 ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
По вашему запросу серверы не найдены. Попробуйте изменить фильтр поиска.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row row-cards g-1">
|
||||||
|
{visibleJumphostServers.map((s) => {
|
||||||
|
const id = s.id;
|
||||||
|
const isActive = id === serverId;
|
||||||
|
const flag = s.country ? countryToFlag(s.country) : '';
|
||||||
|
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
|
||||||
|
return (
|
||||||
|
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={id}>
|
||||||
|
<div
|
||||||
|
className={`card card-sm h-100 cursor-pointer ${
|
||||||
|
isActive ? 'border-primary card-hover' : 'card-hover'
|
||||||
|
}`}
|
||||||
|
onClick={() => setServerId(id)}
|
||||||
|
>
|
||||||
|
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
||||||
|
<span className="avatar avatar-sm bg-primary-lt" title={s.country || ''}>
|
||||||
|
{flag || s.ip?.slice(0, 2) || '?'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div className="fw-semibold text-truncate small" title={s.dns || s.ip || id}>
|
||||||
|
{s.dns || s.ip || id}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small text-truncate" title={line}>
|
||||||
|
{line || [s.country, s.provider].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||||
|
<span className="badge bg-primary-lt text-primary">Сервер: {currentServerLabel}</span>
|
||||||
|
{serverId && (
|
||||||
|
<span className="badge bg-azure-lt text-azure">
|
||||||
|
Шлюз: {gatewayRef || gatewayMeta?.ip || '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Шлюз — только при выбранном сервере */}
|
||||||
|
{serverId && (
|
||||||
|
<>
|
||||||
|
<hr className="my-2" />
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span className="fw-semibold small">Шлюз</span>
|
||||||
|
{gatewaysForServer.length === 0 && (
|
||||||
|
<span className="text-muted small">нет настроенных</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading && !networkConfig ? (
|
||||||
|
<div className="text-muted small">Загрузка сетевых настроек…</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{gatewaysForServer.length > 0 && (
|
||||||
|
<div className="row row-cards g-1 mb-2">
|
||||||
|
{gatewaysForServer.map((g) => {
|
||||||
|
const isSelected = gatewayRef === g.ip || gatewayRef === g.id;
|
||||||
|
const flag = g.country ? countryToFlag(g.country) : '';
|
||||||
|
return (
|
||||||
|
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={g.id || g.ip}>
|
||||||
|
<div
|
||||||
|
className={`card card-sm h-100 cursor-pointer ${
|
||||||
|
isSelected ? 'border-primary card-hover' : 'card-hover'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
const next = isSelected ? '' : (g.ip || g.id);
|
||||||
|
setGatewayRef(next);
|
||||||
|
if (!next) setGatewayMeta(null);
|
||||||
|
else {
|
||||||
|
setGatewayMeta({ ip: g.ip, gateway: g });
|
||||||
|
if (!target && g.ip) setTarget(g.ip);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
||||||
|
<span className="avatar avatar-sm bg-primary-lt" title={g.country || ''}>
|
||||||
|
{flag || (g.ip ? g.ip.slice(0, 2) : '?')}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div className="fw-semibold text-truncate small" title={g.ip || ''}>
|
||||||
|
{g.ip || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small text-truncate">
|
||||||
|
{g.description || [g.country, g.provider].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Цели — карточки в стиле сервера/gateway */}
|
||||||
|
<div className="row row-cards g-1 mt-2">
|
||||||
|
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon: _Icon, color }) => {
|
||||||
|
const isSelected = target === tgt;
|
||||||
|
const bgClass = color === 'red' ? 'bg-red-lt' : color === 'blue' ? 'bg-blue-lt' : 'bg-orange-lt';
|
||||||
|
const textClass = color === 'red' ? 'text-red' : color === 'blue' ? 'text-blue' : 'text-orange';
|
||||||
|
return (
|
||||||
|
<div className="col-12 col-sm-6 col-md-4" key={tid}>
|
||||||
|
<div
|
||||||
|
className={`card card-sm h-100 cursor-pointer ${isSelected ? 'border-primary card-hover' : 'card-hover'}`}
|
||||||
|
onClick={() => setTarget(tgt)}
|
||||||
|
title={tgt}
|
||||||
|
>
|
||||||
|
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
||||||
|
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
|
||||||
|
<_Icon size={20} stroke={1.5} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div className="fw-semibold text-truncate small">{name}</div>
|
||||||
|
<div className="text-muted small text-truncate">{tgt}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Режим и действия — под выбором цели, стандартный размер Tabler */}
|
||||||
|
<div className="d-flex flex-wrap align-items-center gap-2 mt-3 pt-2 border-top">
|
||||||
|
<ul className="nav nav-pills mb-0" role="tablist">
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nav-link ${
|
||||||
|
mode === 'traceroute' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMode('traceroute');
|
||||||
|
setHops([]);
|
||||||
|
setAnalysis([]);
|
||||||
|
setPingResult(null);
|
||||||
|
}}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
Traceroute
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nav-link ${
|
||||||
|
mode === 'ping' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMode('ping');
|
||||||
|
setHops([]);
|
||||||
|
setAnalysis([]);
|
||||||
|
setPingResult(null);
|
||||||
|
}}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
Ping
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="btn-list">
|
||||||
|
<button type="button" className="btn btn-outline-secondary" onClick={handleReset} disabled={running}>
|
||||||
|
<IconRefresh size={16} className="me-1" />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={handleRunCheck} disabled={running || loading}>
|
||||||
|
{running ? 'Выполнение…' : (mode === 'traceroute' ? 'Запустить трассировку' : 'Запустить ping')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Параметры + результаты */}
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card mb-2">
|
||||||
|
<div className="card-header py-2">
|
||||||
|
<h3 className="card-title mb-0">Параметры</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body py-2">
|
||||||
|
{loading && !networkConfig ? (
|
||||||
|
<div className="text-muted small">Загрузка…</div>
|
||||||
|
) : !serverId ? (
|
||||||
|
<div className="text-muted small">Выберите сервер выше.</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label small mb-0">Домен или IP</label>
|
||||||
|
<div className="input-group input-group-flat input-group-sm">
|
||||||
|
<span className="input-group-text"><IconWorld size={14} className="text-muted" /></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="example.com или 8.8.8.8"
|
||||||
|
value={target}
|
||||||
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
|
/>
|
||||||
|
{gatewayMeta?.ip && (
|
||||||
|
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => setTarget(gatewayMeta.ip)}>
|
||||||
|
IP gateway
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mode === 'traceroute' && (
|
||||||
|
<div className="col-6 col-md-5">
|
||||||
|
<label className="form-label small mb-0">Макс. хопов</label>
|
||||||
|
<input type="number" className="form-control form-control-sm" min={1} max={64} value={maxHops} onChange={(e) => setMaxHops(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="form-check form-switch form-check-inline">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="useDnsToggle"
|
||||||
|
checked={useDns}
|
||||||
|
onChange={(e) => setUseDns(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label small"
|
||||||
|
htmlFor="useDnsToggle"
|
||||||
|
>
|
||||||
|
{useDns ? 'DNS+IP' : 'Только IP'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-8">
|
||||||
|
{mode === 'ping' && (
|
||||||
|
<div className="card mb-2">
|
||||||
|
<div className="card-header py-2">
|
||||||
|
<h3 className="card-title mb-0">Результаты ping</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
{!pingResult ? (
|
||||||
|
<div className="p-3 text-muted small">
|
||||||
|
Результаты ping появятся здесь после запуска проверки.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Цель</th>
|
||||||
|
<th style={{ width: 130 }}>Среднее, мс</th>
|
||||||
|
<th style={{ width: 140 }}>Мин / Макс, мс</th>
|
||||||
|
<th style={{ width: 110 }}>Потери</th>
|
||||||
|
<th style={{ width: 200 }}>Оценка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(() => {
|
||||||
|
const avg = typeof pingResult.avgMs === 'number' ? pingResult.avgMs : null;
|
||||||
|
const loss = typeof pingResult.loss === 'number' ? pingResult.loss : null;
|
||||||
|
|
||||||
|
let severity = 'ok';
|
||||||
|
if (loss != null && loss >= 50) severity = 'danger';
|
||||||
|
else if ((loss != null && loss > 0) || (avg != null && avg > 200)) severity = 'warn';
|
||||||
|
|
||||||
|
const rowClass =
|
||||||
|
severity === 'danger'
|
||||||
|
? 'table-danger'
|
||||||
|
: severity === 'warn'
|
||||||
|
? 'table-warning'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={rowClass}>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex flex-column">
|
||||||
|
<span className="fw-semibold">
|
||||||
|
{pingResult.target || target || '—'}
|
||||||
|
</span>
|
||||||
|
{gatewayMeta?.ip && (
|
||||||
|
<span className="text-muted small">
|
||||||
|
через gateway {gatewayMeta.ip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{avg != null ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
avg > 200
|
||||||
|
? 'text-danger fw-semibold'
|
||||||
|
: avg > 120
|
||||||
|
? 'text-warning fw-semibold'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avg.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{pingResult.minMs != null ? pingResult.minMs.toFixed(1) : '—'} /{' '}
|
||||||
|
{pingResult.maxMs != null ? pingResult.maxMs.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{loss != null ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
loss >= 50
|
||||||
|
? 'badge bg-red-lt text-red'
|
||||||
|
: loss > 0
|
||||||
|
? 'badge bg-yellow-lt text-yellow'
|
||||||
|
: 'badge bg-green-lt text-green'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loss}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{severity === 'danger' && (
|
||||||
|
<span className="badge bg-red-lt text-red me-1">Плохое качество</span>
|
||||||
|
)}
|
||||||
|
{severity === 'warn' && (
|
||||||
|
<span className="badge bg-yellow-lt text-yellow me-1">
|
||||||
|
Возможные проблемы
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-muted small">
|
||||||
|
{loss === 0
|
||||||
|
? 'Все пакеты доставлены'
|
||||||
|
: loss != null
|
||||||
|
? `Потеряно пакетов: ${loss}%`
|
||||||
|
: 'Нет данных о потере пакетов'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'traceroute' && (
|
||||||
|
<div className="card mb-2">
|
||||||
|
<div className="card-header py-2">
|
||||||
|
<ul className="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
|
<li className="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nav-link ${tracerouteTab === 'table' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTracerouteTab('table')}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
Результаты
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nav-link ${tracerouteTab === 'analysis' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTracerouteTab('analysis')}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
Расшифровка
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{tracerouteTab === 'table' ? (
|
||||||
|
<div className="card-body p-0">
|
||||||
|
{hops.length === 0 ? (
|
||||||
|
<div className="p-3 text-muted small">
|
||||||
|
Результаты трассировки появятся здесь после запуска проверки.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 70 }}>Хоп</th>
|
||||||
|
<th>Адрес / узел</th>
|
||||||
|
<th style={{ width: 130 }}>Среднее, мс</th>
|
||||||
|
<th style={{ width: 140 }}>Мин / Макс, мс</th>
|
||||||
|
<th style={{ width: 110 }}>Потери</th>
|
||||||
|
<th style={{ width: 200 }}>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{hops.map((h) => {
|
||||||
|
const ip = h.ip || '';
|
||||||
|
const dns = h.dns || '';
|
||||||
|
|
||||||
|
let mainLabel;
|
||||||
|
if (useDns) {
|
||||||
|
if (dns && ip) mainLabel = `${dns} (${ip})`;
|
||||||
|
else mainLabel = dns || ip || h.host || '—';
|
||||||
|
} else {
|
||||||
|
mainLabel = ip || dns || h.host || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const avg = typeof h.avgMs === 'number' ? h.avgMs : null;
|
||||||
|
const loss = typeof h.loss === 'number' ? h.loss : null;
|
||||||
|
const statusRaw = (h.status || '').toString();
|
||||||
|
const statusLower = statusRaw.toLowerCase();
|
||||||
|
const hasTimeout =
|
||||||
|
statusLower.includes('timeout') ||
|
||||||
|
statusLower.includes('no reply') ||
|
||||||
|
statusLower.includes('unreachable');
|
||||||
|
|
||||||
|
let severity = 'ok';
|
||||||
|
if (hasTimeout || (loss != null && loss >= 50)) {
|
||||||
|
severity = 'danger';
|
||||||
|
} else if ((loss != null && loss > 0) || (avg != null && avg > 200)) {
|
||||||
|
severity = 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClass =
|
||||||
|
severity === 'danger'
|
||||||
|
? 'table-danger'
|
||||||
|
: severity === 'warn'
|
||||||
|
? 'table-warning'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={h.hop || `${ip || h.host}-${Math.random()}`} className={rowClass}>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-blue-lt text-blue">
|
||||||
|
{h.hop}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex flex-column">
|
||||||
|
<span className="fw-semibold text-truncate" title={mainLabel}>
|
||||||
|
{mainLabel}
|
||||||
|
</span>
|
||||||
|
{ip && dns && (
|
||||||
|
<span className="text-muted small text-truncate" title={ip}>
|
||||||
|
IP: {ip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{avg != null ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
avg > 200
|
||||||
|
? 'text-danger fw-semibold'
|
||||||
|
: avg > 120
|
||||||
|
? 'text-warning fw-semibold'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avg.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{h.bestMs != null ? h.bestMs.toFixed(1) : '—'} /{' '}
|
||||||
|
{h.worstMs != null ? h.worstMs.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{loss != null ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
loss >= 50
|
||||||
|
? 'badge bg-red-lt text-red'
|
||||||
|
: loss > 0
|
||||||
|
? 'badge bg-yellow-lt text-yellow'
|
||||||
|
: 'badge bg-green-lt text-green'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loss}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{severity === 'danger' && (
|
||||||
|
<span className="badge bg-red-lt text-red me-1">Проблемный хоп</span>
|
||||||
|
)}
|
||||||
|
{severity === 'warn' && (
|
||||||
|
<span className="badge bg-yellow-lt text-yellow me-1">Возможная проблема</span>
|
||||||
|
)}
|
||||||
|
<span className="text-muted small">
|
||||||
|
{statusRaw || (loss != null || avg != null ? 'OK' : '—')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card-body">
|
||||||
|
{analysis.length === 0 ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
После выполнения трассировки здесь появится человекочитаемое описание пути и возможных проблем.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="list-unstyled mb-0">
|
||||||
|
{analysis.map((line, idx) => (
|
||||||
|
<li key={idx} className="mb-1">
|
||||||
|
{line}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTracerouteAnalysis(hops, networkConfig, servers, target) {
|
||||||
|
if (!Array.isArray(hops) || hops.length === 0) return [];
|
||||||
|
const lines = [];
|
||||||
|
const gateways = (networkConfig?.gateways && Array.isArray(networkConfig.gateways)) ? networkConfig.gateways : [];
|
||||||
|
const ifaces = (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces)) ? networkConfig.tunnelInterfaces : [];
|
||||||
|
|
||||||
|
const findServerLabel = (serverId) => {
|
||||||
|
if (!serverId) return null;
|
||||||
|
const s = (servers || []).find((srv) => srv.id === serverId || srv.ip === serverId || srv.dns === serverId);
|
||||||
|
if (!s) return serverId;
|
||||||
|
return s.dns || s.ip || serverId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetStr = String(target || '').trim();
|
||||||
|
lines.push(`Цель трассировки: ${targetStr || 'не указана'}. Хопов в маршруте: ${hops.length}.`);
|
||||||
|
|
||||||
|
let firstTimeoutHop = null;
|
||||||
|
let worstLatencyHop = null;
|
||||||
|
|
||||||
|
hops.forEach((hop, index) => {
|
||||||
|
const ip = hop.ip || hop.host || hop.address || '';
|
||||||
|
const dns = hop.dns || null;
|
||||||
|
const hostDisplay = dns ? `${dns}${ip ? ` (${ip})` : ''}` : (ip || hop.host || '');
|
||||||
|
const avg = hop.avgMs;
|
||||||
|
const loss = hop.loss;
|
||||||
|
|
||||||
|
let descr = `Хоп ${hop.hop || index + 1}: ${hostDisplay || 'неизвестный узел'}`;
|
||||||
|
const details = [];
|
||||||
|
|
||||||
|
if (avg != null) details.push(`среднее время ≈ ${avg.toFixed(1)} мс`);
|
||||||
|
if (hop.bestMs != null && hop.worstMs != null) {
|
||||||
|
details.push(`мин/макс ${hop.bestMs.toFixed(1)}/${hop.worstMs.toFixed(1)} мс`);
|
||||||
|
}
|
||||||
|
if (loss != null) details.push(`потеря пакетов ${loss}%`);
|
||||||
|
|
||||||
|
// Привязка к gateway (по IP)
|
||||||
|
const gw = gateways.find((g) => g.ip && g.ip === ip);
|
||||||
|
if (gw) {
|
||||||
|
const srvLabel = findServerLabel(gw.serverId);
|
||||||
|
details.push(`gateway "${gw.description || gw.ip}" на сервере ${srvLabel || gw.serverId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязка к туннельному интерфейсу (по IP)
|
||||||
|
const iface = ifaces.find((i) => i.localIp === ip || i.remoteIp === ip);
|
||||||
|
if (iface) {
|
||||||
|
const s1 = findServerLabel(iface.serverId);
|
||||||
|
const s2 = findServerLabel(iface.serverId2);
|
||||||
|
details.push(
|
||||||
|
`туннель ${iface.type || ''} ${iface.name || ''} (${iface.localIp} ⇄ ${iface.remoteIp})` +
|
||||||
|
(s1 || s2 ? ` между ${s1 || 'сервером 1'} и ${s2 || 'сервером 2'}` : '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус/таймауты
|
||||||
|
if (hop.status) {
|
||||||
|
const st = String(hop.status).toLowerCase();
|
||||||
|
if (st.includes('timeout') || st.includes('no reply') || st.includes('unreachable')) {
|
||||||
|
details.push('узел не отвечает на ICMP (возможная фильтрация или недоступность)');
|
||||||
|
if (!firstTimeoutHop) firstTimeoutHop = hop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.length > 0) {
|
||||||
|
descr += ` — ${details.join('; ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(descr);
|
||||||
|
|
||||||
|
if (avg != null) {
|
||||||
|
if (!worstLatencyHop || avg > (worstLatencyHop.avgMs || 0)) {
|
||||||
|
worstLatencyHop = hop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (worstLatencyHop && worstLatencyHop.avgMs != null && worstLatencyHop.avgMs > 150) {
|
||||||
|
lines.push(
|
||||||
|
`Максимальная средняя задержка на хопе ${worstLatencyHop.hop}: около ${worstLatencyHop.avgMs.toFixed(
|
||||||
|
1
|
||||||
|
)} мс — возможен "узкое место" или удалённый сегмент сети.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstTimeoutHop) {
|
||||||
|
lines.push(
|
||||||
|
`Начиная с хопа ${firstTimeoutHop.hop} (${firstTimeoutHop.dns ? `${firstTimeoutHop.dns}${firstTimeoutHop.ip ? ` (${firstTimeoutHop.ip})` : ''}` : (firstTimeoutHop.ip || firstTimeoutHop.host || 'неизвестный узел')}) наблюдаются таймауты — цель может быть недоступна или где-то по пути фильтруется ICMP.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push('Таймаутов или явной потери пакетов по пути не обнаружено (по данным MikroTik).');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MikrotikTools;
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { useNotify } from './components/NotifyProvider.jsx';
|
|||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
||||||
import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx';
|
import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
@@ -248,6 +249,7 @@ function NetworkConfigManager() {
|
|||||||
fetchServers();
|
fetchServers();
|
||||||
fetchIpsecPasswords();
|
fetchIpsecPasswords();
|
||||||
fetchUiSettings();
|
fetchUiSettings();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchUiSettings = async () => {
|
const fetchUiSettings = async () => {
|
||||||
@@ -392,6 +394,7 @@ function NetworkConfigManager() {
|
|||||||
label: getServerLabel(id),
|
label: getServerLabel(id),
|
||||||
server: getServerInfo(id),
|
server: getServerInfo(id),
|
||||||
}));
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers
|
||||||
}, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]);
|
}, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]);
|
||||||
|
|
||||||
// === Фильтрация ===
|
// === Фильтрация ===
|
||||||
@@ -430,6 +433,7 @@ function NetworkConfigManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel/gatewayTypeFilter are stable
|
||||||
}, [config.gateways, providerFilter, serverFilter, typeFilter, searchTerm, servers]);
|
}, [config.gateways, providerFilter, serverFilter, typeFilter, searchTerm, servers]);
|
||||||
|
|
||||||
const filteredInterfaces = useMemo(() => {
|
const filteredInterfaces = useMemo(() => {
|
||||||
@@ -455,6 +459,7 @@ function NetworkConfigManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerLabel is stable helper
|
||||||
}, [config.tunnelInterfaces, typeFilter, serverFilter, searchTerm, servers]);
|
}, [config.tunnelInterfaces, typeFilter, serverFilter, searchTerm, servers]);
|
||||||
|
|
||||||
const filteredPools = useMemo(() => {
|
const filteredPools = useMemo(() => {
|
||||||
@@ -467,7 +472,7 @@ function NetworkConfigManager() {
|
|||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
result = result.filter(p => {
|
result = result.filter(p => {
|
||||||
const server = getServerInfo(p.serverId);
|
const _server = getServerInfo(p.serverId);
|
||||||
return (
|
return (
|
||||||
p.name?.toLowerCase().includes(term) ||
|
p.name?.toLowerCase().includes(term) ||
|
||||||
p.cidr?.toLowerCase().includes(term) ||
|
p.cidr?.toLowerCase().includes(term) ||
|
||||||
@@ -478,6 +483,7 @@ function NetworkConfigManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers
|
||||||
}, [config.ipPools, serverFilter, searchTerm, servers]);
|
}, [config.ipPools, serverFilter, searchTerm, servers]);
|
||||||
|
|
||||||
// === Группировка по серверам ===
|
// === Группировка по серверам ===
|
||||||
@@ -527,6 +533,7 @@ function NetworkConfigManager() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper
|
||||||
}, [gatewaysByServer, servers]);
|
}, [gatewaysByServer, servers]);
|
||||||
|
|
||||||
const interfacesByCategory = useMemo(() => {
|
const interfacesByCategory = useMemo(() => {
|
||||||
@@ -546,6 +553,7 @@ function NetworkConfigManager() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper
|
||||||
}, [interfacesByServer, servers]);
|
}, [interfacesByServer, servers]);
|
||||||
|
|
||||||
// === Сброс фильтров ===
|
// === Сброс фильтров ===
|
||||||
@@ -912,7 +920,7 @@ function NetworkConfigManager() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalidPairs = templateServerPairs.filter((pair, index) => {
|
const invalidPairs = templateServerPairs.filter((pair, _index) => {
|
||||||
if (!pair.server1 || !pair.server2) {
|
if (!pair.server1 || !pair.server2) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1273,7 +1281,7 @@ function NetworkConfigManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === Генерация IP из пула ===
|
// === Генерация IP из пула ===
|
||||||
const generateIpFromPool = (cidr, usedIps, excludeIp = null, forRemote = false, pairedLocalIp = null) => {
|
const generateIpFromPool = (cidr, usedIps, _excludeIp = null, forRemote = false, pairedLocalIp = null) => {
|
||||||
// Парсим CIDR (например, "10.10.0.0/24")
|
// Парсим CIDR (например, "10.10.0.0/24")
|
||||||
const [network, prefixLength] = cidr.split('/');
|
const [network, prefixLength] = cidr.split('/');
|
||||||
if (!network || !prefixLength) {
|
if (!network || !prefixLength) {
|
||||||
@@ -1589,6 +1597,7 @@ function NetworkConfigManager() {
|
|||||||
}
|
}
|
||||||
return a.type.localeCompare(b.type);
|
return a.type.localeCompare(b.type);
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- generatePtrZone/getServerInfo are stable helpers
|
||||||
}, [config.tunnelInterfaces, servers, globalPtrZoneReplaceFrom, globalPtrZoneReplaceTo]);
|
}, [config.tunnelInterfaces, servers, globalPtrZoneReplaceFrom, globalPtrZoneReplaceTo]);
|
||||||
|
|
||||||
// === Фильтрация реестра IP ===
|
// === Фильтрация реестра IP ===
|
||||||
@@ -1649,7 +1658,7 @@ function NetworkConfigManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === CRUD для IPSec Passwords ===
|
// === CRUD для IPSec Passwords ===
|
||||||
const handleAddIpsecPassword = () => {
|
const _handleAddIpsecPassword = () => {
|
||||||
setEditingIpsecPassword({ name: '', password: '', description: '' });
|
setEditingIpsecPassword({ name: '', password: '', description: '' });
|
||||||
setIpsecPasswordModalMode('add');
|
setIpsecPasswordModalMode('add');
|
||||||
setIpsecPasswordModalOpen(true);
|
setIpsecPasswordModalOpen(true);
|
||||||
@@ -2020,7 +2029,7 @@ function NetworkConfigManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === Генерация кода MikroTik для рекурсивных маршрутов ===
|
// === Генерация кода MikroTik для рекурсивных маршрутов ===
|
||||||
const generateMikrotikRecursiveRoutes = async () => {
|
const _generateMikrotikRecursiveRoutes = async () => {
|
||||||
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
|
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
|
||||||
|
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
@@ -2072,7 +2081,7 @@ function NetworkConfigManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обрабатываем каждый родительский gateway
|
// Обрабатываем каждый родительский gateway
|
||||||
parentGatewaysList.forEach((parentRef, parentIndex) => {
|
parentGatewaysList.forEach((parentRef, _parentIndex) => {
|
||||||
const parent = getParentGateway(parentRef.id);
|
const parent = getParentGateway(parentRef.id);
|
||||||
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
@@ -2263,7 +2272,7 @@ function NetworkConfigManager() {
|
|||||||
if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj);
|
if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj);
|
||||||
else if (obj.t === 'result') finalData = obj;
|
else if (obj.t === 'result') finalData = obj;
|
||||||
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
@@ -2271,7 +2280,7 @@ function NetworkConfigManager() {
|
|||||||
const obj = JSON.parse(buffer);
|
const obj = JSON.parse(buffer);
|
||||||
if (obj.t === 'result') finalData = obj;
|
if (obj.t === 'result') finalData = obj;
|
||||||
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
data = finalData || {};
|
data = finalData || {};
|
||||||
} else {
|
} else {
|
||||||
@@ -2402,7 +2411,7 @@ function NetworkConfigManager() {
|
|||||||
|
|
||||||
let validRoutesCount = 0;
|
let validRoutesCount = 0;
|
||||||
|
|
||||||
parentGatewaysList.forEach((parentRef, parentIndex) => {
|
parentGatewaysList.forEach((parentRef, _parentIndex) => {
|
||||||
const parent = getParentGateway(parentRef.id);
|
const parent = getParentGateway(parentRef.id);
|
||||||
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
@@ -2541,7 +2550,7 @@ function NetworkConfigManager() {
|
|||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
notify.success('Код скопирован в буфер обмена!');
|
notify.success('Код скопирован в буфер обмена!');
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
notify.error('Не удалось скопировать код');
|
notify.error('Не удалось скопировать код');
|
||||||
}
|
}
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
@@ -2588,7 +2597,7 @@ function NetworkConfigManager() {
|
|||||||
}, [generatedMikrotikCode]);
|
}, [generatedMikrotikCode]);
|
||||||
|
|
||||||
// === Получение названия типа блока ===
|
// === Получение названия типа блока ===
|
||||||
const getBlockTypeLabel = (type) => {
|
const _getBlockTypeLabel = (type) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'recursive-routes':
|
case 'recursive-routes':
|
||||||
return 'Рекурсивные маршруты';
|
return 'Рекурсивные маршруты';
|
||||||
@@ -2966,11 +2975,11 @@ function NetworkConfigManager() {
|
|||||||
// === Рендер карточки Gateway ===
|
// === Рендер карточки Gateway ===
|
||||||
const renderGatewayCard = (gateway) => {
|
const renderGatewayCard = (gateway) => {
|
||||||
const server = getServerInfo(gateway.serverId);
|
const server = getServerInfo(gateway.serverId);
|
||||||
const provider = server?.provider || '';
|
const _provider = server?.provider || '';
|
||||||
const serverCountry = server?.country || '';
|
const serverCountry = server?.country || '';
|
||||||
const gatewayCountry = gateway.country || '';
|
const gatewayCountry = gateway.country || '';
|
||||||
const displayCountry = gatewayCountry || serverCountry;
|
const displayCountry = gatewayCountry || serverCountry;
|
||||||
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
|
const _gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
|
||||||
|
|
||||||
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
|
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
|
||||||
const parentGatewaysList = gateway.type === 'recursive'
|
const parentGatewaysList = gateway.type === 'recursive'
|
||||||
@@ -3134,7 +3143,7 @@ function NetworkConfigManager() {
|
|||||||
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
|
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
|
||||||
|
|
||||||
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
|
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
|
||||||
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
|
const _displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
|
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
|
||||||
@@ -3231,101 +3240,91 @@ function NetworkConfigManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="network-config-manager">
|
<div className="network-config-manager">
|
||||||
{/* Page Header */}
|
<PageHeader
|
||||||
<div className="page-header d-print-none mb-4">
|
title="Сетевые настройки"
|
||||||
<div className="row align-items-center">
|
icon={<IconNetwork size={24} />}
|
||||||
<div className="col">
|
actions={
|
||||||
<div className="page-pretitle">Управление</div>
|
<div className="btn-list">
|
||||||
<h2 className="page-title d-flex align-items-center">
|
<div className="btn-group me-2" role="group">
|
||||||
<IconNetwork className="me-2" size={28} />
|
|
||||||
Сетевые настройки
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="col-auto ms-auto d-print-none">
|
|
||||||
<div className="btn-list">
|
|
||||||
{/* Tabs as button group */}
|
|
||||||
<div className="btn-group me-2" role="group">
|
|
||||||
<button
|
|
||||||
className={`btn ${activeTab === 'gateways' ? 'btn-primary' : 'btn-outline-primary'}`}
|
|
||||||
onClick={() => setActiveTab('gateways')}
|
|
||||||
>
|
|
||||||
<IconWorld size={16} className="me-1 d-none d-sm-inline" />
|
|
||||||
Gateways
|
|
||||||
<span className={`badge ms-1 ${activeTab === 'gateways' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
|
||||||
{config.gateways?.length || 0}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn ${activeTab === 'interfaces' ? 'btn-primary' : 'btn-outline-primary'}`}
|
|
||||||
onClick={() => setActiveTab('interfaces')}
|
|
||||||
>
|
|
||||||
<IconRouter size={16} className="me-1 d-none d-sm-inline" />
|
|
||||||
Интерфейсы
|
|
||||||
<span className={`badge ms-1 ${activeTab === 'interfaces' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
|
||||||
{config.tunnelInterfaces?.length || 0}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn ${activeTab === 'pools' ? 'btn-primary' : 'btn-outline-primary'}`}
|
|
||||||
onClick={() => setActiveTab('pools')}
|
|
||||||
>
|
|
||||||
<IconDatabase size={16} className="me-1 d-none d-sm-inline" />
|
|
||||||
IP пулы
|
|
||||||
<span className={`badge ms-1 ${activeTab === 'pools' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
|
||||||
{config.ipPools?.length || 0}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn ${activeTab === 'ip-registry' ? 'btn-primary' : 'btn-outline-primary'}`}
|
|
||||||
onClick={() => setActiveTab('ip-registry')}
|
|
||||||
>
|
|
||||||
<IconList size={16} className="me-1 d-none d-sm-inline" />
|
|
||||||
Реестр IP
|
|
||||||
<span className={`badge ms-1 ${activeTab === 'ip-registry' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
|
||||||
{ipRegistry.length}
|
|
||||||
</span>
|
|
||||||
{ipRegistry.some(item => item.hasConflict) && (
|
|
||||||
<span className="badge bg-danger ms-1" title="Обнаружены конфликты IP адресов">
|
|
||||||
!
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
className={`btn ${activeTab === 'gateways' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
className="btn btn-outline-secondary"
|
onClick={() => setActiveTab('gateways')}
|
||||||
onClick={() => { fetchConfig(); fetchServers(); }}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<IconRefresh size={16} className={loading ? 'spin' : ''} />
|
<IconWorld size={16} className="me-1 d-none d-sm-inline" />
|
||||||
|
Gateways
|
||||||
|
<span className={`badge ms-1 ${activeTab === 'gateways' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
||||||
|
{config.gateways?.length || 0}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
className={`btn ${activeTab === 'interfaces' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
className="btn btn-outline-primary"
|
onClick={() => setActiveTab('interfaces')}
|
||||||
onClick={handleGenerateMikrotikCode}
|
>
|
||||||
disabled={loading || (
|
<IconRouter size={16} className="me-1 d-none d-sm-inline" />
|
||||||
(config.gateways || []).filter(gw => gw.type === 'recursive').length === 0 &&
|
Интерфейсы
|
||||||
(config.tunnelInterfaces || []).filter(i => i.serverId && i.serverId2 && i.localIp && i.remoteIp).length === 0
|
<span className={`badge ms-1 ${activeTab === 'interfaces' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
||||||
|
{config.tunnelInterfaces?.length || 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${activeTab === 'pools' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setActiveTab('pools')}
|
||||||
|
>
|
||||||
|
<IconDatabase size={16} className="me-1 d-none d-sm-inline" />
|
||||||
|
IP пулы
|
||||||
|
<span className={`badge ms-1 ${activeTab === 'pools' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
||||||
|
{config.ipPools?.length || 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${activeTab === 'ip-registry' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setActiveTab('ip-registry')}
|
||||||
|
>
|
||||||
|
<IconList size={16} className="me-1 d-none d-sm-inline" />
|
||||||
|
Реестр IP
|
||||||
|
<span className={`badge ms-1 ${activeTab === 'ip-registry' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
|
||||||
|
{ipRegistry.length}
|
||||||
|
</span>
|
||||||
|
{ipRegistry.some(item => item.hasConflict) && (
|
||||||
|
<span className="badge bg-danger ms-1" title="Обнаружены конфликты IP адресов">
|
||||||
|
!
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
title="Генерировать код MikroTik для рекурсивных маршрутов и IP адресов интерфейсов"
|
|
||||||
>
|
|
||||||
<IconCode size={16} className="me-1" />
|
|
||||||
Код для MikroTik
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<IconDatabase size={16} className={`me-1 ${saving ? 'spin' : ''}`} />
|
|
||||||
Сохранить
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => { fetchConfig(); fetchServers(); }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} className={loading ? 'spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleGenerateMikrotikCode}
|
||||||
|
disabled={loading || (
|
||||||
|
(config.gateways || []).filter(gw => gw.type === 'recursive').length === 0 &&
|
||||||
|
(config.tunnelInterfaces || []).filter(i => i.serverId && i.serverId2 && i.localIp && i.remoteIp).length === 0
|
||||||
|
)}
|
||||||
|
title="Генерировать код MikroTik для рекурсивных маршрутов и IP адресов интерфейсов"
|
||||||
|
>
|
||||||
|
<IconCode size={16} className="me-1" />
|
||||||
|
Код для MikroTik
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<IconDatabase size={16} className={`me-1 ${saving ? 'spin' : ''}`} />
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="card mb-3">
|
<div className="card mb-3">
|
||||||
@@ -3674,6 +3673,17 @@ function NetworkConfigManager() {
|
|||||||
{/* Interfaces Tab */}
|
{/* Interfaces Tab */}
|
||||||
{activeTab === 'interfaces' && (
|
{activeTab === 'interfaces' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-body d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<div className="text-muted small">
|
||||||
|
Настройки OSPF вынесены в отдельный раздел проекта.
|
||||||
|
</div>
|
||||||
|
<a className="btn btn-outline-primary btn-sm" href="/ospf-tools">
|
||||||
|
Открыть Инструменты OSPF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{filteredInterfaces.length === 0 ? (
|
{filteredInterfaces.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body text-center py-5">
|
<div className="card-body text-center py-5">
|
||||||
@@ -5529,7 +5539,7 @@ function NetworkConfigManager() {
|
|||||||
<div className="row g-2">
|
<div className="row g-2">
|
||||||
{GATEWAY_TEMPLATES.map(template => {
|
{GATEWAY_TEMPLATES.map(template => {
|
||||||
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер
|
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер
|
||||||
const previewIps = template.gateways.map((gw, idx) => {
|
const previewIps = template.gateways.map((gw, _idx) => {
|
||||||
// Для прямых gateways показываем IP сервера, если выбран
|
// Для прямых gateways показываем IP сервера, если выбран
|
||||||
if (gw.type === 'direct' && templateServerId) {
|
if (gw.type === 'direct' && templateServerId) {
|
||||||
const server = getServerInfo(templateServerId);
|
const server = getServerInfo(templateServerId);
|
||||||
@@ -5707,7 +5717,7 @@ function NetworkConfigManager() {
|
|||||||
<label className="form-label small">Родительские gateway/интерфейсы</label>
|
<label className="form-label small">Родительские gateway/интерфейсы</label>
|
||||||
<div className="d-flex flex-column gap-2">
|
<div className="d-flex flex-column gap-2">
|
||||||
{(gw.parentGateways || []).map((parent, parentIndex) => {
|
{(gw.parentGateways || []).map((parent, parentIndex) => {
|
||||||
const parentInfo = getParentGateway(parent.id, templateGateways);
|
const _parentInfo = getParentGateway(parent.id, templateGateways);
|
||||||
return (
|
return (
|
||||||
<div key={parentIndex} className="d-flex align-items-center gap-2">
|
<div key={parentIndex} className="d-flex align-items-center gap-2">
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import { IconRefresh, IconTopologyRing, IconClock } from '@tabler/icons-react';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import NetworkMapUnifi from './NetworkMapUnifi.jsx';
|
||||||
|
|
||||||
|
/** Параллельно выполняем промисы с лимитом одновременных */
|
||||||
|
async function runWithLimit(tasks, limit = 4) {
|
||||||
|
const results = [];
|
||||||
|
let index = 0;
|
||||||
|
async function runNext() {
|
||||||
|
const i = index++;
|
||||||
|
if (i >= tasks.length) return;
|
||||||
|
const task = tasks[i];
|
||||||
|
try {
|
||||||
|
const value = await task();
|
||||||
|
results[i] = { value };
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = { error: err };
|
||||||
|
}
|
||||||
|
await runNext();
|
||||||
|
}
|
||||||
|
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет задачи так, что на каждый ключ (ноду) в один момент времени
|
||||||
|
* выполняется не более одной задачи. Разные ноды — параллельно.
|
||||||
|
* getKey(i) возвращает ключ для задачи с индексом i.
|
||||||
|
*/
|
||||||
|
async function runWithOnePerKey(tasks, getKey) {
|
||||||
|
const n = tasks.length;
|
||||||
|
const keyToIndices = new Map();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const k = String(getKey(i));
|
||||||
|
if (!keyToIndices.has(k)) keyToIndices.set(k, []);
|
||||||
|
keyToIndices.get(k).push(i);
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < n; i++) results[i] = null;
|
||||||
|
|
||||||
|
const keys = Array.from(keyToIndices.keys());
|
||||||
|
let round = 0;
|
||||||
|
while (true) {
|
||||||
|
const batch = [];
|
||||||
|
for (const k of keys) {
|
||||||
|
const indices = keyToIndices.get(k);
|
||||||
|
if (round < indices.length) batch.push(indices[round]);
|
||||||
|
}
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async (i) => {
|
||||||
|
try {
|
||||||
|
const value = await tasks[i]();
|
||||||
|
results[i] = { value };
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = { error: err };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
round++;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NetworkMapDashboard() {
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [connections, setConnections] = useState([]);
|
||||||
|
const [pingMap, setPingMap] = useState({});
|
||||||
|
const [speedMap, setSpeedMap] = useState({});
|
||||||
|
const [pingStaleMap, setPingStaleMap] = useState({});
|
||||||
|
const [speedStaleMap, setSpeedStaleMap] = useState({});
|
||||||
|
const pingTimestampsRef = useRef({});
|
||||||
|
const lastSuccessfulPingRef = useRef({});
|
||||||
|
const lastSuccessfulSpeedRef = useRef({});
|
||||||
|
const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
|
||||||
|
const [alertSettings, setAlertSettings] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pingLoading, setPingLoading] = useState(false);
|
||||||
|
const [speedLoading, setSpeedLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const pingAbortRef = useRef(false);
|
||||||
|
const speedAbortRef = useRef(false);
|
||||||
|
/** Время применения кеша от планировщика (чтобы не запускать пинг/скорость повторно при открытии) */
|
||||||
|
const cacheAppliedAtRef = useRef(null);
|
||||||
|
|
||||||
|
/** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
|
||||||
|
const fetchData = useCallback(async (abortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
cacheAppliedAtRef.current = null;
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
const [serversRes, configRes, uiSettingsRes, cacheRes] = await Promise.all([
|
||||||
|
api.get('/servers', opts),
|
||||||
|
api.get('/network-config', opts).catch(() => ({ data: null })),
|
||||||
|
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
|
||||||
|
api.get('/network-map-cache', opts).catch(() => ({ data: {} })),
|
||||||
|
]);
|
||||||
|
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
||||||
|
const config = configRes?.data || {};
|
||||||
|
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||||||
|
const uiSettings = uiSettingsRes?.data || {};
|
||||||
|
const uiAlertSettings =
|
||||||
|
uiSettings.alertSettings && typeof uiSettings.alertSettings === 'object'
|
||||||
|
? uiSettings.alertSettings
|
||||||
|
: {};
|
||||||
|
const cache = cacheRes?.data || {};
|
||||||
|
const ttlSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
|
||||||
|
);
|
||||||
|
setPingCacheSeconds(ttlSeconds);
|
||||||
|
setAlertSettings(uiAlertSettings);
|
||||||
|
|
||||||
|
const getServer = (serverId) =>
|
||||||
|
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
|
||||||
|
|
||||||
|
const connList = [];
|
||||||
|
tunnelInterfaces.forEach((iface) => {
|
||||||
|
if (!iface.serverId || !iface.serverId2) return;
|
||||||
|
const s1 = getServer(iface.serverId);
|
||||||
|
const s2 = getServer(iface.serverId2);
|
||||||
|
if (!s1 || !s2 || s1.ip === s2.ip) return;
|
||||||
|
const s1Key = s1.id || s1.dns || s1.ip;
|
||||||
|
const s2Key = s2.id || s2.dns || s2.ip;
|
||||||
|
if (!s1Key || !s2Key) return;
|
||||||
|
const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase());
|
||||||
|
const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase());
|
||||||
|
connList.push({
|
||||||
|
from: s1.ip,
|
||||||
|
to: s2.ip,
|
||||||
|
tunnelType: iface.type || 'GRE',
|
||||||
|
interfaceName: iface.name || null,
|
||||||
|
fromKey: s1Key,
|
||||||
|
toKey: s2Key,
|
||||||
|
internalFromTo: iface.remoteIp || null,
|
||||||
|
internalToFrom: iface.localIp || null,
|
||||||
|
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setServers(serversList);
|
||||||
|
setConnections(connList);
|
||||||
|
|
||||||
|
const cacheMaxAgeMs = Math.max((ttlSeconds || 60) * 1000, 60 * 1000);
|
||||||
|
const cachedPingMap = cache.pingMap && typeof cache.pingMap === 'object' ? cache.pingMap : {};
|
||||||
|
const cachedSpeedMap = cache.speedMap && typeof cache.speedMap === 'object' ? cache.speedMap : {};
|
||||||
|
const hasCacheData = Object.keys(cachedPingMap).length > 0 || Object.keys(cachedSpeedMap).length > 0;
|
||||||
|
const cacheValid =
|
||||||
|
cache.updatedAt &&
|
||||||
|
Date.now() - cache.updatedAt < cacheMaxAgeMs &&
|
||||||
|
hasCacheData;
|
||||||
|
|
||||||
|
if (hasCacheData) {
|
||||||
|
// Всегда показываем кеш, если он есть — чтобы на карте отображались хотя бы прошлые пинг/скорость
|
||||||
|
setPingMap(cachedPingMap);
|
||||||
|
setSpeedMap(cachedSpeedMap);
|
||||||
|
Object.keys(cachedPingMap).forEach((k) => {
|
||||||
|
if (typeof cachedPingMap[k] === 'number') {
|
||||||
|
lastSuccessfulPingRef.current[k] = cachedPingMap[k];
|
||||||
|
}
|
||||||
|
pingTimestampsRef.current[k] = cache.updatedAt;
|
||||||
|
});
|
||||||
|
Object.keys(cachedSpeedMap).forEach((k) => {
|
||||||
|
if (cachedSpeedMap[k] && typeof cachedSpeedMap[k] === 'object') {
|
||||||
|
lastSuccessfulSpeedRef.current[k] = cachedSpeedMap[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cacheAppliedAtRef.current = cache.updatedAt;
|
||||||
|
// Устаревший кеш (старше TTL) помечаем как stale, но данные всё равно отображаем
|
||||||
|
if (cacheValid) {
|
||||||
|
setPingStaleMap({});
|
||||||
|
setSpeedStaleMap({});
|
||||||
|
} else {
|
||||||
|
const stalePing = {};
|
||||||
|
const staleSpeed = {};
|
||||||
|
Object.keys(cachedPingMap).forEach((k) => { stalePing[k] = true; });
|
||||||
|
Object.keys(cachedSpeedMap).forEach((k) => { staleSpeed[k] = true; });
|
||||||
|
setPingStaleMap(stalePing);
|
||||||
|
setSpeedStaleMap(staleSpeed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSpeedMap({});
|
||||||
|
setPingStaleMap({});
|
||||||
|
setSpeedStaleMap({});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('NetworkMap fetch:', e);
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
|
||||||
|
setServers([]);
|
||||||
|
setConnections([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Один ключ пинга на ребро (маршруты статичны — одна сторона) */
|
||||||
|
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запросить пинг для всех соединений или для конкретного подмножества.
|
||||||
|
* Если передать force=true, игнорирует TTL кеша по пингу.
|
||||||
|
*/
|
||||||
|
const requestPings = useCallback(async (options = {}) => {
|
||||||
|
const { connections: targetConnections, force = false } = options;
|
||||||
|
const sourceConnections =
|
||||||
|
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
|
||||||
|
if (sourceConnections.length === 0) return;
|
||||||
|
pingAbortRef.current = false;
|
||||||
|
setPingLoading(true);
|
||||||
|
const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000);
|
||||||
|
|
||||||
|
const tasks = [];
|
||||||
|
sourceConnections.forEach((c) => {
|
||||||
|
const from = String(c.from);
|
||||||
|
const to = String(c.to);
|
||||||
|
if (!from || !to || from === to) return;
|
||||||
|
const srcFrom = servers.find((s) => s.ip === from);
|
||||||
|
const srcTo = servers.find((s) => s.ip === to);
|
||||||
|
const canPingFrom =
|
||||||
|
srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo;
|
||||||
|
const canPingTo =
|
||||||
|
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
|
||||||
|
|
||||||
|
const ekey = edgePingKey(from, to);
|
||||||
|
if (!force && ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Один пинг на ребро — с той стороны, где есть jumphost
|
||||||
|
if (canPingFrom) {
|
||||||
|
tasks.push(async () => {
|
||||||
|
if (pingAbortRef.current) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/mikrotik/ping', {
|
||||||
|
serverId: c.fromKey,
|
||||||
|
target: c.internalFromTo,
|
||||||
|
gatewayIp: c.internalFromTo,
|
||||||
|
count: 3,
|
||||||
|
});
|
||||||
|
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
||||||
|
if (ms != null) {
|
||||||
|
pingTimestampsRef.current[ekey] = Date.now();
|
||||||
|
lastSuccessfulPingRef.current[ekey] = ms;
|
||||||
|
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pingTimestampsRef.current[ekey] = Date.now();
|
||||||
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
||||||
|
const lastSuccess = lastSuccessfulPingRef.current[ekey];
|
||||||
|
if (lastSuccess != null) {
|
||||||
|
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
|
||||||
|
} else {
|
||||||
|
setPingMap((prev) => ({ ...prev, [ekey]: null }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (canPingTo) {
|
||||||
|
tasks.push(async () => {
|
||||||
|
if (pingAbortRef.current) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/mikrotik/ping', {
|
||||||
|
serverId: c.toKey,
|
||||||
|
target: c.internalToFrom,
|
||||||
|
gatewayIp: c.internalToFrom,
|
||||||
|
count: 3,
|
||||||
|
});
|
||||||
|
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
||||||
|
if (ms != null) {
|
||||||
|
pingTimestampsRef.current[ekey] = Date.now();
|
||||||
|
lastSuccessfulPingRef.current[ekey] = ms;
|
||||||
|
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pingTimestampsRef.current[ekey] = Date.now();
|
||||||
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
||||||
|
const lastSuccess = lastSuccessfulPingRef.current[ekey];
|
||||||
|
if (lastSuccess != null) {
|
||||||
|
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
|
||||||
|
} else {
|
||||||
|
setPingMap((prev) => ({ ...prev, [ekey]: null }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWithLimit(tasks, 4);
|
||||||
|
if (pingAbortRef.current) return;
|
||||||
|
setPingLoading(false);
|
||||||
|
}, [connections, servers, pingCacheSeconds]);
|
||||||
|
|
||||||
|
/** Ключ скорости по паре серверов (без учёта порядка) */
|
||||||
|
const speedKey = (key1, key2) =>
|
||||||
|
[String(key1), String(key2)].sort().join(':');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запросить скорость для всех соединений или подмножества.
|
||||||
|
* Если передать force=true, принудительно выполняет новый speed-test (игнорируя кеш на бэкенде).
|
||||||
|
*/
|
||||||
|
const requestSpeeds = useCallback(async (options = {}) => {
|
||||||
|
const { connections: targetConnections, force: _force = false } = options;
|
||||||
|
const sourceConnections =
|
||||||
|
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
|
||||||
|
const withSpeed = sourceConnections.filter(
|
||||||
|
(c) => c.speedTestServerId && c.interfaceName
|
||||||
|
);
|
||||||
|
if (withSpeed.length === 0) return;
|
||||||
|
speedAbortRef.current = false;
|
||||||
|
setSpeedLoading(true);
|
||||||
|
const tasks = withSpeed.map((c) => async () => {
|
||||||
|
if (speedAbortRef.current) return;
|
||||||
|
const key = speedKey(c.fromKey, c.toKey);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(
|
||||||
|
'/mikrotik/speed-test',
|
||||||
|
{
|
||||||
|
serverId: c.speedTestServerId,
|
||||||
|
interfaceName: c.interfaceName,
|
||||||
|
forceRefresh: true, // ручной запуск со страницы карты сети всегда должен делать новый замер
|
||||||
|
},
|
||||||
|
{ timeout: 120000 }
|
||||||
|
);
|
||||||
|
if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
|
||||||
|
const speedData = {
|
||||||
|
tcpDownloadBps: data.tcpDownloadBps,
|
||||||
|
tcpUploadBps: data.tcpUploadBps,
|
||||||
|
cached: data.cached === true,
|
||||||
|
durationSeconds: data.durationSeconds,
|
||||||
|
};
|
||||||
|
lastSuccessfulSpeedRef.current[key] = speedData;
|
||||||
|
setSpeedMap((prev) => ({ ...prev, [key]: speedData }));
|
||||||
|
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
||||||
|
const lastSuccess = lastSuccessfulSpeedRef.current[key];
|
||||||
|
if (lastSuccess != null) {
|
||||||
|
setSpeedMap((prev) => ({ ...prev, [key]: lastSuccess }));
|
||||||
|
setSpeedStaleMap((prev) => ({ ...prev, [key]: true }));
|
||||||
|
} else {
|
||||||
|
setSpeedMap((prev) => ({ ...prev, [key]: null }));
|
||||||
|
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// В один момент — только один тест на один сервер назначения (удалённый конец туннеля),
|
||||||
|
// иначе несколько замеров к одному хостингу делят канал и дают заниженные результаты
|
||||||
|
await runWithOnePerKey(tasks, (i) => {
|
||||||
|
const c = withSpeed[i];
|
||||||
|
return c.speedTestServerId === c.fromKey ? c.toKey : c.fromKey;
|
||||||
|
});
|
||||||
|
if (speedAbortRef.current) return;
|
||||||
|
setSpeedLoading(false);
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const handleRefreshPingForConnection = useCallback(
|
||||||
|
(connection) => {
|
||||||
|
if (!connection) return;
|
||||||
|
requestPings({ connections: [connection], force: true });
|
||||||
|
},
|
||||||
|
[requestPings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefreshSpeedForConnection = useCallback(
|
||||||
|
(connection) => {
|
||||||
|
if (!connection) return;
|
||||||
|
requestSpeeds({ connections: [connection], force: true });
|
||||||
|
},
|
||||||
|
[requestSpeeds]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchData(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// Пинг и скорость не запускаются автоматически при открытии карты (даже если кеш просрочен).
|
||||||
|
// Обновление только по кнопкам «Пинг» и «Скорость» или по планировщику.
|
||||||
|
|
||||||
|
const handleRefreshPings = () => {
|
||||||
|
requestPings();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshSpeeds = () => {
|
||||||
|
requestSpeeds({ force: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Карта сети"
|
||||||
|
icon={<IconTopologyRing size={24} />}
|
||||||
|
meta="Серверы и пинг между ними"
|
||||||
|
/>
|
||||||
|
<div className="text-center py-5 text-muted">Загрузка серверов и связей…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Карта сети"
|
||||||
|
icon={<IconTopologyRing size={24} />}
|
||||||
|
meta="Серверы и пинг между ними"
|
||||||
|
/>
|
||||||
|
<div className="alert alert-danger">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Карта сети"
|
||||||
|
icon={<IconTopologyRing size={24} />}
|
||||||
|
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг и скорость"
|
||||||
|
actions={
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleRefreshPings}
|
||||||
|
disabled={pingLoading}
|
||||||
|
>
|
||||||
|
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
|
||||||
|
{pingLoading ? 'Пинг…' : 'Пинг'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleRefreshSpeeds}
|
||||||
|
disabled={speedLoading}
|
||||||
|
>
|
||||||
|
<IconRefresh className={speedLoading ? 'spin me-2' : 'me-2'} size={18} />
|
||||||
|
{speedLoading ? 'Скорость…' : 'Скорость'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
Нет серверов. Добавьте серверы в разделе «Серверы».
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NetworkMapUnifi
|
||||||
|
servers={servers}
|
||||||
|
connections={connections}
|
||||||
|
pingMap={pingMap}
|
||||||
|
speedMap={speedMap}
|
||||||
|
pingStaleMap={pingStaleMap}
|
||||||
|
speedStaleMap={speedStaleMap}
|
||||||
|
onRefreshPingForConnection={handleRefreshPingForConnection}
|
||||||
|
onRefreshSpeedForConnection={handleRefreshSpeedForConnection}
|
||||||
|
pingLoading={pingLoading}
|
||||||
|
speedLoading={speedLoading}
|
||||||
|
alertSettings={alertSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import {
|
||||||
|
IconClockPlay,
|
||||||
|
IconSettings,
|
||||||
|
IconTerminal2,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconRefresh,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
const POLL_LOGS_MS = 2500;
|
||||||
|
const INTERVAL_MIN = 1;
|
||||||
|
const INTERVAL_MAX = 1440;
|
||||||
|
const LOG_LINES_MIN = 100;
|
||||||
|
const LOG_LINES_MAX = 2000;
|
||||||
|
|
||||||
|
export default function NetworkMapSchedulerPage() {
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
enabled: false,
|
||||||
|
intervalMinutes: 5,
|
||||||
|
runPing: true,
|
||||||
|
runSpeedTest: true,
|
||||||
|
logMaxLines: 500,
|
||||||
|
});
|
||||||
|
const [nextRunAt, setNextRunAt] = useState(null);
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const logEndRef = useRef(null);
|
||||||
|
const logContainerRef = useRef(null);
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
|
||||||
|
const fetchSettings = useCallback(async (abortSignal) => {
|
||||||
|
try {
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
const { data } = await api.get('/scheduler/network-map/settings', opts);
|
||||||
|
setSettings({
|
||||||
|
enabled: Boolean(data.enabled),
|
||||||
|
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, Number(data.intervalMinutes) || 5)),
|
||||||
|
runPing: data.runPing !== false,
|
||||||
|
runSpeedTest: data.runSpeedTest !== false,
|
||||||
|
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, Number(data.logMaxLines) || 500)),
|
||||||
|
});
|
||||||
|
setNextRunAt(typeof data.nextRunAt === 'number' ? data.nextRunAt : null);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить настройки');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/scheduler/network-map/logs');
|
||||||
|
setLogs(Array.isArray(data?.lines) ? data.lines : []);
|
||||||
|
} catch (_) {
|
||||||
|
// не показываем ошибку при опросе логов
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await fetchSettings(controller.signal);
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [fetchSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
const t = setInterval(fetchLogs, POLL_LOGS_MS);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings.enabled || !nextRunAt) return;
|
||||||
|
const t = setInterval(fetchSettings, 60 * 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [settings.enabled, nextRunAt, fetchSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && logEndRef.current) {
|
||||||
|
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs, autoScroll]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.patch('/scheduler/network-map/settings', {
|
||||||
|
networkMapSchedulerEnabled: settings.enabled,
|
||||||
|
networkMapSchedulerIntervalMinutes: settings.intervalMinutes,
|
||||||
|
networkMapSchedulerRunPing: settings.runPing,
|
||||||
|
networkMapSchedulerRunSpeedTest: settings.runSpeedTest,
|
||||||
|
networkMapSchedulerLogMaxLines: settings.logMaxLines,
|
||||||
|
});
|
||||||
|
await fetchSettings();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось сохранить');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunNow = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.post('/scheduler/network-map/run-now');
|
||||||
|
setTimeout(fetchLogs, 500);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось запустить');
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Планировщик карты сети"
|
||||||
|
icon={<IconClockPlay size={24} />}
|
||||||
|
meta="Пинг и замер скорости между серверами по расписанию"
|
||||||
|
/>
|
||||||
|
<div className="text-center py-5 text-muted">Загрузка настроек…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Планировщик карты сети"
|
||||||
|
icon={<IconClockPlay size={24} />}
|
||||||
|
meta="Пинг и замер скорости между серверами по расписанию; кеш ускоряет загрузку карты сети."
|
||||||
|
actions={
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleRunNow}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay className={running ? 'spin me-2' : 'me-2'} size={18} />
|
||||||
|
{running ? 'Запуск…' : 'Запустить сейчас'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger alert-dismissible fade show mb-3" role="alert">
|
||||||
|
{error}
|
||||||
|
<button type="button" className="btn-close" aria-label="Закрыть" onClick={() => setError(null)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row row-deck row-cards g-3">
|
||||||
|
<div className="col-12 col-lg-5">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title d-flex align-items-center">
|
||||||
|
<IconSettings className="me-2" size={20} />
|
||||||
|
Настройки
|
||||||
|
</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-check form-switch">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enabled}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, enabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Включить планировщик</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-hint">Периодически обновлять пинг и скорость по связям из «Сетевые настройки»</div>
|
||||||
|
{settings.enabled && nextRunAt != null && (
|
||||||
|
<div className="mt-2 text-muted small">
|
||||||
|
Следующий запуск: {new Date(nextRunAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'medium' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Интервал (минут)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
min={INTERVAL_MIN}
|
||||||
|
max={INTERVAL_MAX}
|
||||||
|
value={settings.intervalMinutes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, parseInt(e.target.value, 10) || INTERVAL_MIN)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="form-hint">От 1 до 1440 (сутки). По умолчанию 5 мин.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Задачи</label>
|
||||||
|
<div>
|
||||||
|
<label className="form-check form-switch me-3">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.runPing}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, runPing: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Пинг</span>
|
||||||
|
</label>
|
||||||
|
<label className="form-check form-switch">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.runSpeedTest}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, runSpeedTest: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Замер скорости</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-0">
|
||||||
|
<label className="form-label">Строк в логе</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
min={LOG_LINES_MIN}
|
||||||
|
max={LOG_LINES_MAX}
|
||||||
|
value={settings.logMaxLines}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, parseInt(e.target.value, 10) || 500)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="form-hint">Кольцевой буфер: от 100 до 2000 строк.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-lg-7">
|
||||||
|
<div className="card flex-fill">
|
||||||
|
<div className="card-header d-flex align-items-center">
|
||||||
|
<h3 className="card-title d-flex align-items-center mb-0">
|
||||||
|
<IconTerminal2 className="me-2" size={20} />
|
||||||
|
Логи
|
||||||
|
</h3>
|
||||||
|
<div className="card-actions ms-auto">
|
||||||
|
<label className="form-check form-switch form-check-inline mb-0 me-2">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoScroll}
|
||||||
|
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label small">Автопрокрутка</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={fetchLogs}
|
||||||
|
aria-label="Обновить логи"
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={logContainerRef}
|
||||||
|
className="card-body p-0"
|
||||||
|
style={{ minHeight: 320 }}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
className="mb-0 p-3 bg-dark text-light rounded-0 rounded-bottom"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
maxHeight: 420,
|
||||||
|
overflow: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<span className="text-muted">Логов пока нет. Включите планировщик и нажмите «Запустить сейчас» или дождитесь следующего запуска.</span>
|
||||||
|
) : (
|
||||||
|
logs.map((line, i) => (
|
||||||
|
<span key={i} style={{ display: 'block' }}>
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<span ref={logEndRef} />
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
|||||||
|
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
|
import { IconDatabase, IconRefresh, IconRoute2, IconWand } from '@tabler/icons-react';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import { useNotify } from './components/NotifyProvider.jsx';
|
||||||
|
|
||||||
|
const OSPF_COST_STEP = 10;
|
||||||
|
|
||||||
|
function moveInArray(arr, fromIndex, toIndex) {
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
|
||||||
|
const copy = [...arr];
|
||||||
|
const [moved] = copy.splice(fromIndex, 1);
|
||||||
|
copy.splice(toIndex, 0, moved);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnknownOspfInterfaceName(name) {
|
||||||
|
const value = String(name || '').trim();
|
||||||
|
if (!value) return true;
|
||||||
|
if (value.startsWith('*')) return true;
|
||||||
|
if (value.toLowerCase() === 'unknown') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOspfRouterIdentity(server) {
|
||||||
|
if (!server || typeof server !== 'object') {
|
||||||
|
return { key: 'unknown', label: 'Unknown router' };
|
||||||
|
}
|
||||||
|
const host = String(server.mikrotikHost || server.ip || server.dns || server.id || '').trim();
|
||||||
|
const port = String(server.mikrotikPort || 80).trim();
|
||||||
|
const user = String(server.mikrotikUser || 'admin').trim();
|
||||||
|
const key = `${host}::${port}::${user}`;
|
||||||
|
const label = host || String(server.dns || server.ip || server.id || 'Unknown router');
|
||||||
|
return { key, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOspfTemplatesFromRouterResults(routerResults = [], servers = []) {
|
||||||
|
const serverById = new Map((servers || []).map((s) => [String(s.id || s.ip || s.dns || ''), s]));
|
||||||
|
const byComposite = new Map();
|
||||||
|
|
||||||
|
(routerResults || []).forEach((result) => {
|
||||||
|
const serverId = String(result?.serverId || '').trim();
|
||||||
|
if (!serverId) return;
|
||||||
|
const server = serverById.get(serverId) || {
|
||||||
|
id: serverId,
|
||||||
|
ip: String(result?.serverLabel || serverId),
|
||||||
|
dns: String(result?.serverLabel || ''),
|
||||||
|
};
|
||||||
|
const identity = getOspfRouterIdentity(server);
|
||||||
|
const templates = Array.isArray(result?.templates) ? result.templates : [];
|
||||||
|
|
||||||
|
templates.forEach((tpl) => {
|
||||||
|
const interfaceName = String(tpl?.interfaceName || '').trim();
|
||||||
|
if (isUnknownOspfInterfaceName(interfaceName)) return;
|
||||||
|
const area = String(tpl?.area || '').trim() || 'backbone';
|
||||||
|
const numericCost = Number(tpl?.cost);
|
||||||
|
const cost = Number.isFinite(numericCost) && numericCost >= 0 ? numericCost : OSPF_COST_STEP;
|
||||||
|
|
||||||
|
const compositeKey = `${identity.key}::${area.toLowerCase()}::${interfaceName.toUpperCase()}`;
|
||||||
|
if (byComposite.has(compositeKey)) return;
|
||||||
|
|
||||||
|
byComposite.set(compositeKey, {
|
||||||
|
key: compositeKey,
|
||||||
|
rosId: tpl?.id || null,
|
||||||
|
serverId,
|
||||||
|
routerKey: identity.key,
|
||||||
|
routerLabel: identity.label,
|
||||||
|
interfaceName,
|
||||||
|
area,
|
||||||
|
cost,
|
||||||
|
active: !tpl?.disabled,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(byComposite.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOptimizerProbabilityMap(routeOptimizerData, servers = []) {
|
||||||
|
const homes = Array.isArray(routeOptimizerData?.homes) ? routeOptimizerData.homes : [];
|
||||||
|
const byRef = new Map();
|
||||||
|
(servers || []).forEach((server) => {
|
||||||
|
[server?.id, server?.ip, server?.dns]
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((ref) => byRef.set(String(ref), server));
|
||||||
|
});
|
||||||
|
|
||||||
|
const probabilities = {};
|
||||||
|
|
||||||
|
homes.forEach((entry) => {
|
||||||
|
const homeRefCandidates = [
|
||||||
|
entry?.home?.id,
|
||||||
|
entry?.home?.ip,
|
||||||
|
entry?.home?.dns,
|
||||||
|
].filter(Boolean).map(String);
|
||||||
|
const homeServer = homeRefCandidates.map((ref) => byRef.get(ref)).find(Boolean);
|
||||||
|
if (!homeServer) return;
|
||||||
|
|
||||||
|
const identity = getOspfRouterIdentity(homeServer);
|
||||||
|
const candidates = Array.isArray(entry?.homeToJumphostCandidates)
|
||||||
|
? [...entry.homeToJumphostCandidates]
|
||||||
|
: [];
|
||||||
|
candidates.sort((a, b) =>
|
||||||
|
Number(b?.probabilityOptimal || 0) - Number(a?.probabilityOptimal || 0) ||
|
||||||
|
Number(b?.score || 0) - Number(a?.score || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueIface = [];
|
||||||
|
const seen = new Set();
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
const iface = String(candidate?.interfaceName || '').trim();
|
||||||
|
if (!iface) return;
|
||||||
|
const key = iface.toUpperCase();
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
uniqueIface.push(candidate);
|
||||||
|
});
|
||||||
|
|
||||||
|
uniqueIface.forEach((candidate, _idx) => {
|
||||||
|
const iface = String(candidate?.interfaceName || '').trim();
|
||||||
|
const hintKey = `${identity.key}::${iface.toUpperCase()}`;
|
||||||
|
const probabilityOptimal = Number(candidate?.probabilityOptimal || 0);
|
||||||
|
const prev = probabilities[hintKey];
|
||||||
|
if (prev == null || probabilityOptimal > prev) {
|
||||||
|
probabilities[hintKey] = probabilityOptimal;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return probabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OspfToolsPage() {
|
||||||
|
const notify = useNotify();
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [optimizerLoading, setOptimizerLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(null);
|
||||||
|
const [optimizerProbabilities, setOptimizerProbabilities] = useState({});
|
||||||
|
|
||||||
|
const groupedByRouter = useMemo(() => {
|
||||||
|
const grouped = {};
|
||||||
|
(templates || []).forEach((item) => {
|
||||||
|
const sid = String(item?.serverId || '');
|
||||||
|
const server = servers.find((s) => String(s.id || s.ip || s.dns || '') === sid);
|
||||||
|
const identity = getOspfRouterIdentity(server || { id: sid, ip: sid });
|
||||||
|
if (!grouped[identity.key]) {
|
||||||
|
grouped[identity.key] = {
|
||||||
|
routerKey: identity.key,
|
||||||
|
routerLabel: identity.label,
|
||||||
|
representativeServerId: sid,
|
||||||
|
areas: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!grouped[identity.key].areas[item.area]) grouped[identity.key].areas[item.area] = [];
|
||||||
|
grouped[identity.key].areas[item.area].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(grouped).map((group) => ({
|
||||||
|
...group,
|
||||||
|
areas: Object.entries(group.areas)
|
||||||
|
.map(([area, areaItems]) => ({
|
||||||
|
area,
|
||||||
|
items: [...areaItems].sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.area.localeCompare(b.area)),
|
||||||
|
}));
|
||||||
|
}, [templates, servers]);
|
||||||
|
const optimalHints = useMemo(() => {
|
||||||
|
const hints = {};
|
||||||
|
|
||||||
|
groupedByRouter.forEach((routerGroup) => {
|
||||||
|
(routerGroup.areas || []).forEach((areaGroup) => {
|
||||||
|
const ranked = [...(areaGroup.items || [])]
|
||||||
|
.map((item) => {
|
||||||
|
const ifaceKey = `${routerGroup.routerKey}::${String(item.interfaceName || '').toUpperCase()}`;
|
||||||
|
const probabilityOptimal = Number(optimizerProbabilities[ifaceKey] || 0);
|
||||||
|
return { item, probabilityOptimal };
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
Number(b.probabilityOptimal || 0) - Number(a.probabilityOptimal || 0) ||
|
||||||
|
String(a.item.interfaceName || '').localeCompare(String(b.item.interfaceName || ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
ranked.forEach(({ item, probabilityOptimal }, idx) => {
|
||||||
|
const hintKey = `${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`;
|
||||||
|
hints[hintKey] = {
|
||||||
|
optimalCost: (idx + 1) * OSPF_COST_STEP,
|
||||||
|
probabilityOptimal,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return hints;
|
||||||
|
}, [groupedByRouter, optimizerProbabilities]);
|
||||||
|
const routerColumnClass = groupedByRouter.length > 1 ? 'col-12 col-xxl-6' : 'col-12';
|
||||||
|
|
||||||
|
const loadServers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/servers');
|
||||||
|
setServers(Array.isArray(response.data) ? response.data : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load servers:', error);
|
||||||
|
setServers([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOspf = useCallback(async ({ silent = false } = {}) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/mikrotik/ospf-interface-templates');
|
||||||
|
const results = Array.isArray(response.data?.results) ? response.data.results : [];
|
||||||
|
const errors = Array.isArray(response.data?.errors) ? response.data.errors : [];
|
||||||
|
setTemplates(buildOspfTemplatesFromRouterResults(results, servers));
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
if (results.length > 0) notify.success(`OSPF загружен: ${results.length} роутер(ов)`);
|
||||||
|
else notify.warning('OSPF Interface Template не найдены');
|
||||||
|
if (errors.length > 0) notify.warning(`Ошибки чтения OSPF: ${errors.length}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load OSPF:', error);
|
||||||
|
if (!silent) notify.error('Не удалось загрузить OSPF с MikroTik');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [servers, notify]);
|
||||||
|
|
||||||
|
const loadOptimizerHints = useCallback(async ({ silent = false } = {}) => {
|
||||||
|
setOptimizerLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/route-optimizer', { timeout: 30000 });
|
||||||
|
const probabilities = buildOptimizerProbabilityMap(response?.data, servers);
|
||||||
|
setOptimizerProbabilities(probabilities);
|
||||||
|
return probabilities;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load route optimizer hints:', error);
|
||||||
|
setOptimizerProbabilities({});
|
||||||
|
if (!silent) notify.warning('Не удалось загрузить рекомендации из оптимизатора маршрутов');
|
||||||
|
return {};
|
||||||
|
} finally {
|
||||||
|
setOptimizerLoading(false);
|
||||||
|
}
|
||||||
|
}, [servers, notify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadServers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (servers.length === 0) return;
|
||||||
|
loadOspf({ silent: true });
|
||||||
|
loadOptimizerHints({ silent: true });
|
||||||
|
}, [servers.length, loadOspf, loadOptimizerHints]);
|
||||||
|
|
||||||
|
const onDrop = (routerKey, area, targetKey) => {
|
||||||
|
if (!dragging || dragging.routerKey !== routerKey || dragging.area !== area) return;
|
||||||
|
if (dragging.key === targetKey) {
|
||||||
|
setDragging(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplates((prev) => {
|
||||||
|
const current = prev
|
||||||
|
.filter((item) => item.routerKey === routerKey && String(item.area) === String(area))
|
||||||
|
.sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0));
|
||||||
|
const fromIndex = current.findIndex((item) => item.key === dragging.key);
|
||||||
|
const toIndex = current.findIndex((item) => item.key === targetKey);
|
||||||
|
if (fromIndex < 0 || toIndex < 0) return prev;
|
||||||
|
|
||||||
|
const reordered = moveInArray(current, fromIndex, toIndex).map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
cost: (idx + 1) * OSPF_COST_STEP,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const other = prev.filter(
|
||||||
|
(item) => !(item.routerKey === routerKey && String(item.area) === String(area))
|
||||||
|
);
|
||||||
|
return [...other, ...reordered];
|
||||||
|
});
|
||||||
|
setDragging(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const optimizeCosts = async () => {
|
||||||
|
let hints = optimalHints;
|
||||||
|
if (!hints || Object.keys(hints).length === 0) {
|
||||||
|
const probabilities = await loadOptimizerHints({ silent: true });
|
||||||
|
const refreshedHints = {};
|
||||||
|
groupedByRouter.forEach((routerGroup) => {
|
||||||
|
(routerGroup.areas || []).forEach((areaGroup) => {
|
||||||
|
const ranked = [...(areaGroup.items || [])]
|
||||||
|
.map((item) => {
|
||||||
|
const ifaceKey = `${routerGroup.routerKey}::${String(item.interfaceName || '').toUpperCase()}`;
|
||||||
|
const probabilityOptimal = Number((probabilities || {})[ifaceKey] || 0);
|
||||||
|
return { item, probabilityOptimal };
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
Number(b.probabilityOptimal || 0) - Number(a.probabilityOptimal || 0) ||
|
||||||
|
String(a.item.interfaceName || '').localeCompare(String(b.item.interfaceName || ''))
|
||||||
|
);
|
||||||
|
ranked.forEach(({ item, probabilityOptimal }, idx) => {
|
||||||
|
const hintKey = `${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`;
|
||||||
|
refreshedHints[hintKey] = {
|
||||||
|
optimalCost: (idx + 1) * OSPF_COST_STEP,
|
||||||
|
probabilityOptimal,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
hints = refreshedHints;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hints || Object.keys(hints).length === 0) {
|
||||||
|
notify.warning('Нет рекомендаций оптимизатора для расчета cost');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changedCount = 0;
|
||||||
|
let hintedCount = 0;
|
||||||
|
const nextTemplates = (templates || []).map((item) => {
|
||||||
|
const hintKey = `${item.routerKey}::${item.area}::${String(item.interfaceName || '').toUpperCase()}`;
|
||||||
|
const hint = hints[hintKey];
|
||||||
|
if (!hint) return item;
|
||||||
|
hintedCount += 1;
|
||||||
|
const nextCost = Number(hint.optimalCost);
|
||||||
|
if (!Number.isFinite(nextCost) || nextCost < 0 || Number(item.cost) === nextCost) return item;
|
||||||
|
changedCount += 1;
|
||||||
|
return { ...item, cost: nextCost };
|
||||||
|
});
|
||||||
|
setTemplates(nextTemplates);
|
||||||
|
|
||||||
|
if (hintedCount === 0) {
|
||||||
|
notify.warning('Для интерфейсов на странице нет рекомендаций оптимизатора');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (changedCount === 0) {
|
||||||
|
notify.success('Текущие cost уже соответствуют оптимальным рекомендациям');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify.success(`Оптимизация применена: обновлено cost у ${changedCount} интерфейсов`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveOspf = async () => {
|
||||||
|
if (groupedByRouter.length === 0) {
|
||||||
|
notify.warning('Нет OSPF данных для сохранения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
servers: groupedByRouter.map((group) => ({
|
||||||
|
serverId: group.representativeServerId,
|
||||||
|
templates: group.areas.flatMap((areaGroup) =>
|
||||||
|
areaGroup.items.map((item) => ({
|
||||||
|
rosId: item.rosId || null,
|
||||||
|
interfaceName: item.interfaceName,
|
||||||
|
area: item.area,
|
||||||
|
cost: item.cost,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await api.post('/mikrotik/ospf-interface-templates/apply', payload);
|
||||||
|
const summary = response.data?.summary || {};
|
||||||
|
notify.success(`OSPF сохранен: обновлено ${summary.updated || 0}, пропущено ${summary.skipped || 0}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save OSPF:', error);
|
||||||
|
notify.error('Не удалось сохранить OSPF в MikroTik');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ospf-tools-page">
|
||||||
|
<PageHeader
|
||||||
|
title="Инструменты OSPF"
|
||||||
|
icon={<IconRoute2 size={24} />}
|
||||||
|
actions={(
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
loadOspf();
|
||||||
|
loadOptimizerHints({ silent: true });
|
||||||
|
}}
|
||||||
|
disabled={loading || optimizerLoading}
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} className={`me-1 ${(loading || optimizerLoading) ? 'spin' : ''}`} />
|
||||||
|
Загрузить из MikroTik
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={optimizeCosts}
|
||||||
|
disabled={loading || optimizerLoading || groupedByRouter.length === 0}
|
||||||
|
title="Проставить интерфейсам потенциально оптимальный cost"
|
||||||
|
>
|
||||||
|
<IconWand size={16} className="me-1" />
|
||||||
|
Оптимизировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={saveOspf}
|
||||||
|
disabled={saving || groupedByRouter.length === 0}
|
||||||
|
>
|
||||||
|
<IconDatabase size={16} className={`me-1 ${saving ? 'spin' : ''}`} />
|
||||||
|
Сохранить OSPF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="text-muted small">
|
||||||
|
Данные OSPF берутся напрямую с MikroTik. Группировка: роутер → Area → интерфейсы.
|
||||||
|
Чем меньше cost, тем выше приоритет. Рядом отображается потенциально оптимальный cost из оптимизатора маршрутов.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{groupedByRouter.length === 0 ? (
|
||||||
|
<div className="text-muted">OSPF Interface Template не найдены.</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3">
|
||||||
|
{groupedByRouter.map((routerGroup) => (
|
||||||
|
<div key={routerGroup.routerKey} className={routerColumnClass}>
|
||||||
|
<div className="card card-sm border">
|
||||||
|
<div className="card-header fw-semibold">{routerGroup.routerLabel}</div>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
{routerGroup.areas.map((areaGroup) => (
|
||||||
|
<div key={`${routerGroup.routerKey}-${areaGroup.area}`} className="border-top">
|
||||||
|
<div className="px-3 py-2 bg-light text-muted small fw-semibold">
|
||||||
|
Area: {areaGroup.area}
|
||||||
|
</div>
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{areaGroup.items.map((item) => (
|
||||||
|
(() => {
|
||||||
|
const hint = optimalHints[`${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`];
|
||||||
|
const hintMatches = hint && Number(hint.optimalCost) === Number(item.cost);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="list-group-item"
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragging({
|
||||||
|
routerKey: routerGroup.routerKey,
|
||||||
|
area: areaGroup.area,
|
||||||
|
key: item.key,
|
||||||
|
})}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => onDrop(routerGroup.routerKey, areaGroup.area, item.key)}
|
||||||
|
onDragEnd={() => setDragging(null)}
|
||||||
|
style={{ cursor: 'grab' }}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||||
|
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-muted small">::</span>
|
||||||
|
<code>{item.interfaceName}</code>
|
||||||
|
<span className={`badge ${item.active ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}>
|
||||||
|
{item.active ? 'Активен' : 'Неактивен'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-2 ms-auto">
|
||||||
|
{hint && (
|
||||||
|
<span
|
||||||
|
className={`badge ${hintMatches ? 'bg-green-lt text-green' : 'bg-yellow-lt text-yellow'}`}
|
||||||
|
title={`Вероятность оптимальности: ${hint.probabilityOptimal}%`}
|
||||||
|
>
|
||||||
|
opt cost {hint.optimalCost}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="badge bg-primary-lt text-primary">cost {item.cost}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconDeviceFloppy,
|
||||||
|
IconCloud,
|
||||||
|
IconSearch,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import FormModal from './components/FormModal.jsx';
|
||||||
|
import FormField from './components/FormField.jsx';
|
||||||
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||||
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||||
|
import IconPicker from './components/IconPicker.jsx';
|
||||||
|
import { getIconById } from './lib/brandIcons.js';
|
||||||
|
|
||||||
|
const COLOR_OPTIONS = [
|
||||||
|
{ value: 'primary', label: 'Основной' },
|
||||||
|
{ value: 'blue', label: 'Синий' },
|
||||||
|
{ value: 'green', label: 'Зелёный' },
|
||||||
|
{ value: 'orange', label: 'Оранжевый' },
|
||||||
|
{ value: 'red', label: 'Красный' },
|
||||||
|
{ value: 'pink', label: 'Розовый' },
|
||||||
|
{ value: 'purple', label: 'Фиолетовый' },
|
||||||
|
{ value: 'cyan', label: 'Голубой' },
|
||||||
|
{ value: 'yellow', label: 'Жёлтый' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function slugId(name) {
|
||||||
|
const s = String(name || '').trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||||
|
return s || `svc-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PingServicesManager() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [rawSettings, setRawSettings] = useState({});
|
||||||
|
const [etag, setEtag] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
|
||||||
|
const [newItem, setNewItem] = useState({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: '443',
|
||||||
|
icon: 'IconBrandGoogle',
|
||||||
|
color: 'blue',
|
||||||
|
});
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [editDraft, setEditDraft] = useState({ id: '', name: '', host: '', port: '443', icon: 'IconBrandGoogle', color: 'blue' });
|
||||||
|
|
||||||
|
const loadSettings = async (abortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
const res = await api.get('/ui-settings', opts);
|
||||||
|
const data = res?.data || {};
|
||||||
|
setRawSettings(data);
|
||||||
|
setEtag(String(res?.headers?.etag || res?.headers?.ETag || ''));
|
||||||
|
const list = Array.isArray(data.pingServicesList) ? data.pingServicesList : [];
|
||||||
|
setItems(list.map((s) => ({
|
||||||
|
id: String(s?.id ?? '').trim() || slugId(s?.name),
|
||||||
|
name: String(s?.name ?? '').trim(),
|
||||||
|
host: String(s?.host ?? '').trim(),
|
||||||
|
port: Math.max(1, parseInt(s?.port, 10) || 443),
|
||||||
|
icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld',
|
||||||
|
color: typeof s?.color === 'string' ? s.color : 'primary',
|
||||||
|
})).filter((s) => s.host));
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error(e);
|
||||||
|
setError('Не удалось загрузить настройки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
loadSettings(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
const payloadToSave = {
|
||||||
|
...rawSettings,
|
||||||
|
pingServicesList: items.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
port: s.port,
|
||||||
|
icon: s.icon,
|
||||||
|
color: s.color,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
let currentEtag = etag;
|
||||||
|
let res = await api.post('/ui-settings', { settings: payloadToSave, etag: currentEtag });
|
||||||
|
setRawSettings(payloadToSave);
|
||||||
|
setEtag(String(res?.data?.etag || currentEtag));
|
||||||
|
setSuccess('Список сервисов сохранён');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.response?.status === 412) {
|
||||||
|
try {
|
||||||
|
const fresh = await api.get('/ui-settings');
|
||||||
|
const freshData = fresh?.data || {};
|
||||||
|
const freshEtag = String(fresh?.headers?.etag || fresh?.headers?.ETag || '');
|
||||||
|
const retryPayload = { ...freshData, pingServicesList: payloadToSave.pingServicesList };
|
||||||
|
const retryRes = await api.post('/ui-settings', { settings: retryPayload, etag: freshEtag });
|
||||||
|
setRawSettings(retryPayload);
|
||||||
|
setEtag(String(retryRes?.data?.etag || freshEtag));
|
||||||
|
setSuccess('Список сервисов сохранён (настройки были обновлены)');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
} catch (retryErr) {
|
||||||
|
setError(retryErr?.response?.data?.message || 'Настройки изменились. Обновите страницу и сохраните снова.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(e?.response?.data?.message || 'Не удалось сохранить');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setNewItem({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: '443',
|
||||||
|
icon: 'IconBrandGoogle',
|
||||||
|
color: 'blue',
|
||||||
|
});
|
||||||
|
setAddModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAdd = () => {
|
||||||
|
const name = String(newItem.name || '').trim();
|
||||||
|
const host = String(newItem.host || '').trim();
|
||||||
|
if (!name) { setError('Укажите название'); return; }
|
||||||
|
if (!host) { setError('Укажите хост'); return; }
|
||||||
|
const id = newItem.id?.trim() || slugId(name);
|
||||||
|
if (items.some((i) => i.id === id)) { setError('Такой ID уже есть'); return; }
|
||||||
|
setError('');
|
||||||
|
setItems((prev) => [...prev, {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
port: Math.max(1, parseInt(newItem.port, 10) || 443),
|
||||||
|
icon: newItem.icon || 'IconWorld',
|
||||||
|
color: newItem.color || 'primary',
|
||||||
|
}]);
|
||||||
|
setAddModalOpen(false);
|
||||||
|
setSuccess('Сервис добавлен. Нажмите «Сохранить».');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setEditDraft({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
host: item.host,
|
||||||
|
port: String(item.port),
|
||||||
|
icon: item.icon || 'IconWorld',
|
||||||
|
color: item.color || 'primary',
|
||||||
|
});
|
||||||
|
setEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEdit = () => {
|
||||||
|
const name = String(editDraft.name || '').trim();
|
||||||
|
const host = String(editDraft.host || '').trim();
|
||||||
|
if (!name) { setError('Укажите название'); return; }
|
||||||
|
if (!host) { setError('Укажите хост'); return; }
|
||||||
|
const id = editDraft.id?.trim() || editingItem.id;
|
||||||
|
if (id !== editingItem.id && items.some((i) => i.id === id)) { setError('Такой ID уже есть'); return; }
|
||||||
|
setError('');
|
||||||
|
setItems((prev) => prev.map((i) => (i.id === editingItem.id ? {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
port: Math.max(1, parseInt(editDraft.port, 10) || 443),
|
||||||
|
icon: editDraft.icon || 'IconWorld',
|
||||||
|
color: editDraft.color || 'primary',
|
||||||
|
} : i)));
|
||||||
|
setEditModalOpen(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
setSuccess('Сервис обновлён. Нажмите «Сохранить».');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDelete = (item) => {
|
||||||
|
setItemToDelete(item);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (itemToDelete) {
|
||||||
|
setItems((prev) => prev.filter((i) => i.id !== itemToDelete.id));
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setItemToDelete(null);
|
||||||
|
setSuccess('Сервис удалён. Нажмите «Сохранить».');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const filtered = items.filter((i) => {
|
||||||
|
const q = searchTerm.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return (i.name || '').toLowerCase().includes(q) || (i.host || '').toLowerCase().includes(q) || (i.id || '').toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Пинг сервисов"
|
||||||
|
icon={<IconCloud size={24} />}
|
||||||
|
actions={(
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={openAdd}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconPlus size={18} className="me-1" />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={saveSettings}
|
||||||
|
disabled={saving || loading}
|
||||||
|
>
|
||||||
|
{saving && <span className="spinner-border spinner-border-sm me-2" />}
|
||||||
|
<IconDeviceFloppy size={18} className="me-1" />
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <ErrorAlert message={error} onClose={() => setError('')} onRetry={loadSettings} />}
|
||||||
|
{success && (
|
||||||
|
<div className="alert alert-success alert-dismissible">
|
||||||
|
{success}
|
||||||
|
<button type="button" className="btn-close" onClick={() => setSuccess('')} aria-label="Закрыть" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<div className="input-group input-group-flat" style={{ maxWidth: 280 }}>
|
||||||
|
<span className="input-group-text">
|
||||||
|
<IconSearch size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Поиск по названию, хосту, ID..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted small ms-2">
|
||||||
|
{filtered.length} из {items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 56 }}>Иконка</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Хост</th>
|
||||||
|
<th>Порт</th>
|
||||||
|
<th>Цвет</th>
|
||||||
|
<th className="w-1">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="text-center py-4 text-muted">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="text-center py-4 text-muted">
|
||||||
|
Нет сервисов. Нажмите «Добавить» или сохраните список — на главной будут использоваться сервисы по умолчанию.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((item) => {
|
||||||
|
const { Icon } = getIconById(item.icon);
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}>
|
||||||
|
<Icon size={18} stroke={1.5} />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><code className="small">{item.id}</code></td>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td><code className="small">{item.host}</code></td>
|
||||||
|
<td>{item.port}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge bg-${item.color}-lt text-${item.color}`}>{item.color}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => openDelete(item)}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormModal
|
||||||
|
show={addModalOpen}
|
||||||
|
onClose={() => setAddModalOpen(false)}
|
||||||
|
onSubmit={submitAdd}
|
||||||
|
title="Добавить сервис для пинга"
|
||||||
|
submitLabel="Добавить"
|
||||||
|
>
|
||||||
|
<FormField label="Название" name="name" value={newItem.name} onChange={(v) => setNewItem((p) => ({ ...p, name: v }))} placeholder="Google" />
|
||||||
|
<FormField label="Хост (IP или домен)" name="host" value={newItem.host} onChange={(v) => setNewItem((p) => ({ ...p, host: v }))} placeholder="8.8.8.8" />
|
||||||
|
<FormField label="Порт" name="port" type="number" value={newItem.port} onChange={(v) => setNewItem((p) => ({ ...p, port: v }))} min={1} max={65535} />
|
||||||
|
<IconPicker value={newItem.icon} onChange={(id) => setNewItem((p) => ({ ...p, icon: id }))} />
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="form-label small">Цвет</label>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={newItem.color}
|
||||||
|
onChange={(e) => setNewItem((p) => ({ ...p, color: e.target.value }))}
|
||||||
|
>
|
||||||
|
{COLOR_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<FormField label="ID (необязательно)" name="id" value={newItem.id} onChange={(v) => setNewItem((p) => ({ ...p, id: v }))} placeholder="auto из названия" helpText="Уникальный ключ. Пусто — из названия." />
|
||||||
|
</FormModal>
|
||||||
|
|
||||||
|
<FormModal
|
||||||
|
show={editModalOpen}
|
||||||
|
onClose={() => { setEditModalOpen(false); setEditingItem(null); }}
|
||||||
|
onSubmit={submitEdit}
|
||||||
|
title="Редактировать сервис"
|
||||||
|
>
|
||||||
|
<FormField label="ID" name="id" value={editDraft.id} onChange={(v) => setEditDraft((p) => ({ ...p, id: v }))} />
|
||||||
|
<FormField label="Название" name="name" value={editDraft.name} onChange={(v) => setEditDraft((p) => ({ ...p, name: v }))} />
|
||||||
|
<FormField label="Хост" name="host" value={editDraft.host} onChange={(v) => setEditDraft((p) => ({ ...p, host: v }))} />
|
||||||
|
<FormField label="Порт" name="port" type="number" value={editDraft.port} onChange={(v) => setEditDraft((p) => ({ ...p, port: v }))} min={1} max={65535} />
|
||||||
|
<IconPicker value={editDraft.icon} onChange={(id) => setEditDraft((p) => ({ ...p, icon: id }))} />
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="form-label small">Цвет</label>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={editDraft.color}
|
||||||
|
onChange={(e) => setEditDraft((p) => ({ ...p, color: e.target.value }))}
|
||||||
|
>
|
||||||
|
{COLOR_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
show={deleteModalOpen}
|
||||||
|
title="Удалить сервис?"
|
||||||
|
message={itemToDelete ? `Сервис «${itemToDelete.name}» (${itemToDelete.host}) будет удалён из списка.` : ''}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onClose={() => { setDeleteModalOpen(false); setItemToDelete(null); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import {
|
||||||
|
IconServer,
|
||||||
|
IconRefresh,
|
||||||
|
IconChartBar,
|
||||||
|
IconAlertCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes == null || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Цвет прогресс-бара по проценту: зелёный / жёлтый / красный */
|
||||||
|
function progressVariant(percent) {
|
||||||
|
if (percent == null) return 'secondary';
|
||||||
|
if (percent >= 90) return 'danger';
|
||||||
|
if (percent >= 70) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один KPI-блок: заголовок, значение, прогресс-бар (как на референсной карточке) */
|
||||||
|
function KpiBlock({ label, value, percent, variant }) {
|
||||||
|
const barVariant = variant || progressVariant(percent);
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-uppercase small fw-semibold text-secondary mb-1">{label}</div>
|
||||||
|
<div className="d-flex align-items-baseline gap-2 flex-wrap">
|
||||||
|
<span className="h4 mb-0 fw-bold">{value}</span>
|
||||||
|
{percent != null && (
|
||||||
|
<span className={`small fw-medium text-${barVariant}`}>{percent}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{percent != null && (
|
||||||
|
<div className="progress progress-sm mt-1" style={{ height: 6 }}>
|
||||||
|
<div
|
||||||
|
className={`progress-bar bg-${barVariant}`}
|
||||||
|
role="progressbar"
|
||||||
|
style={{ width: `${Math.min(100, percent)}%` }}
|
||||||
|
aria-valuenow={percent}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Карточка одного роутера: иконка перед названием, сводка, KPI (RAM, CPU, HDD) */
|
||||||
|
function RouterResourceCard({ router }) {
|
||||||
|
const { name, host, error, resource } = router;
|
||||||
|
const hasResource = resource && !error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card rounded-3 shadow-sm border-0 overflow-hidden h-100">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="d-flex align-items-center mb-3">
|
||||||
|
<span className="avatar avatar-lg bg-primary-lt text-primary rounded-3 me-3 flex-shrink-0">
|
||||||
|
<IconServer size={28} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="h4 fw-bold mb-0 text-body text-truncate">
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-secondary small mb-0 mt-1">
|
||||||
|
{host}
|
||||||
|
{resource?.version && (
|
||||||
|
<span className="ms-1"> · RouterOS {resource.version}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger py-2 mb-3 d-flex align-items-center">
|
||||||
|
<IconAlertCircle size={20} className="me-2 flex-shrink-0" />
|
||||||
|
<span className="small">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasResource && (
|
||||||
|
<div className="row g-3">
|
||||||
|
{/* RAM */}
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<KpiBlock
|
||||||
|
label="RAM"
|
||||||
|
value={
|
||||||
|
resource.usedMemory != null && resource.totalMemory != null
|
||||||
|
? `${formatBytes(resource.usedMemory)} / ${formatBytes(resource.totalMemory)}`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
percent={resource.memoryUsagePercent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* CPU */}
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<KpiBlock
|
||||||
|
label="CPU"
|
||||||
|
value={
|
||||||
|
resource.cpuLoad != null
|
||||||
|
? `${resource.cpuLoad}%`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
percent={resource.cpuLoad}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* HDD */}
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<KpiBlock
|
||||||
|
label="HDD"
|
||||||
|
value={
|
||||||
|
resource.usedHddSpace != null && resource.totalHddSpace != null
|
||||||
|
? `${formatBytes(resource.usedHddSpace)} / ${formatBytes(resource.totalHddSpace)}`
|
||||||
|
: resource.totalHddSpace != null
|
||||||
|
? formatBytes(resource.totalHddSpace)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
percent={resource.hddUsagePercent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasResource && (resource.boardName || resource.uptime) && (
|
||||||
|
<div className="mt-2 pt-2 border-top small text-muted">
|
||||||
|
{resource.boardName && <span>{resource.boardName}</span>}
|
||||||
|
{resource.uptime && (
|
||||||
|
<span className="ms-2"> · Uptime: {resource.uptime}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResourceStatsPage() {
|
||||||
|
const [routers, setRouters] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async (abortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const opts = { timeout: REQUEST_TIMEOUT_MS, noRetry: true, ...(abortSignal ? { signal: abortSignal } : {}) };
|
||||||
|
try {
|
||||||
|
const res = await api.get('/resources/stats', opts);
|
||||||
|
const data = res?.data;
|
||||||
|
setRouters(Array.isArray(data?.routers) ? data.routers : []);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
|
||||||
|
// Не очищаем список — пользователь видит старые данные и сообщение об ошибке
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchStats(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-body">
|
||||||
|
<PageHeader
|
||||||
|
title="Статистика ресурсов"
|
||||||
|
pretitle="Роутеры"
|
||||||
|
icon={<IconChartBar size={28} />}
|
||||||
|
meta="Потребление RAM, CPU и HDD по каждому роутеру (MikroTik)"
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={() => fetchStats()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconRefresh size={18} className={loading ? 'spin' : ''} />
|
||||||
|
<span className="ms-1">Обновить</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger d-flex align-items-center">
|
||||||
|
<IconAlertCircle size={24} className="me-2" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && routers.length === 0 ? (
|
||||||
|
<div className="row">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="col-12 col-lg-6 col-xl-4 mb-4">
|
||||||
|
<div className="card rounded-3 border-0 placeholder-glow">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="placeholder col-6 mb-2" />
|
||||||
|
<div className="placeholder col-8 mb-3" />
|
||||||
|
<div className="placeholder col-12" style={{ height: 60 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : routers.length === 0 ? (
|
||||||
|
<div className="card rounded-3 border-0">
|
||||||
|
<div className="card-body text-center py-5 text-muted">
|
||||||
|
<IconServer size={48} className="mb-2 opacity-50" />
|
||||||
|
<p className="mb-0">Нет роутеров с настроенным MikroTik API (jumphost/home).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
{routers.map((router) => (
|
||||||
|
<div key={router.serverId || router.name} className="col-12 col-lg-6 col-xl-4 mb-4">
|
||||||
|
<RouterResourceCard router={router} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
IconRoute2,
|
||||||
|
IconRefresh,
|
||||||
|
IconBrain,
|
||||||
|
IconAlertCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import { countryToFlag } from './utils/serverUtils.js';
|
||||||
|
|
||||||
|
function MetricBadge({ label, value, tone = 'secondary' }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge bg-${tone}-lt text-${tone}`}>
|
||||||
|
{label}: {value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtProb(v) {
|
||||||
|
return typeof v === 'number' ? `${v}%` : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSpeed(dl, ul, fallback) {
|
||||||
|
if (typeof dl === 'number' || typeof ul === 'number') {
|
||||||
|
return `DL: ${dl ?? '—'} Mbps, UL: ${ul ?? '—'} Mbps`;
|
||||||
|
}
|
||||||
|
return `speed: ${fallback ?? '—'} Mbps`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeNodeKey(node) {
|
||||||
|
return String(node?.id || node?.dns || node?.ip || node?.label || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPT_TABLE_STYLE = { tableLayout: 'fixed', width: '100%' };
|
||||||
|
const OPT_COLS = ['24%', '20%', '22%', '18%', '16%'];
|
||||||
|
|
||||||
|
function OptimizerColgroup() {
|
||||||
|
return (
|
||||||
|
<colgroup>
|
||||||
|
{OPT_COLS.map((w) => (
|
||||||
|
<col key={w} style={{ width: w }} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RouteOptimizerPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await api.get('/route-optimizer', { timeout: 30000 });
|
||||||
|
setData(res?.data || null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RouteOptimizerPage] load failed:', e);
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить маршруты');
|
||||||
|
setData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const homes = Array.isArray(data?.homes) ? data.homes : [];
|
||||||
|
const communityOptimization = Array.isArray(data?.communityOptimizationByJumphost)
|
||||||
|
? data.communityOptimizationByJumphost
|
||||||
|
: [];
|
||||||
|
const communityFullRoutes = (() => {
|
||||||
|
const out = [];
|
||||||
|
const homesSafe = Array.isArray(homes) ? homes : [];
|
||||||
|
const byJh = new Map();
|
||||||
|
homesSafe.forEach((h) => {
|
||||||
|
const home = h?.home || null;
|
||||||
|
const routes = Array.isArray(h?.fullRouteCandidates) ? h.fullRouteCandidates : [];
|
||||||
|
routes.forEach((r) => {
|
||||||
|
const jhKey = routeNodeKey(r?.jumphost);
|
||||||
|
const exKey = routeNodeKey(r?.exit);
|
||||||
|
if (!jhKey || !exKey) return;
|
||||||
|
const mapKey = `${jhKey}:::${exKey}`;
|
||||||
|
const prev = byJh.get(mapKey);
|
||||||
|
if (!prev || Number(r?.score || 0) > Number(prev?.route?.score || 0)) {
|
||||||
|
byJh.set(mapKey, { home, route: r });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
communityOptimization.forEach((item) => {
|
||||||
|
const jh = item?.jumphost || null;
|
||||||
|
const jhKey = routeNodeKey(jh);
|
||||||
|
const rows = Array.isArray(item?.recommendations) ? item.recommendations : [];
|
||||||
|
rows.forEach((r) => {
|
||||||
|
const rec = r?.recommended || null;
|
||||||
|
const exKey = routeNodeKey(rec?.exit);
|
||||||
|
if (!rec || !jhKey || !exKey) return;
|
||||||
|
const bestFull = byJh.get(`${jhKey}:::${exKey}`);
|
||||||
|
if (!bestFull?.route) return;
|
||||||
|
out.push({
|
||||||
|
community: r.community,
|
||||||
|
communityInfo: r.communityInfo,
|
||||||
|
home: bestFull.home || bestFull.route.home,
|
||||||
|
jumphost: bestFull.route.jumphost || jh,
|
||||||
|
exit: bestFull.route.exit || rec.exit,
|
||||||
|
fullRouteScore: bestFull.route.score,
|
||||||
|
fullRouteProbability: bestFull.route.probabilityOptimal,
|
||||||
|
fullRouteConfidence: bestFull.route.confidence,
|
||||||
|
homeToJumphost: bestFull.route.homeToJumphost || null,
|
||||||
|
jumphostToExit: bestFull.route.jumphostToExit || null,
|
||||||
|
recommendedGateway: rec.gateway || null,
|
||||||
|
tunnelGateway: rec.tunnelGateway || null,
|
||||||
|
pinnedBySettings: Boolean(r?.pinnedBySettings),
|
||||||
|
shouldSwitch: Boolean(r?.shouldSwitch),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return out.sort((a, b) => Number(b.fullRouteScore || 0) - Number(a.fullRouteScore || 0));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Локальный ИИ: оптимизатор маршрутов"
|
||||||
|
icon={<IconBrain size={24} />}
|
||||||
|
meta="Rule-based выбор маршрутов: Home → Jumphost → Exit"
|
||||||
|
actions={(
|
||||||
|
<div className="btn-list">
|
||||||
|
<Link to="/settings#route-ai" className="btn btn-outline-secondary">
|
||||||
|
Настройки AI
|
||||||
|
</Link>
|
||||||
|
<button type="button" className="btn btn-outline-primary" onClick={load} disabled={loading}>
|
||||||
|
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
|
||||||
|
{loading ? 'Обновление...' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger d-flex align-items-center">
|
||||||
|
<IconAlertCircle className="me-2" size={18} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="text-muted py-4">Расчёт маршрутов...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && homes.length === 0 && (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
Нет доступных маршрутов. Проверьте типы серверов (`home`, `jumphost`, `exit`) и `network-config.tunnelInterfaces`.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
{homes.map((entry) => {
|
||||||
|
const best = entry.bestFullRoute;
|
||||||
|
const bestHomeToJh = entry.bestHomeToJumphost || null;
|
||||||
|
const home = entry.home || {};
|
||||||
|
const hjCandidates = Array.isArray(entry.homeToJumphostCandidates)
|
||||||
|
? entry.homeToJumphostCandidates
|
||||||
|
: [];
|
||||||
|
const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : [];
|
||||||
|
return (
|
||||||
|
<div className="col-12" key={home.id || home.ip || home.dns || home.label || 'home'}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||||
|
<h3 className="card-title mb-0 d-flex align-items-center">
|
||||||
|
<IconRoute2 className="me-2" size={18} />
|
||||||
|
{home.label || home.dns || home.ip || 'Home'}
|
||||||
|
</h3>
|
||||||
|
{best ? (
|
||||||
|
<MetricBadge
|
||||||
|
label="Вероятность оптимальности лучшего маршрута"
|
||||||
|
value={`${best.probabilityOptimal}%`}
|
||||||
|
tone="green"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MetricBadge label="Статус" value="Нет полного маршрута до Exit" tone="orange" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||||
|
<div className="fw-semibold">Оптимальный маршрут Home → Jumphost</div>
|
||||||
|
{bestHomeToJh ? (
|
||||||
|
<MetricBadge
|
||||||
|
label="Вероятность оптимальности"
|
||||||
|
value={`${bestHomeToJh.probabilityOptimal}%`}
|
||||||
|
tone="green"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MetricBadge label="Статус" value="Нет кандидатов Home→Jumphost" tone="orange" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
|
||||||
|
<OptimizerColgroup />
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Jumphost</th>
|
||||||
|
<th>Интерфейс</th>
|
||||||
|
<th>Ping / Speed</th>
|
||||||
|
<th>Score / Confidence</th>
|
||||||
|
<th>Вероятность</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{hjCandidates.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-muted">
|
||||||
|
Кандидаты Home→Jumphost не найдены.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : hjCandidates.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="fw-semibold text-break">{c.jumphost?.label || c.jumphost?.dns || c.jumphost?.ip || 'jumphost'}</td>
|
||||||
|
<td className="small text-muted text-break">{c.interfaceName || '—'}</td>
|
||||||
|
<td className="small text-muted text-break">
|
||||||
|
ping: {c.pingMs ?? '—'} ms, {fmtSpeed(c.speedDownloadMbps, c.speedUploadMbps, c.speedMbps)}
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
{c.score} / {c.confidence}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${bestHomeToJh?.id === c.id ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}>
|
||||||
|
{fmtProb(c.probabilityOptimal)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{best ? (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="fw-semibold mb-2">Оптимальный маршрут</div>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<span className="badge bg-blue-lt text-blue">{best.home?.label || 'home'}</span>
|
||||||
|
<span className="text-muted">→</span>
|
||||||
|
<span className="badge bg-indigo-lt text-indigo">{best.jumphost?.label || 'jumphost'}</span>
|
||||||
|
<span className="text-muted">→</span>
|
||||||
|
<span className="badge bg-orange-lt text-orange">{best.exit?.label || 'exit'}</span>
|
||||||
|
<MetricBadge label="Score" value={best.score} tone="primary" />
|
||||||
|
<MetricBadge label="Confidence" value={best.confidence} tone="secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="fw-semibold mb-2">Полные маршруты Home → Jumphost → Exit</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
|
||||||
|
<OptimizerColgroup />
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Маршрут</th>
|
||||||
|
<th>Home→Jumphost</th>
|
||||||
|
<th>Jumphost→Exit</th>
|
||||||
|
<th>Score / Confidence</th>
|
||||||
|
<th>Вероятность</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{routes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-muted">
|
||||||
|
Полные маршруты до exit-ноды не найдены.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : routes.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="text-break">
|
||||||
|
<span className="fw-semibold">{r.home?.label}</span>
|
||||||
|
<span className="text-muted"> → </span>
|
||||||
|
<span className="fw-semibold">{r.jumphost?.label}</span>
|
||||||
|
<span className="text-muted"> → </span>
|
||||||
|
<span className="fw-semibold">{r.exit?.label}</span>
|
||||||
|
</td>
|
||||||
|
<td className="small text-muted text-break">
|
||||||
|
ping: {r.homeToJumphost?.pingMs ?? '—'} ms, {fmtSpeed(
|
||||||
|
r.homeToJumphost?.speedDownloadMbps,
|
||||||
|
r.homeToJumphost?.speedUploadMbps,
|
||||||
|
r.homeToJumphost?.speedMbps
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="small text-muted text-break">
|
||||||
|
ping: {r.jumphostToExit?.pingMs ?? '—'} ms, {fmtSpeed(
|
||||||
|
r.jumphostToExit?.speedDownloadMbps,
|
||||||
|
r.jumphostToExit?.speedUploadMbps,
|
||||||
|
r.jumphostToExit?.speedMbps
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="small">{r.score} / {r.confidence ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-green-lt text-green">{r.probabilityOptimal}%</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title mb-0">Оптимизированные маршруты Home -> JumpHost -> Exit по BGP community</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="text-muted small mb-3">
|
||||||
|
Таблица показывает лучший полный маршрут для каждой community с учетом рекомендованного
|
||||||
|
gateway на Jumphost.
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive mb-0">
|
||||||
|
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
|
||||||
|
<OptimizerColgroup />
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Community</th>
|
||||||
|
<th>Маршрут (Home -> JumpHost -> Exit)</th>
|
||||||
|
<th>Сегменты</th>
|
||||||
|
<th>Gateway / Действие</th>
|
||||||
|
<th>Вероятность</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{communityFullRoutes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-muted">Недостаточно данных для построения полных маршрутов по community.</td>
|
||||||
|
</tr>
|
||||||
|
) : communityFullRoutes.map((r, idx) => (
|
||||||
|
<tr key={`${r.community}-${routeNodeKey(r.home)}-${routeNodeKey(r.jumphost)}-${routeNodeKey(r.exit)}-${idx}`}>
|
||||||
|
<td className="text-break">
|
||||||
|
<div className="fw-semibold">{r.community}</div>
|
||||||
|
{(r.communityInfo?.name || r.communityInfo?.description) && (
|
||||||
|
<div className="small text-muted">{r.communityInfo?.name || r.communityInfo?.description}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="small text-break">
|
||||||
|
<span className="fw-semibold">{r.home?.label || r.home?.dns || r.home?.ip || 'home'}</span>
|
||||||
|
<span className="text-muted"> → </span>
|
||||||
|
<span className="fw-semibold">{r.jumphost?.label || r.jumphost?.dns || r.jumphost?.ip || 'jumphost'}</span>
|
||||||
|
<span className="text-muted"> → </span>
|
||||||
|
<span className="fw-semibold">{r.exit?.label || r.exit?.dns || r.exit?.ip || 'exit'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="small text-break">
|
||||||
|
<div>
|
||||||
|
H→J: ping {r.homeToJumphost?.pingMs ?? '—'} ms, {fmtSpeed(
|
||||||
|
r.homeToJumphost?.speedDownloadMbps,
|
||||||
|
r.homeToJumphost?.speedUploadMbps,
|
||||||
|
r.homeToJumphost?.speedMbps
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
J→E: ping {r.jumphostToExit?.pingMs ?? '—'} ms, {fmtSpeed(
|
||||||
|
r.jumphostToExit?.speedDownloadMbps,
|
||||||
|
r.jumphostToExit?.speedUploadMbps,
|
||||||
|
r.jumphostToExit?.speedMbps
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="small text-break">
|
||||||
|
<div>{r.recommendedGateway || '—'}</div>
|
||||||
|
{r.tunnelGateway && <div className="text-muted">{'->'} {r.tunnelGateway}</div>}
|
||||||
|
<div className="mt-1">
|
||||||
|
{r.pinnedBySettings ? (
|
||||||
|
<span className="badge bg-blue-lt text-blue">pin</span>
|
||||||
|
) : r.shouldSwitch ? (
|
||||||
|
<span className="badge bg-orange-lt text-orange">switch</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-green-lt text-green">ok</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
<div>{fmtProb(r.fullRouteProbability)}</div>
|
||||||
|
<div className="text-muted">score {r.fullRouteScore} / conf {r.fullRouteConfidence ?? '—'}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="mb-3">Оптимизация по community и filters</h3>
|
||||||
|
<div className="text-muted small mb-3">
|
||||||
|
Рекомендации строятся на основе ваших <code>server-filters</code> (связки
|
||||||
|
<code> community -> gateway</code>) и текущих метрик канала
|
||||||
|
<code> jumphost -> exit</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{communityOptimization.length === 0 ? (
|
||||||
|
<div className="alert alert-info">Нет данных по <code>server-filters</code> для jumphost.</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3">
|
||||||
|
{communityOptimization.map((item) => {
|
||||||
|
const jh = item.jumphost || {};
|
||||||
|
const rows = Array.isArray(item.recommendations) ? item.recommendations : [];
|
||||||
|
return (
|
||||||
|
<div className="col-12" key={jh.id || jh.ip || jh.dns || jh.label || 'jumphost'}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<h3 className="card-title mb-0">
|
||||||
|
Jumphost: {jh.label || jh.dns || jh.ip || 'unknown'}
|
||||||
|
</h3>
|
||||||
|
<MetricBadge label="Communities" value={rows.length} tone="blue" />
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table align-middle" style={OPT_TABLE_STYLE}>
|
||||||
|
<OptimizerColgroup />
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Community</th>
|
||||||
|
<th>Текущий (рекурсивный -> туннель)</th>
|
||||||
|
<th>Рекомендуемый (рекурсивный -> туннель)</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th>Вероятность (тек/реком)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-muted">
|
||||||
|
Для этого jumphost нет фильтров.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : rows.map((r) => (
|
||||||
|
<tr key={`${jh.label}-${r.community}`}>
|
||||||
|
<td className="text-break">
|
||||||
|
<div className="fw-semibold">{r.community}</div>
|
||||||
|
{(r.communityInfo?.name || r.communityInfo?.description) && (
|
||||||
|
<div className="small text-muted">
|
||||||
|
{r.communityInfo?.name || r.communityInfo?.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="small text-break">
|
||||||
|
<div>{r.current?.gateway || r.currentGateway || '—'}</div>
|
||||||
|
{r.current?.tunnelGateway && (
|
||||||
|
<div className="text-muted">
|
||||||
|
{'->'} {r.current.tunnelGateway}
|
||||||
|
{r.current.distance != null ? ` (dist ${r.current.distance})` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(r.current?.interfaceName || r.current?.exit?.country) && (
|
||||||
|
<div className="text-muted">
|
||||||
|
{r.current?.exit?.country ? `${countryToFlag(r.current.exit.country)} ` : ''}
|
||||||
|
{r.current?.interfaceName ? `iface: ${r.current.interfaceName}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="small text-break">
|
||||||
|
<div>{r.recommended?.gateway || '—'}</div>
|
||||||
|
{r.pinnedBySettings && (
|
||||||
|
<div className="text-muted">
|
||||||
|
pin: {r.pinnedGateway || '—'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.recommended?.tunnelGateway && (
|
||||||
|
<div className="text-muted">
|
||||||
|
{'->'} {r.recommended.tunnelGateway}
|
||||||
|
{r.recommended.distance != null ? ` (dist ${r.recommended.distance})` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(r.recommended?.interfaceName || r.recommended?.exit?.country) && (
|
||||||
|
<div className="text-muted">
|
||||||
|
{r.recommended?.exit?.country ? `${countryToFlag(r.recommended.exit.country)} ` : ''}
|
||||||
|
{r.recommended?.interfaceName ? `iface: ${r.recommended.interfaceName}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-break">
|
||||||
|
{r.current == null && r.recommended == null ? (
|
||||||
|
<span className="badge bg-secondary-lt text-secondary">Недостаточно данных</span>
|
||||||
|
) : r.pinnedBySettings && !r.pinnedGatewayResolved ? (
|
||||||
|
<span className="badge bg-yellow-lt text-yellow">Pin задан, но gateway не найден</span>
|
||||||
|
) : r.pinnedBySettings && r.shouldSwitch ? (
|
||||||
|
<span className="badge bg-orange-lt text-orange">Принудительный switch (pin)</span>
|
||||||
|
) : r.pinnedBySettings ? (
|
||||||
|
<span className="badge bg-blue-lt text-blue">Зафиксировано (pin)</span>
|
||||||
|
) : r.current == null ? (
|
||||||
|
<span className="badge bg-orange-lt text-orange">Текущий gateway не сопоставлен</span>
|
||||||
|
) : r.shouldSwitch ? (
|
||||||
|
<span className="badge bg-orange-lt text-orange">Рекомендовано переключить</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-green-lt text-green">Оставить текущий</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
{fmtProb(r.current?.probabilityOptimal)} / {fmtProb(r.recommended?.probabilityOptimal)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
+258
-93
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||||
@@ -31,8 +31,11 @@ import {
|
|||||||
LinkGeneratorModal,
|
LinkGeneratorModal,
|
||||||
ServerSidebar,
|
ServerSidebar,
|
||||||
ServerCard,
|
ServerCard,
|
||||||
|
ServerGroupCard,
|
||||||
ServerBulkActions
|
ServerBulkActions
|
||||||
} from './components/server/index.js';
|
} from './components/server/index.js';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -52,12 +55,12 @@ function ServerManager() {
|
|||||||
// === Основные данные ===
|
// === Основные данные ===
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
|
const [networkConfigGateways, setNetworkConfigGateways] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// === UI State ===
|
// === UI State ===
|
||||||
const [activeTab, setActiveTab] = useState('servers'); // 'servers' | 'connections'
|
const [activeTab, setActiveTab] = useState('servers'); // 'servers' | 'connections'
|
||||||
const [viewMode, setViewMode] = useState('cards'); // 'table' | 'cards'
|
const [viewMode, setViewMode] = useState('cards'); // 'table' | 'cards'
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
||||||
|
|
||||||
// === Фильтры и поиск ===
|
// === Фильтры и поиск ===
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
@@ -124,9 +127,27 @@ function ServerManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === Загрузка данных ===
|
// === Загрузка данных ===
|
||||||
|
const fetchNetworkConfig = async (abortSignal) => {
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
const res = await api.get('/network-config', opts);
|
||||||
|
const config = res.data || {};
|
||||||
|
setNetworkConfigGateways(Array.isArray(config.gateways) ? config.gateways : []);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
console.warn('Network config not loaded:', e?.message || e);
|
||||||
|
setNetworkConfigGateways([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
const controller = new AbortController();
|
||||||
fetchConnections();
|
const signal = controller.signal;
|
||||||
|
fetchServers(signal);
|
||||||
|
fetchConnections(signal);
|
||||||
|
fetchNetworkConfig(signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,10 +162,11 @@ function ServerManager() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchServers = async () => {
|
const fetchServers = async (abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/servers`);
|
const response = await api.get(`/servers`, opts);
|
||||||
const normalized = (Array.isArray(response.data) ? response.data : []).map((srv) => ({
|
const normalized = (Array.isArray(response.data) ? response.data : []).map((srv) => ({
|
||||||
...srv,
|
...srv,
|
||||||
type: normalizeServerType(srv.type),
|
type: normalizeServerType(srv.type),
|
||||||
@@ -152,6 +174,7 @@ function ServerManager() {
|
|||||||
}));
|
}));
|
||||||
setServers(normalized);
|
setServers(normalized);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
console.error('Error fetching servers:', error);
|
console.error('Error fetching servers:', error);
|
||||||
notify.error('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
|
notify.error('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -159,19 +182,47 @@ function ServerManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchConnections = async () => {
|
const fetchConnections = async (abortSignal) => {
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/server-connections`);
|
const response = await api.get(`/server-connections`, opts);
|
||||||
if (Array.isArray(response.data)) {
|
if (Array.isArray(response.data)) {
|
||||||
setConnections(response.data);
|
setConnections(response.data);
|
||||||
} else {
|
} else {
|
||||||
setConnections([]);
|
setConnections([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
console.error('Error fetching server connections:', error);
|
console.error('Error fetching server connections:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Шлюзы для карточки: из /network-config по serverId, иначе из server.gateways
|
||||||
|
const getGatewaysForServer = useMemo(() => {
|
||||||
|
const gatewaysByServerId = {};
|
||||||
|
(networkConfigGateways || []).forEach((gw) => {
|
||||||
|
if (!gw.serverId) return;
|
||||||
|
if (!gatewaysByServerId[gw.serverId]) gatewaysByServerId[gw.serverId] = [];
|
||||||
|
gatewaysByServerId[gw.serverId].push(gw);
|
||||||
|
});
|
||||||
|
return (server) => {
|
||||||
|
const ids = [server?.id, server?.ip, server?.dns].filter(Boolean);
|
||||||
|
for (const id of ids) {
|
||||||
|
const gws = gatewaysByServerId[id];
|
||||||
|
if (gws?.length > 0) {
|
||||||
|
return gws.map((gw, idx) => ({
|
||||||
|
id: gw.id,
|
||||||
|
name: gw.description || gw.id || gw.ip,
|
||||||
|
ip: gw.ip,
|
||||||
|
comment: gw.description,
|
||||||
|
primary: idx === 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return server?.gateways || [];
|
||||||
|
};
|
||||||
|
}, [networkConfigGateways]);
|
||||||
|
|
||||||
// === Фильтрация и сортировка ===
|
// === Фильтрация и сортировка ===
|
||||||
const filteredServers = useMemo(() => {
|
const filteredServers = useMemo(() => {
|
||||||
let result = [...servers];
|
let result = [...servers];
|
||||||
@@ -216,9 +267,44 @@ function ServerManager() {
|
|||||||
return result;
|
return result;
|
||||||
}, [servers, filters, searchTerm, sortField, sortOrder]);
|
}, [servers, filters, searchTerm, sortField, sortOrder]);
|
||||||
|
|
||||||
|
// Элементы для отображения: серверы с одинаковым groupKey (и type home) объединяются в одну карточку
|
||||||
|
const displayItems = useMemo(() => {
|
||||||
|
const keyToServers = {};
|
||||||
|
filteredServers.forEach((s) => {
|
||||||
|
const k = (s.groupKey && String(s.groupKey).trim()) || null;
|
||||||
|
if (k) {
|
||||||
|
if (!keyToServers[k]) keyToServers[k] = [];
|
||||||
|
keyToServers[k].push(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const seenKeys = new Set();
|
||||||
|
const items = [];
|
||||||
|
for (const s of filteredServers) {
|
||||||
|
const k = (s.groupKey && String(s.groupKey).trim()) || null;
|
||||||
|
if (!k) {
|
||||||
|
items.push({ type: 'server', server: s });
|
||||||
|
} else {
|
||||||
|
if (!seenKeys.has(k)) {
|
||||||
|
seenKeys.add(k);
|
||||||
|
const group = keyToServers[k];
|
||||||
|
items.push(group.length >= 2 ? { type: 'group', groupKey: k, servers: group } : { type: 'server', server: group[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [filteredServers]);
|
||||||
|
|
||||||
// === Пагинация ===
|
// === Пагинация ===
|
||||||
const totalPages = Math.ceil(filteredServers.length / pageSize);
|
const totalPagesCards = Math.ceil(displayItems.length / pageSize);
|
||||||
|
const totalPagesTable = Math.ceil(filteredServers.length / pageSize);
|
||||||
|
const totalPages = viewMode === 'cards' ? totalPagesCards : totalPagesTable;
|
||||||
|
const paginatedDisplayItems = displayItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
|
const totalItemsCount = viewMode === 'cards' ? displayItems.length : filteredServers.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1);
|
||||||
|
}, [viewMode, totalPages, currentPage]);
|
||||||
|
|
||||||
// === Handlers ===
|
// === Handlers ===
|
||||||
const handleSort = (field) => {
|
const handleSort = (field) => {
|
||||||
@@ -247,11 +333,22 @@ function ServerManager() {
|
|||||||
setSelectedServers(newSet);
|
setSelectedServers(newSet);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleGroupSelection = (servers, selectAll) => {
|
||||||
|
setSelectedServers((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
servers.forEach((s) => {
|
||||||
|
if (selectAll) next.add(s.ip);
|
||||||
|
else next.delete(s.ip);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const selectAllServers = () => {
|
const selectAllServers = () => {
|
||||||
if (selectedServers.size === filteredServers.length) {
|
if (selectedServers.size === filteredServers.length) {
|
||||||
setSelectedServers(new Set());
|
setSelectedServers(new Set());
|
||||||
} else {
|
} else {
|
||||||
setSelectedServers(new Set(filteredServers.map(s => s.ip)));
|
setSelectedServers(new Set(filteredServers.map((s) => s.ip)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -405,7 +502,7 @@ function ServerManager() {
|
|||||||
return `${urlSettings.baseUrl}?${params.toString()}`;
|
return `${urlSettings.baseUrl}?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateLink = (server) => {
|
const _handleGenerateLink = (server) => {
|
||||||
setLinkGeneratorServer(server);
|
setLinkGeneratorServer(server);
|
||||||
setLinkGeneratorOpen(true);
|
setLinkGeneratorOpen(true);
|
||||||
};
|
};
|
||||||
@@ -415,7 +512,7 @@ function ServerManager() {
|
|||||||
setLinkGeneratorServer(null);
|
setLinkGeneratorServer(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPreset = (name) => {
|
const _applyPreset = (name) => {
|
||||||
const p = presets[name];
|
const p = presets[name];
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
setUrlSettings(prev => ({ ...prev, ...p }));
|
setUrlSettings(prev => ({ ...prev, ...p }));
|
||||||
@@ -433,7 +530,7 @@ function ServerManager() {
|
|||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
notify.success('Скопировано в буфер обмена!');
|
notify.success('Скопировано в буфер обмена!');
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
notify.error('Не удалось скопировать');
|
notify.error('Не удалось скопировать');
|
||||||
}
|
}
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
@@ -445,14 +542,14 @@ function ServerManager() {
|
|||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
notify.success('Скопировано в буфер обмена!');
|
notify.success('Скопировано в буфер обмена!');
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
// fallback
|
// fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fallbackCopyToClipboard(text);
|
fallbackCopyToClipboard(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const quickCopyLink = async (server) => {
|
const _quickCopyLink = async (server) => {
|
||||||
const url = generateServerUrl(server);
|
const url = generateServerUrl(server);
|
||||||
await copyToClipboard(url);
|
await copyToClipboard(url);
|
||||||
};
|
};
|
||||||
@@ -550,6 +647,12 @@ function ServerManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshAll = () => {
|
||||||
|
fetchServers();
|
||||||
|
fetchConnections();
|
||||||
|
fetchNetworkConfig();
|
||||||
|
};
|
||||||
|
|
||||||
// === Connections ===
|
// === Connections ===
|
||||||
const addConnectionInternal = (draft) => {
|
const addConnectionInternal = (draft) => {
|
||||||
if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) {
|
if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) {
|
||||||
@@ -586,28 +689,32 @@ function ServerManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="server-manager-layout">
|
<div className="server-manager-layout">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="page-header d-print-none mb-3">
|
pretitle="Управление"
|
||||||
<div className="row align-items-center">
|
title="Серверы"
|
||||||
<div className="col">
|
icon={<IconServer size={24} />}
|
||||||
<h2 className="page-title d-flex align-items-center">
|
meta={
|
||||||
<IconServer className="me-2" />
|
<span className="text-muted">
|
||||||
Управление серверами
|
Всего серверов: <strong>{servers.length}</strong>
|
||||||
</h2>
|
</span>
|
||||||
<div className="page-pretitle">
|
}
|
||||||
{filteredServers.length} из {servers.length} серверов
|
actions={
|
||||||
{selectedServers.size > 0 && (
|
<>
|
||||||
<span className="ms-2 text-primary">• {selectedServers.size} выбрано</span>
|
<PageHeaderActions
|
||||||
)}
|
loading={loading}
|
||||||
</div>
|
onRefresh={handleRefreshAll}
|
||||||
</div>
|
disableRefresh={loading}
|
||||||
<div className="col-auto ms-auto d-print-none">
|
onExport={exportAllLinks}
|
||||||
<div className="btn-list">
|
disableExport={servers.length === 0}
|
||||||
{/* Tabs */}
|
onSave={handleSaveChanges}
|
||||||
<div className="btn-group me-2" role="group">
|
disableSave={loading}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 d-flex flex-wrap align-items-center justify-content-end gap-2">
|
||||||
|
<div className="btn-group" role="group" aria-label="Переключение режимов">
|
||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === 'servers' ? 'btn-primary' : 'btn-outline-primary'}`}
|
className={`btn ${activeTab === 'servers' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
onClick={() => setActiveTab('servers')}
|
onClick={() => setActiveTab('servers')}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<IconServer size={16} className="me-1" />
|
<IconServer size={16} className="me-1" />
|
||||||
Серверы
|
Серверы
|
||||||
@@ -615,59 +722,104 @@ function ServerManager() {
|
|||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === 'connections' ? 'btn-primary' : 'btn-outline-primary'}`}
|
className={`btn ${activeTab === 'connections' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
onClick={() => setActiveTab('connections')}
|
onClick={() => setActiveTab('connections')}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<IconGitBranch size={16} className="me-1" />
|
<IconGitBranch size={16} className="me-1" />
|
||||||
Связи
|
Связи
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{/* Actions */}
|
type="button"
|
||||||
<button type="button" className="btn btn-outline-secondary" onClick={exportAllLinks} disabled={servers.length === 0}>
|
className="btn btn-primary"
|
||||||
<IconDownload size={16} className="me-1" /> Экспорт
|
onClick={handleOpenAddServerModal}
|
||||||
</button>
|
>
|
||||||
<button type="button" className="btn btn-outline-primary" onClick={handleSaveChanges} disabled={loading}>
|
|
||||||
<IconDatabase size={16} className={loading ? 'spin me-1' : 'me-1'} /> Сохранить
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={handleOpenAddServerModal}>
|
|
||||||
<IconPlus size={16} className="me-1" /> Добавить
|
<IconPlus size={16} className="me-1" /> Добавить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Main content with sidebar */}
|
{/* Offcanvas: фильтры (только для вкладки «Серверы») */}
|
||||||
{activeTab === 'servers' && (
|
{activeTab === 'servers' && (
|
||||||
<div className="d-flex" style={{ gap: '1rem' }}>
|
<>
|
||||||
{/* Sidebar */}
|
<div
|
||||||
{!sidebarCollapsed && (
|
className="offcanvas offcanvas-start"
|
||||||
<ServerSidebar
|
id="server-filters-offcanvas"
|
||||||
servers={servers}
|
tabIndex={-1}
|
||||||
filters={filters}
|
aria-labelledby="server-filters-offcanvas-label"
|
||||||
onFiltersChange={(newFilters) => { setFilters(newFilters); setCurrentPage(1); }}
|
style={{ width: '340px', maxWidth: '90vw' }}
|
||||||
searchTerm={searchTerm}
|
|
||||||
onSearch={(term) => { setSearchTerm(term); setCurrentPage(1); }}
|
|
||||||
onReset={handleResetFilters}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toggle sidebar button */}
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost-secondary btn-icon position-absolute"
|
|
||||||
style={{ left: sidebarCollapsed ? 10 : 290, top: 200, zIndex: 100 }}
|
|
||||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
||||||
title={sidebarCollapsed ? 'Показать фильтры' : 'Скрыть фильтры'}
|
|
||||||
>
|
>
|
||||||
<IconChevronRight size={16} style={{ transform: sidebarCollapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: '0.2s' }} />
|
<div className="offcanvas-header border-bottom">
|
||||||
</button>
|
<h5 className="offcanvas-title d-flex align-items-center" id="server-filters-offcanvas-label">
|
||||||
|
<IconFilter size={18} className="text-primary me-2" />
|
||||||
|
Фильтры
|
||||||
|
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
|
||||||
|
<span className="badge bg-primary-lt text-primary ms-2">
|
||||||
|
{Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h5>
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost-secondary btn-sm"
|
||||||
|
onClick={handleResetFilters}
|
||||||
|
title="Сбросить фильтры"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
data-bs-dismiss="offcanvas"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="offcanvas-body px-3 py-0 d-flex flex-column overflow-hidden">
|
||||||
|
<ServerSidebar
|
||||||
|
servers={servers}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={(newFilters) => { setFilters(newFilters); setCurrentPage(1); }}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onSearch={(term) => { setSearchTerm(term); setCurrentPage(1); }}
|
||||||
|
onReset={handleResetFilters}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Servers list */}
|
{/* Main content */}
|
||||||
|
{activeTab === 'servers' && (
|
||||||
|
<div>
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="card mb-3">
|
<div className="card mb-3">
|
||||||
<div className="card-body py-2">
|
<div className="card-body py-2">
|
||||||
<div className="d-flex align-items-center justify-content-between">
|
<div className="d-flex flex-column flex-sm-row flex-wrap align-items-stretch align-items-sm-center justify-content-between gap-2">
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
{/* Открыть фильтры (offcanvas) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
|
||||||
|
data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#server-filters-offcanvas"
|
||||||
|
aria-controls="server-filters-offcanvas"
|
||||||
|
title="Фильтры"
|
||||||
|
>
|
||||||
|
<IconFilter size={16} className="me-1" />
|
||||||
|
Фильтры
|
||||||
|
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
|
||||||
|
<span className="badge bg-primary-lt text-primary ms-1">
|
||||||
|
{Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
{/* Select all */}
|
{/* Select all */}
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
|
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
|
||||||
@@ -737,18 +889,31 @@ function ServerManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'cards' ? (
|
) : viewMode === 'cards' ? (
|
||||||
/* Cards view */
|
/* Cards view: одиночные серверы и группы (groupKey) */
|
||||||
<div>
|
<div>
|
||||||
{paginatedServers.map(server => (
|
{paginatedDisplayItems.map((item) =>
|
||||||
<ServerCard
|
item.type === 'group' ? (
|
||||||
key={server.id || server.ip}
|
<ServerGroupCard
|
||||||
server={server}
|
key={`group-${item.groupKey}`}
|
||||||
selected={selectedServers.has(server.ip)}
|
groupKey={item.groupKey}
|
||||||
onSelect={toggleServerSelection}
|
servers={item.servers.map((s) => ({ ...s, gateways: getGatewaysForServer(s) }))}
|
||||||
onEdit={handleEdit}
|
selectedSet={selectedServers}
|
||||||
onDelete={confirmDelete}
|
onSelect={toggleServerSelection}
|
||||||
/>
|
onSelectGroup={toggleGroupSelection}
|
||||||
))}
|
onEdit={handleEdit}
|
||||||
|
onDelete={confirmDelete}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ServerCard
|
||||||
|
key={item.server.id || item.server.ip}
|
||||||
|
server={{ ...item.server, gateways: getGatewaysForServer(item.server) }}
|
||||||
|
selected={selectedServers.has(item.server.ip)}
|
||||||
|
onSelect={toggleServerSelection}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={confirmDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Table view */
|
/* Table view */
|
||||||
@@ -837,7 +1002,7 @@ function ServerManager() {
|
|||||||
<div className="card mt-3">
|
<div className="card mt-3">
|
||||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
<div className="card-footer d-flex align-items-center justify-content-between">
|
||||||
<div className="text-muted">
|
<div className="text-muted">
|
||||||
{(currentPage - 1) * pageSize + 1} – {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length}
|
{(currentPage - 1) * pageSize + 1} – {Math.min(currentPage * pageSize, totalItemsCount)} из {totalItemsCount}
|
||||||
</div>
|
</div>
|
||||||
<ul className="pagination m-0">
|
<ul className="pagination m-0">
|
||||||
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
||||||
@@ -923,22 +1088,22 @@ function ServerManager() {
|
|||||||
<h3 className="card-title mb-0">Добавить связь между серверами</h3>
|
<h3 className="card-title mb-0">Добавить связь между серверами</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="row g-3">
|
<div className="row g-2 g-md-3">
|
||||||
<div className="col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<label className="form-label">Сервер A</label>
|
<label className="form-label">Сервер A</label>
|
||||||
<select className="form-select" value={newConnection.from} onChange={e => setNewConnection({ ...newConnection, from: e.target.value })}>
|
<select className="form-select" value={newConnection.from} onChange={e => setNewConnection({ ...newConnection, from: e.target.value })}>
|
||||||
<option value="">Выберите сервер</option>
|
<option value="">Выберите сервер</option>
|
||||||
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
|
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<label className="form-label">Сервер B</label>
|
<label className="form-label">Сервер B</label>
|
||||||
<select className="form-select" value={newConnection.to} onChange={e => setNewConnection({ ...newConnection, to: e.target.value })}>
|
<select className="form-select" value={newConnection.to} onChange={e => setNewConnection({ ...newConnection, to: e.target.value })}>
|
||||||
<option value="">Выберите сервер</option>
|
<option value="">Выберите сервер</option>
|
||||||
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
|
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
<label className="form-label">Тип туннеля</label>
|
<label className="form-label">Тип туннеля</label>
|
||||||
<select className="form-select" value={newConnection.tunnelType} onChange={e => setNewConnection({ ...newConnection, tunnelType: e.target.value })}>
|
<select className="form-select" value={newConnection.tunnelType} onChange={e => setNewConnection({ ...newConnection, tunnelType: e.target.value })}>
|
||||||
<option value="GRE">GRE</option>
|
<option value="GRE">GRE</option>
|
||||||
@@ -947,11 +1112,11 @@ function ServerManager() {
|
|||||||
<option value="OpenVPN">OpenVPN</option>
|
<option value="OpenVPN">OpenVPN</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
<label className="form-label">IP туннеля A</label>
|
<label className="form-label">IP туннеля A</label>
|
||||||
<input className="form-control" value={newConnection.ipA} onChange={e => setNewConnection({ ...newConnection, ipA: e.target.value })} placeholder="10.10.100.1" />
|
<input className="form-control" value={newConnection.ipA} onChange={e => setNewConnection({ ...newConnection, ipA: e.target.value })} placeholder="10.10.100.1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
<label className="form-label">IP туннеля B</label>
|
<label className="form-label">IP туннеля B</label>
|
||||||
<input className="form-control" value={newConnection.ipB} onChange={e => setNewConnection({ ...newConnection, ipB: e.target.value })} placeholder="10.10.100.2" />
|
<input className="form-control" value={newConnection.ipB} onChange={e => setNewConnection({ ...newConnection, ipB: e.target.value })} placeholder="10.10.100.2" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1091,8 +1256,8 @@ function ServerManager() {
|
|||||||
<span className="badge bg-blue-lt text-blue">{newConnection.to || 'B'}</span>
|
<span className="badge bg-blue-lt text-blue">{newConnection.to || 'B'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row g-3">
|
<div className="row g-2 g-md-3">
|
||||||
<div className="col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="Туннель"
|
label="Туннель"
|
||||||
name="tunnelType"
|
name="tunnelType"
|
||||||
@@ -1108,7 +1273,7 @@ function ServerManager() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="IP сервера A"
|
label="IP сервера A"
|
||||||
name="ipA"
|
name="ipA"
|
||||||
@@ -1118,7 +1283,7 @@ function ServerManager() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="IP сервера B"
|
label="IP сервера B"
|
||||||
name="ipB"
|
name="ipB"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,392 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import {
|
||||||
|
IconChartPie,
|
||||||
|
IconRefresh,
|
||||||
|
IconServer,
|
||||||
|
IconAlertCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
} from 'recharts';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
|
||||||
|
/** Палитра в стиле UniFi: зелёные, жёлтые, оранжевые, красные, фиолетовые, серые */
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'#2fb344', '#5eba00', '#94c748', '#fab005', '#fd7e14', '#f76707',
|
||||||
|
'#e64980', '#ae3ec9', '#7950f2', '#5c7cfa', '#748cab', '#868e96',
|
||||||
|
'#adb5bd', '#ced4da', '#206bc4', '#20c997',
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DONUT_SIZE = 200;
|
||||||
|
const DONUT_OUTER = 88;
|
||||||
|
const DONUT_INNER = 52;
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
function JumphostCard({ jumphost }) {
|
||||||
|
const { name, host, error, interfaces } = jumphost;
|
||||||
|
const hasData = Array.isArray(interfaces) && interfaces.length > 0;
|
||||||
|
const totalBytes = hasData ? interfaces.reduce((acc, i) => acc + (i.totalBytes || 0), 0) : 0;
|
||||||
|
const chartData = hasData
|
||||||
|
? interfaces
|
||||||
|
.filter((i) => (i.totalBytes || 0) > 0)
|
||||||
|
.map((i, idx) => ({
|
||||||
|
name: i.name,
|
||||||
|
value: i.totalBytes,
|
||||||
|
rxBytes: i.rxBytes,
|
||||||
|
txBytes: i.txBytes,
|
||||||
|
fill: CHART_COLORS[idx % CHART_COLORS.length],
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card h-100">
|
||||||
|
<div className="card-header d-flex align-items-center">
|
||||||
|
<span className="avatar avatar-sm me-2 bg-blue-lt text-blue">
|
||||||
|
<IconServer size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<h3 className="card-title mb-0 text-truncate" title={name}>
|
||||||
|
{name || host || 'Jumphost'}
|
||||||
|
</h3>
|
||||||
|
{host && (
|
||||||
|
<div className="text-muted small text-truncate" title={host}>
|
||||||
|
{host}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-warning py-2 mb-0 d-flex align-items-center">
|
||||||
|
<IconAlertCircle className="me-2 flex-shrink-0" size={18} />
|
||||||
|
<span className="small">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && !hasData && (
|
||||||
|
<div className="text-muted text-center py-4 small">
|
||||||
|
Нет данных по интерфейсам
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && hasData && chartData.length === 0 && (
|
||||||
|
<div className="text-muted text-center py-4 small">
|
||||||
|
Все счётчики нулевые
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && chartData.length > 0 && (
|
||||||
|
<div className="traffic-donut-wrapper d-flex flex-wrap align-items-start gap-3">
|
||||||
|
<div
|
||||||
|
className="position-relative flex-shrink-0"
|
||||||
|
style={{ width: DONUT_SIZE, height: DONUT_SIZE }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width={DONUT_SIZE} height={DONUT_SIZE}>
|
||||||
|
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={DONUT_INNER}
|
||||||
|
outerRadius={DONUT_OUTER}
|
||||||
|
paddingAngle={0}
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => formatBytes(value)}
|
||||||
|
contentStyle={{ fontSize: '12px' }}
|
||||||
|
labelFormatter={(name, payload) => {
|
||||||
|
const p = payload?.[0];
|
||||||
|
if (!p) return name;
|
||||||
|
return `${name} — RX: ${formatBytes(p.rxBytes || 0)}, TX: ${formatBytes(p.txBytes || 0)}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div
|
||||||
|
className="position-absolute top-50 start-50 translate-middle text-center text-body"
|
||||||
|
style={{ pointerEvents: 'none', width: '60%' }}
|
||||||
|
>
|
||||||
|
<div className="fw-bold lh-1" style={{ fontSize: '1.1rem' }}>
|
||||||
|
{formatBytes(totalBytes)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small mt-1" style={{ fontSize: '0.65rem', letterSpacing: '0.02em' }}>
|
||||||
|
TRAFFIC
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="traffic-legend flex-grow-1 min-w-0" style={{ maxWidth: 240 }}>
|
||||||
|
{chartData.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className="d-flex align-items-center mb-2 small"
|
||||||
|
title={`${item.name}: RX ${formatBytes(item.rxBytes)}, TX ${formatBytes(item.txBytes)}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 rounded"
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
backgroundColor: item.fill,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="ms-2 text-truncate">
|
||||||
|
{item.name} - {formatBytes(item.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ jumphost для сопоставления с настройками (как в SettingsPage) */
|
||||||
|
function getJumphostKey(jh) {
|
||||||
|
return String(jh?.serverId ?? jh?.host ?? jh?.name ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Множество выбранных пар (serverKey + interfaceName). Пустое = учитывать все интерфейсы. */
|
||||||
|
function getTrafficInterfaceFilter(settings) {
|
||||||
|
const raw = settings?.trafficInterfaces;
|
||||||
|
if (!raw || !Array.isArray(raw)) return null;
|
||||||
|
const pairs = raw
|
||||||
|
.filter((p) => p && (p.serverKey != null || p.serverId != null) && (p.interfaceName != null || p.name != null))
|
||||||
|
.map((p) => `${String(p.serverKey ?? p.serverId ?? '')}\n${String(p.interfaceName ?? p.name ?? '')}`);
|
||||||
|
return pairs.length > 0 ? new Set(pairs) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrafficDashboard() {
|
||||||
|
const [jumphosts, setJumphosts] = useState([]);
|
||||||
|
const [uiSettings, setUiSettings] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const interfaceFilter = getTrafficInterfaceFilter(uiSettings);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async (abortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const opts = { timeout: REQUEST_TIMEOUT_MS, ...(abortSignal ? { signal: abortSignal } : {}) };
|
||||||
|
try {
|
||||||
|
const [statsRes, settingsRes] = await Promise.all([
|
||||||
|
api.get('/traffic/interface-stats', opts),
|
||||||
|
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
|
||||||
|
]);
|
||||||
|
const data = statsRes?.data || {};
|
||||||
|
setJumphosts(Array.isArray(data?.jumphosts) ? data.jumphosts : []);
|
||||||
|
setUiSettings(settingsRes?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
console.error('Traffic stats:', e);
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
|
||||||
|
setJumphosts([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchStats(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
const filteredJumphosts = useMemo(() => {
|
||||||
|
if (!interfaceFilter) return jumphosts;
|
||||||
|
return jumphosts.map((jh) => {
|
||||||
|
const sk = getJumphostKey(jh);
|
||||||
|
return {
|
||||||
|
...jh,
|
||||||
|
interfaces: Array.isArray(jh.interfaces)
|
||||||
|
? jh.interfaces.filter(
|
||||||
|
(i) =>
|
||||||
|
i.name != null &&
|
||||||
|
interfaceFilter.has(`${sk}\n${String(i.name).trim()}`)
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [jumphosts, interfaceFilter]);
|
||||||
|
|
||||||
|
// Суммарная статистика по всем отображаемым системам (без ошибок, с данными)
|
||||||
|
const summary = (() => {
|
||||||
|
const withData = (filteredJumphosts || []).filter((jh) => !jh.error && Array.isArray(jh.interfaces) && jh.interfaces.length > 0);
|
||||||
|
let totalBytes = 0;
|
||||||
|
let totalRx = 0;
|
||||||
|
let totalTx = 0;
|
||||||
|
const byJumphost = [];
|
||||||
|
for (const jh of withData) {
|
||||||
|
let jhTotal = 0;
|
||||||
|
let jhRx = 0;
|
||||||
|
let jhTx = 0;
|
||||||
|
for (const i of jh.interfaces) {
|
||||||
|
jhTotal += i.totalBytes || 0;
|
||||||
|
jhRx += i.rxBytes || 0;
|
||||||
|
jhTx += i.txBytes || 0;
|
||||||
|
}
|
||||||
|
totalBytes += jhTotal;
|
||||||
|
totalRx += jhRx;
|
||||||
|
totalTx += jhTx;
|
||||||
|
if (jhTotal > 0) {
|
||||||
|
byJumphost.push({
|
||||||
|
name: jh.name || jh.host || 'Jumphost',
|
||||||
|
value: jhTotal,
|
||||||
|
fill: CHART_COLORS[byJumphost.length % CHART_COLORS.length],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
totalBytes,
|
||||||
|
totalRx,
|
||||||
|
totalTx,
|
||||||
|
systemsCount: withData.length,
|
||||||
|
byJumphost,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const hasSummary = summary.systemsCount > 0 && summary.totalBytes > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Расход трафика"
|
||||||
|
icon={<IconChartPie size={24} />}
|
||||||
|
meta="Статистика по интерфейсам MikroTik RouterOS (jumphost)"
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={() => fetchStats()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
|
||||||
|
{loading ? 'Загрузка…' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger d-flex align-items-center">
|
||||||
|
<IconAlertCircle className="me-2" size={20} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && jumphosts.length === 0 && !error && (
|
||||||
|
<div className="text-center py-5 text-muted">
|
||||||
|
Загрузка статистики с роутеров…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && jumphosts.length === 0 && (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
Нет jumphost-серверов с настроенным MikroTik API. Добавьте серверы типа «Jumphost» и укажите учётные данные RouterOS.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && jumphosts.length > 0 && (
|
||||||
|
<>
|
||||||
|
{hasSummary && (
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title mb-0">Суммарно по всем системам</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex flex-wrap align-items-stretch gap-4">
|
||||||
|
<div className="position-relative flex-shrink-0" style={{ width: 220, height: 220 }}>
|
||||||
|
<ResponsiveContainer width={220} height={220}>
|
||||||
|
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<Pie
|
||||||
|
data={summary.byJumphost}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={58}
|
||||||
|
outerRadius={98}
|
||||||
|
paddingAngle={0}
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{summary.byJumphost.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(value) => formatBytes(value)} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div
|
||||||
|
className="position-absolute top-50 start-50 translate-middle text-center text-body"
|
||||||
|
style={{ pointerEvents: 'none', width: '55%' }}
|
||||||
|
>
|
||||||
|
<div className="fw-bold lh-1" style={{ fontSize: '1.35rem' }}>
|
||||||
|
{formatBytes(summary.totalBytes)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small mt-1" style={{ fontSize: '0.7rem', letterSpacing: '0.02em' }}>
|
||||||
|
TRAFFIC
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap gap-4 align-items-center flex-grow-1">
|
||||||
|
<div className="traffic-summary-legend">
|
||||||
|
{summary.byJumphost.map((item) => (
|
||||||
|
<div key={item.name} className="d-flex align-items-center mb-2 small">
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 rounded"
|
||||||
|
style={{ width: 10, height: 10, backgroundColor: item.fill }}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">
|
||||||
|
{item.name} - {formatBytes(item.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap gap-3 ms-md-auto">
|
||||||
|
<div className="px-3 py-2 rounded bg-blue-lt text-blue">
|
||||||
|
<span className="d-block small text-muted">Всего трафика</span>
|
||||||
|
<span className="fw-bold">{formatBytes(summary.totalBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 rounded bg-green-lt text-green">
|
||||||
|
<span className="d-block small text-muted">RX</span>
|
||||||
|
<span className="fw-bold">{formatBytes(summary.totalRx)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 rounded bg-orange-lt text-orange">
|
||||||
|
<span className="d-block small text-muted">TX</span>
|
||||||
|
<span className="fw-bold">{formatBytes(summary.totalTx)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 rounded bg-azure-lt text-azure">
|
||||||
|
<span className="d-block small text-muted">Систем</span>
|
||||||
|
<span className="fw-bold">{summary.systemsCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="row row-cards g-3">
|
||||||
|
{filteredJumphosts.map((jh) => (
|
||||||
|
<div key={jh.serverId || jh.host || jh.name} className="col-12 col-md-6 col-xl-4">
|
||||||
|
<JumphostCard jumphost={jh} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import {
|
||||||
|
IconClock,
|
||||||
|
IconRefresh,
|
||||||
|
IconServer,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCircleX,
|
||||||
|
IconChartLine,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import { formatRelative } from './lib/datetime.js';
|
||||||
|
|
||||||
|
const DEFAULT_CHECK_INTERVAL_SEC = 120;
|
||||||
|
const PARALLEL_CHECKS = 4; // количество одновременных проверок
|
||||||
|
const HISTORY_MAX = 500;
|
||||||
|
const STORAGE_KEY = 'uptime-monitor-history';
|
||||||
|
|
||||||
|
function loadHistoryFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object') return parsed;
|
||||||
|
} catch { /* no-op */ }
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHistoryToStorage(historyMap) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
|
||||||
|
} catch { /* no-op */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
|
||||||
|
function historyToIncidentsByDay(history) {
|
||||||
|
if (!Array.isArray(history) || history.length === 0) return [];
|
||||||
|
const byDay = new Map(); // dayKey -> count of down checks
|
||||||
|
history.forEach(({ ts, ok }) => {
|
||||||
|
if (ok) return;
|
||||||
|
const d = new Date(ts);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
byDay.set(key, (byDay.get(key) || 0) + 1);
|
||||||
|
});
|
||||||
|
return Array.from(byDay.entries())
|
||||||
|
.map(([day, incidents]) => ({
|
||||||
|
day,
|
||||||
|
dayLabel: new Date(day + 'T12:00:00').toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
}),
|
||||||
|
incidents,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.day.localeCompare(b.day));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** История в точки для графика задержек: { time, ts, ms } */
|
||||||
|
function historyToResponseTimeData(history) {
|
||||||
|
if (!Array.isArray(history) || history.length === 0) return [];
|
||||||
|
return history.map(({ ts, ok, ms }) => ({
|
||||||
|
ts,
|
||||||
|
time: new Date(ts).toLocaleString('ru-RU', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
ms: ok && ms != null ? Math.round(ms) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UptimeMonitorPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [statusMap, setStatusMap] = useState({});
|
||||||
|
/** По serverId: массив { ts, ok, ms } */
|
||||||
|
const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage);
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(null);
|
||||||
|
const [checkIntervalSeconds, setCheckIntervalSeconds] = useState(DEFAULT_CHECK_INTERVAL_SEC);
|
||||||
|
/** serverId, по которым сейчас идёт проверка — показываем «Проверка…» в строке (до PARALLEL_CHECKS одновременно) */
|
||||||
|
const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]);
|
||||||
|
const intervalRef = useRef(null);
|
||||||
|
const checkingRef = useRef(false);
|
||||||
|
const activeRunIdRef = useRef(0);
|
||||||
|
const checkControllersRef = useRef(new Set());
|
||||||
|
|
||||||
|
const jumphosts = useMemo(
|
||||||
|
() =>
|
||||||
|
servers.filter((s) =>
|
||||||
|
['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
|
||||||
|
),
|
||||||
|
[servers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const controllerRef = useRef(null);
|
||||||
|
|
||||||
|
const fetchServers = useCallback(async (abortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
try {
|
||||||
|
const [serversRes, settingsRes] = await Promise.all([
|
||||||
|
api.get('/servers', opts),
|
||||||
|
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
|
||||||
|
]);
|
||||||
|
const data = serversRes?.data;
|
||||||
|
setServers(Array.isArray(data) ? data : []);
|
||||||
|
const ui = settingsRes?.data || {};
|
||||||
|
const interval = Math.max(30, Math.min(86400, parseInt(ui.uptimeMonitorIntervalSeconds, 10) || DEFAULT_CHECK_INTERVAL_SEC));
|
||||||
|
setCheckIntervalSeconds(interval);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить серверы');
|
||||||
|
setServers([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const c = new AbortController();
|
||||||
|
controllerRef.current = c;
|
||||||
|
fetchServers(c.signal);
|
||||||
|
return () => {
|
||||||
|
c.abort();
|
||||||
|
controllerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [fetchServers]);
|
||||||
|
|
||||||
|
// При заходе на страницу подставляем кеш — сразу видны последние проверки
|
||||||
|
useEffect(() => {
|
||||||
|
if (jumphosts.length === 0) return;
|
||||||
|
const signal = controllerRef.current?.signal;
|
||||||
|
if (!signal) return;
|
||||||
|
api.get('/uptime/cache', { signal })
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (data?.results && typeof data.results === 'object' && Object.keys(data.results).length > 0) {
|
||||||
|
setStatusMap((prev) => ({ ...prev, ...data.results }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
|
});
|
||||||
|
}, [jumphosts]);
|
||||||
|
|
||||||
|
const checkIntervalMs = checkIntervalSeconds * 1000;
|
||||||
|
|
||||||
|
/** Параллельный запуск проверок (до PARALLEL_CHECKS одновременно), результат по мере готовности */
|
||||||
|
const runChecks = useCallback(async () => {
|
||||||
|
if (jumphosts.length === 0) return;
|
||||||
|
if (checkingRef.current) return;
|
||||||
|
const runId = ++activeRunIdRef.current;
|
||||||
|
checkingRef.current = true;
|
||||||
|
setChecking(true);
|
||||||
|
setCurrentCheckingServerIds([]);
|
||||||
|
let index = 0;
|
||||||
|
const getNext = () => {
|
||||||
|
const i = index++;
|
||||||
|
return i < jumphosts.length ? jumphosts[i] : null;
|
||||||
|
};
|
||||||
|
const checkOne = async () => {
|
||||||
|
let server;
|
||||||
|
while ((server = getNext()) != null) {
|
||||||
|
if (activeRunIdRef.current !== runId) break;
|
||||||
|
const serverId = server.id || server.dns || server.ip;
|
||||||
|
if (!serverId) continue;
|
||||||
|
setCurrentCheckingServerIds((prev) => [...prev, serverId]);
|
||||||
|
const t0 = Date.now();
|
||||||
|
let ok = false;
|
||||||
|
let ms = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
checkControllersRef.current.add(controller);
|
||||||
|
try {
|
||||||
|
const { data: checkData } = await api.post('/uptime/check', { serverId }, { signal: controller.signal });
|
||||||
|
ok = checkData?.ok === true;
|
||||||
|
ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'ERR_CANCELED' || e?.name === 'CanceledError' || e?.name === 'AbortError') {
|
||||||
|
setCurrentCheckingServerIds((prev) => prev.filter((id) => id !== serverId));
|
||||||
|
checkControllersRef.current.delete(controller);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
} finally {
|
||||||
|
checkControllersRef.current.delete(controller);
|
||||||
|
}
|
||||||
|
const lastCheckTs = Date.now();
|
||||||
|
const entry = { ts: lastCheckTs, ok, ms };
|
||||||
|
setStatusMap((prev) => ({ ...prev, [serverId]: { ok, lastCheckTs } }));
|
||||||
|
setHistoryMap((prev) => {
|
||||||
|
const list = Array.isArray(prev[serverId]) ? prev[serverId] : [];
|
||||||
|
const merged = [...list, entry].slice(-HISTORY_MAX);
|
||||||
|
return { ...prev, [serverId]: merged };
|
||||||
|
});
|
||||||
|
setCurrentCheckingServerIds((prev) => prev.filter((id) => id !== serverId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne));
|
||||||
|
if (activeRunIdRef.current !== runId) return;
|
||||||
|
setCurrentCheckingServerIds([]);
|
||||||
|
checkingRef.current = false;
|
||||||
|
setChecking(false);
|
||||||
|
}, [jumphosts]);
|
||||||
|
|
||||||
|
// Первый запуск проверки через несколько секунд после загрузки (после перезапуска контейнера кеш может быть пустым или устаревшим)
|
||||||
|
useEffect(() => {
|
||||||
|
if (jumphosts.length === 0) return;
|
||||||
|
const t = setTimeout(runChecks, 4000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [jumphosts.length, runChecks]);
|
||||||
|
|
||||||
|
// Автопроверка по интервалу в фоне
|
||||||
|
useEffect(() => {
|
||||||
|
if (jumphosts.length === 0) return;
|
||||||
|
intervalRef.current = setInterval(runChecks, checkIntervalMs);
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [jumphosts, runChecks, checkIntervalMs]);
|
||||||
|
|
||||||
|
// Остановка фоновых uptime-check при уходе со страницы
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname === '/uptime-monitor') return;
|
||||||
|
activeRunIdRef.current += 1;
|
||||||
|
checkingRef.current = false;
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
const controllers = checkControllersRef.current;
|
||||||
|
controllers.forEach((controller) => controller.abort());
|
||||||
|
controllers.clear();
|
||||||
|
setCurrentCheckingServerIds([]);
|
||||||
|
setChecking(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controllers = checkControllersRef.current;
|
||||||
|
return () => {
|
||||||
|
activeRunIdRef.current += 1;
|
||||||
|
checkingRef.current = false;
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
controllers.forEach((controller) => controller.abort());
|
||||||
|
controllers.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Сохраняем историю в localStorage при изменении
|
||||||
|
useEffect(() => {
|
||||||
|
saveHistoryToStorage(historyMap);
|
||||||
|
}, [historyMap]);
|
||||||
|
|
||||||
|
const selectedServer = selectedServerId
|
||||||
|
? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId)
|
||||||
|
: null;
|
||||||
|
const selectedHistory = useMemo(
|
||||||
|
() => (selectedServerId ? historyMap[selectedServerId] || [] : []),
|
||||||
|
[selectedServerId, historyMap]
|
||||||
|
);
|
||||||
|
const responseTimeData = useMemo(
|
||||||
|
() => historyToResponseTimeData(selectedHistory),
|
||||||
|
[selectedHistory]
|
||||||
|
);
|
||||||
|
const incidentsByDay = useMemo(
|
||||||
|
() => historyToIncidentsByDay(selectedHistory),
|
||||||
|
[selectedHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-body">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<PageHeader
|
||||||
|
title="Uptime Monitor"
|
||||||
|
icon={<IconClock size={28} />}
|
||||||
|
pretitle="Extra"
|
||||||
|
meta="Проверка доступности всех Jumphost и домашних роутеров (MikroTik API)"
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={runChecks}
|
||||||
|
disabled={jumphosts.length === 0 || checking}
|
||||||
|
>
|
||||||
|
<IconRefresh className={checking ? 'spin' : ''} size={18} />
|
||||||
|
{checking ? ' Проверка…' : ' Проверить все'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && jumphosts.length === 0 && (
|
||||||
|
<div className="empty">
|
||||||
|
<div className="empty-icon">
|
||||||
|
<IconChartLine size={48} />
|
||||||
|
</div>
|
||||||
|
<p className="empty-title">Нет Jumphost</p>
|
||||||
|
<p className="empty-subtitle text-muted">
|
||||||
|
Добавьте серверы типа «Jumphost» или «Home» с настроенным MikroTik API — здесь будет отображаться их доступность.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && jumphosts.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Доступность Jumphost и домашних роутеров</h3>
|
||||||
|
<div className="card-actions text-muted small">
|
||||||
|
Проверка каждые {checkIntervalSeconds} сек · Выберите сервер для графиков
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Сервер</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Последняя проверка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jumphosts.map((s) => {
|
||||||
|
const id = s.id || s.dns || s.ip;
|
||||||
|
const status = statusMap[id];
|
||||||
|
const label = s.dns || s.name || s.ip || id;
|
||||||
|
const isSelected = selectedServerId === id;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setSelectedServerId(id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedServerId(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={isSelected ? 'table-active' : ''}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconServer size={18} className="me-2 text-muted" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{currentCheckingServerIds.includes(id) && (
|
||||||
|
<span className="badge bg-blue-lt text-blue">
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
|
||||||
|
Проверка…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!currentCheckingServerIds.includes(id) && status == null && (
|
||||||
|
<span className="badge bg-secondary">—</span>
|
||||||
|
)}
|
||||||
|
{!currentCheckingServerIds.includes(id) && status?.ok === true && (
|
||||||
|
<span className="badge bg-success-lt text-success">
|
||||||
|
<IconCircleCheck size={14} /> Доступен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!currentCheckingServerIds.includes(id) && status?.ok === false && (
|
||||||
|
<span className="badge bg-danger-lt text-danger">
|
||||||
|
<IconCircleX size={14} /> Недоступен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted">
|
||||||
|
{currentCheckingServerIds.includes(id)
|
||||||
|
? '…'
|
||||||
|
: status?.lastCheckTs
|
||||||
|
? formatRelative(status.lastCheckTs)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedServer && (
|
||||||
|
<div className="row mt-4">
|
||||||
|
<div className="col-lg-6 mb-4">
|
||||||
|
<div className="card h-100">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Время ответа за период</h3>
|
||||||
|
<div className="card-actions text-muted small">
|
||||||
|
{selectedServer.dns || selectedServer.name || selectedServer.ip}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{responseTimeData.length === 0 ? (
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Нет данных. Проверки накапливаются со временем (хранятся в браузере).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart
|
||||||
|
data={responseTimeData}
|
||||||
|
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorMs" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#206bc4" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#206bc4" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
unit=" мс"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
domain={[0, (dataMax) => Math.max(dataMax || 0, 100)]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [value != null ? `${value} мс` : '—', 'Задержка']}
|
||||||
|
labelFormatter={(_, payload) =>
|
||||||
|
payload[0]?.payload?.time ?? ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ms"
|
||||||
|
stroke="#206bc4"
|
||||||
|
fill="url(#colorMs)"
|
||||||
|
strokeWidth={2}
|
||||||
|
connectNulls={false}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 mb-4">
|
||||||
|
<div className="card h-100">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Инциденты по дням</h3>
|
||||||
|
<div className="card-actions text-muted small">
|
||||||
|
Недоступность (кол-во проверок)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{incidentsByDay.length === 0 ? (
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Нет инцидентов за сохранённый период или нет данных.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart
|
||||||
|
data={incidentsByDay}
|
||||||
|
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="dayLabel"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
domain={[0, (dataMax) => Math.max(dataMax || 0, 1)]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [value, 'Инцидентов']}
|
||||||
|
labelFormatter={(_, payload) =>
|
||||||
|
payload[0]?.payload?.day ?? ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="incidents"
|
||||||
|
fill="#d63939"
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
name="Инциденты"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import FormModal from './FormModal.jsx';
|
||||||
|
import FormField from './FormField.jsx';
|
||||||
|
import CommunityAutocompleteInput from './CommunityAutocompleteInput.jsx';
|
||||||
|
import { IconPlus, IconWorld, IconNetwork, IconHash } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальное модальное окно для добавления записи (домен/IP-диапазон/ASN)
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.open - Открыто ли модальное окно
|
||||||
|
* @param {function} props.onClose - Закрытие модального окна
|
||||||
|
* @param {function} props.onSubmit - Обработчик добавления (item) => void
|
||||||
|
* @param {string} props.type - Тип записи: 'domain' | 'ipRange' | 'asn'
|
||||||
|
* @param {Array} props.communities - Список community для автодополнения
|
||||||
|
* @param {boolean} props.loading - Состояние загрузки
|
||||||
|
*/
|
||||||
|
export default function AddItemModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
type = 'domain',
|
||||||
|
communities = [],
|
||||||
|
loading = false
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState({ value: '', community: '' });
|
||||||
|
const [errors, setErrors] = useState({ value: '', community: '' });
|
||||||
|
|
||||||
|
// Сброс формы при закрытии
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setFormData({ value: '', community: '' });
|
||||||
|
setErrors({ value: '', community: '' });
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const validators = {
|
||||||
|
domain: (v) => {
|
||||||
|
const val = String(v).trim().toLowerCase();
|
||||||
|
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(val);
|
||||||
|
},
|
||||||
|
ipRange: (v) => {
|
||||||
|
const val = String(v).trim();
|
||||||
|
const parts = val.split('/');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
const [ip, mask] = parts;
|
||||||
|
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
|
||||||
|
const octets = ip.split('.');
|
||||||
|
if (!octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255)) return false;
|
||||||
|
if (!/^\d{1,2}$/.test(mask)) return false;
|
||||||
|
const m = Number(mask);
|
||||||
|
return m >= 0 && m <= 32;
|
||||||
|
},
|
||||||
|
asn: (v) => {
|
||||||
|
const val = String(v).trim();
|
||||||
|
return /^[0-9]+$/.test(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidCommunity = (v) => /^(\d+|\d+:\d+)$/.test(String(v).trim());
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const valueValid = validators[type]?.(formData.value);
|
||||||
|
const communityValid = isValidCommunity(formData.community);
|
||||||
|
|
||||||
|
setErrors({
|
||||||
|
value: valueValid ? '' : getValueError(),
|
||||||
|
community: communityValid ? '' : 'Неверный формат community (N или N:N)'
|
||||||
|
});
|
||||||
|
|
||||||
|
return valueValid && communityValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueError = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'Неверный формат домена (sub.domain.tld)';
|
||||||
|
case 'ipRange':
|
||||||
|
return 'Неверный формат IP-диапазона (A.B.C.D/Mask)';
|
||||||
|
case 'asn':
|
||||||
|
return 'Неверный формат ASN (только цифры, например 12345)';
|
||||||
|
default:
|
||||||
|
return 'Неверный формат';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueLabel = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'Домен';
|
||||||
|
case 'ipRange':
|
||||||
|
return 'IP-диапазон';
|
||||||
|
case 'asn':
|
||||||
|
return 'ASN';
|
||||||
|
default:
|
||||||
|
return 'Значение';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValuePlaceholder = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'example.com';
|
||||||
|
case 'ipRange':
|
||||||
|
return '192.168.1.0/24';
|
||||||
|
case 'asn':
|
||||||
|
return '12345';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueHelp = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'Формат: sub.domain.tld (например foo.example.org)';
|
||||||
|
case 'ipRange':
|
||||||
|
return 'Формат: A.B.C.D/Mask, например 10.0.0.0/24';
|
||||||
|
case 'asn':
|
||||||
|
return 'Формат: только цифры, например 12345';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return IconWorld;
|
||||||
|
case 'ipRange':
|
||||||
|
return IconNetwork;
|
||||||
|
case 'asn':
|
||||||
|
return IconHash;
|
||||||
|
default:
|
||||||
|
return IconHash;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'Добавить домен';
|
||||||
|
case 'ipRange':
|
||||||
|
return 'Добавить IP-диапазон';
|
||||||
|
case 'asn':
|
||||||
|
return 'Добавить ASN';
|
||||||
|
default:
|
||||||
|
return 'Добавить запись';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
const normalizedValue = type === 'domain'
|
||||||
|
? formData.value.trim().toLowerCase()
|
||||||
|
: formData.value.trim();
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
[type === 'domain' ? 'domain' : type === 'ipRange' ? 'ipRange' : 'asn']: normalizedValue,
|
||||||
|
community: String(formData.community).trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit?.(item);
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ValueIcon = getValueIcon();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
show={open}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
title={getTitle()}
|
||||||
|
submitLabel="Добавить"
|
||||||
|
submitIcon={IconPlus}
|
||||||
|
loading={loading}
|
||||||
|
size="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
label={getValueLabel()}
|
||||||
|
name="value"
|
||||||
|
type="text"
|
||||||
|
value={formData.value}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFormData({ ...formData, value: v });
|
||||||
|
if (errors.value) setErrors({ ...errors, value: '' });
|
||||||
|
}}
|
||||||
|
onValidate={(v) => ({
|
||||||
|
valid: validators[type]?.(v) ?? false,
|
||||||
|
message: validators[type]?.(v) ? 'Корректный формат' : getValueError()
|
||||||
|
})}
|
||||||
|
placeholder={getValuePlaceholder()}
|
||||||
|
helpText={getValueHelp()}
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
icon={ValueIcon}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">
|
||||||
|
Community
|
||||||
|
<span className="text-danger ms-1" aria-label="обязательное поле">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="input-icon">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconHash size={18} />
|
||||||
|
</span>
|
||||||
|
<CommunityAutocompleteInput
|
||||||
|
value={formData.community}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFormData({ ...formData, community: v });
|
||||||
|
if (errors.community) setErrors({ ...errors, community: '' });
|
||||||
|
}}
|
||||||
|
communities={communities}
|
||||||
|
placeholder="# Начните вводить номер или имя"
|
||||||
|
className={`form-control${errors.community ? ' is-invalid' : isValidCommunity(formData.community) ? ' is-valid' : ''}`}
|
||||||
|
useShortValue={true}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.community && (
|
||||||
|
<div className="invalid-feedback d-block">{errors.community}</div>
|
||||||
|
)}
|
||||||
|
{isValidCommunity(formData.community) && !errors.community && (
|
||||||
|
<div className="valid-feedback d-block">Корректный формат</div>
|
||||||
|
)}
|
||||||
|
{!errors.community && !isValidCommunity(formData.community) && (
|
||||||
|
<div className="form-text text-muted small">
|
||||||
|
Формат: N или N:N (например 65000:100)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
if (!formData.community) return null;
|
||||||
|
const match = communities.find(c => c.value === String(formData.community).trim());
|
||||||
|
if (!match) return null;
|
||||||
|
return (
|
||||||
|
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
||||||
|
<div className="d-flex">
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<strong>{match.name || match.value}</strong>
|
||||||
|
{match.description && <div className="text-muted small mt-1">{match.description}</div>}
|
||||||
|
{match.tags && match.tags.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{match.tags.map((tag, idx) => (
|
||||||
|
<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react';
|
||||||
|
import { useAlerts } from '../contexts/AlertsContext.jsx';
|
||||||
|
|
||||||
|
const SEVERITY_ICONS = {
|
||||||
|
critical: IconAlertTriangle,
|
||||||
|
warning: IconAlertTriangle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
server_offline: IconServerOff,
|
||||||
|
mikrotik_unreachable: IconServerOff,
|
||||||
|
high_cpu: IconCpu,
|
||||||
|
high_ram: IconDeviceDesktop,
|
||||||
|
high_hdd: IconDatabase,
|
||||||
|
};
|
||||||
|
|
||||||
|
function AlertIcon({ type, severity }) {
|
||||||
|
const Icon = TYPE_ICONS[type] || SEVERITY_ICONS[severity] || IconAlertTriangle;
|
||||||
|
const colorClass = severity === 'critical' ? 'text-danger' : 'text-warning';
|
||||||
|
return <Icon size={18} className={colorClass} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 320;
|
||||||
|
const PANEL_MAX_HEIGHT = 320;
|
||||||
|
const Z_INDEX = 1050;
|
||||||
|
|
||||||
|
function AlertsPanelContent({ loading, refresh, alerts, onClose }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card-header d-flex align-items-center justify-content-between py-2 px-3">
|
||||||
|
<span className="fw-semibold">Оповещения</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost-secondary btn-sm"
|
||||||
|
onClick={(e) => { e.preventDefault(); refresh(); }}
|
||||||
|
disabled={loading}
|
||||||
|
title="Обновить"
|
||||||
|
>
|
||||||
|
{loading ? '…' : '↻'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="list-group list-group-flush list-group-hoverable" style={{ maxHeight: `${PANEL_MAX_HEIGHT}px`, overflowY: 'auto' }}>
|
||||||
|
{alerts.length === 0 && !loading && (
|
||||||
|
<div className="list-group-item text-muted small text-center py-3">
|
||||||
|
Нет активных оповещений
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{alerts.slice(0, 20).map((alert) => (
|
||||||
|
<Link
|
||||||
|
key={alert.id}
|
||||||
|
to={alert.link || '/dashboard'}
|
||||||
|
className="list-group-item list-group-item-action py-2"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-start gap-2">
|
||||||
|
<span className="flex-shrink-0 mt-1">
|
||||||
|
<AlertIcon type={alert.type} severity={alert.severity} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-grow-1">
|
||||||
|
<div className="fw-medium small">{alert.title}</div>
|
||||||
|
<div className="text-muted small text-truncate" title={alert.description}>
|
||||||
|
{alert.entity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{alerts.length > 20 && (
|
||||||
|
<div className="list-group-item text-muted small text-center py-2">
|
||||||
|
и ещё {alerts.length - 20}…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-footer py-2 px-3 d-flex align-items-center gap-2 flex-nowrap">
|
||||||
|
<Link to="/settings#alerts" className="btn btn-sm btn-ghost-secondary text-nowrap" onClick={onClose}>
|
||||||
|
Настройки
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard" className="btn btn-sm btn-outline-primary text-nowrap" onClick={onClose}>
|
||||||
|
Панель
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertsBell({ dropup = false }) {
|
||||||
|
const { alerts, total, loading, refresh } = useAlerts();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
const el = triggerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const padding = 4;
|
||||||
|
const maxLeft = window.innerWidth - PANEL_WIDTH - padding;
|
||||||
|
if (dropup) {
|
||||||
|
setPosition({
|
||||||
|
left: Math.max(padding, Math.min(rect.left, maxLeft)),
|
||||||
|
bottom: window.innerHeight - rect.top + padding,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPosition({
|
||||||
|
top: rect.bottom + padding,
|
||||||
|
left: Math.max(padding, Math.min(rect.left, maxLeft)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dropup]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open) updatePosition();
|
||||||
|
}, [open, dropup, updatePosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onScroll = () => updatePosition();
|
||||||
|
const onResize = () => updatePosition();
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
const onClickOutside = (e) => {
|
||||||
|
const panel = panelRef.current;
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
if (!panel || !trigger) return;
|
||||||
|
if (!panel.contains(e.target) && !trigger.contains(e.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', onScroll, true);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.addEventListener('mousedown', onClickOutside);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll, true);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.removeEventListener('mousedown', onClickOutside);
|
||||||
|
};
|
||||||
|
}, [open, updatePosition]);
|
||||||
|
|
||||||
|
const handleTriggerClick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dropup) {
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dropup) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="nav-item">
|
||||||
|
<a
|
||||||
|
ref={triggerRef}
|
||||||
|
href="#"
|
||||||
|
className="nav-link px-2 d-inline-flex align-items-center justify-content-center"
|
||||||
|
onClick={handleTriggerClick}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-label={`Оповещения: ${total} активных`}
|
||||||
|
title="Оповещения о проблемах"
|
||||||
|
>
|
||||||
|
<span className="position-relative d-inline-flex">
|
||||||
|
<IconBell size={20} />
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="badge badge-sm bg-danger position-absolute rounded-pill" style={{ top: '-2px', right: '-2px' }}>
|
||||||
|
{total > 99 ? '99+' : total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{open && typeof document !== 'undefined' && createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="dropdown-menu dropdown-menu-card show overflow-hidden shadow-lg border"
|
||||||
|
data-bs-theme="light"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: position.left,
|
||||||
|
...(position.bottom !== undefined ? { bottom: position.bottom } : { top: position.top }),
|
||||||
|
width: `${PANEL_WIDTH}px`,
|
||||||
|
maxWidth: 'calc(100vw - 1rem)',
|
||||||
|
zIndex: Z_INDEX,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertsPanelContent
|
||||||
|
loading={loading}
|
||||||
|
refresh={refresh}
|
||||||
|
alerts={alerts}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="nav-link px-2 dropdown-toggle position-relative d-inline-flex align-items-center justify-content-center"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
aria-label={`Оповещения: ${total} активных`}
|
||||||
|
title="Оповещения о проблемах"
|
||||||
|
>
|
||||||
|
<span className="position-relative d-inline-flex">
|
||||||
|
<IconBell size={20} />
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="badge badge-sm bg-danger position-absolute top-0 end-0 translate-middle rounded-pill" style={{ top: '-2px', right: '-2px' }}>
|
||||||
|
{total > 99 ? '99+' : total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}>
|
||||||
|
<AlertsPanelContent loading={loading} refresh={refresh} alerts={alerts} onClose={() => {}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,120 +1,73 @@
|
|||||||
import { IconTrash, IconEdit, IconDownload, IconX, IconChecks } from '@tabler/icons-react'
|
import { IconTrash, IconEdit, IconDownload } from '@tabler/icons-react';
|
||||||
|
import FloatingBulkActionsBar from './FloatingBulkActionsBar.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Панель массовых действий для таблиц
|
* Панель массовых действий для таблиц (всплывающая, как на скрине).
|
||||||
* Показывается когда выбраны элементы
|
* Показывается когда выбраны элементы. Использует единый дизайн тёмной плавающей панели.
|
||||||
|
* «Выбрать все» остаётся в тулбаре/шапке таблицы (чекбокс).
|
||||||
*/
|
*/
|
||||||
function BulkActionsBar({
|
function BulkActionsBar({
|
||||||
selectedCount = 0,
|
selectedCount = 0,
|
||||||
totalCount = 0,
|
totalCount: _totalCount = 0,
|
||||||
onSelectAll = () => {},
|
onSelectAll: _onSelectAll = () => {},
|
||||||
onDeselectAll = () => {},
|
onDeselectAll = () => {},
|
||||||
onDelete = null,
|
onDelete = null,
|
||||||
onEdit = null,
|
onEdit = null,
|
||||||
onExport = null,
|
onExport = null,
|
||||||
customActions = [], // [{icon: Icon, label, onClick, variant}]
|
customActions = [],
|
||||||
className = ''
|
className: _className = '',
|
||||||
}) {
|
}) {
|
||||||
if (selectedCount === 0) return null
|
const actions = [];
|
||||||
|
|
||||||
const allSelected = selectedCount === totalCount
|
if (onExport) {
|
||||||
|
actions.push({
|
||||||
|
type: 'button',
|
||||||
|
label: 'Экспорт',
|
||||||
|
icon: IconDownload,
|
||||||
|
onClick: onExport,
|
||||||
|
title: 'Экспортировать выбранное',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onEdit) {
|
||||||
|
actions.push({
|
||||||
|
type: 'button',
|
||||||
|
label: 'Изменить',
|
||||||
|
icon: IconEdit,
|
||||||
|
onClick: onEdit,
|
||||||
|
title: 'Редактировать выбранное',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
customActions.forEach((action) => {
|
||||||
|
actions.push({
|
||||||
|
type: 'button',
|
||||||
|
label: action.label,
|
||||||
|
icon: action.icon,
|
||||||
|
onClick: action.onClick,
|
||||||
|
variant: action.variant === 'btn-outline-danger' || action.variant === 'danger' ? 'danger' : undefined,
|
||||||
|
title: action.title,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onDelete) {
|
||||||
|
actions.push({
|
||||||
|
type: 'button',
|
||||||
|
label: 'Удалить',
|
||||||
|
icon: IconTrash,
|
||||||
|
onClick: onDelete,
|
||||||
|
variant: 'danger',
|
||||||
|
title: 'Удалить выбранное',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`card mb-3 ${className}`} style={{
|
<FloatingBulkActionsBar
|
||||||
position: 'sticky',
|
selectedCount={selectedCount}
|
||||||
top: '10px',
|
onClearSelection={onDeselectAll}
|
||||||
zIndex: 10,
|
actions={actions}
|
||||||
boxShadow: '0 0.5rem 1rem rgba(0,0,0,.15)'
|
/>
|
||||||
}}>
|
);
|
||||||
<div className="card-body py-2 px-3">
|
|
||||||
<div className="d-flex align-items-center justify-content-between">
|
|
||||||
{/* Левая часть: информация о выборе */}
|
|
||||||
<div className="d-flex align-items-center gap-3">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<IconChecks size={20} className="text-blue me-2" />
|
|
||||||
<span className="fw-bold">
|
|
||||||
Выбрано: <span className="text-blue">{selectedCount}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопка выбрать все / снять выбор */}
|
|
||||||
{!allSelected && totalCount > selectedCount && (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
onClick={onSelectAll}
|
|
||||||
>
|
|
||||||
Выбрать все ({totalCount})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-ghost-secondary"
|
|
||||||
onClick={onDeselectAll}
|
|
||||||
>
|
|
||||||
<IconX size={16} className="me-1" />
|
|
||||||
Снять выбор
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Правая часть: действия */}
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
|
||||||
{/* Экспорт */}
|
|
||||||
{onExport && (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
onClick={onExport}
|
|
||||||
title="Экспортировать выбранное"
|
|
||||||
>
|
|
||||||
<IconDownload size={16} className="me-1" />
|
|
||||||
Экспорт
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Редактирование */}
|
|
||||||
{onEdit && (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
onClick={onEdit}
|
|
||||||
title="Редактировать выбранное"
|
|
||||||
>
|
|
||||||
<IconEdit size={16} className="me-1" />
|
|
||||||
Изменить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Пользовательские действия */}
|
|
||||||
{customActions.map((action, idx) => {
|
|
||||||
const Icon = action.icon
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={idx}
|
|
||||||
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
|
|
||||||
onClick={action.onClick}
|
|
||||||
title={action.title}
|
|
||||||
>
|
|
||||||
{Icon && <Icon size={16} className="me-1" />}
|
|
||||||
{action.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Удаление */}
|
|
||||||
{onDelete && (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-danger"
|
|
||||||
onClick={onDelete}
|
|
||||||
title="Удалить выбранное"
|
|
||||||
>
|
|
||||||
<IconTrash size={16} className="me-1" />
|
|
||||||
Удалить ({selectedCount})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BulkActionsBar
|
export default BulkActionsBar;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
IconDatabase,
|
IconDatabase,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconKeyboard
|
IconKeyboard,
|
||||||
|
IconChartPie,
|
||||||
|
IconSettings,
|
||||||
|
IconClock,
|
||||||
|
IconClockPlay,
|
||||||
|
IconShield
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,14 +43,20 @@ function CommandPalette() {
|
|||||||
// Список команд
|
// Список команд
|
||||||
const commands = [
|
const commands = [
|
||||||
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
|
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
|
||||||
|
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
|
||||||
|
{ icon: IconNetwork, label: 'Карта сети', description: 'Граф серверов и пинг между ними', action: () => navigate('/network-map'), keywords: ['карта', 'сеть', 'network', 'map', 'пинг', 'ping'] },
|
||||||
|
{ icon: IconClock, label: 'Uptime Monitor', description: 'Мониторинг доступности по ping с выбранного роутера', action: () => navigate('/uptime-monitor'), keywords: ['uptime', 'мониторинг', 'доступность', 'ping', 'роутер'] },
|
||||||
|
{ icon: IconClockPlay, label: 'Планировщик карты сети', description: 'Пинг и скорость по расписанию, логи', action: () => navigate('/scheduler'), keywords: ['планировщик', 'scheduler', 'карта', 'сеть', 'логи'] },
|
||||||
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
|
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
|
||||||
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
|
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
|
||||||
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
|
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
|
||||||
{ icon: IconFilter, label: 'Community', description: 'Справочник BGP Community', action: () => navigate('/communities'), keywords: ['community', 'справочник'] },
|
{ icon: IconFilter, label: 'Community', description: 'Справочник BGP Community', action: () => navigate('/communities'), keywords: ['community', 'справочник'] },
|
||||||
{ icon: IconServer, label: 'Серверы', description: 'Управление серверами', action: () => navigate('/servers'), keywords: ['серверы', 'servers'] },
|
{ icon: IconServer, label: 'Серверы', description: 'Управление серверами', action: () => navigate('/servers'), keywords: ['серверы', 'servers'] },
|
||||||
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
|
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
|
||||||
|
{ icon: IconShield, label: 'Firewall', description: 'Списки блокировки ban и ban_counter с роутера', action: () => navigate('/firewall'), keywords: ['firewall', 'ban', 'address-list', 'блокировка', 'роутер'] },
|
||||||
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
|
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
|
||||||
{ icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] },
|
{ icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] },
|
||||||
|
{ icon: IconSettings, label: 'Настройки интерфейса', description: 'WebSocket, DoH, пинг, PTR зона', action: () => navigate('/settings'), keywords: ['настройки', 'settings', 'интерфейс', 'doh', 'websocket'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Фильтрация команд по поисковому запросу
|
// Фильтрация команд по поисковому запросу
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { IconHash } from '@tabler/icons-react';
|
import { IconHash } from '@tabler/icons-react';
|
||||||
|
import { getIconById } from '../lib/brandIcons.js';
|
||||||
|
|
||||||
function CommunityAutocompleteInput({
|
function CommunityAutocompleteInput({
|
||||||
value,
|
value,
|
||||||
@@ -105,6 +106,7 @@ function CommunityAutocompleteInput({
|
|||||||
const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`;
|
const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`;
|
||||||
const description = s.description || '';
|
const description = s.description || '';
|
||||||
const tags = Array.isArray(s.tags) ? s.tags : [];
|
const tags = Array.isArray(s.tags) ? s.tags : [];
|
||||||
|
const iconInfo = s.icon ? getIconById(s.icon) : null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -114,9 +116,15 @@ function CommunityAutocompleteInput({
|
|||||||
onMouseEnter={() => setActiveIndex(idx)}
|
onMouseEnter={() => setActiveIndex(idx)}
|
||||||
>
|
>
|
||||||
<div className="d-flex align-items-start">
|
<div className="d-flex align-items-start">
|
||||||
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
|
{iconInfo ? (
|
||||||
<IconHash size={14} />
|
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
|
||||||
</span>
|
<iconInfo.Icon size={14} stroke={1.5} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
|
||||||
|
<IconHash size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex-fill text-start">
|
<div className="flex-fill text-start">
|
||||||
<div className="fw-medium">{label}</div>
|
<div className="fw-medium">{label}</div>
|
||||||
{(description || tags.length > 0) && (
|
{(description || tags.length > 0) && (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getIconById } from '../lib/brandIcons.js';
|
||||||
|
|
||||||
function normalizeTablerColor(input) {
|
function normalizeTablerColor(input) {
|
||||||
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']);
|
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']);
|
||||||
const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
|
const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
|
||||||
@@ -11,8 +13,12 @@ function CommunityBadge({ value, communities = [] }) {
|
|||||||
const color = normalizeTablerColor(meta?.color || 'blue');
|
const color = normalizeTablerColor(meta?.color || 'blue');
|
||||||
const cls = `badge bg-${color}-lt text-${color}`;
|
const cls = `badge bg-${color}-lt text-${color}`;
|
||||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
|
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
|
||||||
|
const iconInfo = meta?.icon ? getIconById(meta.icon) : null;
|
||||||
return (
|
return (
|
||||||
<span className={cls} title={title}>{v}</span>
|
<span className={cls} title={title}>
|
||||||
|
{iconInfo ? <iconInfo.Icon size={12} stroke={1.8} className="me-1" /> : null}
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ function CommunityStats({ communities = [] }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Общая статистика */}
|
{/* Общая статистика */}
|
||||||
<div className="row mb-4">
|
<div className="row g-2 g-md-3 mb-4">
|
||||||
<div className="col-md-4">
|
<div className="col-6 col-md-4">
|
||||||
<div className="card card-sm bg-blue-lt">
|
<div className="card card-sm bg-blue-lt">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="h1 mb-0">{stats.length}</div>
|
<div className="h1 mb-0">{stats.length}</div>
|
||||||
@@ -109,7 +109,7 @@ function CommunityStats({ communities = [] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-6 col-md-4">
|
||||||
<div className="card card-sm bg-green-lt">
|
<div className="card card-sm bg-green-lt">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="h1 mb-0">{totalUsage}</div>
|
<div className="h1 mb-0">{totalUsage}</div>
|
||||||
@@ -117,7 +117,7 @@ function CommunityStats({ communities = [] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-6 col-md-4">
|
||||||
<div className="card card-sm bg-purple-lt">
|
<div className="card card-sm bg-purple-lt">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="h1 mb-0">{Object.keys(statsByCategory).length}</div>
|
<div className="h1 mb-0">{Object.keys(statsByCategory).length}</div>
|
||||||
@@ -198,12 +198,12 @@ function CommunityStats({ communities = [] }) {
|
|||||||
{Object.keys(statsByCategory).length > 1 && (
|
{Object.keys(statsByCategory).length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3">По категориям</h4>
|
<h4 className="mb-3">По категориям</h4>
|
||||||
<div className="row g-3">
|
<div className="row g-2 g-md-3">
|
||||||
{Object.entries(statsByCategory).map(([category, items]) => {
|
{Object.entries(statsByCategory).map(([category, items]) => {
|
||||||
const categoryTotal = items.reduce((sum, item) => sum + item.count, 0);
|
const categoryTotal = items.reduce((sum, item) => sum + item.count, 0);
|
||||||
const categoryPercentage = ((categoryTotal / totalUsage) * 100).toFixed(1);
|
const categoryPercentage = ((categoryTotal / totalUsage) * 100).toFixed(1);
|
||||||
return (
|
return (
|
||||||
<div key={category} className="col-md-6">
|
<div key={category} className="col-12 col-md-6">
|
||||||
<div className="card card-sm">
|
<div className="card card-sm">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React from 'react';
|
||||||
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
|
||||||
import EmptyState from './EmptyState.jsx';
|
import EmptyState from './EmptyState.jsx';
|
||||||
@@ -34,6 +34,9 @@ import Tooltip from './Tooltip.jsx';
|
|||||||
* - [{ icon: Component, label: string, onClick: (item) => void, variant?: string }]
|
* - [{ icon: Component, label: string, onClick: (item) => void, variant?: string }]
|
||||||
* @param {boolean} props.selectable - Включить чекбоксы выбора (default: true)
|
* @param {boolean} props.selectable - Включить чекбоксы выбора (default: true)
|
||||||
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
|
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
|
||||||
|
* @param {boolean} props.striped - Чередование фона строк (Tabler UX, default: true)
|
||||||
|
* @param {boolean} props.compact - Уменьшенные отступы ячеек (default: false)
|
||||||
|
* @param {boolean} props.flat - Таблица без карточной подложки: только горизонтальные разделители (default: false)
|
||||||
*/
|
*/
|
||||||
function DataTable({
|
function DataTable({
|
||||||
columns = [],
|
columns = [],
|
||||||
@@ -56,11 +59,14 @@ function DataTable({
|
|||||||
actions = [],
|
actions = [],
|
||||||
selectable = true,
|
selectable = true,
|
||||||
skeletonRows = 10,
|
skeletonRows = 10,
|
||||||
|
striped = true,
|
||||||
|
compact = false,
|
||||||
|
flat = false,
|
||||||
}) {
|
}) {
|
||||||
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
|
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
|
||||||
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
|
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
|
||||||
|
|
||||||
// Рендер заголовка колонки
|
// Рендер заголовка колонки (col.title — подпись, col.tooltip — подсказка для UX)
|
||||||
const renderColumnHeader = (col) => {
|
const renderColumnHeader = (col) => {
|
||||||
const isSortable = col.sortable !== false && onSort;
|
const isSortable = col.sortable !== false && onSort;
|
||||||
const isActive = sortField === col.key;
|
const isActive = sortField === col.key;
|
||||||
@@ -77,22 +83,27 @@ function DataTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const thProps = {
|
||||||
|
key: col.key,
|
||||||
|
scope: 'col',
|
||||||
|
style: col.width ? { width: col.width } : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
if (isSortable) {
|
if (isSortable) {
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
{...thProps}
|
||||||
className="cursor-pointer user-select-none"
|
className="cursor-pointer user-select-none"
|
||||||
onClick={() => onSort(col.key)}
|
onClick={() => onSort(col.key)}
|
||||||
style={col.width ? { width: col.width } : undefined}
|
|
||||||
>
|
>
|
||||||
{content}
|
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th key={col.key} style={col.width ? { width: col.width } : undefined}>
|
<th {...thProps}>
|
||||||
{content}
|
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -210,15 +221,21 @@ function DataTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
|
const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
|
||||||
|
const tableClass = [
|
||||||
|
'table table-vcenter table-nowrap mb-0',
|
||||||
|
flat ? 'table-flat' : 'card-table',
|
||||||
|
!flat && striped && 'table-striped',
|
||||||
|
compact && 'table-sm',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className={tableClass} role="grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<th style={{ width: '40px' }}>
|
<th scope="col" style={{ width: '40px' }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="form-check-input"
|
className="form-check-input"
|
||||||
@@ -229,7 +246,7 @@ function DataTable({
|
|||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
{columns.map(renderColumnHeader)}
|
{columns.map(renderColumnHeader)}
|
||||||
{hasActions && <th className="text-end">Действия</th>}
|
{hasActions && <th scope="col" className="text-end td-actions">Действия</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -261,7 +278,7 @@ function DataTable({
|
|||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{hasActions && (
|
{hasActions && (
|
||||||
<td className="text-end">
|
<td className="text-end td-actions">
|
||||||
{renderActions(item)}
|
{renderActions(item)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ class ErrorBoundary extends Component {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error) {
|
static getDerivedStateFromError(_error) {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error, errorInfo) {
|
componentDidCatch(error, errorInfo) {
|
||||||
// Логируем ошибку
|
// Логируем ошибку
|
||||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
|
||||||
// Отправляем в мониторинг (если настроен)
|
// Отправляем в мониторинг (если настроен)
|
||||||
this.logErrorToService(error, errorInfo);
|
this.logErrorToService(error, errorInfo);
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ class ErrorBoundary extends Component {
|
|||||||
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
|
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
|
{import.meta.env.DEV && this.state.errorInfo && (
|
||||||
<div className="card mt-3">
|
<div className="card mt-3">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
|
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { IconX } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Всплывающая панель массовых действий в стиле со скрина:
|
||||||
|
* тёмная панель по центру снизу, счётчик "N выбрано", кнопки действий, закрыть.
|
||||||
|
* Используется на страницах типа Manager при выборе элементов.
|
||||||
|
*
|
||||||
|
* @param {number} selectedCount - количество выбранных элементов
|
||||||
|
* @param {() => void} onClearSelection - снять выделение (кнопка X)
|
||||||
|
* @param {Array<{ type: 'button', label: string, icon: React.ComponentType, onClick: () => void, variant?: 'danger', title?: string } | { type: 'dropdown', label: string, icon: React.ComponentType, items: Array<{ label: string, onClick: () => void }>, title?: string }>} actions - кнопки и выпадающие меню
|
||||||
|
*/
|
||||||
|
function FloatingBulkActionsBar({ selectedCount = 0, onClearSelection, actions = [] }) {
|
||||||
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="floating-bulk-actions-bar"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1050,
|
||||||
|
maxWidth: '720px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '0 12px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center justify-content-between gap-3 rounded-3 shadow-lg px-3 py-2"
|
||||||
|
style={{
|
||||||
|
animation: 'floatingBulkSlideUp 0.2s ease',
|
||||||
|
background: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Счётчик выбранных */}
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="rounded"
|
||||||
|
style={{
|
||||||
|
background: '#3b82f6',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedCount}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#e2e8f0', fontWeight: 500 }}>выбрано</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Действия */}
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{actions.map((action, idx) => {
|
||||||
|
if (action.type === 'dropdown') {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="dropdown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="floating-bulk-btn dropdown-toggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
title={action.title}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={15} />}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</button>
|
||||||
|
<div className="dropdown-menu dropdown-menu-dark">
|
||||||
|
{action.items?.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="dropdown-item"
|
||||||
|
onClick={() => item.onClick()}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const Icon = action.icon;
|
||||||
|
const isDanger = action.variant === 'danger';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
className={`floating-bulk-btn ${isDanger ? 'floating-bulk-btn-danger' : ''}`}
|
||||||
|
onClick={action.onClick}
|
||||||
|
title={action.title}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={15} />}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
margin: '0 4px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="floating-bulk-btn-close"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
title="Снять выделение"
|
||||||
|
aria-label="Снять выделение"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes floatingBulkSlideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.floating-bulk-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.floating-bulk-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.floating-bulk-btn-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.floating-bulk-btn-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.4);
|
||||||
|
color: #fef2f2;
|
||||||
|
}
|
||||||
|
.floating-bulk-btn-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.floating-bulk-btn-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FloatingBulkActionsBar;
|
||||||
@@ -40,7 +40,7 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
|
|||||||
notify.success('Откат выполнен успешно');
|
notify.success('Откат выполнен успешно');
|
||||||
onRolledBack?.(res.data || {});
|
onRolledBack?.(res.data || {});
|
||||||
onClose?.();
|
onClose?.();
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
notify.error('Не удалось выполнить откат');
|
notify.error('Не удалось выполнить откат');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
|
||||||
|
import { IconSearch, IconMinus } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор иконки бренда с поиском и предпросмотром.
|
||||||
|
* value — id иконки (например IconBrandGoogle), onChange(id) — при выборе.
|
||||||
|
*/
|
||||||
|
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда', allowNone = false }) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selected = allowNone && !value
|
||||||
|
? { id: '', label: 'Без иконки', Icon: IconMinus }
|
||||||
|
: getIconById(value);
|
||||||
|
const filteredIcons = useMemo(() => {
|
||||||
|
const q = (search || '').trim().toLowerCase();
|
||||||
|
if (!q) return BRAND_ICONS;
|
||||||
|
return BRAND_ICONS.filter(
|
||||||
|
(item) =>
|
||||||
|
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [search]);
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!allowNone) return filteredIcons;
|
||||||
|
const q = (search || '').trim().toLowerCase();
|
||||||
|
const noneMatches = q.length === 0 || 'без иконки'.includes(q) || 'none'.includes(q);
|
||||||
|
if (!noneMatches) return filteredIcons;
|
||||||
|
return [{ id: '', label: 'Без иконки', Icon: IconMinus }, ...filteredIcons];
|
||||||
|
}, [allowNone, filteredIcons, search]);
|
||||||
|
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
onChange?.(item.id);
|
||||||
|
setOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(e.target) && !e.target.closest('button[aria-expanded]')) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="form-label small">{label}</label>
|
||||||
|
<div className="d-flex align-items-center gap-2 flex-wrap" ref={panelRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary d-flex align-items-center gap-2"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
<span className="avatar bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center" style={{ width: 34, height: 34 }}>
|
||||||
|
<selected.Icon size={20} stroke={1.5} />
|
||||||
|
</span>
|
||||||
|
<span>{selected.label}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="border rounded p-3 shadow-sm bg-body" style={{ minWidth: 560, maxWidth: 640 }}>
|
||||||
|
<div className="input-group input-group-sm mb-2">
|
||||||
|
<span className="input-group-text">
|
||||||
|
<IconSearch size={14} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Поиск по названию..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="d-flex flex-wrap gap-1 overflow-auto"
|
||||||
|
style={{ maxHeight: 320 }}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
|
||||||
|
value === item.id ? 'btn-primary' : 'btn-outline-secondary'
|
||||||
|
}`}
|
||||||
|
style={{ width: 56, height: 56 }}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
title={item.label}
|
||||||
|
role="option"
|
||||||
|
aria-selected={value === item.id}
|
||||||
|
>
|
||||||
|
<item.Icon size={26} stroke={1.5} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="small text-muted py-2">Ничего не найдено</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getIconById };
|
||||||
@@ -178,7 +178,7 @@ function ImportModal({
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="row g-2">
|
<div className="row g-2">
|
||||||
<div className="col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<div className="card bg-green-lt">
|
<div className="card bg-green-lt">
|
||||||
<div className="card-body py-2">
|
<div className="card-body py-2">
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
@@ -191,7 +191,7 @@ function ImportModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<div className="card bg-red-lt">
|
<div className="card bg-red-lt">
|
||||||
<div className="card-body py-2">
|
<div className="card-body py-2">
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ function MobileCardView({
|
|||||||
// минимальная дистанция свайпа в px
|
// минимальная дистанция свайпа в px
|
||||||
const minSwipeDistance = 50
|
const minSwipeDistance = 50
|
||||||
|
|
||||||
const onTouchStart = (e, item) => {
|
const onTouchStart = (e, _item) => {
|
||||||
setTouchEnd(null)
|
setTouchEnd(null)
|
||||||
setTouchStart(e.targetTouches[0].clientX)
|
setTouchStart(e.targetTouches[0].clientX)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTouchMove = (e, item) => {
|
const onTouchMove = (e, _item) => {
|
||||||
setTouchEnd(e.targetTouches[0].clientX)
|
setTouchEnd(e.targetTouches[0].clientX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
|
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ export function notifyMutationSuccess(message, details) {
|
|||||||
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
|
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
|
||||||
window.notify.success(text, details);
|
window.notify.success(text, details);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorByType(type) {
|
function colorByType(type) {
|
||||||
@@ -121,31 +122,50 @@ function iconByType(type) {
|
|||||||
|
|
||||||
function NotifyViewport({ items, onClose }) {
|
function NotifyViewport({ items, onClose }) {
|
||||||
return (
|
return (
|
||||||
<div className="position-fixed top-0 end-0 p-3" style={{ zIndex: 1080, pointerEvents: 'none' }}>
|
<div className="toast-container toast-container-end notify-viewport" style={{ zIndex: 1079 }}>
|
||||||
<div className="d-flex flex-column gap-2 align-items-end">
|
{items.map((n) => (
|
||||||
{items.map((n) => (
|
<div
|
||||||
<div key={n.id} className={`alert alert-${colorByType(n.type)} alert-dismissible shadow-sm`} role="alert" style={{ minWidth: 320, pointerEvents: 'auto' }}>
|
key={n.id}
|
||||||
<div className="d-flex align-items-start">
|
className="toast show toast-alert"
|
||||||
<div className="me-1 mt-1">{iconByType(n.type)}</div>
|
role="alert"
|
||||||
<div className="flex-grow-1">
|
aria-live="assertive"
|
||||||
|
aria-atomic="true"
|
||||||
|
style={{
|
||||||
|
minWidth: '320px',
|
||||||
|
maxWidth: '420px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 'var(--tblr-border-radius, 0.25rem)',
|
||||||
|
animation: 'slideInRight 0.3s ease-out forwards',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`alert alert-${colorByType(n.type)} toast-alert-inner mb-0`}>
|
||||||
|
<div className="toast-header toast-alert-header">
|
||||||
|
<span className="toast-alert-icon me-2">{iconByType(n.type)}</span>
|
||||||
|
<strong className="me-auto">
|
||||||
|
{n.type === 'success' && 'Успех'}
|
||||||
|
{n.type === 'error' && 'Ошибка'}
|
||||||
|
{n.type === 'warning' && 'Внимание'}
|
||||||
|
{n.type === 'info' && 'Информация'}
|
||||||
|
</strong>
|
||||||
|
<button type="button" className="btn-close" aria-label="Закрыть" onClick={() => onClose(n.id)} />
|
||||||
|
</div>
|
||||||
|
<div className="toast-body">
|
||||||
|
<div className="alert-description">
|
||||||
{n.message}
|
{n.message}
|
||||||
{n.details && (
|
{n.count > 1 && <span className="badge bg-white text-body border ms-2">×{n.count}</span>}
|
||||||
<details className="small mt-1">
|
|
||||||
<summary>Подробнее</summary>
|
|
||||||
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
{n.count > 1 && (
|
|
||||||
<span className="badge bg-white text-body border ms-2">×{n.count}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn-close" aria-label="Close" onClick={() => onClose(n.id)}>
|
{n.details && (
|
||||||
<IconX size={16} />
|
<details className="small mt-1">
|
||||||
</button>
|
<summary>Подробнее</summary>
|
||||||
|
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,37 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
function PageHeader({ title, pretitle, actions, meta }) {
|
/**
|
||||||
|
* Единый заголовок страницы в стиле Tabler UI.
|
||||||
|
* Используется на всех страницах для одинакового UX.
|
||||||
|
* Кнопки (actions) отображаются под названием страницы.
|
||||||
|
* @param {string} title - Заголовок страницы
|
||||||
|
* @param {React.ReactNode} [icon] - Иконка слева от заголовка (например, <IconServer />)
|
||||||
|
* @param {string} [pretitle] - Небольшая надпись над заголовком (page-pretitle)
|
||||||
|
* @param {React.ReactNode} [meta] - Подзаголовок или мета-информация под заголовком
|
||||||
|
* @param {React.ReactNode} [actions] - Кнопки/действия под названием страницы (btn-list, btn-group)
|
||||||
|
*/
|
||||||
|
function PageHeader({ title, icon, pretitle, actions, meta }) {
|
||||||
return (
|
return (
|
||||||
<div className="page-header d-print-none mb-4">
|
<div className="page-header d-print-none mb-4">
|
||||||
{/* Верхняя строка: только заголовок */}
|
|
||||||
<div className="row align-items-center">
|
<div className="row align-items-center">
|
||||||
<div className="col">
|
<div className="col">
|
||||||
<h2 className="page-title">{title}</h2>
|
|
||||||
{pretitle && (
|
{pretitle && (
|
||||||
<div className="page-pretitle mt-1">{pretitle}</div>
|
<div className="page-pretitle">{pretitle}</div>
|
||||||
)}
|
)}
|
||||||
|
<h1 className="page-title d-flex align-items-center">
|
||||||
|
{icon && <span className="me-2 d-flex align-items-center">{icon}</span>}
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
{meta && (
|
{meta && (
|
||||||
<div className="text-muted small mt-1">{meta}</div>
|
<div className="text-muted small mt-1">{meta}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Отдельная строка под заголовком: действия страницы */}
|
|
||||||
{actions && (
|
{actions && (
|
||||||
<div className="mt-4 d-print-none">
|
<div className="row mt-2">
|
||||||
{actions}
|
<div className="col d-print-none overflow-auto">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,9 +59,13 @@ function PageHeaderActions({
|
|||||||
{(onImport || onExport) && placeholderBtn(160)}
|
{(onImport || onExport) && placeholderBtn(160)}
|
||||||
{onClear && placeholderBtn(130)}
|
{onClear && placeholderBtn(130)}
|
||||||
{onClearCommunities && placeholderBtn(180)}
|
{onClearCommunities && placeholderBtn(180)}
|
||||||
{onSave && placeholderBtn(140)}
|
|
||||||
{onHistory && placeholderBtn(110)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{(onSave || onHistory) && (
|
||||||
|
<div className="header-actions-right ms-auto d-flex align-items-center gap-2">
|
||||||
|
{onSave && placeholderBtn(140)}
|
||||||
|
{onHistory && placeholderBtn(110)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,33 +144,37 @@ function PageHeaderActions({
|
|||||||
<IconEraser className="me-1" /> Очистить community
|
<IconEraser className="me-1" /> Очистить community
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onSave && (
|
|
||||||
<button className="btn btn-primary" type="button" onClick={onSave} disabled={disableSave} title="Сохранить">
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
|
||||||
Сохранение...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<IconDeviceFloppy className="me-1" /> Сохранить
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onHistory && (
|
|
||||||
<div className="btn-group">
|
|
||||||
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title="История версий">История</button>
|
|
||||||
{lastModified && (
|
|
||||||
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title={`Последние изменения: ${formatDateTimeWithRelative(lastModified)}`}>
|
|
||||||
<IconClock className="me-1" />
|
|
||||||
<span className="d-none d-sm-inline">{formatDateTimeWithRelative(lastModified)}</span>
|
|
||||||
<span className="d-inline d-sm-none">{formatDateTimeWithRelative(lastModified)}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{(onSave || onHistory) && (
|
||||||
|
<div className="header-actions-right ms-auto d-flex align-items-center gap-2">
|
||||||
|
{onSave && (
|
||||||
|
<button className="btn btn-primary" type="button" onClick={onSave} disabled={disableSave} title="Сохранить">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
Сохранение...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconDeviceFloppy className="me-1" /> Сохранить
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onHistory && (
|
||||||
|
<div className="btn-group d-none d-sm-inline-flex">
|
||||||
|
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title="История версий">История</button>
|
||||||
|
{lastModified && (
|
||||||
|
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title={`Последние изменения: ${formatDateTimeWithRelative(lastModified)}`}>
|
||||||
|
<IconClock className="me-1" />
|
||||||
|
<span className="d-none d-sm-inline">{formatDateTimeWithRelative(lastModified)}</span>
|
||||||
|
<span className="d-inline d-sm-none">{formatDateTimeWithRelative(lastModified)}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Компактное меню для маленьких экранов */}
|
{/* Компактное меню для маленьких экранов */}
|
||||||
{secondaryButtons && (
|
{secondaryButtons && (
|
||||||
<div className="btn-group d-inline-flex d-sm-none">
|
<div className="btn-group d-inline-flex d-sm-none">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
function getBody() {
|
function getBody() {
|
||||||
@@ -20,7 +20,7 @@ function PortalDropdown({
|
|||||||
const [coords, setCoords] = useState({ top: 0, left: 0, minWidth: 0 });
|
const [coords, setCoords] = useState({ top: 0, left: 0, minWidth: 0 });
|
||||||
const BASE_Z = 1035; // above navbar (1030), below modal-backdrop (1040) and modal (1050)
|
const BASE_Z = 1035; // above navbar (1030), below modal-backdrop (1040) and modal (1050)
|
||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = useCallback(() => {
|
||||||
const btn = buttonRef.current;
|
const btn = buttonRef.current;
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
@@ -32,12 +32,11 @@ function PortalDropdown({
|
|||||||
left = Math.max(0, rect.right - (menuWidth || rect.width));
|
left = Math.max(0, rect.right - (menuWidth || rect.width));
|
||||||
}
|
}
|
||||||
setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth });
|
setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth });
|
||||||
};
|
}, [align]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (open) updatePosition();
|
if (open) updatePosition();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [open, updatePosition]);
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -60,7 +59,7 @@ function PortalDropdown({
|
|||||||
document.removeEventListener('keydown', onKey);
|
document.removeEventListener('keydown', onKey);
|
||||||
document.removeEventListener('mousedown', onClickOutside);
|
document.removeEventListener('mousedown', onClickOutside);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open, updatePosition]);
|
||||||
|
|
||||||
const body = getBody();
|
const body = getBody();
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function QuickAddBar({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="row g-3 mt-2">
|
<div className="row g-2 g-md-3 mt-2">
|
||||||
<div className="col">
|
<div className="col">
|
||||||
<div className="card"><div className="card-body p-2">
|
<div className="card"><div className="card-body p-2">
|
||||||
<div className="text-muted">Готово</div>
|
<div className="text-muted">Готово</div>
|
||||||
|
|||||||
@@ -7,19 +7,25 @@ function S3MetaBar({ keys = [], className = '' }) {
|
|||||||
const [meta, setMeta] = useState({});
|
const [meta, setMeta] = useState({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const fetchMeta = async () => {
|
const fetchMeta = async (abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/s3/last-modified');
|
const res = await api.get('/s3/last-modified', opts);
|
||||||
setMeta(res.data || {});
|
setMeta(res.data || {});
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||||
setMeta({});
|
setMeta({});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchMeta(); }, []);
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchMeta(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const copyMeta = async (k) => {
|
const copyMeta = async (k) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function SavedFilters({
|
|||||||
const [savedFilters, setSavedFilters] = useState([])
|
const [savedFilters, setSavedFilters] = useState([])
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
const [filterName, setFilterName] = useState('')
|
const [filterName, setFilterName] = useState('')
|
||||||
const [showList, setShowList] = useState(false)
|
const [_showList, setShowList] = useState(false)
|
||||||
|
|
||||||
const storageKey = `savedFilters_${pageKey}`
|
const storageKey = `savedFilters_${pageKey}`
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import FormField from './FormField';
|
import FormField from './FormField';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import api from '../lib/api.js';
|
import api from '../lib/api.js';
|
||||||
import { IconSettings, IconDeviceFloppy } from '@tabler/icons-react';
|
import { IconSettings, IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
import ErrorAlert from './ErrorAlert';
|
import ErrorAlert from './ErrorAlert';
|
||||||
|
import ServerAutocompleteInput from './ServerAutocompleteInput.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SettingsModal - модальное окно настроек интерфейса
|
* SettingsModal - модальное окно настроек интерфейса
|
||||||
@@ -18,28 +19,84 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
const [dohServer, setDohServer] = useState('');
|
const [dohServer, setDohServer] = useState('');
|
||||||
const [wsUrl, setWsUrl] = useState('');
|
const [wsUrl, setWsUrl] = useState('');
|
||||||
const [baseAS, setBaseAS] = useState('65001');
|
const [baseAS, setBaseAS] = useState('65001');
|
||||||
|
const [pingDomain, setPingDomain] = useState('');
|
||||||
|
const [pingCacheMinutes, setPingCacheMinutes] = useState('');
|
||||||
const [ptrZoneReplaceFrom, setPtrZoneReplaceFrom] = useState('');
|
const [ptrZoneReplaceFrom, setPtrZoneReplaceFrom] = useState('');
|
||||||
const [ptrZoneReplaceTo, setPtrZoneReplaceTo] = useState('');
|
const [ptrZoneReplaceTo, setPtrZoneReplaceTo] = useState('');
|
||||||
|
const [pingServicesSource, setPingServicesSource] = useState('web');
|
||||||
|
const [pingServicesServerId, setPingServicesServerId] = useState('');
|
||||||
|
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
||||||
|
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
|
||||||
|
const [pingServicesSchedulerEnabled, setPingServicesSchedulerEnabled] = useState(true);
|
||||||
|
const [pingServicesSchedulerIntervalMinutes, setPingServicesSchedulerIntervalMinutes] = useState('2');
|
||||||
|
const [serversList, setServersList] = useState([]);
|
||||||
|
const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp');
|
||||||
|
const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10');
|
||||||
|
const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState('');
|
||||||
|
|
||||||
|
const routerServersForPing = useMemo(() => {
|
||||||
|
return (serversList || []).filter(
|
||||||
|
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
|
||||||
|
);
|
||||||
|
}, [serversList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/ui-settings');
|
const [settingsRes, serversRes] = await Promise.all([
|
||||||
const data = res?.data || {};
|
api.get('/ui-settings'),
|
||||||
|
api.get('/servers').catch(() => ({ data: [] })),
|
||||||
|
]);
|
||||||
|
const data = settingsRes?.data || {};
|
||||||
setRawSettings(data);
|
setRawSettings(data);
|
||||||
setDohServer(String(data?.dohServer || ''));
|
setDohServer(String(data?.dohServer || ''));
|
||||||
setWsUrl(String(data?.wsUpdateUrl || ''));
|
setWsUrl(String(data?.wsUpdateUrl || ''));
|
||||||
setBaseAS(String(data?.baseAS || '65001'));
|
setBaseAS(String(data?.baseAS || '65001'));
|
||||||
|
setPingDomain(String(data?.pingDomain || '').trim());
|
||||||
|
setPingCacheMinutes(
|
||||||
|
data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : ''
|
||||||
|
);
|
||||||
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
|
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
|
||||||
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
|
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
|
||||||
const e = res?.headers?.etag || res?.headers?.ETag || '';
|
setPingServicesSource(String(data?.pingServicesSource || 'web').toLowerCase() === 'router' ? 'router' : 'web');
|
||||||
|
setPingServicesServerId(String(data?.pingServicesServerId || '').trim());
|
||||||
|
setPingServicesGatewayIp(String(data?.pingServicesGatewayIp || '').trim());
|
||||||
|
setPingServicesCacheSeconds(
|
||||||
|
data?.pingServicesCacheSeconds != null
|
||||||
|
? String(data.pingServicesCacheSeconds)
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
setPingServicesSchedulerEnabled(data?.pingServicesSchedulerEnabled !== false);
|
||||||
|
setPingServicesSchedulerIntervalMinutes(
|
||||||
|
data?.pingServicesSchedulerIntervalMinutes != null
|
||||||
|
? String(data.pingServicesSchedulerIntervalMinutes)
|
||||||
|
: '2'
|
||||||
|
);
|
||||||
|
setInterfaceSpeedTestProtocol(
|
||||||
|
String(data?.interfaceSpeedTestProtocol || 'tcp').toLowerCase() ===
|
||||||
|
'udp'
|
||||||
|
? 'udp'
|
||||||
|
: 'tcp'
|
||||||
|
);
|
||||||
|
setInterfaceSpeedTestDurationSeconds(
|
||||||
|
data?.interfaceSpeedTestDurationSeconds != null
|
||||||
|
? String(data.interfaceSpeedTestDurationSeconds)
|
||||||
|
: '5'
|
||||||
|
);
|
||||||
|
setInterfaceSpeedTestCacheMinutes(
|
||||||
|
data?.interfaceSpeedTestCacheMinutes != null
|
||||||
|
? String(data.interfaceSpeedTestCacheMinutes)
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
||||||
setEtag(e ? String(e) : '');
|
setEtag(e ? String(e) : '');
|
||||||
} catch (e) {
|
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
||||||
|
} catch (_e) {
|
||||||
setError('Не удалось загрузить настройки');
|
setError('Не удалось загрузить настройки');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -97,8 +154,32 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
dohServer: String(dohServer || '').trim(),
|
dohServer: String(dohServer || '').trim(),
|
||||||
wsUpdateUrl: String(wsUrl || '').trim(),
|
wsUpdateUrl: String(wsUrl || '').trim(),
|
||||||
baseAS: String(baseAS || '65001').trim(),
|
baseAS: String(baseAS || '65001').trim(),
|
||||||
|
pingDomain: String(pingDomain || '').trim(),
|
||||||
|
pingCacheMinutes: Math.max(0, parseInt(pingCacheMinutes, 10) || 0),
|
||||||
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
|
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
|
||||||
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(),
|
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(),
|
||||||
|
pingServicesSource: pingServicesSource === 'router' ? 'router' : 'web',
|
||||||
|
pingServicesServerId: String(pingServicesServerId || '').trim(),
|
||||||
|
pingServicesGatewayIp: String(pingServicesGatewayIp || '').trim(),
|
||||||
|
pingServicesCacheSeconds: Math.max(
|
||||||
|
0,
|
||||||
|
parseInt(pingServicesCacheSeconds, 10) || 0
|
||||||
|
),
|
||||||
|
pingServicesSchedulerEnabled: Boolean(pingServicesSchedulerEnabled),
|
||||||
|
pingServicesSchedulerIntervalMinutes: Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(1440, parseInt(pingServicesSchedulerIntervalMinutes, 10) || 2)
|
||||||
|
),
|
||||||
|
interfaceSpeedTestProtocol:
|
||||||
|
interfaceSpeedTestProtocol === 'udp' ? 'udp' : 'tcp',
|
||||||
|
interfaceSpeedTestDurationSeconds: Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(interfaceSpeedTestDurationSeconds, 10) || 5
|
||||||
|
),
|
||||||
|
interfaceSpeedTestCacheMinutes: Math.max(
|
||||||
|
0,
|
||||||
|
parseInt(interfaceSpeedTestCacheMinutes, 10) || 0
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const payload = {
|
const payload = {
|
||||||
settings: mergedSettings,
|
settings: mergedSettings,
|
||||||
@@ -198,6 +279,171 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Домен для пинга"
|
||||||
|
name="pingDomain"
|
||||||
|
type="text"
|
||||||
|
value={pingDomain}
|
||||||
|
onChange={setPingDomain}
|
||||||
|
placeholder="8.8.8.8 или ya.ru"
|
||||||
|
helpText="Домен или IP для проверки пинга через MikroTik (например, 8.8.8.8, ya.ru). Если не задан, используется значение по умолчанию."
|
||||||
|
disabled={loading || saving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Срок кеша пинга (мин)"
|
||||||
|
name="pingCacheMinutes"
|
||||||
|
type="number"
|
||||||
|
value={pingCacheMinutes}
|
||||||
|
onChange={setPingCacheMinutes}
|
||||||
|
placeholder="0"
|
||||||
|
helpText="0 — без кеша. При значении больше 0 результаты пинга кешируются в S3 на указанное количество минут."
|
||||||
|
disabled={loading || saving}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-top">
|
||||||
|
<h6 className="mb-2">Измерение скорости (интерфейсы)</h6>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="form-label small">Протокол измерения</label>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={interfaceSpeedTestProtocol}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInterfaceSpeedTestProtocol(e.target.value)
|
||||||
|
}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
<div className="form-text small">
|
||||||
|
Используется как настройка по умолчанию для инструментов
|
||||||
|
измерения скорости RouterOS.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Время замера (сек)"
|
||||||
|
name="interfaceSpeedTestDurationSeconds"
|
||||||
|
type="number"
|
||||||
|
value={interfaceSpeedTestDurationSeconds}
|
||||||
|
onChange={setInterfaceSpeedTestDurationSeconds}
|
||||||
|
placeholder="5"
|
||||||
|
helpText="Интервал, за который измеряется средняя скорость по интерфейсу."
|
||||||
|
disabled={loading || saving}
|
||||||
|
min={1}
|
||||||
|
max={600}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Кеш результата замера (мин)"
|
||||||
|
name="interfaceSpeedTestCacheMinutes"
|
||||||
|
type="number"
|
||||||
|
value={interfaceSpeedTestCacheMinutes}
|
||||||
|
onChange={setInterfaceSpeedTestCacheMinutes}
|
||||||
|
placeholder="0"
|
||||||
|
helpText="0 — без кеша. При значении больше 0 результаты замеров скорости по интерфейсу кешируются в S3."
|
||||||
|
disabled={loading || saving}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-top">
|
||||||
|
<h6 className="mb-2">Пинг сервисов на главной</h6>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="form-label small">Источник пинга</label>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={pingServicesSource}
|
||||||
|
onChange={(e) => setPingServicesSource(e.target.value)}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<option value="web">Веб (TCP с сервера приложения)</option>
|
||||||
|
<option value="router">Роутер (RouterOS API)</option>
|
||||||
|
</select>
|
||||||
|
<div className="form-text small">
|
||||||
|
«Веб» — задержка до 8.8.8.8, 1.1.1.1 и др. с сервера. «Роутер» — пинг через выбранный MikroTik (jumphost).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<FormField
|
||||||
|
label="Время кеширования пингов (сек)"
|
||||||
|
name="pingServicesCacheSeconds"
|
||||||
|
type="number"
|
||||||
|
value={pingServicesCacheSeconds}
|
||||||
|
onChange={setPingServicesCacheSeconds}
|
||||||
|
placeholder="0"
|
||||||
|
helpText="0 — без кеша. Результаты пинга до Google, Cloudflare и др. будут кешироваться на указанное число секунд."
|
||||||
|
disabled={loading || saving}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="form-check form-switch mb-1">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="modalPingServicesSchedulerEnabled"
|
||||||
|
checked={pingServicesSchedulerEnabled}
|
||||||
|
onChange={(e) => setPingServicesSchedulerEnabled(e.target.checked)}
|
||||||
|
disabled={loading || saving}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label small" htmlFor="modalPingServicesSchedulerEnabled">
|
||||||
|
Планировщик: обновлять кеш пингов по расписанию
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="Интервал обновления (мин)"
|
||||||
|
name="pingServicesSchedulerIntervalMinutes"
|
||||||
|
type="number"
|
||||||
|
value={pingServicesSchedulerIntervalMinutes}
|
||||||
|
onChange={setPingServicesSchedulerIntervalMinutes}
|
||||||
|
placeholder="2"
|
||||||
|
helpText="Как часто обновлять кеш (1–1440 мин). По умолчанию 2."
|
||||||
|
disabled={loading || saving}
|
||||||
|
min={1}
|
||||||
|
max={1440}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{pingServicesSource === 'router' && (
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label small">Домашний роутер</label>
|
||||||
|
<div className={loading || saving ? 'opacity-75 pe-none' : ''}>
|
||||||
|
<ServerAutocompleteInput
|
||||||
|
value={pingServicesServerId}
|
||||||
|
onChange={(v) => setPingServicesServerId(String(v || '').trim())}
|
||||||
|
servers={routerServersForPing}
|
||||||
|
placeholder="Выберите роутер (jumphost или входной)"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
maxSuggestions={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-text small">
|
||||||
|
Выбор роутера с MikroTik API для пинга с главной. Пусто — первый jumphost из списка.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="IP шлюза (не обязательно)"
|
||||||
|
name="pingServicesGatewayIp"
|
||||||
|
type="text"
|
||||||
|
value={pingServicesGatewayIp}
|
||||||
|
onChange={setPingServicesGatewayIp}
|
||||||
|
placeholder="IP шлюза"
|
||||||
|
helpText="Не обязательно. Пусто — используется первый шлюз выбранного роутера."
|
||||||
|
disabled={loading || saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-top">
|
<div className="mt-4 pt-3 border-top">
|
||||||
<h6 className="mb-3">Настройка PTR зоны</h6>
|
<h6 className="mb-3">Настройка PTR зоны</h6>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Skeleton loader для таблиц с анимацией pulse
|
* Skeleton loader для таблиц с анимацией pulse
|
||||||
|
* @param {boolean} flat - без карточной подложки (table-flat)
|
||||||
*/
|
*/
|
||||||
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false }) {
|
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false, flat = false }) {
|
||||||
|
const tableClass = flat
|
||||||
|
? 'table table-flat table-vcenter table-nowrap mb-0'
|
||||||
|
: 'table card-table table-vcenter table-nowrap mb-0';
|
||||||
const Row = () => (
|
const Row = () => (
|
||||||
<tr>
|
<tr>
|
||||||
{hasCheckbox && (
|
{hasCheckbox && (
|
||||||
@@ -18,7 +22,7 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className={tableClass}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{hasCheckbox && <th style={{ width: '40px' }}></th>}
|
{hasCheckbox && <th style={{ width: '40px' }}></th>}
|
||||||
@@ -35,10 +39,13 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableEmpty({ cols = 3, children }) {
|
export function TableEmpty({ cols = 3, flat = false, children }) {
|
||||||
|
const tableClass = flat
|
||||||
|
? 'table table-flat table-vcenter table-nowrap mb-0'
|
||||||
|
: 'table card-table table-vcenter table-nowrap mb-0';
|
||||||
return (
|
return (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className={tableClass}>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={cols} className="text-center text-muted py-5">
|
<td colSpan={cols} className="text-center text-muted py-5">
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useState, useEffect, createContext, useContext } from 'react'
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { useState, useEffect, createContext, useContext, useCallback, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconX,
|
IconX,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconInfoCircle,
|
IconInfoCircle
|
||||||
IconRefresh
|
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контекст для глобального управления toast уведомлениями
|
* Контекст для глобального управления toast уведомлениями.
|
||||||
|
* Тосты в стиле Tabler Alerts, всплывают справа как Tabler Toasts.
|
||||||
*/
|
*/
|
||||||
const ToastContext = createContext({
|
const ToastContext = createContext({
|
||||||
success: () => {},
|
success: () => {},
|
||||||
@@ -19,25 +20,30 @@ const ToastContext = createContext({
|
|||||||
|
|
||||||
export const useToast = () => useContext(ToastContext)
|
export const useToast = () => useContext(ToastContext)
|
||||||
|
|
||||||
|
const DEFAULT_TITLES = {
|
||||||
|
success: 'Выполнено успешно',
|
||||||
|
error: 'Произошла ошибка',
|
||||||
|
warning: 'Внимание',
|
||||||
|
info: 'Информация',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toast уведомление (отдельный элемент)
|
* Один toast: разметка Tabler (toast + toast-header + toast-body), стиль Tabler Alerts.
|
||||||
*/
|
*/
|
||||||
function Toast({ toast, onClose }) {
|
function Toast({ toast, onClose }) {
|
||||||
const [isLeaving, setIsLeaving] = useState(false)
|
const [isLeaving, setIsLeaving] = useState(false)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setIsLeaving(true)
|
||||||
|
setTimeout(() => onClose(toast.id), 300)
|
||||||
|
}, [onClose, toast.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toast.duration && toast.duration > 0) {
|
if (toast.duration && toast.duration > 0) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => handleClose(), toast.duration)
|
||||||
handleClose()
|
|
||||||
}, toast.duration)
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [toast.duration])
|
}, [toast.duration, handleClose])
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsLeaving(true)
|
|
||||||
setTimeout(() => onClose(toast.id), 300) // анимация 300ms
|
|
||||||
}
|
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (toast.type) {
|
switch (toast.type) {
|
||||||
@@ -45,47 +51,53 @@ function Toast({ toast, onClose }) {
|
|||||||
case 'error': return <IconX size={20} />
|
case 'error': return <IconX size={20} />
|
||||||
case 'warning': return <IconAlertTriangle size={20} />
|
case 'warning': return <IconAlertTriangle size={20} />
|
||||||
case 'info': return <IconInfoCircle size={20} />
|
case 'info': return <IconInfoCircle size={20} />
|
||||||
default: return null
|
default: return <IconInfoCircle size={20} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getColorClass = () => {
|
const alertClass = {
|
||||||
switch (toast.type) {
|
success: 'alert-success',
|
||||||
case 'success': return 'alert-success'
|
error: 'alert-danger',
|
||||||
case 'error': return 'alert-danger'
|
warning: 'alert-warning',
|
||||||
case 'warning': return 'alert-warning'
|
info: 'alert-info',
|
||||||
case 'info': return 'alert-info'
|
}[toast.type] || 'alert-info'
|
||||||
default: return 'alert-info'
|
|
||||||
}
|
const title = toast.title ?? DEFAULT_TITLES[toast.type]
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`alert ${getColorClass()} alert-dismissible fade ${isLeaving ? '' : 'show'}`}
|
className={`toast show toast-alert ${isLeaving ? 'toast-leaving' : ''}`}
|
||||||
role="alert"
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
aria-atomic="true"
|
||||||
|
data-toast-type={toast.type}
|
||||||
style={{
|
style={{
|
||||||
minWidth: '300px',
|
minWidth: '320px',
|
||||||
maxWidth: '400px',
|
maxWidth: '420px',
|
||||||
boxShadow: '0 0.5rem 1rem rgba(0,0,0,.15)',
|
overflow: 'hidden',
|
||||||
animation: isLeaving ? 'slideOutRight 0.3s ease-out' : 'slideInRight 0.3s ease-out'
|
borderRadius: 'var(--tblr-border-radius, 0.25rem)',
|
||||||
|
animation: isLeaving ? 'slideOutRight 0.3s ease-out forwards' : 'slideInRight 0.3s ease-out forwards',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="d-flex align-items-start">
|
<div className={`alert ${alertClass} toast-alert-inner mb-0`}>
|
||||||
<div className="me-2 mt-1">
|
<div className="toast-header toast-alert-header">
|
||||||
{getIcon()}
|
<span className="toast-alert-icon me-2">{getIcon()}</span>
|
||||||
|
<strong className="me-auto">{title}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow-1">
|
<div className="toast-body">
|
||||||
{toast.title && (
|
<div className="alert-description">{toast.message}</div>
|
||||||
<h4 className="alert-title">{toast.title}</h4>
|
|
||||||
)}
|
|
||||||
<div className="text-secondary">{toast.message}</div>
|
|
||||||
|
|
||||||
{/* Действия */}
|
|
||||||
{toast.actions && toast.actions.length > 0 && (
|
{toast.actions && toast.actions.length > 0 && (
|
||||||
<div className="btn-list mt-2">
|
<div className="btn-list mt-2">
|
||||||
{toast.actions.map((action, idx) => (
|
{toast.actions.map((action, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
|
type="button"
|
||||||
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
|
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
action.onClick?.()
|
action.onClick?.()
|
||||||
@@ -99,12 +111,6 @@ function Toast({ toast, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-close"
|
|
||||||
onClick={handleClose}
|
|
||||||
aria-label="Close"
|
|
||||||
></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,7 +122,7 @@ function Toast({ toast, onClose }) {
|
|||||||
export function ToastContainer({ children }) {
|
export function ToastContainer({ children }) {
|
||||||
const [toasts, setToasts] = useState([])
|
const [toasts, setToasts] = useState([])
|
||||||
|
|
||||||
const addToast = (type, message, options = {}) => {
|
const addToast = useCallback((type, message, options = {}) => {
|
||||||
const id = Date.now()
|
const id = Date.now()
|
||||||
const toast = {
|
const toast = {
|
||||||
id,
|
id,
|
||||||
@@ -131,67 +137,94 @@ export function ToastContainer({ children }) {
|
|||||||
}
|
}
|
||||||
setToasts(prev => [...prev, toast])
|
setToasts(prev => [...prev, toast])
|
||||||
return id
|
return id
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const removeToast = (id) => {
|
const removeToast = useCallback((id) => {
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = useMemo(() => ({
|
||||||
success: (message, options) => addToast('success', message, options),
|
success: (message, options) => addToast('success', message, options),
|
||||||
error: (message, options) => addToast('error', message, options),
|
error: (message, options) => addToast('error', message, options),
|
||||||
warning: (message, options) => addToast('warning', message, options),
|
warning: (message, options) => addToast('warning', message, options),
|
||||||
info: (message, options) => addToast('info', message, options),
|
info: (message, options) => addToast('info', message, options),
|
||||||
}
|
}), [addToast])
|
||||||
|
|
||||||
// Глобальный доступ через window.toast (для обратной совместимости)
|
// Глобальный доступ через window.toast (для обратной совместимости)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.toast = contextValue
|
window.toast = contextValue
|
||||||
}, [])
|
}, [contextValue])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={contextValue}>
|
<ToastContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* Контейнер с toast'ами */}
|
{/* Контейнер тостов справа (как в Tabler) */}
|
||||||
<div
|
<div className="toast-container toast-container-end">
|
||||||
style={{
|
{toasts.map((t) => (
|
||||||
position: 'fixed',
|
<Toast key={t.id} toast={t} onClose={removeToast} />
|
||||||
bottom: '20px',
|
|
||||||
right: '20px',
|
|
||||||
zIndex: 9999,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '10px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{toasts.map(toast => (
|
|
||||||
<Toast key={toast.id} toast={toast} onClose={removeToast} />
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CSS анимации */}
|
{/* Стили: тосты в стиле Tabler Alerts, всплывают справа */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes slideInRight {
|
.toast-container-end {
|
||||||
from {
|
position: fixed;
|
||||||
transform: translateX(100%);
|
top: 1rem;
|
||||||
opacity: 0;
|
right: 1rem;
|
||||||
}
|
z-index: 9999;
|
||||||
to {
|
display: flex;
|
||||||
transform: translateX(0);
|
flex-direction: column;
|
||||||
opacity: 1;
|
gap: 0.5rem;
|
||||||
}
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast-container-end .toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
/* NotifyProvider тосты ниже, чтобы не перекрывать useToast */
|
||||||
|
.toast-container-end.notify-viewport {
|
||||||
|
top: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast в стиле Tabler Alert: колоночный layout, без дублирования отступов */
|
||||||
|
.toast-alert .toast-alert-inner {
|
||||||
|
display: block;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
border-radius: var(--tblr-border-radius, 0.25rem);
|
||||||
|
box-shadow: var(--tblr-shadow, 0 0.5rem 1rem rgba(0, 0, 0, 0.15));
|
||||||
|
}
|
||||||
|
.toast-alert .toast-alert-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.toast-alert .toast-alert-icon {
|
||||||
|
color: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toast-alert .toast-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.toast-alert .toast-body .alert-description {
|
||||||
|
color: var(--tblr-secondary);
|
||||||
|
}
|
||||||
|
.toast-alert.alert-danger .toast-alert-header { border-bottom-color: rgba(0, 0, 0, 0.1); }
|
||||||
|
.toast-alert .btn-close {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
@keyframes slideOutRight {
|
@keyframes slideOutRight {
|
||||||
from {
|
from { transform: translateX(0); opacity: 1; }
|
||||||
transform: translateX(0);
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { IconTrophy, IconMapPin, IconCloud, IconHash, IconChartBar } from '@tabl
|
|||||||
function TopNStats({ data, loading }) {
|
function TopNStats({ data, loading }) {
|
||||||
const [topCountries, setTopCountries] = useState([]);
|
const [topCountries, setTopCountries] = useState([]);
|
||||||
const [topProviders, setTopProviders] = useState([]);
|
const [topProviders, setTopProviders] = useState([]);
|
||||||
const [topCommunities, setTopCommunities] = useState([]);
|
const [_topCommunities, _setTopCommunities] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || !Array.isArray(data.servers)) return;
|
if (!data || !Array.isArray(data.servers)) return;
|
||||||
@@ -58,9 +58,9 @@ function TopNStats({ data, loading }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row g-3">
|
<div className="row g-2 g-md-3">
|
||||||
{/* Top Страны */}
|
{/* Top Страны */}
|
||||||
<div className="col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title">
|
<h3 className="card-title">
|
||||||
@@ -107,7 +107,7 @@ function TopNStats({ data, loading }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Провайдеры */}
|
{/* Top Провайдеры */}
|
||||||
<div className="col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title">
|
<h3 className="card-title">
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function ValidatedInput({
|
|||||||
message: validationResult.message || '',
|
message: validationResult.message || '',
|
||||||
isValidating: false
|
isValidating: false
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setValidationState({
|
setValidationState({
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'Ошибка валидации',
|
message: 'Ошибка валидации',
|
||||||
@@ -84,7 +84,7 @@ function ValidatedInput({
|
|||||||
// Запускаем валидацию сразу при потере фокуса
|
// Запускаем валидацию сразу при потере фокуса
|
||||||
if (validate && !validateOnChange) {
|
if (validate && !validateOnChange) {
|
||||||
const result = validate(value)
|
const result = validate(value)
|
||||||
const validationResult = result instanceof Promise ? result.then(r => {
|
const _validationResult = result instanceof Promise ? result.then(r => {
|
||||||
setValidationState({
|
setValidationState({
|
||||||
valid: r.valid,
|
valid: r.valid,
|
||||||
message: r.message || '',
|
message: r.message || '',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useMemo } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
|
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
|
||||||
import TableSkeleton from './TableSkeleton.jsx';
|
import TableSkeleton from './TableSkeleton.jsx';
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ function WsUpdateModal({ show, url, onClose }) {
|
|||||||
|
|
||||||
ws.onerror = () => setStatus('error');
|
ws.onerror = () => setStatus('error');
|
||||||
ws.onclose = () => setStatus('closed');
|
ws.onclose = () => setStatus('closed');
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try { wsRef.current?.close(); } catch {}
|
try { wsRef.current?.close(); } catch { /* no-op */ }
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
};
|
};
|
||||||
}, [show, url]);
|
}, [show, url]);
|
||||||
@@ -96,7 +96,7 @@ function WsUpdateModal({ show, url, onClose }) {
|
|||||||
const copyLog = async () => {
|
const copyLog = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(plainLog);
|
await navigator.clipboard.writeText(plainLog);
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearLog = () => {
|
const clearLog = () => {
|
||||||
|
|||||||
@@ -168,29 +168,34 @@ export default function BillingCard({
|
|||||||
|
|
||||||
{/* Правая часть: действия */}
|
{/* Правая часть: действия */}
|
||||||
<div className="d-flex align-items-center gap-2 px-3 border-start">
|
<div className="d-flex align-items-center gap-2 px-3 border-start">
|
||||||
<button
|
<div className="btn-group" role="group" aria-label="Действия с записью биллинга">
|
||||||
className="btn btn-outline-success"
|
<button
|
||||||
title="Записать платёж"
|
className="btn btn-outline-success"
|
||||||
onClick={(e) => { e.stopPropagation(); onAddPayment?.(item); }}
|
title="Записать платёж"
|
||||||
>
|
style={{ minWidth: '6.5rem' }}
|
||||||
<IconCreditCard size={16} className="me-1" />
|
onClick={(e) => { e.stopPropagation(); onAddPayment?.(item); }}
|
||||||
Оплатить
|
>
|
||||||
</button>
|
<IconCreditCard size={16} className="me-1" />
|
||||||
<button
|
Оплатить
|
||||||
className="btn btn-outline-secondary"
|
</button>
|
||||||
title="Редактировать"
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
|
className="btn btn-outline-secondary"
|
||||||
>
|
title="Редактировать"
|
||||||
<IconEdit size={16} className="me-1" />
|
style={{ minWidth: '6.5rem' }}
|
||||||
Изменить
|
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
|
||||||
</button>
|
>
|
||||||
<button
|
<IconEdit size={16} className="me-1" />
|
||||||
className="btn btn-outline-danger"
|
Изменить
|
||||||
title="Удалить"
|
</button>
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
|
<button
|
||||||
>
|
className="btn btn-outline-danger d-flex align-items-center justify-content-center"
|
||||||
<IconTrash size={16} />
|
title="Удалить"
|
||||||
</button>
|
style={{ minWidth: '6.5rem' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost-secondary btn-icon ms-1"
|
className="btn btn-ghost-secondary btn-icon ms-1"
|
||||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClo
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
onNewFilterChange({ ...newFilter, community: v });
|
onNewFilterChange({ ...newFilter, community: v });
|
||||||
const found = communities.find(c => c.value === v);
|
|
||||||
if (found && found.gatewayDefault && !newFilter.gateway) {
|
|
||||||
onNewFilterChange({ ...newFilter, community: v, gateway: found.gatewayDefault });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,19 +9,18 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
|
|||||||
const modalRef = useRef(null);
|
const modalRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
const modalEl = modalRef.current;
|
||||||
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
if (window.Tabler && window.Tabler.Modal && modalEl) {
|
||||||
|
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
|
||||||
if (show) {
|
if (show) {
|
||||||
modalInstance.show();
|
modalInstance.show();
|
||||||
} else {
|
} else {
|
||||||
modalInstance.hide();
|
modalInstance.hide();
|
||||||
}
|
}
|
||||||
const handler = () => onClose && onClose();
|
const handler = () => onClose && onClose();
|
||||||
modalRef.current.addEventListener('hide.bs.modal', handler);
|
modalEl.addEventListener('hide.bs.modal', handler);
|
||||||
return () => {
|
return () => {
|
||||||
if (modalRef.current) {
|
modalEl.removeEventListener('hide.bs.modal', handler);
|
||||||
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [show, onClose]);
|
}, [show, onClose]);
|
||||||
|
|||||||
@@ -8,19 +8,18 @@ function DeleteServerModal({ show, server, onDelete, onClose }) {
|
|||||||
const modalRef = useRef(null);
|
const modalRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
const modalEl = modalRef.current;
|
||||||
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
if (window.Tabler && window.Tabler.Modal && modalEl) {
|
||||||
|
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
|
||||||
if (show) {
|
if (show) {
|
||||||
modalInstance.show();
|
modalInstance.show();
|
||||||
} else {
|
} else {
|
||||||
modalInstance.hide();
|
modalInstance.hide();
|
||||||
}
|
}
|
||||||
const handler = () => onClose && onClose();
|
const handler = () => onClose && onClose();
|
||||||
modalRef.current.addEventListener('hide.bs.modal', handler);
|
modalEl.addEventListener('hide.bs.modal', handler);
|
||||||
return () => {
|
return () => {
|
||||||
if (modalRef.current) {
|
modalEl.removeEventListener('hide.bs.modal', handler);
|
||||||
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [show, onClose]);
|
}, [show, onClose]);
|
||||||
|
|||||||
@@ -40,19 +40,18 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
|
|||||||
}, [show, server]);
|
}, [show, server]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
const modalEl = modalRef.current;
|
||||||
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
if (window.Tabler && window.Tabler.Modal && modalEl) {
|
||||||
|
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
|
||||||
if (show) {
|
if (show) {
|
||||||
modalInstance.show();
|
modalInstance.show();
|
||||||
} else {
|
} else {
|
||||||
modalInstance.hide();
|
modalInstance.hide();
|
||||||
}
|
}
|
||||||
const handler = () => onClose && onClose();
|
const handler = () => onClose && onClose();
|
||||||
modalRef.current.addEventListener('hide.bs.modal', handler);
|
modalEl.addEventListener('hide.bs.modal', handler);
|
||||||
return () => {
|
return () => {
|
||||||
if (modalRef.current) {
|
modalEl.removeEventListener('hide.bs.modal', handler);
|
||||||
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [show, onClose]);
|
}, [show, onClose]);
|
||||||
|
|||||||
@@ -13,19 +13,18 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
|
|||||||
}, [urlSettings]);
|
}, [urlSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
|
const modalEl = modalRef.current;
|
||||||
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
|
if (window.Tabler && window.Tabler.Modal && modalEl) {
|
||||||
|
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
|
||||||
if (show) {
|
if (show) {
|
||||||
modalInstance.show();
|
modalInstance.show();
|
||||||
} else {
|
} else {
|
||||||
modalInstance.hide();
|
modalInstance.hide();
|
||||||
}
|
}
|
||||||
const handler = () => onClose && onClose();
|
const handler = () => onClose && onClose();
|
||||||
modalRef.current.addEventListener('hide.bs.modal', handler);
|
modalEl.addEventListener('hide.bs.modal', handler);
|
||||||
return () => {
|
return () => {
|
||||||
if (modalRef.current) {
|
modalEl.removeEventListener('hide.bs.modal', handler);
|
||||||
modalRef.current.removeEventListener('hide.bs.modal', handler);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [show, onClose]);
|
}, [show, onClose]);
|
||||||
@@ -55,8 +54,8 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
|
|||||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<div className="row">
|
<div className="row g-2">
|
||||||
<div className="col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<h6 className="mb-3">Настройки URL</h6>
|
<h6 className="mb-3">Настройки URL</h6>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Базовый URL</label>
|
<label className="form-label">Базовый URL</label>
|
||||||
@@ -99,7 +98,7 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Telegram Gateway</label>
|
<label className="form-label">Telegram Gateway</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconEdit,
|
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconX,
|
IconX,
|
||||||
IconNetwork,
|
IconNetwork,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconCheck
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import FloatingBulkActionsBar from '../FloatingBulkActionsBar.jsx';
|
||||||
|
|
||||||
export default function ServerBulkActions({
|
export default function ServerBulkActions({
|
||||||
selectedCount,
|
selectedCount,
|
||||||
@@ -15,169 +14,48 @@ export default function ServerBulkActions({
|
|||||||
onBulkChangeTunnel,
|
onBulkChangeTunnel,
|
||||||
onBulkExport,
|
onBulkExport,
|
||||||
onBulkCopyLinks,
|
onBulkCopyLinks,
|
||||||
tunnelTypes = ['GRE', 'IPSec', 'WireGuard', 'OpenVPN']
|
tunnelTypes = ['GRE', 'IPSec', 'WireGuard', 'OpenVPN'],
|
||||||
}) {
|
}) {
|
||||||
if (selectedCount === 0) return null;
|
const actions = [
|
||||||
|
{
|
||||||
|
type: 'dropdown',
|
||||||
|
label: 'Туннель',
|
||||||
|
icon: IconNetwork,
|
||||||
|
title: 'Сменить тип туннеля',
|
||||||
|
items: tunnelTypes.map((tunnel) => ({
|
||||||
|
label: tunnel,
|
||||||
|
onClick: () => onBulkChangeTunnel(tunnel),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
label: 'Ссылки',
|
||||||
|
icon: IconCopy,
|
||||||
|
onClick: onBulkCopyLinks,
|
||||||
|
title: 'Скопировать ссылки',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
label: 'Экспорт',
|
||||||
|
icon: IconDownload,
|
||||||
|
onClick: onBulkExport,
|
||||||
|
title: 'Экспорт',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
label: 'Удалить',
|
||||||
|
icon: IconTrash,
|
||||||
|
onClick: onBulkDelete,
|
||||||
|
variant: 'danger',
|
||||||
|
title: 'Удалить',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FloatingBulkActionsBar
|
||||||
className="server-bulk-actions position-sticky rounded-3 shadow-lg mx-auto px-3 py-2"
|
selectedCount={selectedCount}
|
||||||
style={{
|
onClearSelection={onClearSelection}
|
||||||
bottom: '20px',
|
actions={actions}
|
||||||
zIndex: 1050,
|
/>
|
||||||
maxWidth: '720px',
|
|
||||||
animation: 'slideUp 0.2s ease',
|
|
||||||
background: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center justify-content-between gap-3">
|
|
||||||
{/* Счётчик выбранных */}
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="badge fs-6"
|
|
||||||
style={{
|
|
||||||
background: '#3b82f6',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '6px 10px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedCount}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: '#e2e8f0', fontWeight: 500 }}>выбрано</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Действия */}
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
|
||||||
{/* Смена типа туннеля */}
|
|
||||||
<div className="dropdown">
|
|
||||||
<button
|
|
||||||
className="bulk-btn dropdown-toggle"
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
>
|
|
||||||
<IconNetwork size={15} />
|
|
||||||
<span>Туннель</span>
|
|
||||||
</button>
|
|
||||||
<div className="dropdown-menu dropdown-menu-dark">
|
|
||||||
<div className="dropdown-header">Сменить тип туннеля</div>
|
|
||||||
{tunnelTypes.map(tunnel => (
|
|
||||||
<button
|
|
||||||
key={tunnel}
|
|
||||||
className="dropdown-item"
|
|
||||||
onClick={() => onBulkChangeTunnel(tunnel)}
|
|
||||||
>
|
|
||||||
{tunnel}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Копировать ссылки */}
|
|
||||||
<button
|
|
||||||
className="bulk-btn"
|
|
||||||
onClick={onBulkCopyLinks}
|
|
||||||
title="Скопировать ссылки"
|
|
||||||
>
|
|
||||||
<IconCopy size={15} />
|
|
||||||
<span>Ссылки</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Экспорт */}
|
|
||||||
<button
|
|
||||||
className="bulk-btn"
|
|
||||||
onClick={onBulkExport}
|
|
||||||
title="Экспорт"
|
|
||||||
>
|
|
||||||
<IconDownload size={15} />
|
|
||||||
<span>Экспорт</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ width: '1px', height: '20px', background: 'rgba(255,255,255,0.2)', margin: '0 4px' }} />
|
|
||||||
|
|
||||||
{/* Удалить */}
|
|
||||||
<button
|
|
||||||
className="bulk-btn bulk-btn-danger"
|
|
||||||
onClick={onBulkDelete}
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
<IconTrash size={15} />
|
|
||||||
<span>Удалить</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Отменить выделение */}
|
|
||||||
<button
|
|
||||||
className="bulk-btn-close"
|
|
||||||
onClick={onClearSelection}
|
|
||||||
title="Снять выделение"
|
|
||||||
>
|
|
||||||
<IconX size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-btn-danger {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-btn-danger:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.4);
|
|
||||||
color: #fef2f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-btn-close {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: #94a3b8;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-btn-close:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,55 +169,46 @@ export default function ServerCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Расширенная информация */}
|
{/* Расширенная информация в стиле Tabler datagrid */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-top bg-light" style={{ borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' }}>
|
<div className="border-top bg-light p-3" style={{ borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' }}>
|
||||||
<div className="row g-0">
|
<div className="datagrid">
|
||||||
{/* IP адреса */}
|
<div className="datagrid-item">
|
||||||
<div className="col-md-6 p-3 border-end">
|
<div className="datagrid-title">IP</div>
|
||||||
<div className="text-muted small mb-2 fw-medium">
|
<div className="datagrid-content"><code className="small">{server.ip}</code></div>
|
||||||
<IconWorld size={14} className="me-1" />
|
|
||||||
Сетевые адреса
|
|
||||||
</div>
|
|
||||||
<div className="row g-2">
|
|
||||||
<div className="col-4">
|
|
||||||
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>IP</div>
|
|
||||||
<code className="small">{server.ip}</code>
|
|
||||||
</div>
|
|
||||||
<div className="col-4">
|
|
||||||
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Внешний</div>
|
|
||||||
<code className="small">{server.extIp || '—'}</code>
|
|
||||||
</div>
|
|
||||||
<div className="col-4">
|
|
||||||
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Внутренний</div>
|
|
||||||
<code className="small">{server.internalIp || '—'}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="datagrid-item">
|
||||||
{/* Шлюзы */}
|
<div className="datagrid-title">Внешний</div>
|
||||||
<div className="col-md-6 p-3">
|
<div className="datagrid-content"><code className="small">{server.extIp || '—'}</code></div>
|
||||||
<div className="text-muted small mb-2 fw-medium">
|
</div>
|
||||||
|
<div className="datagrid-item">
|
||||||
|
<div className="datagrid-title">Внутренний</div>
|
||||||
|
<div className="datagrid-content"><code className="small">{server.internalIp || '—'}</code></div>
|
||||||
|
</div>
|
||||||
|
<div className="datagrid-item">
|
||||||
|
<div className="datagrid-title">
|
||||||
<IconServer size={14} className="me-1" />
|
<IconServer size={14} className="me-1" />
|
||||||
Шлюзы ({server.gateways?.length || 0})
|
Шлюзы ({server.gateways?.length || 0})
|
||||||
</div>
|
</div>
|
||||||
{server.gateways?.length > 0 ? (
|
<div className="datagrid-content">
|
||||||
<div className="d-flex flex-wrap gap-2">
|
{server.gateways?.length > 0 ? (
|
||||||
{server.gateways.map((gw, idx) => (
|
<div className="d-flex flex-wrap gap-2">
|
||||||
<span
|
{server.gateways.map((gw, idx) => (
|
||||||
key={gw.id || idx}
|
<span
|
||||||
className={`badge ${gw.primary ? 'bg-blue-lt text-blue' : 'bg-secondary-lt text-secondary'}`}
|
key={gw.id || idx}
|
||||||
title={gw.comment || (gw.primary ? 'Основной' : 'Резервный')}
|
className={`badge ${gw.primary ? 'bg-blue-lt text-blue' : 'bg-secondary-lt text-secondary'}`}
|
||||||
>
|
title={gw.comment || (gw.primary ? 'Основной' : 'Резервный')}
|
||||||
{gw.primary && <IconCheck size={10} className="me-1" />}
|
>
|
||||||
{gw.name}
|
{gw.primary && <IconCheck size={10} className="me-1" />}
|
||||||
{gw.ip && <span className="ms-1 opacity-75">({gw.ip})</span>}
|
{gw.name}
|
||||||
</span>
|
{gw.ip && <span className="ms-1 opacity-75">({gw.ip})</span>}
|
||||||
))}
|
</span>
|
||||||
</div>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<span className="text-muted small">Нет шлюзов</span>
|
) : (
|
||||||
)}
|
<span className="text-muted small">Нет шлюзов</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import {
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronUp,
|
||||||
|
IconRouter,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconNetwork,
|
||||||
|
IconCloud
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
function countryToFlag(isoCode) {
|
||||||
|
if (!isoCode) return '';
|
||||||
|
const codeMap = {
|
||||||
|
'SWE': 'SE', 'RUS': 'RU', 'USA': 'US', 'GER': 'DE', 'FIN': 'FI', 'NLD': 'NL'
|
||||||
|
};
|
||||||
|
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
|
||||||
|
if (!code || code.length !== 2) return isoCode;
|
||||||
|
return code.replace(/./g, char =>
|
||||||
|
String.fromCodePoint(127397 + char.charCodeAt())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка группы серверов в том же формате, что и одиночная ServerCard:
|
||||||
|
* слева бейдж HOME, центр — флаг, название, провайдер и DNS с тегами, справа кнопки.
|
||||||
|
*/
|
||||||
|
export default function ServerGroupCard({
|
||||||
|
groupKey,
|
||||||
|
servers,
|
||||||
|
selectedSet,
|
||||||
|
onSelect,
|
||||||
|
onSelectGroup,
|
||||||
|
onEdit,
|
||||||
|
onDelete
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const first = servers[0];
|
||||||
|
const title = groupKey || (first?.dns?.split('.')[0] || 'Группа');
|
||||||
|
const tunnel = first?.tunnel;
|
||||||
|
const wanCount = servers.length;
|
||||||
|
const anySelected = servers.some((s) => selectedSet.has(s.ip));
|
||||||
|
const allSelected = servers.length > 0 && servers.every((s) => selectedSet.has(s.ip));
|
||||||
|
|
||||||
|
const handleGroupSelect = () => {
|
||||||
|
if (onSelectGroup) {
|
||||||
|
onSelectGroup(servers, !allSelected);
|
||||||
|
} else {
|
||||||
|
servers.forEach((s) => {
|
||||||
|
const selected = selectedSet.has(s.ip);
|
||||||
|
if (allSelected && selected) onSelect?.(s);
|
||||||
|
if (!allSelected && !selected) onSelect?.(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`server-group-card card mb-2 ${anySelected ? 'border-primary' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderRadius: '12px',
|
||||||
|
borderWidth: anySelected ? '2px' : '1px',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
boxShadow: anySelected ? '0 0 0 3px rgba(32, 107, 196, 0.15)' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
{/* Основная строка — как у ServerCard */}
|
||||||
|
<div className="d-flex align-items-stretch">
|
||||||
|
{/* Левая часть: бейдж HOME */}
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center justify-content-center px-3 bg-green-lt"
|
||||||
|
style={{
|
||||||
|
minWidth: '80px',
|
||||||
|
borderTopLeftRadius: anySelected ? '10px' : '11px',
|
||||||
|
borderBottomLeftRadius: expanded ? '0' : (anySelected ? '10px' : '11px'),
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={handleGroupSelect}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-1">
|
||||||
|
{allSelected ? (
|
||||||
|
<IconCircleCheck size={20} className="text-green" />
|
||||||
|
) : (
|
||||||
|
<IconRouter size={20} className="text-green" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="fw-bold text-green" style={{ fontSize: '0.65rem', letterSpacing: '0.5px' }}>
|
||||||
|
HOME
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Центр: в свёрнутом виде — все провайдеры и домены, чтобы было однозначно */}
|
||||||
|
<div
|
||||||
|
className="flex-grow-1 py-2 px-3"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center mb-1">
|
||||||
|
<span className="me-2" style={{ fontSize: '1.25rem' }}>{countryToFlag(first?.country)}</span>
|
||||||
|
<div>
|
||||||
|
<span className="fw-semibold">{title}</span>
|
||||||
|
<span className="text-muted ms-2">· мультиван</span>
|
||||||
|
</div>
|
||||||
|
<span className="badge bg-success-lt text-success ms-auto">
|
||||||
|
{wanCount} WAN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<span className="text-muted small">
|
||||||
|
{servers.map((s) => s.dns?.split('.')[0] || s.ip).join(', ')}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted small">—</span>
|
||||||
|
{servers.map((s, i) => (
|
||||||
|
<code key={s.id || s.ip} className="text-muted" style={{ fontSize: '0.75rem' }}>
|
||||||
|
{s.dns || s.ip}{i < servers.length - 1 ? ' · ' : ''}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
{tunnel && (
|
||||||
|
<span className="badge bg-secondary-lt text-secondary">
|
||||||
|
<IconNetwork size={12} className="me-1" />
|
||||||
|
{tunnel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="badge bg-green-lt text-green">
|
||||||
|
<IconCloud size={12} className="me-1" />
|
||||||
|
{wanCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая часть: кнопки как у необъединённой карточки */}
|
||||||
|
<div className="d-flex align-items-center gap-2 px-3 border-start">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
title="Редактировать"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit?.(first); }}
|
||||||
|
>
|
||||||
|
<IconEdit size={16} className="me-1" />
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
title="Удалить"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete?.(first); }}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} className="me-1" />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-icon ms-1"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||||||
|
title={expanded ? 'Свернуть' : 'Подробнее'}
|
||||||
|
>
|
||||||
|
{expanded ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Развёрнутый список WAN: визуально отделённый блок, у каждой строки — бейдж HOME */}
|
||||||
|
{expanded && (
|
||||||
|
<div
|
||||||
|
className="border-top border-start border-3 border-green bg-green-lt"
|
||||||
|
style={{
|
||||||
|
borderBottomLeftRadius: '7px',
|
||||||
|
borderBottomRightRadius: '7px',
|
||||||
|
borderLeftColor: 'var(--tblr-green)',
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.06)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="small text-muted px-3 py-1 border-bottom bg-green-lt" style={{ fontSize: '0.7rem' }}>
|
||||||
|
WAN-подключения группы
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
{servers.map((server) => {
|
||||||
|
const isSelected = selectedSet.has(server.ip);
|
||||||
|
const serverName = server.dns?.split('.')[0] || server.ip;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={server.id || server.ip}
|
||||||
|
className={`d-flex align-items-stretch rounded mb-1 overflow-hidden ${isSelected ? 'bg-primary-lt' : 'bg-white'}`}
|
||||||
|
style={{ boxShadow: '0 0 0 1px rgba(0,0,0,0.06)' }}
|
||||||
|
>
|
||||||
|
{/* Бейдж HOME — клик как у обычной карточки, без явной галочки */}
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center justify-content-center bg-green-lt text-green px-2"
|
||||||
|
style={{
|
||||||
|
minWidth: '52px',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={() => onSelect?.(server)}
|
||||||
|
title={isSelected ? 'Снять выбор' : 'Выбрать'}
|
||||||
|
>
|
||||||
|
{isSelected ? (
|
||||||
|
<IconCircleCheck size={14} className="me-1" />
|
||||||
|
) : (
|
||||||
|
<IconRouter size={14} className="me-1" />
|
||||||
|
)}
|
||||||
|
HOME
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center flex-grow-1 py-2 px-2 min-w-0">
|
||||||
|
<span className="me-2 flex-shrink-0" style={{ fontSize: '1.1rem' }}>{countryToFlag(server.country)}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="fw-medium">{serverName}</span>
|
||||||
|
<span className="text-muted ms-2 small">{server.dns || server.ip}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-2 px-3 border-start">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
title="Редактировать"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit?.(server); }}
|
||||||
|
>
|
||||||
|
<IconEdit size={16} className="me-1" />
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
title="Удалить"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete?.(server); }}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} className="me-1" />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user