Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ba6d47e3 | ||
|
|
fe7acd655e | ||
|
|
d86434e9c9 | ||
|
|
f290288f43 | ||
|
|
e6dc99f403 | ||
|
|
58ee09b70e | ||
|
|
a9a7e98be5 | ||
|
|
cf3437c70c | ||
|
|
50f9c91843 | ||
|
|
0d0ac2936a | ||
|
|
1c6e6ab24a | ||
|
|
7f3eaea40a | ||
|
|
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 |
@@ -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 "${{ secrets.portainer_webhook }}"
|
|
||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- v4
|
||||||
|
- v5
|
||||||
|
- tabler
|
||||||
|
- v3
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -25,11 +30,35 @@ jobs:
|
|||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
# Ветка v5: в реестр только теги v5 / v5-<sha> (без latest)
|
||||||
|
- name: Build and push Docker image (v5)
|
||||||
|
if: ${{ gitea.ref_name == 'v5' }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile.fast
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
tags: |
|
||||||
|
git.shts.su/${{ gitea.repository }}:v5
|
||||||
|
git.shts.su/${{ gitea.repository }}:v5-${{ gitea.sha }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=RouterOS Manager
|
||||||
|
org.opencontainers.image.description=RouterOS Manager for domains and AS management
|
||||||
|
org.opencontainers.image.version=v5
|
||||||
|
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||||
|
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||||
|
build-args: |
|
||||||
|
BUILDKIT_INLINE_CACHE=1
|
||||||
|
|
||||||
|
- name: Build and push Docker image (main и прочие ветки)
|
||||||
|
if: ${{ gitea.ref_name != 'v5' }}
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.fast
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
@@ -38,8 +67,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 +76,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 "${{ secrets.portainer_webhook }}"
|
curl -X POST -sS --fail "${{ secrets.portainer_webhook }}"
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
# AI-READY DEVELOPMENT SPEC
|
||||||
|
|
||||||
|
## 1. SYSTEM OVERVIEW (СИСТЕМНЫЙ КОНТЕКСТ)
|
||||||
|
|
||||||
|
Система `router-lists-ui` управляет сетевыми данными и конфигурациями RouterOS/MikroTik: домены, ASN, IP-диапазоны, фильтры, серверные конфиги, бэкапы и мониторинг доступности/ресурсов. UI (`React + Vite`) работает с API (`Express`) через `/api/*`.
|
||||||
|
|
||||||
|
Фактическая модель хранения гибридная:
|
||||||
|
- справочники BGP (domains/asns/ip-ranges/communities/auto-urls) обслуживаются через EvoBGP API (`evobgpClient`);
|
||||||
|
- локальные конфиги/кеш/версии хранятся в SQLite (`better-sqlite3`) через слой совместимости `s3Service`/`blobStorage`;
|
||||||
|
- `.rsc` backup MikroTik хранится в локальной ФС (`MIKROTIK_BACKUP_DIR`).
|
||||||
|
|
||||||
|
### Ключевые бизнес-процессы
|
||||||
|
- Управление BGP-справочниками (CRUD через EvoBGP).
|
||||||
|
- Управление фильтрами (`global/simple`) в `app_filter_rules`.
|
||||||
|
- Управление серверами, server-configs, server-filters.
|
||||||
|
- Генерация/применение MikroTik-конфигов и диагностические операции (ping/traceroute/speed-test).
|
||||||
|
- Планировщики: network-map, ping-services, uptime-monitor, backups.
|
||||||
|
- История/rollback для versioned blob-объектов.
|
||||||
|
|
||||||
|
### Основные сущности
|
||||||
|
- `DomainCommunity`: `{ domain, community }`
|
||||||
|
- `AsnCommunity`: `{ domain(asn), type(community) }`
|
||||||
|
- `IpRangeCommunity`: `{ ipRange, community }`
|
||||||
|
- `Server`: jumphost/exit + gateways + creds
|
||||||
|
- `FilterRule`: `{ community, gateway, description, ...extra }` + `scope`
|
||||||
|
- `BlobObject`: logical key + body + etag + versions
|
||||||
|
- `UiSettings`
|
||||||
|
- `MikrotikBackup`
|
||||||
|
|
||||||
|
### Критические ограничения (НЕ НАРУШАТЬ)
|
||||||
|
- Нельзя заменять EvoBGP-данные локальной SQLite-логикой для `domains-new/asns/ip-ranges/communities/auto-urls`.
|
||||||
|
- Нельзя удалять/ломать ETag-конкурентность (`If-None-Match`, `If-Match`, `412`) в read/write API.
|
||||||
|
- Нельзя ломать namespace/ключи blob-объектов (`servers.json`, `filter-manager/*`, `bgp_data/rt_ui_settings.json` и т.д.) без миграционного плана.
|
||||||
|
- Нельзя отключать `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout`.
|
||||||
|
- Нельзя менять формат ответа ошибок `{ code, message, details, requestId }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. REPOSITORY MAP (КАРТА РЕПОЗИТОРИЯ)
|
||||||
|
|
||||||
|
> Примечание: в текущем репозитории нет отдельных top-level директорий `/db`, `/services`, `/api`, `/agents`; их роль реализована внутри `backend/`.
|
||||||
|
|
||||||
|
### `/frontend`
|
||||||
|
- Назначение: UI-приложение (`React 19`, `react-router-dom`, `@tanstack/react-query`, `axios`, Tabler).
|
||||||
|
- Ключевые зоны:
|
||||||
|
- `src/App.jsx`: роутинг, layout, providers (`QueryClientProvider`, `NotifyProvider`, `PingProvider`, `AlertsProvider`).
|
||||||
|
- `src/lib/api.js`: единый HTTP-клиент, retry, ETag-based GET-cache, нормализация ошибок.
|
||||||
|
- `src/hooks/useApiQuery.js`: реактивный data-layer + optimistic updates.
|
||||||
|
- Правила изменений:
|
||||||
|
- Любой новый экран регистрировать в `App.jsx` routes + navigation.
|
||||||
|
- Новые API-вызовы делать только через `src/lib/api.js`.
|
||||||
|
- Мутации должны инвалидацировать/обновлять react-query cache.
|
||||||
|
- Зависимости: backend `/api`, browser localStorage (theme/lang/layout), глобальный notifier.
|
||||||
|
|
||||||
|
### `/backend`
|
||||||
|
- Назначение: API, интеграции, storage abstraction, schedulers.
|
||||||
|
- Ключевые зоны:
|
||||||
|
- `server.js`: composition root, middleware, endpoint registration.
|
||||||
|
- `routes/*.js`: endpoint-level orchestration.
|
||||||
|
- `services/*.js`: storage/EvoBGP/MikroTik/scheduler логика.
|
||||||
|
- `db/sqliteDb.js` + `db/migrations/*.sql`: DB init/migrations.
|
||||||
|
- `middleware/*`: unified errors + soft locks.
|
||||||
|
- Правила изменений:
|
||||||
|
- Новый endpoint добавлять через route/service, не встраивать бизнес-логику в `server.js`.
|
||||||
|
- Для persistent данных использовать `s3Service`/`filterRulesStorage`/`backupFsService` по назначению.
|
||||||
|
- Сохранять совместимость legacy-имен (`s3*`) пока фронт/интеграции их используют.
|
||||||
|
- Зависимости: SQLite, EvoBGP HTTP API, локальная ФС для backups, env-конфиг.
|
||||||
|
|
||||||
|
### `/backend/db` (внутренний `/db`)
|
||||||
|
- Назначение: схема и поведение SQLite.
|
||||||
|
- Состав:
|
||||||
|
- `migrations/001_init.sql`: blobs/meta/cache/versioning.
|
||||||
|
- `migrations/002_filter_rules.sql`: `app_filter_rules`.
|
||||||
|
- Правила изменений:
|
||||||
|
- Только миграциями; прямые ручные правки существующих таблиц запрещены.
|
||||||
|
- Миграции должны быть backward-compatible и idempotent.
|
||||||
|
|
||||||
|
### `/backend/services` (внутренний `/services`)
|
||||||
|
- Назначение: слой бизнес-операций и инфраструктурных адаптеров.
|
||||||
|
- Ключевые модули:
|
||||||
|
- `evobgpClient.js` (внешний источник правды для BGP reference).
|
||||||
|
- `s3Service.js` + `blobStorage.js` (SQLite-backed blob API).
|
||||||
|
- `filterRulesStorage.js` (SQL-таблица фильтров).
|
||||||
|
- `mikrotik*`, `*Scheduler.js` (операции и периодические задачи).
|
||||||
|
- Правила изменений:
|
||||||
|
- Не смешивать transport-логику endpoint с data-access.
|
||||||
|
- Любая write-операция должна возвращать meta для `sendOk`.
|
||||||
|
|
||||||
|
### `/backend/routes` (внутренний `/api`)
|
||||||
|
- Назначение: HTTP contracts + валидация входа + orchestrator.
|
||||||
|
- Правила изменений:
|
||||||
|
- Вход валидируется до вызова сервиса.
|
||||||
|
- Ошибки наружу только через `sendError`.
|
||||||
|
- ETag/Last-Modified сохранять в GET/POST где уже реализовано.
|
||||||
|
|
||||||
|
### `/agents`
|
||||||
|
- В репозитории отсутствует.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ARCHITECTURE RULES (АРХИТЕКТУРНЫЕ ПРАВИЛА)
|
||||||
|
|
||||||
|
### Слои и границы
|
||||||
|
- DO: `Route -> Service -> Storage/External API`.
|
||||||
|
- DO: хранить валидацию запроса на route-слое, а доменную обработку в service.
|
||||||
|
- DON'T: вызывать SQLite напрямую из UI.
|
||||||
|
- DON'T: переносить доменную логику в `server.js`.
|
||||||
|
|
||||||
|
### Бизнес-логика
|
||||||
|
- DO: для filters использовать только `filterRulesStorage` (`app_filter_rules`).
|
||||||
|
- DO: для BGP-reference использовать только `evobgpClient`.
|
||||||
|
- DON'T: писать domains/asns/ip-ranges в `blobs`.
|
||||||
|
|
||||||
|
### State management (frontend)
|
||||||
|
- DO: network-state через React Query.
|
||||||
|
- DO: optimistic updates + rollback на error.
|
||||||
|
- DO: invalidation query keys после мутаций.
|
||||||
|
- DON'T: дублировать fetch/caching logic вне `api.js`/`useApiQuery.js`.
|
||||||
|
|
||||||
|
### Доступ к БД
|
||||||
|
- DO: через `s3Service`/`blobStorage`/`filterRulesStorage`.
|
||||||
|
- DO: учитывать `etag`, `lastModified`, `contentLength`.
|
||||||
|
- DON'T: менять ключевые `object_key` без миграции и impact map.
|
||||||
|
|
||||||
|
### Изменения высокой опасности
|
||||||
|
- Запрещено менять без полной цепочки рефакторинга:
|
||||||
|
- форматы payload для `/api/domains-new`, `/api/asns`, `/api/ip-ranges`;
|
||||||
|
- schema ключей blob-хранилища;
|
||||||
|
- `sendOk/sendError` формат;
|
||||||
|
- scheduler settings endpoints и их JSON-структуры;
|
||||||
|
- semantics `server-configs` (только jumphost в листинге).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. BUSINESS LOGIC SPECIFICATION
|
||||||
|
|
||||||
|
### 4.1 Процесс: BGP Reference Sync (domains/asns/ip-ranges)
|
||||||
|
- Inputs: HTTP GET/POST, EvoBGP env (`EVOBGP_API_URL`, `EVOBGP_API_TOKEN`), payload массив.
|
||||||
|
- Outputs: данные справочников + `sendOk` metadata.
|
||||||
|
- States:
|
||||||
|
- `IDLE` -> `VALIDATE_INPUT` -> `CALL_EVOBGP` -> `NORMALIZE` -> `RESPOND_OK`
|
||||||
|
- ветка ошибки: `VALIDATE_INPUT -> RESPOND_400` или `CALL_EVOBGP -> RESPOND_5XX`.
|
||||||
|
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_EVOBGP`, `E_EVOBGP_NOT_CONFIGURED`.
|
||||||
|
- Side effects: запись в EvoBGP, обновление remote reference set.
|
||||||
|
|
||||||
|
### 4.2 Процесс: Filter Rules Replace (`global/simple`)
|
||||||
|
- Inputs: `scope`, массив правил `{community,gateway,...}`.
|
||||||
|
- Outputs: новый snapshot правил + meta (`etag/lastModified`).
|
||||||
|
- States:
|
||||||
|
- `READ_SCOPE_META` -> `VALIDATE_ARRAY` -> `TX_BEGIN` -> `DELETE_SCOPE_ROWS` -> `INSERT_ROWS_ORDERED` -> `UPDATE_META_MTIME` -> `TX_COMMIT` -> `RESPOND_OK`
|
||||||
|
- rollback: `TX_BEGIN -> ERROR -> TX_ROLLBACK`.
|
||||||
|
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_STORAGE`.
|
||||||
|
- Side effects: перезапись всех правил scope (full replace).
|
||||||
|
|
||||||
|
### 4.3 Процесс: Blob Object Write (legacy s3Service API)
|
||||||
|
- Inputs: `object_key`, `content`.
|
||||||
|
- Outputs: `sendOk(meta)`.
|
||||||
|
- States:
|
||||||
|
- `VALIDATE_KEY` -> `READ_EXISTING` -> `IF_VERSIONED_SAVE_OLD_VERSION` -> `UPSERT_BLOB` -> `TRIM_VERSIONS` -> `INVALIDATE_MEMORY_CACHE` -> `RESPOND_OK`.
|
||||||
|
- Errors: `E_STORAGE`.
|
||||||
|
- Side effects: изменение `blobs`, опционально `blob_versions`.
|
||||||
|
|
||||||
|
### 4.4 Процесс: Server Config Management
|
||||||
|
- Inputs: `serverId`, config text / filters json.
|
||||||
|
- Outputs: per-server config/filter data.
|
||||||
|
- States:
|
||||||
|
- `VALIDATE_SERVER_ID` -> `READ_OR_WRITE_BLOB_KEY` -> `RESPOND`.
|
||||||
|
- Для `/api/server-configs` list: `READ_SERVERS` -> `FILTER_JUMPHOST` -> `MAP_ID_NAME` -> `RESPOND`.
|
||||||
|
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_S3/E_STORAGE`.
|
||||||
|
- Side effects: запись в `filter-manager/config-*.txt` и `filter-manager/server-filters-*.json`.
|
||||||
|
|
||||||
|
### 4.5 Процесс: Auto URLs Ingestion
|
||||||
|
- Inputs: список URL + community.
|
||||||
|
- Outputs: summary counters.
|
||||||
|
- States:
|
||||||
|
- `LOAD_URLS` -> `FETCH_CONTENT` -> `PARSE_LINES` -> `CLASSIFY(IP/CIDR/DOMAIN)` -> `DEDUP_WITH_CURRENT` -> `SAVE_TO_EVOBGP` -> `RESPOND`.
|
||||||
|
- Errors: `E_BAD_REQUEST`, `E_EVOBGP`.
|
||||||
|
- Side effects: расширение списков IP/domains в EvoBGP.
|
||||||
|
|
||||||
|
### 4.6 Процесс: Ping Services Cache Refresh
|
||||||
|
- Inputs: UI settings (`pingServicesSource`, cache TTL), services list.
|
||||||
|
- Outputs: `{ byId, history }`.
|
||||||
|
- States:
|
||||||
|
- `LOAD_UI_SETTINGS` -> (`ROUTER_MODE` | `WEB_MODE`) -> `MEASURE_RTT` -> `WRITE_CACHE` -> `APPEND_HISTORY` -> `RESPOND`.
|
||||||
|
- Errors: fallback на `ms: null` payload при деградации.
|
||||||
|
- Side effects: запись в blob-ключи `ping-services/cache_*`, `ping-services/history_*`.
|
||||||
|
|
||||||
|
### 4.7 Процесс: History Rollback
|
||||||
|
- Inputs: `resource`, `versionId`.
|
||||||
|
- Outputs: `sendOk(meta)` после rollback.
|
||||||
|
- States:
|
||||||
|
- `MAP_RESOURCE_TO_KEY` -> `VALIDATE_SUPPORT` -> `ROLLBACK_VERSION` -> `RESPOND`.
|
||||||
|
- Errors: `E_RESOURCE`, `E_HISTORY_EV`, `E_HISTORY_FILTERS_SQL`, `E_STORAGE`.
|
||||||
|
- Side effects: восстановление blob из `blob_versions`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API CONTRACTS (ФОРМАЛЬНЫЕ КОНТРАКТЫ)
|
||||||
|
|
||||||
|
### 5.1 Глобальные правила API
|
||||||
|
- Base path: `/api`.
|
||||||
|
- Auth level: явной auth-схемы нет (network-level trust, rate-limit, CORS, security headers).
|
||||||
|
- Success write format: `{ ok, etag, lastModified, contentLength }`.
|
||||||
|
- Error format: `{ code, message, details, requestId }`.
|
||||||
|
- Конкурентность: `If-None-Match` для GET, `If-Match`/`etag` для отдельных write.
|
||||||
|
|
||||||
|
### 5.2 Контракты endpoint’ов
|
||||||
|
|
||||||
|
| Route | Method | Auth | Input schema (JSON) | Output schema | Side effects | Таблицы/ключи | Сервисы |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `/health` | GET | public | - | `{ok:true}` | none | - | - |
|
||||||
|
| `/ready` | GET | public | - | `{ok:true}` | none | - | - |
|
||||||
|
| `/metrics` | GET | internal | - | Prometheus text | none | - | prom-client |
|
||||||
|
| `/api/version` | GET | public | - | `{version,gitSha,buildAt}` | none | - | env |
|
||||||
|
| `/api/domains` | GET/POST | public | POST: `{domains:[{domain,type}],etag?}` | GET std/raw list, POST `sendOk` | remote write/read | EvoBGP | evobgpClient |
|
||||||
|
| `/api/asns` | GET/POST | public | POST: `{domains:[{domain,type}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
|
||||||
|
| `/api/domains-new` | GET/POST | public | POST: `{domains:[{domain,community}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
|
||||||
|
| `/api/ip-ranges` | GET/POST | public | POST: `{ipRanges:[{ipRange,community}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
|
||||||
|
| `/api/servers` | GET/POST | public | POST: `{servers:[...],etag?}` | JSON list / `sendOk` | persist servers | `blobs:servers.json` | serversRoutes,s3Service |
|
||||||
|
| `/api/server-connections` | GET/POST | public | POST: `{domains:[{from,to,tunnelType,...}]}` | list / `sendOk` | persist graph links | blob key `server-connections.json` | jsonDataRoutes |
|
||||||
|
| `/api/billing` | GET/POST | public | POST: `{items:[...],etag?}` (route expects `domains` in factory; adapters normalize) | list / `sendOk` | persist billing | blob key `servers-billing.json` | jsonDataRoutes |
|
||||||
|
| `/api/filters` | GET/POST | public | POST: `{domains:[{community,gateway,...}]}` | list / `sendOk` | replace scope rules | `app_filter_rules(scope=global)` | filterRulesStorage |
|
||||||
|
| `/api/simple-filters` | GET/POST | public | POST: `{domains:[{community,gateway,...}]}` | list / `sendOk` | replace scope rules | `app_filter_rules(scope=simple)` | filterRulesStorage |
|
||||||
|
| `/api/network-config` | GET/POST | public | POST: `{domains:{gateways?,tunnelInterfaces?,ipPools?}}` | object / `sendOk` | persist network config | blob key `network-config.json` | jsonDataRoutes |
|
||||||
|
| `/api/communities` | GET/POST | public | POST: `{communities:[{value,name?,...}]}` | list / `sendOk` | EvoBGP write | EvoBGP | communitiesRoutes |
|
||||||
|
| `/api/communities/stats` | GET | public | - | `{stats:[{community,count}],total}` | none | EvoBGP + `app_filter_rules` | communitiesRoutes |
|
||||||
|
| `/api/filters/generate-config` | GET | public | query params | generated config | none/write temp | derived blobs | filtersRoutes |
|
||||||
|
| `/api/filters/export-config` | POST | public | export params | file/content json | write export | derived blobs/fs | filtersRoutes |
|
||||||
|
| `/api/server-filters/generate-config` | POST | public | `{serverId,...}` | generated config | none | per-server blobs | filtersRoutes |
|
||||||
|
| `/api/server-configs` | GET/POST | public | POST: `{servers:[jumphost...]}` | jumphost list / `sendOk` | rewrite servers subset | `servers.json` | serverConfigsRoutes |
|
||||||
|
| `/api/server-configs/:serverId` | GET/POST/DELETE | public | POST: `{config:string}` | `{config}` / `sendOk` | write/delete server config | `filter-manager/config-<id>.txt` | serverConfigsRoutes |
|
||||||
|
| `/api/server-configs/:serverId/complete` | DELETE | public | - | `{ok:true,...}` | delete config+filters | `filter-manager/config-*`, `server-filters-*` | serverConfigsRoutes |
|
||||||
|
| `/api/server-filters/:serverId` | GET/POST | public | POST: `{filters:[{community,gateway,...}],etag?}` | `{filters}` / `sendOk` | write server filters | `filter-manager/server-filters-<id>.json` | serverConfigsRoutes |
|
||||||
|
| `/api/locks/:resource` | GET/POST/DELETE | public | POST:`{owner?,ttlSeconds?}` | lock status/result | in-memory lock mutate | RAM only | lockManager |
|
||||||
|
| `/api/history/:resource` | GET | public | query:`countOnly,format` | versions list | none | `blob_versions` | miscRoutes+s3Service |
|
||||||
|
| `/api/history/:resource/rollback` | POST | public | `{versionId}` | `sendOk` | rollback snapshot | `blobs`,`blob_versions` | miscRoutes+s3Service |
|
||||||
|
| `/api/s3/last-modified` | GET | public | - | map metas | none | blobs/filter_rules | miscRoutes |
|
||||||
|
| `/api/auto-urls` | GET/POST | public | POST:`{urls:[{url,community,...}]}` | list / `sendOk` | EvoBGP write | EvoBGP | miscRoutes |
|
||||||
|
| `/api/auto-urls/process` | POST | public | optional params | process summary | pulls URLs, writes lists | EvoBGP | miscRoutes |
|
||||||
|
| `/api/servers/availability` | GET | public | query:`ttlSeconds?` | `{online,total,statuses}` | refresh memory cache | RAM | miscRoutes |
|
||||||
|
| `/api/update-bgp/background` | POST | public (rate limited) | passthrough body | upstream response | outbound HTTP POST | - | miscRoutes |
|
||||||
|
| `/api/ws/url` | GET | public | - | `{url}` | none | `bgp_data/rt_ui_settings.json` | miscRoutes |
|
||||||
|
| `/api/ui-settings` | GET/POST | public | POST:`{settings,etag?}` | settings / `sendOk` | write ui settings | `bgp_data/rt_ui_settings.json` | miscRoutes |
|
||||||
|
| `/api/ping-services-list` | GET | public | - | `{list:[...]}` | none | ui settings | miscRoutes |
|
||||||
|
| `/api/ping-services` | GET | public | query:`refresh|nocache` | `{byId,history}` | cache+history writes | `ping-services/cache_*`,`history_*` | miscRoutes |
|
||||||
|
| `/api/ipsec-passwords` | GET/POST | public | POST DTO ipsec secret | list / item / `sendOk` | secret storage update | blobs/config | ipsecPasswordsRoutes |
|
||||||
|
| `/api/ipsec-passwords/:id` | GET/PUT/DELETE | public | PUT DTO | item / `sendOk` | update/delete secret | blobs/config | ipsecPasswordsRoutes |
|
||||||
|
| `/api/mikrotik/generate` | POST | public | generation payload | config text/json | none | derived | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/generate-interfaces` | GET | public | query | generated config | none | derived | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/generate-recursive-routes` | GET | public | query | generated config | none | derived | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/test-connection` | POST | public | connection payload | test result | network probe | - | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/ping` | GET/POST | public | POST:`{serverId,gateway,target,...}` | health/ping result | RouterOS ping | - | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/traceroute` | POST | public | traceroute payload | trace result | RouterOS command | - | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/speed-test` | POST | public | speed-test payload | throughput metrics | RouterOS command | speed cache blobs | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/apply` | POST | public | apply payload | apply report | config apply + backup | fs+blobs | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/run-script` | POST | public | script payload | execution output | RouterOS script execution | - | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/address-lists` | GET | public | query | list data | none | EvoBGP/derived | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/ospf-interface-templates` | GET | public | query | templates | none | blobs/config | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/ospf-interface-templates/apply` | POST | public | apply payload | `sendOk`/report | RouterOS mutation | - | mikrotikConfigRoutes |
|
||||||
|
| `/api/mikrotik/address-lists/apply-summary` | POST | public | summary payload | report | RouterOS mutation | - | mikrotikConfigRoutes |
|
||||||
|
| `/api/uptime/cache` | GET | public | query | uptime snapshot | none | uptime cache blob | mikrotikConfigRoutes |
|
||||||
|
| `/api/uptime/check` | POST | public | check payload | status report | ping/http checks | uptime cache blob | mikrotikConfigRoutes |
|
||||||
|
| `/api/traffic/interface-stats` | GET | public | query | traffic stats | optional cache update | cache blobs | trafficRoutes |
|
||||||
|
| `/api/resources/stats` | GET | public | query | cpu/ram/hdd stats | optional cache update | cache blobs | resourceStatsRoutes |
|
||||||
|
| `/api/alerts` | GET | public | query | alerts aggregate | none | combines caches | alertsRoutes |
|
||||||
|
| `/api/mikrotik/backups` | GET/POST | public | POST backup payload | list / backup result | write/read `.rsc` | local FS + metadata | mikrotikBackupRoutes |
|
||||||
|
| `/api/mikrotik/backups/item` | GET | public | query id/path | backup item | read FS | local FS | mikrotikBackupRoutes |
|
||||||
|
| `/api/mikrotik/backups/diff` | POST | public | `{from,to,...}` | diff result | none | local FS | mikrotikBackupRoutes |
|
||||||
|
| `/api/mikrotik/backups/run` | POST | public | optional | run-now result | scheduler trigger | local FS | mikrotikBackupRoutes |
|
||||||
|
| `/api/network-map-cache` | GET | public | query | cached map | none | cache blobs | schedulerRoutes |
|
||||||
|
| `/api/route-optimizer` | GET | public | query topology | optimization result | none | derived/cache | routeOptimizerRoutes |
|
||||||
|
| `/api/scheduler/network-map/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | blobs/ui settings | schedulerRoutes |
|
||||||
|
| `/api/scheduler/network-map/logs` | GET | public | query | logs | none | blobs/logs | schedulerRoutes |
|
||||||
|
| `/api/scheduler/network-map/run-now` | POST | public | optional | trigger result | immediate job | cache update | schedulerRoutes |
|
||||||
|
| `/api/scheduler/ping-services/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | ui settings | schedulerRoutes |
|
||||||
|
| `/api/scheduler/ping-services/run-now` | POST | public | optional | trigger result | immediate cache refresh | ping-services cache | schedulerRoutes |
|
||||||
|
| `/api/scheduler/uptime-monitor/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | ui settings | schedulerRoutes |
|
||||||
|
| `/api/scheduler/uptime-monitor/run-now` | POST | public | optional | trigger result | immediate uptime job | uptime cache | schedulerRoutes |
|
||||||
|
| `/api/mikrotik/validate` | POST | public | `{config:string}` | validation report | none | - | mikrotik-validator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DATABASE BEHAVIOR MODEL
|
||||||
|
|
||||||
|
### 6.1 Критичные таблицы
|
||||||
|
|
||||||
|
| Таблица | Назначение | Критичность | Можно менять? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `blobs` | текущее состояние logical objects | very high | только через миграцию + compatibility layer |
|
||||||
|
| `blob_versions` | история версий для rollback | high | нельзя удалять без замены rollback механизма |
|
||||||
|
| `versioned_keys` | whitelist ключей с версионированием | high | расширять можно, удаление рискованно |
|
||||||
|
| `app_filter_rules` | глобальные/simple фильтры | very high | менять с миграцией + route/service sync |
|
||||||
|
| `app_meta` | meta timestamps/flags | medium | осторожно; используется для etag/mtime |
|
||||||
|
| `cache_entries` | TTL-кеш | medium | можно расширять, не ломая purge logic |
|
||||||
|
| `schema_migrations` | applied versions | critical | не редактировать вручную |
|
||||||
|
|
||||||
|
### 6.2 Cascade/relations
|
||||||
|
- `blob_versions.object_key -> blobs.object_key ON DELETE CASCADE`.
|
||||||
|
- Удаление blob удаляет историю версий автоматически.
|
||||||
|
|
||||||
|
### 6.3 Transactional boundaries
|
||||||
|
- `blobStorage.writeBlobTx`: атомарно сохраняет новую версию + snapshot old version + trim.
|
||||||
|
- `filterRulesStorage.replaceRules`: атомарно `DELETE scope` + `INSERT new set` + update mtime.
|
||||||
|
- Rollback версии выполняется внутри DB write flow.
|
||||||
|
|
||||||
|
### 6.4 Consistency rules
|
||||||
|
- `etag` вычисляется по SHA-256 body; должен соответствовать текущему blob содержимому.
|
||||||
|
- `lastModified` и `contentLength` обязательны для корректной cache-конкурентности UI/API.
|
||||||
|
- `app_filter_rules.position` определяет порядок; запись всегда full-replace.
|
||||||
|
- `SQLITE_PATH` должен быть persistent volume в production.
|
||||||
|
- `journal_mode=WAL` обязателен для конкурентного I/O.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. FRONTEND RULES (UI ENGINEERING RULESET)
|
||||||
|
|
||||||
|
### Структура страниц
|
||||||
|
- Все маршруты регистрируются централизованно в `App.jsx`.
|
||||||
|
- Экран = manager/page компонент + общие UI-компоненты (`components/*`).
|
||||||
|
|
||||||
|
### State management rules
|
||||||
|
- Server-state: только React Query (`useQuery/useMutation`).
|
||||||
|
- UI-state: local component state + Context (`Language`, `Theme`, `Ping`, `Alerts`).
|
||||||
|
- Запрещено хранить API snapshot в произвольных singleton-объектах вне React Query.
|
||||||
|
|
||||||
|
### Data fetching rules
|
||||||
|
- Только через `src/lib/api.js`.
|
||||||
|
- Для GET использовать `If-None-Match`/ETag-механику клиента.
|
||||||
|
- Для write-путей с поддержкой ETag передавать `If-Match`/`etag`.
|
||||||
|
|
||||||
|
### Caching strategy
|
||||||
|
- React Query `staleTime` default 30s.
|
||||||
|
- Axios in-memory response cache для GET.
|
||||||
|
- Для `/communities` отключён условный GET (всегда fresh).
|
||||||
|
|
||||||
|
### Routing dependencies
|
||||||
|
- Навигация и route paths должны совпадать (sidebar/horizontal menu).
|
||||||
|
- Любой новый route требует:
|
||||||
|
- компонент страницы;
|
||||||
|
- запись в nav categories;
|
||||||
|
- route registration (оба layout блока).
|
||||||
|
|
||||||
|
### Запрещённые паттерны
|
||||||
|
- Прямой `fetch` в компонентах (мимо `api.js`).
|
||||||
|
- Изменение `queryClient` политики точечно без общего обоснования.
|
||||||
|
- UI-изменения, которые ломают optimistic rollback сценарии.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. AI AGENT OPERATING PROTOCOL
|
||||||
|
|
||||||
|
### 8.1 Роли агентов
|
||||||
|
- `architect agent`: проверяет архитектурные границы, планирует миграции/контракты.
|
||||||
|
- `backend agent`: реализует route/service/storage изменения с сохранением backward compatibility.
|
||||||
|
- `frontend agent`: реализует UI/state/data-fetch изменения по правилам React Query.
|
||||||
|
- `refactor agent`: выполняет безопасные структурные изменения с impact map.
|
||||||
|
- `debug agent`: локализует баги, подтверждает root cause, готовит минимальный fix.
|
||||||
|
|
||||||
|
### 8.2 Правила работы агентов
|
||||||
|
- Перед правками читать: `README.md`, `backend/server.js`, целевой route/service, соответствующий frontend hook/page.
|
||||||
|
- Изменения планировать от контракта к реализации: API -> service -> storage -> UI.
|
||||||
|
- При любом endpoint-change проверять:
|
||||||
|
- формат success/error;
|
||||||
|
- ETag/headers;
|
||||||
|
- влияние на React Query keys и optimistic updates.
|
||||||
|
- Избегать конфликтов:
|
||||||
|
- не менять shared utility без cross-feature impact check;
|
||||||
|
- при рефакторинге route переносить логику в service, не наоборот.
|
||||||
|
|
||||||
|
### 8.3 SAFE EDITING RULE (обязательный)
|
||||||
|
Перед изменением кода агент обязан:
|
||||||
|
1. Найти все зависимости (routes/services/hooks/components/schedulers).
|
||||||
|
2. Проверить влияние на API (input/output, status codes, headers).
|
||||||
|
3. Проверить влияние на DB (таблицы, ключи blobs, миграции, транзакции).
|
||||||
|
4. Проверить UI impact (query keys, route path, optimistic update behavior).
|
||||||
|
5. Сформировать `plan diff` (что меняется, что не меняется, как верифицируется).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. CHANGE MANAGEMENT PROTOCOL
|
||||||
|
|
||||||
|
### STEP 1 — ANALYZE
|
||||||
|
- Определить feature/bug scope.
|
||||||
|
- Идентифицировать owner-слой (frontend/backend/storage/external).
|
||||||
|
- Собрать baseline контрактов.
|
||||||
|
|
||||||
|
### STEP 2 — IMPACT MAP
|
||||||
|
- Построить карту зависимостей:
|
||||||
|
- endpoint -> service -> storage/external;
|
||||||
|
- endpoint -> frontend hooks -> pages/components.
|
||||||
|
- Классифицировать риск: low/medium/high.
|
||||||
|
|
||||||
|
### STEP 3 — IMPLEMENT PLAN
|
||||||
|
- Реализовать минимально-инвазивные изменения.
|
||||||
|
- Сохранять backward-compatible payload где возможно.
|
||||||
|
- Для high-risk сначала добавить адаптерный слой, потом migration.
|
||||||
|
|
||||||
|
### STEP 4 — APPLY PATCH
|
||||||
|
- Вносить правки атомарными логическими блоками.
|
||||||
|
- Не смешивать функциональные и косметические изменения.
|
||||||
|
- Фиксировать invariants в комментариях только при необходимости.
|
||||||
|
|
||||||
|
### STEP 5 — VERIFY CONSISTENCY
|
||||||
|
- Проверить lint/type/build.
|
||||||
|
- Проверить ключевые API сценарии (read/write/etag/error).
|
||||||
|
- Проверить UI critical path (list load -> edit -> save -> refresh).
|
||||||
|
- Проверить scheduler/side effect пути, если затронуты.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. DEBUGGING & RECOVERY RULES
|
||||||
|
|
||||||
|
### Диагностика ошибок
|
||||||
|
- Backend:
|
||||||
|
- проверять structured logs (`pino-http`) с `requestId`;
|
||||||
|
- проверять метрики (`http_errors_total`, `http_request_duration_seconds`);
|
||||||
|
- валидировать env для EvoBGP/MikroTik.
|
||||||
|
- Frontend:
|
||||||
|
- проверять network ошибки через `apiErrorHandler` + notify payload;
|
||||||
|
- проверять retry/noRetry и cache behavior.
|
||||||
|
|
||||||
|
### Откат изменений
|
||||||
|
- Код: откат через VCS commit revert.
|
||||||
|
- Данные:
|
||||||
|
- blob keys: `/api/history/:resource/rollback` (если поддерживается).
|
||||||
|
- filters/evobgp resources: rollback через локальный history API не поддерживается; нужен внешний restore plan.
|
||||||
|
|
||||||
|
### Изоляция багов
|
||||||
|
- Сначала локализовать слой (UI/API/Service/Storage/External).
|
||||||
|
- Воспроизвести минимальный сценарий.
|
||||||
|
- Проверить контракт на границе слоя перед deep refactor.
|
||||||
|
|
||||||
|
### Логирование проблем
|
||||||
|
- Каждый баг-репорт должен включать:
|
||||||
|
- requestId/endpoint;
|
||||||
|
- входной payload (без секретов);
|
||||||
|
- фактический vs ожидаемый ответ;
|
||||||
|
- impacted таблицы/ключи.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. EXTENSION GUIDE
|
||||||
|
|
||||||
|
### Добавление новой фичи (общий шаблон)
|
||||||
|
1. Добавить/расширить service (доменная логика).
|
||||||
|
2. Добавить route с валидацией и contract-consistent ответом.
|
||||||
|
3. Добавить frontend hook в `useApiQuery.js` или отдельный data hook.
|
||||||
|
4. Подключить UI page/component + route entry.
|
||||||
|
5. Добавить проверку impact на ETag/cache/retry.
|
||||||
|
|
||||||
|
### Расширение API
|
||||||
|
- Предпочитать additive changes (новые поля/роуты) вместо breaking.
|
||||||
|
- Для breaking change:
|
||||||
|
- ввести transitional endpoint или feature flag;
|
||||||
|
- обновить frontend и consumers синхронно;
|
||||||
|
- подготовить rollback plan.
|
||||||
|
|
||||||
|
### Добавление новой таблицы
|
||||||
|
- Только новой миграцией `00X_*.sql`.
|
||||||
|
- Определить:
|
||||||
|
- primary key + indexes;
|
||||||
|
- relation/cascade policy;
|
||||||
|
- transaction boundaries;
|
||||||
|
- read/write service API.
|
||||||
|
|
||||||
|
### Как не ломать текущую систему
|
||||||
|
- Не удалять legacy aliases без full usage audit.
|
||||||
|
- Не менять ключи объектов в `blobs` без map миграции.
|
||||||
|
- Не менять `sendError/sendOk` contract.
|
||||||
|
- Не обходить `evobgpClient` для BGP-reference данных.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OPERATIONAL CHECKLIST FOR LLM AGENTS
|
||||||
|
|
||||||
|
- [ ] Прочитан `README.md` и целевые route/service/frontend файлы.
|
||||||
|
- [ ] Сформирован `plan diff` и impact map.
|
||||||
|
- [ ] Проверены API contract invariants (status/errors/headers).
|
||||||
|
- [ ] Проверены DB invariants (transactions/etag/versioning).
|
||||||
|
- [ ] Проверен UI flow (query cache + optimistic updates).
|
||||||
|
- [ ] Выполнены lint/build/ручная smoke-валидация критических сценариев.
|
||||||
|
|
||||||
@@ -1,240 +1,213 @@
|
|||||||
# Docker Images
|
# Docker Images
|
||||||
|
|
||||||
Этот проект предоставляет Docker образы для S3 Lists Manager с двумя различными версиями интерфейса.
|
Образ собирается из [`Dockerfile.fast`](Dockerfile.fast) (многостадийная сборка: Vite frontend + `npm ci` backend, рантайм **distroless** Node 20). В Gitea Actions используется тот же файл (см. [`.gitea/workflows/docker-publish.yml`](.gitea/workflows/docker-publish.yml)).
|
||||||
|
|
||||||
## 🏷️ Доступные теги
|
## Теги в реестре `git.shts.su`
|
||||||
|
|
||||||
### Основная версия (main branch)
|
Замените `[repository]` на путь репозитория в Gitea (например `denozord/router-lists-ui`).
|
||||||
- `git.shts.su/[repository]:latest` - Стабильная версия с базовым интерфейсом
|
|
||||||
- `git.shts.su/[repository]:[commit-sha]` - Версия с конкретным коммитом
|
|
||||||
|
|
||||||
### Tabler версия (tabler branch)
|
| Ветка (триггер push) | Образы в реестре |
|
||||||
- `git.shts.su/[repository]:tabler` - Версия с современным Tabler UI
|
|----------------------|-------------------|
|
||||||
- `git.shts.su/[repository]:tabler-[commit-sha]` - Tabler версия с конкретным коммитом
|
| **main** (и v4, v3 и т.д. из workflow) | `git.shts.su/[repository]:latest` и `git.shts.su/[repository]:<полный-sha>` |
|
||||||
|
| **v5** | **только** `git.shts.su/[repository]:v5` и `git.shts.su/[repository]:v5-<полный-sha>` — тег **`latest` с ветки v5 не публикуется** |
|
||||||
|
| **tabler** | см. отдельный workflow `docker-publish-tabler.yml`, если включён |
|
||||||
|
|
||||||
## 🚀 Запуск
|
Пин на коммит: используйте тег с суффиксом `-<sha>`; «плавающий» тег ветки (`:v5` или `:latest`) всегда указывает на последнюю успешную сборку.
|
||||||
|
|
||||||
|
## Запуск (production)
|
||||||
|
|
||||||
|
### Общие рекомендации
|
||||||
|
|
||||||
|
1. **Данные на хосте** — смонтируйте том для SQLite и каталог бэкапов MikroTik, иначе при удалении контейнера пропадут БД и `.rsc`.
|
||||||
|
2. **Переменные** — минимум `ENCRYPTION_KEY`; для справочников и UI — `EVOBGP_API_URL` и `EVOBGP_API_TOKEN`; при необходимости `CORS_ORIGINS`, `PORT`.
|
||||||
|
3. **Рантайм distroless** — внутри образа **нет** `/bin/bash` и пакетного менеджера; отладка через `docker logs` / метрики. Интерактивная оболочка недоступна.
|
||||||
|
|
||||||
|
### Основная линия (`:latest`)
|
||||||
|
|
||||||
### Основная версия
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name s3-lists-manager \
|
--name router-lists-ui \
|
||||||
-p 3001:3001 \
|
-p 3001:3001 \
|
||||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
-e ENCRYPTION_KEY=<64-hex> \
|
||||||
|
-e EVOBGP_API_URL=https://evobgp.example/v1 \
|
||||||
|
-e EVOBGP_API_TOKEN=<token> \
|
||||||
|
-e SQLITE_PATH=/data/router-lists.db \
|
||||||
|
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
|
||||||
|
-v router-lists-data:/data \
|
||||||
git.shts.su/[repository]:latest
|
git.shts.su/[repository]:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tabler версия
|
Каталог `/data` на томе должен быть доступен пользователю **nonroot** (образ distroless); при ошибках прав создайте том заранее или используйте bind-mount с `chown` под UID образа (обычно `65532` у nonroot).
|
||||||
|
|
||||||
|
### Ветка v5 (только теги `v5` / `v5-<sha>`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.shts.su/[repository]:v5
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name router-lists-ui-v5 \
|
||||||
|
-p 3002:3001 \
|
||||||
|
-e ENCRYPTION_KEY=<64-hex> \
|
||||||
|
-e EVOBGP_API_URL=https://evobgp.example/v1 \
|
||||||
|
-e EVOBGP_API_TOKEN=<token> \
|
||||||
|
-e SQLITE_PATH=/data/router-lists.db \
|
||||||
|
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
|
||||||
|
-v router-lists-v5-data:/data \
|
||||||
|
git.shts.su/[repository]:v5
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы зафиксировать конкретный коммит:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d ... git.shts.su/[repository]:v5-1c6e6ab...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabler-ветка (если публикуется отдельным workflow)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name s3-lists-manager-tabler \
|
--name router-lists-ui-tabler \
|
||||||
-p 3001:3001 \
|
-p 3003:3001 \
|
||||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
--env-file ./backend/.env \
|
||||||
|
-v router-lists-tabler-data:/data \
|
||||||
git.shts.su/[repository]:tabler
|
git.shts.su/[repository]:tabler
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔄 Обновление
|
## Обновление образа
|
||||||
|
|
||||||
|
### latest
|
||||||
|
|
||||||
### Основная версия
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.shts.su/[repository]:latest
|
docker pull git.shts.su/[repository]:latest
|
||||||
docker stop s3-lists-manager
|
docker stop router-lists-ui && docker rm router-lists-ui
|
||||||
docker rm s3-lists-manager
|
# затем снова docker run ... как выше
|
||||||
docker run -d \
|
|
||||||
--name s3-lists-manager \
|
|
||||||
-p 3001:3001 \
|
|
||||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
|
||||||
git.shts.su/[repository]:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tabler версия
|
### v5
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.shts.su/[repository]:tabler
|
docker pull git.shts.su/[repository]:v5
|
||||||
docker stop s3-lists-manager-tabler
|
docker stop router-lists-ui-v5 && docker rm router-lists-ui-v5
|
||||||
docker rm s3-lists-manager-tabler
|
docker run -d ... git.shts.su/[repository]:v5
|
||||||
docker run -d \
|
|
||||||
--name s3-lists-manager-tabler \
|
|
||||||
-p 3001:3001 \
|
|
||||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
|
||||||
git.shts.su/[repository]:tabler
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## Docker Compose
|
||||||
|
|
||||||
Создайте файл `docker-compose.yml`:
|
Пример **одной** службы на `latest` с томом данных (подставьте свой `image:` и при необходимости `env_file`):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
s3-lists-manager:
|
router-lists-ui:
|
||||||
image: git.shts.su/[repository]:latest
|
image: git.shts.su/[repository]:latest
|
||||||
container_name: s3-lists-manager
|
container_name: router-lists-ui
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
NODE_ENV: production
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
EVOBGP_API_URL: ${EVOBGP_API_URL}
|
||||||
|
EVOBGP_API_TOKEN: ${EVOBGP_API_TOKEN}
|
||||||
|
SQLITE_PATH: /data/router-lists.db
|
||||||
|
MIKROTIK_BACKUP_DIR: /data/backups/mikrotik
|
||||||
|
volumes:
|
||||||
|
- router-lists-data:/data
|
||||||
|
|
||||||
s3-lists-manager-tabler:
|
volumes:
|
||||||
image: git.shts.su/[repository]:tabler
|
router-lists-data:
|
||||||
container_name: s3-lists-manager-tabler
|
|
||||||
ports:
|
|
||||||
- "3002:3001"
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Создайте файл `.env` в той же директории:**
|
Отдельный сервис для **v5** — тот же фрагмент, но `image: git.shts.su/[repository]:v5`, другой `container_name` и при желании отдельный volume.
|
||||||
```bash
|
|
||||||
ENCRYPTION_KEY=your-64-hex-character-key-here
|
## Переменные окружения (контейнер)
|
||||||
|
|
||||||
|
| Переменная | Описание | Обязательная |
|
||||||
|
|------------|----------|----------------|
|
||||||
|
| `ENCRYPTION_KEY` | AES-256 для IPSec и паролей MikroTik (64 hex) | **Да** |
|
||||||
|
| `EVOBGP_API_URL` | Базовый URL API EvoBGP (серверный прокси `/api/evobgp` и бэкенд-справочники) | По сценарию |
|
||||||
|
| `EVOBGP_API_TOKEN` | Bearer к EvoBGP | По сценарию |
|
||||||
|
| `SQLITE_PATH` | Путь к файлу SQLite внутри контейнера (персистентный том) | Рекомендуется явно задать, напр. `/data/router-lists.db` |
|
||||||
|
| `MIKROTIK_BACKUP_DIR` | Каталог бэкапов `.rsc` на диске | Рекомендуется с томом, напр. `/data/backups/mikrotik` |
|
||||||
|
| `PORT` | Порт HTTP внутри контейнера | Нет (по умолчанию `3001`) |
|
||||||
|
| `CORS_ORIGINS` | Список Origin через запятую | Нет |
|
||||||
|
| `LOG_LEVEL` | Уровень логов pino | Нет |
|
||||||
|
|
||||||
|
`ENCRYPTION_KEY` должен быть **один и тот же** между перезапусками, иначе не расшифровать уже сохранённые секреты.
|
||||||
|
|
||||||
|
### EvoBGP и reverse proxy (nginx)
|
||||||
|
|
||||||
|
Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI.
|
||||||
|
|
||||||
|
**Рекомендуемый вариант** (путь `/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;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Запуск:
|
**Типичная ошибка**: `proxy_pass http://127.0.0.1:3001/;` — nginx срезает `/api`. В коде есть запасной маршрут на `/evobgp`, но лучше исправить nginx.
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Переменные окружения
|
**Проверка**: `GET http://<хост>:<порт>/api/evobgp` → JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}`.
|
||||||
|
|
||||||
| Переменная | Описание | По умолчанию | Обязательная |
|
## Генерация `ENCRYPTION_KEY`
|
||||||
|------------|----------|--------------|--------------|
|
|
||||||
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
|
|
||||||
| `PORT` | Порт для запуска сервера | `3001` | Нет |
|
|
||||||
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
|
|
||||||
|
|
||||||
### ENCRYPTION_KEY
|
|
||||||
|
|
||||||
**Важно**: `ENCRYPTION_KEY` является обязательным параметром для работы с IPSec паролями. Этот ключ используется для шифрования/дешифрования паролей, хранящихся в S3.
|
|
||||||
|
|
||||||
**Генерация ключа:**
|
|
||||||
```bash
|
```bash
|
||||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Пример запуска с ENCRYPTION_KEY:**
|
## Мониторинг
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker logs -f router-lists-ui
|
||||||
--name s3-lists-manager \
|
docker stats router-lists-ui
|
||||||
-p 3001:3001 \
|
|
||||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
|
||||||
git.shts.su/[repository]:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**⚠️ Внимание**: Используйте один и тот же `ENCRYPTION_KEY` для всех запусков контейнера. При изменении ключа старые зашифрованные пароли не смогут быть расшифрованы.
|
## Локальная сборка (как в CI)
|
||||||
|
|
||||||
## 📊 Мониторинг
|
|
||||||
|
|
||||||
### Просмотр логов
|
|
||||||
```bash
|
```bash
|
||||||
# Основная версия
|
docker build -f Dockerfile.fast -t router-lists-ui:local .
|
||||||
docker logs s3-lists-manager
|
docker run --rm -p 3001:3001 --env-file ./backend/.env -v $(pwd)/data:/data \
|
||||||
|
-e SQLITE_PATH=/data/router-lists.db \
|
||||||
# Tabler версия
|
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
|
||||||
docker logs s3-lists-manager-tabler
|
router-lists-ui:local
|
||||||
```
|
```
|
||||||
|
|
||||||
### Статистика контейнеров
|
(PowerShell на Windows: вместо `$(pwd)` используйте путь к каталогу данных.)
|
||||||
|
|
||||||
|
## CI/CD (Gitea Actions)
|
||||||
|
|
||||||
|
- [`.gitea/workflows/docker-publish.yml`](.gitea/workflows/docker-publish.yml) — пуш в перечисленные ветки (включая **v5**): сборка и push в `git.shts.su`.
|
||||||
|
- Для **v5** в реестр уходят **только** теги `v5` и `v5-<sha>` (без `latest`).
|
||||||
|
- Остальные ветки из списка workflow — `latest` и тег по SHA.
|
||||||
|
|
||||||
|
Шаг **Trigger container update webhook** (если задан `secrets.portainer_webhook`) выполняется после сборки; при ошибке шаг не валит job (`continue-on-error: true`).
|
||||||
|
|
||||||
|
## Безопасность и сеть
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker stats s3-lists-manager s3-lists-manager-tabler
|
docker network create router-lists-net
|
||||||
|
docker run -d --network router-lists-net ... git.shts.su/[repository]:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔍 Отладка
|
Ограничение ресурсов:
|
||||||
|
|
||||||
### Вход в контейнер
|
|
||||||
```bash
|
```bash
|
||||||
# Основная версия
|
docker run -d --memory=512m --cpus=0.5 ... git.shts.su/[repository]:latest
|
||||||
docker exec -it s3-lists-manager /bin/bash
|
|
||||||
|
|
||||||
# Tabler версия
|
|
||||||
docker exec -it s3-lists-manager-tabler /bin/bash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Проверка процессов
|
## Устранение неполадок
|
||||||
```bash
|
|
||||||
docker exec s3-lists-manager ps aux
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🏗️ Сборка локально
|
|
||||||
|
|
||||||
### Основная версия
|
|
||||||
```bash
|
|
||||||
git checkout main
|
|
||||||
docker build -t s3-lists-manager:local .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tabler версия
|
|
||||||
```bash
|
|
||||||
git checkout tabler
|
|
||||||
docker build -t s3-lists-manager-tabler:local .
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 CI/CD
|
|
||||||
|
|
||||||
### Автоматическая сборка
|
|
||||||
|
|
||||||
- **Ветка `main`**: Автоматически создает образ с тегом `latest`
|
|
||||||
- **Ветка `tabler`**: Автоматически создает образ с тегом `tabler`
|
|
||||||
|
|
||||||
### Workflow файлы
|
|
||||||
|
|
||||||
- `.gitea/workflows/docker-publish.yml` - Сборка основной версии
|
|
||||||
- `.gitea/workflows/docker-publish-tabler.yml` - Сборка Tabler версии
|
|
||||||
|
|
||||||
## 🔒 Безопасность
|
|
||||||
|
|
||||||
### Проверка образа
|
|
||||||
```bash
|
|
||||||
docker scan git.shts.su/[repository]:latest
|
|
||||||
docker scan git.shts.su/[repository]:tabler
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск в изолированной сети
|
|
||||||
```bash
|
|
||||||
docker network create s3-lists-network
|
|
||||||
docker run -d \
|
|
||||||
--name s3-lists-manager \
|
|
||||||
--network s3-lists-network \
|
|
||||||
-p 3001:3001 \
|
|
||||||
git.shts.su/[repository]:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📈 Производительность
|
|
||||||
|
|
||||||
### Ограничение ресурсов
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name s3-lists-manager \
|
|
||||||
--memory=512m \
|
|
||||||
--cpus=0.5 \
|
|
||||||
-p 3001:3001 \
|
|
||||||
git.shts.su/[repository]:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Мониторинг ресурсов
|
|
||||||
```bash
|
|
||||||
docker stats --no-stream s3-lists-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🆘 Устранение неполадок
|
|
||||||
|
|
||||||
### Проверка статуса контейнера
|
|
||||||
```bash
|
|
||||||
docker ps -a | grep s3-lists-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
### Перезапуск контейнера
|
|
||||||
```bash
|
|
||||||
docker restart s3-lists-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
### Очистка неиспользуемых образов
|
|
||||||
```bash
|
```bash
|
||||||
|
docker ps -a --filter name=router-lists-ui
|
||||||
|
docker restart router-lists-ui
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Примечание**: Замените `[repository]` на актуальное имя вашего репозитория в Gitea.
|
**Замените `[repository]`** на актуальный путь репозитория в Gitea (например `denozord/router-lists-ui`).
|
||||||
|
|||||||
@@ -48,8 +48,13 @@ COPY backend/middleware ./middleware
|
|||||||
COPY backend/routes ./routes
|
COPY backend/routes ./routes
|
||||||
COPY backend/services ./services
|
COPY backend/services ./services
|
||||||
COPY backend/utils ./utils
|
COPY backend/utils ./utils
|
||||||
|
COPY backend/db ./db
|
||||||
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 ...
|
||||||
|
|||||||
@@ -1,347 +1,129 @@
|
|||||||
# Router Lists UI
|
# Router Lists UI
|
||||||
|
|
||||||
Полноценный UI/Backend для управления списками BGP (домены, IP-диапазоны, ASNs), фильтрами и конфигурациями MikroTik на базе S3 (Yandex Object Storage). Интерфейс построен на Tabler, frontend — Vite + React, backend — Express.
|
UI/Backend для управления BGP-списками, фильтрами и конфигурациями MikroTik.
|
||||||
|
Текущая модель хранения: **SQLite + локальная файловая система** (для `.rsc` backup MikroTik).
|
||||||
|
|
||||||
|
## Актуальная архитектура хранения
|
||||||
|
|
||||||
|
- Основные данные и объекты приложения хранятся в SQLite (через `better-sqlite3`).
|
||||||
|
- MikroTik backup-файлы (`.rsc`) хранятся в локальной ФС в каталоге `MIKROTIK_BACKUP_DIR`.
|
||||||
|
- AWS SDK/S3 в runtime не используются.
|
||||||
|
- Часть имен API/сервисов (`s3*`) сохранена для обратной совместимости, но фактический backend storage — SQLite.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
### Требования
|
### Требования
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- S3-совместимое хранилище (Yandex Object Storage)
|
- npm
|
||||||
- Доступы AWS: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `AWS_REGION`
|
|
||||||
|
|
||||||
### Запуск backend
|
### Запуск backend
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd backend
|
cd backend
|
||||||
npm i
|
npm install
|
||||||
# создайте .env и заполните (пример ниже)
|
# создайте .env и заполните (пример ниже)
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
Сервис поднимется на `http://localhost:3001`.
|
|
||||||
|
Backend поднимется на `http://localhost:3001`.
|
||||||
|
|
||||||
### Запуск frontend
|
### Запуск frontend
|
||||||
```bash
|
|
||||||
|
```powershell
|
||||||
cd frontend
|
cd frontend
|
||||||
npm i
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
Frontend доступен на `http://localhost:5173` (по умолчанию). Production-сборка: `npm run build`.
|
|
||||||
|
|
||||||
## Главные особенности
|
Frontend доступен на `http://localhost:5173`.
|
||||||
- Единая UX/UI библиотека Tabler, адаптивные панели действий и навигация.
|
|
||||||
- Онлайн-обновление (WebSocket) BGP c потоковым логом и фоновое обновление (HTTP POST) из UI.
|
|
||||||
- Блокировки (soft-lock) ресурсов с TTL, чтобы избежать гонок при одновременном редактировании.
|
|
||||||
- Версионирование данных (история/откат), если включено версии в бакете.
|
|
||||||
- Генерация конфигурации MikroTik из фильтров (`/api/filters/generate-config`) и экспорт в S3.
|
|
||||||
- Метрики Prometheus: `/metrics`, health/ready: `/health`, `/ready`.
|
|
||||||
|
|
||||||
### Важные переменные окружения
|
## Важные переменные окружения
|
||||||
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
|
|
||||||
- `ENCRYPTION_KEY` — ключ шифрования (64 hex символа) для IPSec и MikroTik паролей
|
- `SQLITE_PATH` — путь к файлу SQLite (по умолчанию `backend/data/router-lists.db`).
|
||||||
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все)
|
- `MIKROTIK_BACKUP_DIR` — каталог для локальных `.rsc` backup-файлов MikroTik.
|
||||||
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию)
|
- `ENCRYPTION_KEY` — 64 hex-символа для шифрования секретов (IPSec/MikroTik).
|
||||||
- `PORT` — порт backend (по умолчанию 3001)
|
- `EVOBGP_API_URL`, `EVOBGP_API_TOKEN` — если используете интеграцию EvoBGP.
|
||||||
- `BGP_BACKGROUND_URL` — адрес фонового обновления BGP, например:
|
- `CORS_ORIGINS`, `LOG_LEVEL`, `PORT` — эксплуатационные настройки сервиса.
|
||||||
- `http://77.232.38.173:8080/api/update_bgp/background?api_key=...`
|
- `BGP_BACKGROUND_URL` — URL фонового обновления BGP для прокси-эндпоинта `POST /api/update-bgp/background`.
|
||||||
- Используется эндпоинтом прокси `POST /api/update-bgp/background` для обхода CORS
|
|
||||||
|
|
||||||
Пример `.env`:
|
Пример `.env`:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
PORT=3001
|
PORT=3001
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
AWS_ACCESS_KEY_ID=...
|
SQLITE_PATH=./data/router-lists.db
|
||||||
AWS_SECRET_ACCESS_KEY=...
|
MIKROTIK_BACKUP_DIR=./data/backups/mikrotik
|
||||||
AWS_REGION=ru-central1
|
ENCRYPTION_KEY=<64-hex>
|
||||||
S3_BUCKET_NAME=...
|
|
||||||
CORS_ORIGINS=http://localhost:5173
|
CORS_ORIGINS=http://localhost:5173
|
||||||
BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502
|
BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=...
|
||||||
```
|
```
|
||||||
|
|
||||||
## API (backend)
|
## API (backend)
|
||||||
|
|
||||||
Все ответы об ошибке имеют единый формат:
|
Формат ошибок:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "code": "E_*", "message": "...", "details": {}, "requestId": "..." }
|
{ "code": "E_*", "message": "...", "details": {}, "requestId": "..." }
|
||||||
```
|
```
|
||||||
Успешные POST/PUT возвращают:
|
|
||||||
|
Формат успешных `POST/PUT`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "ok": true, "etag": "...", "lastModified": "ISO", "contentLength": 123 }
|
{ "ok": true, "etag": "...", "lastModified": "ISO", "contentLength": 123 }
|
||||||
```
|
```
|
||||||
И всегда выставляют заголовки `ETag`, `Last-Modified`, `Content-Length-Source` (если есть). GET поддерживают `countOnly=true` там, где это логично.
|
|
||||||
|
|
||||||
### Данные
|
### Примечание по legacy-терминам
|
||||||
- GET `/api/domains-new` — список доменов `{ domain, community }`
|
|
||||||
- `?q=`, `?offset=`, `?limit=`, `?countOnly=true`, `?format=std`
|
|
||||||
- POST `/api/domains-new` — `{ domains: [{domain, community}], etag }`
|
|
||||||
|
|
||||||
- GET `/api/ip-ranges` — список `[{ ipRange, community }]`
|
- `GET /api/s3/last-modified` и функции вида `readS3*`/`writeS3*` — это **legacy-названия**.
|
||||||
- POST `/api/ip-ranges` — `{ ipRanges: [{ipRange, community}], etag }`
|
- Фактически эти операции работают с SQLite-backed хранилищем.
|
||||||
|
|
||||||
- GET `/api/asns` — список `[{ domain, type }]` (domain = AS, type = community)
|
### Основные эндпоинты
|
||||||
- POST `/api/asns` — `{ domains: [{domain, type}], etag }`
|
|
||||||
|
|
||||||
- GET `/api/communities` — справочник community (JSON)
|
- Данные: `/api/domains-new`, `/api/ip-ranges`, `/api/asns`, `/api/communities`
|
||||||
- POST `/api/communities` — `{ communities: [...] }` (валидация уникальности value)
|
- Фильтры/конфиги: `/api/filters`, `/api/server-configs`, `/api/server-filters`
|
||||||
|
- MikroTik: `/api/mikrotik/generate`, `/api/mikrotik/generate-interfaces`, `/api/mikrotik/generate-recursive-routes`, `/api/mikrotik/test-connection`, `/api/mikrotik/apply`
|
||||||
|
- Прочее: `/api/servers`, `/api/billing`, `/api/auto-urls`, `/api/servers/availability`, `/api/locks/:resource`, `/api/history/:resource`
|
||||||
|
|
||||||
### Фильтры и конфигурации
|
## Docker
|
||||||
- GET `/api/filters` / POST `/api/filters` — фильтры для всех серверов.
|
|
||||||
- GET `/api/filters/generate-config?format=text|json` — сгенерировать конфиг MikroTik из `filters.json`.
|
|
||||||
- POST `/api/filters/export-config` — сохранить сгенерированный конфиг в S3 (`mikrotik-frouting-config.txt`).
|
|
||||||
|
|
||||||
- GET `/api/server-configs` / POST `/api/server-configs` — список серверов (id, name, ...).
|
Полная инструкция: [DOCKER.md](DOCKER.md).
|
||||||
- GET `/api/server-configs/:serverId` / POST `/api/server-configs/:serverId` — конфиг конкретного сервера.
|
|
||||||
- DELETE `/api/server-configs/:serverId` — удалить конфиг.
|
|
||||||
- DELETE `/api/server-configs/:serverId/complete` — удалить конфиг и фильтры.
|
|
||||||
|
|
||||||
- GET `/api/server-filters/:serverId` — фильтры сервера.
|
Критично для production:
|
||||||
- POST `/api/server-filters/:serverId` — сохранить фильтры сервера.
|
|
||||||
- POST `/api/server-filters/generate-config` — сгенерировать конфиг MikroTik на лету из переданных `{ filters, format?: 'text'|'json' }`.
|
|
||||||
- POST `/api/mikrotik/generate` — сгенерировать конфиг интерфейсов и маршрутов. Body: `{ format?: 'text'|'json', type?: 'interfaces'|'recursive'|'all', serverId?: string, config?, servers? }`. Возвращает `{ blocks }` — массив блоков с полем `code` (text) или `operations` (json).
|
|
||||||
- GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы.
|
|
||||||
- GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты.
|
|
||||||
- POST `/api/mikrotik/test-connection` — проверить соединение с MikroTik. Body: `{ serverId }` или `{ host, port?, user?, password }`.
|
|
||||||
- POST `/api/mikrotik/apply` — применить конфигурацию на MikroTik по API. Body: `{ serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }`. Только для jumphost.
|
|
||||||
|
|
||||||
### Прочее
|
- Обязательно смонтировать volume для пути с `SQLITE_PATH`.
|
||||||
- GET `/api/servers` / POST `/api/servers` — список серверов.
|
- Обязательно смонтировать volume для `MIKROTIK_BACKUP_DIR`, иначе `.rsc` backup-файлы потеряются при пересоздании контейнера.
|
||||||
- GET `/api/billing` / POST `/api/billing` — биллинг (ноды, статусы и т.п.).
|
- Использовать стабильный `ENCRYPTION_KEY` между перезапусками.
|
||||||
- GET `/api/auto-urls` / POST `/api/auto-urls` — список авто-URL.
|
|
||||||
- POST `/api/auto-urls/process` — обработать авто-URL и добавить IP в `bgp_data/ips.txt`.
|
|
||||||
- GET `/api/servers/availability?ttlSeconds=60` — быстрый TCP‑чек доступности нод.
|
|
||||||
- GET `/api/s3/last-modified` — метаданные S3 (etag/lastModified/contentLength) по ключевым файлам.
|
|
||||||
- POST `/api/update-bgp/background` — прокси к фоновой задаче обновления BGP. Требует `BGP_BACKGROUND_URL` в `.env`.
|
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## Frontend
|
Пример запуска:
|
||||||
- Vite + React, Tabler CSS/JS (`@tabler/core`).
|
|
||||||
- Общий компонент `PageHeaderActions` — единый toolbar на страницах данных.
|
|
||||||
- Модалка `WsUpdateModal` — поток логов online-обновления (ws).
|
|
||||||
- Доступность: роли, подписи, фокус-кольца, hot-path без мыши.
|
|
||||||
|
|
||||||
### Скрипты
|
```powershell
|
||||||
```bash
|
docker run -d `
|
||||||
npm run dev # dev-сервер
|
--name router-lists-ui `
|
||||||
npm run build # продакшн сборка
|
-p 3001:3001 `
|
||||||
```
|
--env-file ./backend/.env `
|
||||||
|
-e SQLITE_PATH=/data/router-lists.db `
|
||||||
## Структура репозитория
|
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik `
|
||||||
```
|
-v router-lists-data:/data `
|
||||||
backend/ # Express API
|
|
||||||
frontend/ # Vite React UI
|
|
||||||
```
|
|
||||||
|
|
||||||
## Безопасность и эксплуатация
|
|
||||||
- Helmet, RateLimit, CORS, отключён слабый etag на JSON.
|
|
||||||
- Prometheus метрики по умолчанию.
|
|
||||||
- Для истории версий включите versioning в бакете S3.
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
MIT
|
|
||||||
|
|
||||||
# 📂 S3 Lists Manager
|
|
||||||
|
|
||||||
|
|
||||||
Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах:
|
|
||||||
|
|
||||||
- **domains.txt** - список доменов и их шлюзов
|
|
||||||
- **asns.txt** - список AS номеров и их шлюзов
|
|
||||||
- **servers.json** - список серверов с расширенной информацией (IP, DNS, страна, провайдер, тип туннеля)
|
|
||||||
|
|
||||||
## ✨ Возможности
|
|
||||||
|
|
||||||
- **Три режима работы:** Управление списками доменов, AS-номеров и серверов через вкладки.
|
|
||||||
- **CRUD операции:** Полный набор действий: создание, чтение, обновление и удаление записей.
|
|
||||||
- **Поиск в реальном времени:** Мгновенная фильтрация списков по мере ввода.
|
|
||||||
- **Массовая замена:** Быстрое обновление шлюзов для сотен записей в один клик.
|
|
||||||
- **Сохранение в S3:** Все изменения сохраняются непосредственно в файлах в бакете Yandex Cloud.
|
|
||||||
- **Docker-контейнеризация:** Готовый `Dockerfile` для сборки и запуска приложения в изолированном окружении.
|
|
||||||
- **CI/CD с Gitea Actions:** Автоматическая сборка и публикация Docker-образа в Gitea Registry при пуше в `main`.
|
|
||||||
- **Современный интерфейс:** Полностью интегрированный Tabler UI с официальными компонентами.
|
|
||||||
|
|
||||||
## 🛠️ Технологический стек
|
|
||||||
|
|
||||||
| Область | Технология |
|
|
||||||
|--------------|-----------------------------------------------------------------------------------------------------------|
|
|
||||||
| **Фронтенд** | [**React**](https://reactjs.org/) + [**Vite**](https://vitejs.dev/) |
|
|
||||||
| | [**@tabler/core**](https://tabler.io/) (UI-компоненты для Tabler версии) |
|
|
||||||
| | [**Tabler Icons**](https://tabler-icons.io/) (иконки) |
|
|
||||||
| | [**Axios**](https://axios-http.com/) (HTTP-клиент) |
|
|
||||||
| **Бэкенд** | [**Node.js**](https://nodejs.org/) + [**Express**](https://expressjs.com/) |
|
|
||||||
| | [**AWS SDK for JS**](https://aws.amazon.com/sdk-for-javascript/) (для работы с Yandex Cloud S3) |
|
|
||||||
| **CI/CD** | [**Docker**](https://www.docker.com/), [**Gitea Actions**](https://gitea.com/blog/2022/10/01/gitea-actions/) |
|
|
||||||
|
|
||||||
## 🏗️ Архитектура
|
|
||||||
|
|
||||||
Приложение состоит из двух основных частей: фронтенд на React и бэкенд на Node.js/Express, которые взаимодействуют через REST API.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph Browser
|
|
||||||
A[React Frontend]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Server
|
|
||||||
B(Node.js/Express API)
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Yandex Cloud
|
|
||||||
C{S3 Bucket}
|
|
||||||
D1[domains.txt]
|
|
||||||
D2[asns.txt]
|
|
||||||
D3[servers.json]
|
|
||||||
end
|
|
||||||
|
|
||||||
A -- HTTP Requests --> B
|
|
||||||
B -- AWS SDK --> C
|
|
||||||
C --- D1
|
|
||||||
C --- D2
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Установка и запуск
|
|
||||||
|
|
||||||
### Предварительные требования
|
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/) (v20.x или выше)
|
|
||||||
- [npm](https://www.npmjs.com/) или [yarn](https://yarnpkg.com/)
|
|
||||||
- Доступ к бакету Yandex Cloud S3 и сервисный аккаунт с правами на чтение и запись.
|
|
||||||
|
|
||||||
### 1. Настройка бэкенда
|
|
||||||
|
|
||||||
1. Перейдите в директорию `backend`:
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
```
|
|
||||||
2. Создайте файл `.env` на основе примера `.env.example`. Заполните его вашими учетными данными от Yandex Cloud S3:
|
|
||||||
```env
|
|
||||||
# .env
|
|
||||||
S3_ACCESS_KEY_ID=ВАШ_КЛЮЧ_ДОСТУПА
|
|
||||||
S3_SECRET_ACCESS_KEY=ВАШ_СЕКРЕТНЫЙ_КЛЮЧ
|
|
||||||
S3_BUCKET_NAME=ИМЯ_ВАШЕГО_БАКЕТА
|
|
||||||
```
|
|
||||||
3. Установите зависимости:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Настройка фронтенда
|
|
||||||
|
|
||||||
1. Перейдите в директорию `frontend`:
|
|
||||||
```bash
|
|
||||||
cd ../frontend
|
|
||||||
```
|
|
||||||
2. Установите зависимости:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Запуск приложения
|
|
||||||
|
|
||||||
1. **Запустите бэкенд-сервер.** В директории `backend` выполните:
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
Сервер запустится на `http://localhost:3001`.
|
|
||||||
|
|
||||||
2. **Запустите фронтенд.** В новой вкладке терминала, в директории `frontend`, выполните:
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
Приложение будет доступно по адресу `http://localhost:5173` и будет автоматически проксировать API-запросы на бэкенд.
|
|
||||||
|
|
||||||
## 🐳 Docker
|
|
||||||
|
|
||||||
Приложение полностью готово к запуску в Docker с двумя вариантами интерфейса.
|
|
||||||
|
|
||||||
### Доступные образы
|
|
||||||
|
|
||||||
#### Основная версия (main branch)
|
|
||||||
```bash
|
|
||||||
git.shts.su/[repository]:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Tabler версия (tabler branch)
|
|
||||||
```bash
|
|
||||||
git.shts.su/[repository]:tabler
|
|
||||||
```
|
|
||||||
|
|
||||||
### Быстрый запуск
|
|
||||||
|
|
||||||
#### Основная версия
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name s3-lists-manager \
|
|
||||||
-p 3001:3001 \
|
|
||||||
--env-file ./backend/.env \
|
|
||||||
git.shts.su/[repository]:latest
|
git.shts.su/[repository]:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Tabler версия
|
## Чек-лист проверки соответствия (S3 -> SQLite)
|
||||||
```bash
|
|
||||||
docker run -d \
|
1. В `backend/package.json` нет зависимостей AWS SDK.
|
||||||
--name s3-lists-manager-tabler \
|
2. Приложение стартует с `SQLITE_PATH` и создает/использует файл БД.
|
||||||
-p 3002:3001 \
|
3. CRUD по основным данным (`/api/domains-new`, `/api/ip-ranges`, `/api/asns`) работает после перезапуска контейнера с тем же volume.
|
||||||
--env-file ./backend/.env \
|
4. Созданный MikroTik backup появляется как локальный `.rsc` файл в каталоге `MIKROTIK_BACKUP_DIR`.
|
||||||
git.shts.su/[repository]:tabler
|
5. После перезапуска контейнера с тем же volume backup-файл остается доступным.
|
||||||
|
6. `GET /api/s3/last-modified` возвращает метаданные, но интерпретируется как legacy endpoint поверх SQLite.
|
||||||
|
|
||||||
|
## Структура репозитория
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/ Express API
|
||||||
|
frontend/ Vite + React UI
|
||||||
```
|
```
|
||||||
|
|
||||||
### Локальная сборка
|
## Лицензия
|
||||||
|
|
||||||
Для сборки образа выполните команду в корневой директории проекта:
|
MIT
|
||||||
```bash
|
|
||||||
docker build -t s3-lists-manager .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск контейнера
|
|
||||||
|
|
||||||
Для запуска контейнера необходимо передать переменные окружения. Это можно сделать с помощью флага `-e` или через `--env-file`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm -p 3001:3001 --env-file ./backend/.env s3-lists-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
После этого приложение будет доступно по адресу `http://localhost:3001`.
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
|
|
||||||
Создайте файл `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
s3-lists-manager:
|
|
||||||
image: git.shts.su/[repository]:latest
|
|
||||||
container_name: s3-lists-manager
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- ./backend/.env
|
|
||||||
|
|
||||||
s3-lists-manager-tabler:
|
|
||||||
image: git.shts.su/[repository]:tabler
|
|
||||||
container_name: s3-lists-manager-tabler
|
|
||||||
ports:
|
|
||||||
- "3002:3001"
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- ./backend/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Запуск:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Подробная документация по Docker образам доступна в файле [DOCKER.md](DOCKER.md).
|
|
||||||
|
|
||||||
## ⚙️ API Endpoints
|
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
|
||||||
|--------|---------------|----------------------------------|
|
|
||||||
| `GET` | `/api/domains`| Получить список всех доменов. |
|
|
||||||
| `POST` | `/api/domains`| Сохранить изменения в `domains.txt`. |
|
|
||||||
| `GET` | `/api/asns` | Получить список всех AS. |
|
|
||||||
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
|
|
||||||
| `GET` | `/api/servers`| Получить список всех серверов. |
|
|
||||||
| `POST` | `/api/servers`| Сохранить изменения в `servers.json`. |
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Краткое описание
|
## Краткое описание
|
||||||
|
|
||||||
Проведена комплексная модернизация пользовательского интерфейса S3 Lists Manager с внедрением современных UX/UI практик, улучшением доступности и мобильного опыта.
|
Проведена комплексная модернизация пользовательского интерфейса RouterOS Manager с внедрением современных UX/UI практик, улучшением доступности и мобильного опыта.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,68 @@
|
|||||||
|
-- Router-lists local storage (SQLite). EvoBGP holds reference lists; this DB holds config + caches.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
version INTEGER NOT NULL UNIQUE,
|
||||||
|
applied_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS blobs (
|
||||||
|
object_key TEXT PRIMARY KEY,
|
||||||
|
namespace TEXT NOT NULL CHECK (namespace IN (
|
||||||
|
'config',
|
||||||
|
'derived',
|
||||||
|
'per_server',
|
||||||
|
'cache'
|
||||||
|
)),
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
body BLOB NOT NULL,
|
||||||
|
byte_size INTEGER NOT NULL,
|
||||||
|
etag TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blobs_namespace_updated
|
||||||
|
ON blobs (namespace, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blobs_prefix
|
||||||
|
ON blobs (object_key);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS blob_versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
object_key TEXT NOT NULL,
|
||||||
|
body BLOB NOT NULL,
|
||||||
|
byte_size INTEGER NOT NULL,
|
||||||
|
etag TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (object_key) REFERENCES blobs(object_key) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blob_versions_key_time
|
||||||
|
ON blob_versions (object_key, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS versioned_keys (
|
||||||
|
object_key TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO versioned_keys (object_key) VALUES
|
||||||
|
('filters.json'),
|
||||||
|
('servers.json');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cache_entries (
|
||||||
|
cache_key TEXT PRIMARY KEY,
|
||||||
|
namespace TEXT NOT NULL DEFAULT 'cache',
|
||||||
|
body BLOB NOT NULL,
|
||||||
|
byte_size INTEGER NOT NULL,
|
||||||
|
etag TEXT,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cache_expires
|
||||||
|
ON cache_entries (expires_at);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Нормализованные глобальные и simple-фильтры (вместо filters.json / simple-filters.json в blobs)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_filter_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scope TEXT NOT NULL CHECK (scope IN ('global', 'simple')),
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
community TEXT NOT NULL,
|
||||||
|
gateway TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
extra_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_app_filter_rules_scope_pos ON app_filter_rules(scope, position);
|
||||||
|
|
||||||
|
DELETE FROM versioned_keys WHERE object_key = 'filters.json';
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Single-process SQLite connection, migrations, PRAGMAs.
|
||||||
|
* Designed so the same API can later move to a dedicated storage service process.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
let _db = null;
|
||||||
|
let _cachePurgeTimer = null;
|
||||||
|
|
||||||
|
function defaultDbPath() {
|
||||||
|
return path.join(__dirname, '..', 'data', 'router-lists.db');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDbPath() {
|
||||||
|
const p = process.env.SQLITE_PATH || defaultDbPath();
|
||||||
|
const dir = path.dirname(p);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the file exists before better-sqlite3 opens it.
|
||||||
|
// This makes container startup resilient when SQLITE_PATH points to
|
||||||
|
// a persistent volume and database file is not created yet.
|
||||||
|
if (!fs.existsSync(p)) {
|
||||||
|
fs.closeSync(fs.openSync(p, 'a'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.accessSync(p, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
|
} catch (err) {
|
||||||
|
const details = err && err.message ? err.message : String(err);
|
||||||
|
throw new Error(`SQLite file is not readable/writable at "${p}": ${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMigration001(db) {
|
||||||
|
const sql = fs.readFileSync(path.join(__dirname, 'migrations', '001_init.sql'), 'utf8');
|
||||||
|
db.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMigration002(db) {
|
||||||
|
const sql = fs.readFileSync(path.join(__dirname, 'migrations', '002_filter_rules.sql'), 'utf8');
|
||||||
|
db.exec(sql);
|
||||||
|
|
||||||
|
const migrateBlobToRules = (objectKey, scope) => {
|
||||||
|
const row = db.prepare('SELECT body FROM blobs WHERE object_key = ?').get(objectKey);
|
||||||
|
if (!row) return;
|
||||||
|
let bodyStr = '';
|
||||||
|
try {
|
||||||
|
bodyStr = Buffer.isBuffer(row.body) ? row.body.toString('utf8') : String(row.body);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let arr = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(bodyStr);
|
||||||
|
arr = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const delRules = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
|
||||||
|
const ins = db.prepare(
|
||||||
|
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
const run = () => {
|
||||||
|
delRules.run(scope);
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
const it = arr[i] || {};
|
||||||
|
const community = String(it.community || '').trim();
|
||||||
|
const gateway = String(it.gateway || '').trim();
|
||||||
|
const description = String(it.description || '').trim();
|
||||||
|
const extra = { ...it };
|
||||||
|
delete extra.community;
|
||||||
|
delete extra.gateway;
|
||||||
|
delete extra.description;
|
||||||
|
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
|
||||||
|
}
|
||||||
|
db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)').run(
|
||||||
|
`filter_rules_mtime_${scope}`,
|
||||||
|
String(now),
|
||||||
|
);
|
||||||
|
db.prepare('DELETE FROM blob_versions WHERE object_key = ?').run(objectKey);
|
||||||
|
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
|
||||||
|
};
|
||||||
|
db.transaction(run)();
|
||||||
|
};
|
||||||
|
|
||||||
|
migrateBlobToRules('filters.json', 'global');
|
||||||
|
migrateBlobToRules('filter-manager/simple-filters.json', 'simple');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {import('better-sqlite3').Database}
|
||||||
|
*/
|
||||||
|
function openDatabase() {
|
||||||
|
if (_db) return _db;
|
||||||
|
|
||||||
|
const filePath = getDbPath();
|
||||||
|
const db = new Database(filePath);
|
||||||
|
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('synchronous = NORMAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
db.pragma(`busy_timeout = ${Number(process.env.SQLITE_BUSY_MS) || 8000}`);
|
||||||
|
const cacheKb = Number(process.env.SQLITE_CACHE_KB);
|
||||||
|
if (Number.isFinite(cacheKb) && cacheKb !== 0) {
|
||||||
|
db.pragma(`cache_size = ${-Math.abs(Math.floor(cacheKb))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userVersion = db.pragma('user_version', { simple: true });
|
||||||
|
if (userVersion < 1) {
|
||||||
|
applyMigration001(db);
|
||||||
|
db.pragma('user_version = 1');
|
||||||
|
const now = Date.now();
|
||||||
|
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(now);
|
||||||
|
userVersion = 1;
|
||||||
|
}
|
||||||
|
if (userVersion < 2) {
|
||||||
|
applyMigration002(db);
|
||||||
|
db.pragma('user_version = 2');
|
||||||
|
const now2 = Date.now();
|
||||||
|
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (2, ?)').run(now2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_db = db;
|
||||||
|
|
||||||
|
if (!_cachePurgeTimer) {
|
||||||
|
const intervalMs = Math.max(60_000, Number(process.env.SQLITE_CACHE_PURGE_MS) || 300_000);
|
||||||
|
_cachePurgeTimer = setInterval(purgeExpiredCacheEntries, intervalMs);
|
||||||
|
_cachePurgeTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function purgeExpiredCacheEntries() {
|
||||||
|
const db = openDatabase();
|
||||||
|
const now = Date.now();
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
openDatabase,
|
||||||
|
getDbPath,
|
||||||
|
purgeExpiredCacheEntries,
|
||||||
|
};
|
||||||
Generated
+409
-1666
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.687.0",
|
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
"better-sqlite3": "^11.6.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.0.1",
|
"dotenv": "^17.0.1",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,38 +1,52 @@
|
|||||||
/**
|
/**
|
||||||
* Роуты для работы с communities (справочник BGP Community)
|
* Роуты для работы с communities (справочник BGP Community) — данные из EvoBGP API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service');
|
const evobgpClient = require('../services/evobgpClient');
|
||||||
|
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
||||||
const { streamToString } = require('../services/s3Service');
|
|
||||||
const validators = require('../lib/validators');
|
const validators = require('../lib/validators');
|
||||||
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
|
||||||
|
|
||||||
const S3_KEY = 'bgp_data/communities.json';
|
function requireEvobgp(res) {
|
||||||
|
if (!evobgpClient.isConfigured()) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
503,
|
||||||
|
'EvoBGP не настроен: задайте EVOBGP_API_URL и EVOBGP_API_TOKEN',
|
||||||
|
'E_EVOBGP_NOT_CONFIGURED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/communities
|
// GET /api/communities
|
||||||
async function getCommunities(req, res) {
|
async function getCommunities(req, res) {
|
||||||
await getS3JsonWithHeaders(S3_KEY, req, res, {
|
const err = requireEvobgp(res);
|
||||||
transform: (communities) => {
|
if (err) return err;
|
||||||
// Нормализация
|
try {
|
||||||
return communities
|
const communities = await evobgpClient.listCommunities();
|
||||||
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
|
const normalized = communities
|
||||||
.map((c) => ({
|
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
|
||||||
value: String(c.value).trim(),
|
.map((c) => ({
|
||||||
name: c.name ? String(c.name) : '',
|
value: String(c.value).trim(),
|
||||||
description: c.description ? String(c.description) : '',
|
name: c.name ? String(c.name) : '',
|
||||||
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
description: c.description ? String(c.description) : '',
|
||||||
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
|
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
||||||
color: c.color ? String(c.color) : ''
|
color: c.color ? String(c.color) : '',
|
||||||
}));
|
icon: c.icon ? String(c.icon) : '',
|
||||||
},
|
}));
|
||||||
defaultValue: []
|
res.json(normalized);
|
||||||
});
|
} catch (e) {
|
||||||
|
console.error('getCommunities', e);
|
||||||
|
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/communities
|
// POST /api/communities
|
||||||
async function postCommunities(req, res) {
|
async function postCommunities(req, res) {
|
||||||
|
const err = requireEvobgp(res);
|
||||||
|
if (err) return err;
|
||||||
|
|
||||||
const { communities } = req.body;
|
const { communities } = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(communities)) {
|
if (!Array.isArray(communities)) {
|
||||||
@@ -68,8 +82,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,
|
||||||
@@ -81,91 +95,72 @@ async function postCommunities(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await s3.send(new PutObjectCommand({
|
await evobgpClient.saveCommunitiesData(normalized);
|
||||||
Bucket: BUCKET_NAME,
|
return sendOk(res, {
|
||||||
Key: S3_KEY,
|
etag: null,
|
||||||
Body: JSON.stringify(normalized, null, 2),
|
lastModified: new Date().toISOString(),
|
||||||
ContentType: 'application/json',
|
contentLength: null,
|
||||||
}));
|
});
|
||||||
invalidateCacheForKey(S3_KEY);
|
|
||||||
const { headMeta } = require('../services/s3Service');
|
|
||||||
const meta = await headMeta(S3_KEY);
|
|
||||||
return sendOk(res, meta);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error writing communities to S3:', error);
|
console.error('Error writing communities to EvoBGP:', error);
|
||||||
return sendError(res, 500, 'Error writing communities to S3', 'E_S3');
|
return sendError(res, 500, error.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countCommunityInLines(rows, getCommunity) {
|
||||||
|
const usage = new Map();
|
||||||
|
for (const row of rows || []) {
|
||||||
|
const c = getCommunity(row);
|
||||||
|
if (c) usage.set(c, (usage.get(c) || 0) + 1);
|
||||||
|
}
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/communities/stats
|
// GET /api/communities/stats
|
||||||
async function getCommunityStats(req, res) {
|
async function getCommunityStats(req, res) {
|
||||||
|
const err = requireEvobgp(res);
|
||||||
|
if (err) return err;
|
||||||
try {
|
try {
|
||||||
const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([
|
const [domains, ipRanges, asns] = await Promise.allSettled([
|
||||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })),
|
evobgpClient.fetchDomainsData(),
|
||||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })),
|
evobgpClient.fetchIpRangesData(),
|
||||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })),
|
evobgpClient.fetchAsnsData(),
|
||||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const communityUsage = new Map();
|
const communityUsage = new Map();
|
||||||
|
|
||||||
// Подсчет использования в доменах
|
if (domains.status === 'fulfilled') {
|
||||||
if (domainsRes.status === 'fulfilled') {
|
const m = countCommunityInLines(domains.value, (r) => r.community);
|
||||||
const text = await streamToString(domainsRes.value.Body);
|
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||||
text.split('\n').filter(Boolean).forEach(line => {
|
|
||||||
const parts = line.trim().split(/\s+/);
|
|
||||||
if (parts[1]) {
|
|
||||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подсчет в IP ranges
|
if (ipRanges.status === 'fulfilled') {
|
||||||
if (ipRangesRes.status === 'fulfilled') {
|
const m = countCommunityInLines(ipRanges.value, (r) => r.community);
|
||||||
const text = await streamToString(ipRangesRes.value.Body);
|
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||||
text.split('\n').filter(Boolean).forEach(line => {
|
|
||||||
const parts = line.trim().split(/\s+/);
|
|
||||||
if (parts[1]) {
|
|
||||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подсчет в ASNs
|
if (asns.status === 'fulfilled') {
|
||||||
if (asnsRes.status === 'fulfilled') {
|
const m = countCommunityInLines(asns.value, (r) => r.community);
|
||||||
const text = await streamToString(asnsRes.value.Body);
|
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||||
text.split('\n').filter(Boolean).forEach(line => {
|
|
||||||
const parts = line.trim().split(/\s+/);
|
|
||||||
if (parts[1]) {
|
|
||||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подсчет в фильтрах
|
try {
|
||||||
if (filtersRes.status === 'fulfilled') {
|
const simpleFilters = filterRulesStorage.listRules('simple');
|
||||||
try {
|
for (const f of simpleFilters) {
|
||||||
const text = await streamToString(filtersRes.value.Body);
|
if (f.community) {
|
||||||
const filters = JSON.parse(text);
|
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
|
||||||
if (Array.isArray(filters)) {
|
|
||||||
filters.forEach(f => {
|
|
||||||
if (f.community) {
|
|
||||||
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {}
|
}
|
||||||
}
|
} catch (_) {}
|
||||||
|
|
||||||
const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({
|
const stats = Array.from(communityUsage.entries())
|
||||||
community,
|
.map(([community, count]) => ({ community, count }))
|
||||||
count,
|
.sort((a, b) => b.count - a.count);
|
||||||
})).sort((a, b) => b.count - a.count);
|
|
||||||
|
|
||||||
res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) });
|
res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting community stats:', error);
|
console.error('Error getting community stats:', error);
|
||||||
return sendError(res, 500, 'Error getting community stats', 'E_S3');
|
return sendError(res, 500, 'Error getting community stats', 'E_EVOBGP');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,4 +169,3 @@ module.exports = {
|
|||||||
postCommunities,
|
postCommunities,
|
||||||
getCommunityStats,
|
getCommunityStats,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* GET/POST для справочников из EvoBGP (ранее textDataRoutes + S3).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const evobgpClient = require('../services/evobgpClient');
|
||||||
|
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
|
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
|
||||||
|
|
||||||
|
function getCountOnlyCache(cacheKey) {
|
||||||
|
const v = countOnlyCache.map.get(cacheKey);
|
||||||
|
if (!v) return null;
|
||||||
|
if (Date.now() > v.at + countOnlyCache.ttlMs) {
|
||||||
|
countOnlyCache.map.delete(cacheKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return v.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCountOnlyCache(cacheKey, value) {
|
||||||
|
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function etagForJson(obj) {
|
||||||
|
const s = JSON.stringify(obj);
|
||||||
|
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
|
||||||
|
return `"${h}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPage(items, q, offset, limit) {
|
||||||
|
const qstr = q ? String(q).toLowerCase() : '';
|
||||||
|
const filtered = !qstr
|
||||||
|
? items
|
||||||
|
: items.filter((it) => JSON.stringify(it).toLowerCase().includes(qstr));
|
||||||
|
const total = filtered.length;
|
||||||
|
if (limit > 0) {
|
||||||
|
return { items: filtered.slice(offset, offset + limit), total };
|
||||||
|
}
|
||||||
|
return { items: filtered, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvobgpListGET({ fetchItems, cachePrefix, validate }) {
|
||||||
|
return async (req, res) => {
|
||||||
|
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
||||||
|
if (!evobgpClient.isConfigured()) {
|
||||||
|
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (countOnly === 'true') {
|
||||||
|
const cacheKey = `${cachePrefix}:count:${q}`;
|
||||||
|
const cached = getCountOnlyCache(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
|
return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
||||||
|
}
|
||||||
|
const all = await fetchItems();
|
||||||
|
const { total } = filterPage(all, q, 0, 0);
|
||||||
|
setCountOnlyCache(cacheKey, total);
|
||||||
|
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(limit) > 0) {
|
||||||
|
const all = await fetchItems();
|
||||||
|
if (!validate(all)) {
|
||||||
|
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||||
|
}
|
||||||
|
const { items, total } = filterPage(all, q, Number(offset) || 0, Number(limit) || 0);
|
||||||
|
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await fetchItems();
|
||||||
|
if (!validate(all)) {
|
||||||
|
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||||
|
}
|
||||||
|
const etag = etagForJson(all);
|
||||||
|
res.set('ETag', etag);
|
||||||
|
res.set('Last-Modified', new Date().toUTCString());
|
||||||
|
res.set('Content-Length-Source', String(Buffer.byteLength(JSON.stringify(all), 'utf8')));
|
||||||
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
if (format === 'std') {
|
||||||
|
return res.json({ items: all, total: all.length, meta: {} });
|
||||||
|
}
|
||||||
|
return res.json(all);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(cachePrefix, e);
|
||||||
|
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIfMatch(req) {
|
||||||
|
const raw = req.headers['if-match'] || req.headers['If-Match'];
|
||||||
|
if (!raw) return null;
|
||||||
|
return String(raw).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvobgpDomainsNewPOST({ validate, validateItem }) {
|
||||||
|
return async (req, res) => {
|
||||||
|
const { domains: items, etag: bodyEtag } = req.body || {};
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
if (!validate(items)) {
|
||||||
|
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||||
|
}
|
||||||
|
if (validateItem) {
|
||||||
|
const validationErrors = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const errors = validateItem(items[i], i);
|
||||||
|
if (errors.length > 0) validationErrors.push(...errors);
|
||||||
|
}
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||||
|
errors: validationErrors.slice(0, 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!evobgpClient.isConfigured()) {
|
||||||
|
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const current = await evobgpClient.fetchDomainsData();
|
||||||
|
const currentEtag = etagForJson(current);
|
||||||
|
const ifMatch = readIfMatch(req);
|
||||||
|
const expected = ifMatch || bodyEtag;
|
||||||
|
if (expected && String(expected).trim() !== currentEtag) {
|
||||||
|
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||||
|
}
|
||||||
|
const normalized = items.map((d) => ({
|
||||||
|
domain: String(d.domain || '').trim().toLowerCase(),
|
||||||
|
community: String(d.community || '').trim(),
|
||||||
|
}));
|
||||||
|
await evobgpClient.saveDomainsData(normalized, current);
|
||||||
|
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvobgpAsnsPOST({ validate, validateItem }) {
|
||||||
|
return async (req, res) => {
|
||||||
|
const { domains: items, etag: bodyEtag } = req.body || {};
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
if (!validate(items)) {
|
||||||
|
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||||
|
}
|
||||||
|
if (validateItem) {
|
||||||
|
const validationErrors = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const errors = validateItem(items[i], i);
|
||||||
|
if (errors.length > 0) validationErrors.push(...errors);
|
||||||
|
}
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||||
|
errors: validationErrors.slice(0, 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!evobgpClient.isConfigured()) {
|
||||||
|
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const current = await evobgpClient.fetchAsnsData();
|
||||||
|
const currentEtag = etagForJson(
|
||||||
|
current.map((a) => ({ domain: a.asn, type: a.community })),
|
||||||
|
);
|
||||||
|
const ifMatch = readIfMatch(req);
|
||||||
|
const expected = ifMatch || bodyEtag;
|
||||||
|
if (expected && String(expected).trim() !== currentEtag) {
|
||||||
|
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||||
|
}
|
||||||
|
const normalized = items.map((a) => ({
|
||||||
|
asn: String(a.domain || a.asn || '').trim(),
|
||||||
|
community: String(a.type || a.community || '').trim(),
|
||||||
|
}));
|
||||||
|
await evobgpClient.saveAsnsData(normalized, current);
|
||||||
|
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvobgpIpRangesPOST({ validate, validateItem }) {
|
||||||
|
return async (req, res) => {
|
||||||
|
const payload = Array.isArray(req.body?.domains)
|
||||||
|
? req.body.domains
|
||||||
|
: Array.isArray(req.body?.ipRanges)
|
||||||
|
? req.body.ipRanges
|
||||||
|
: null;
|
||||||
|
const { etag: bodyEtag } = req.body || {};
|
||||||
|
if (!Array.isArray(payload)) {
|
||||||
|
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
if (!validate(payload)) {
|
||||||
|
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||||
|
}
|
||||||
|
if (validateItem) {
|
||||||
|
const validationErrors = [];
|
||||||
|
for (let i = 0; i < payload.length; i++) {
|
||||||
|
const errors = validateItem(payload[i], i);
|
||||||
|
if (errors.length > 0) validationErrors.push(...errors);
|
||||||
|
}
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||||
|
errors: validationErrors.slice(0, 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!evobgpClient.isConfigured()) {
|
||||||
|
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const current = await evobgpClient.fetchIpRangesData();
|
||||||
|
const currentEtag = etagForJson(current);
|
||||||
|
const ifMatch = readIfMatch(req);
|
||||||
|
const expected = ifMatch || bodyEtag;
|
||||||
|
if (expected && String(expected).trim() !== currentEtag) {
|
||||||
|
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||||
|
}
|
||||||
|
const normalized = payload.map((ip) => ({
|
||||||
|
ipRange: String(ip.ipRange || '').trim(),
|
||||||
|
community: String(ip.community || '').trim(),
|
||||||
|
}));
|
||||||
|
await evobgpClient.saveIpRangesData(normalized, current);
|
||||||
|
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy /api/domains: тот же модуль ASN, ответ в полях domain + type. */
|
||||||
|
function createEvobgpLegacyDomainsGET({ validate }) {
|
||||||
|
return createEvobgpListGET({
|
||||||
|
cachePrefix: 'domains',
|
||||||
|
validate,
|
||||||
|
fetchItems: async () => {
|
||||||
|
const rows = await evobgpClient.fetchAsnsData();
|
||||||
|
return rows.map((r) => ({ domain: r.asn, type: r.community }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createEvobgpListGET,
|
||||||
|
createEvobgpDomainsNewPOST,
|
||||||
|
createEvobgpAsnsPOST,
|
||||||
|
createEvobgpIpRangesPOST,
|
||||||
|
createEvobgpLegacyDomainsGET,
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* GET/POST /api/filters и /api/simple-filters — данные в app_filter_rules, не в blobs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||||
|
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'global'|'simple'} scope
|
||||||
|
* @param {(item: any, i: number) => string|null} validateItem
|
||||||
|
*/
|
||||||
|
function createFilterRulesRoutes(scope, validateItem) {
|
||||||
|
return {
|
||||||
|
get: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const head = filterRulesStorage.headMetaForScope(scope);
|
||||||
|
if (head.etag) res.set('ETag', String(head.etag));
|
||||||
|
if (head.lastModified) {
|
||||||
|
res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
|
}
|
||||||
|
if (typeof head.contentLength === 'number') {
|
||||||
|
res.set('Content-Length-Source', String(head.contentLength));
|
||||||
|
}
|
||||||
|
if (checkIfNoneMatch(req, res, head.etag)) return;
|
||||||
|
res.json(filterRulesStorage.listRules(scope));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('filterRules get', e);
|
||||||
|
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
post: async (req, res) => {
|
||||||
|
const { domains: items } = req.body;
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const meta = filterRulesStorage.replaceRules(scope, items, { validateItem });
|
||||||
|
return sendOk(res, meta);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'E_SCHEMA') {
|
||||||
|
return sendError(res, 400, e.message || 'Validation failed', 'E_SCHEMA');
|
||||||
|
}
|
||||||
|
console.error('filterRules post', e);
|
||||||
|
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createFilterRulesRoutes,
|
||||||
|
};
|
||||||
@@ -2,12 +2,10 @@
|
|||||||
* Роуты для фильтров и генерации конфигураций MikroTik
|
* Роуты для фильтров и генерации конфигураций MikroTik
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service');
|
const { writeS3TextObject } = require('../services/s3Service');
|
||||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
const { streamToString } = require('../services/s3Service');
|
|
||||||
const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
|
const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
|
||||||
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
|
||||||
|
|
||||||
// GET /api/filters/generate-config?format=text|json
|
// GET /api/filters/generate-config?format=text|json
|
||||||
async function generateConfig(req, res) {
|
async function generateConfig(req, res) {
|
||||||
@@ -16,13 +14,16 @@ async function generateConfig(req, res) {
|
|||||||
if (!['text', 'json'].includes(format)) {
|
if (!['text', 'json'].includes(format)) {
|
||||||
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
await getS3JsonWithHeaders('filters.json', req, res, {
|
const head = filterRulesStorage.headMetaForScope('global');
|
||||||
transform: (filters) => {
|
if (head.etag) res.set('ETag', String(head.etag));
|
||||||
const result = generateMikrotikConfig(filters, { format });
|
if (head.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
return format === 'json' ? result : { config: result };
|
if (typeof head.contentLength === 'number') {
|
||||||
},
|
res.set('Content-Length-Source', String(head.contentLength));
|
||||||
defaultValue: [],
|
}
|
||||||
});
|
if (checkIfNoneMatch(req, res, head.etag)) return;
|
||||||
|
const filters = filterRulesStorage.listRules('global');
|
||||||
|
const result = generateMikrotikConfig(filters, { format });
|
||||||
|
res.json(format === 'json' ? result : { config: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return sendError(res, 500, 'Error generating configuration', 'E_S3');
|
return sendError(res, 500, 'Error generating configuration', 'E_S3');
|
||||||
@@ -32,17 +33,7 @@ async function generateConfig(req, res) {
|
|||||||
// POST /api/filters/export-config
|
// POST /api/filters/export-config
|
||||||
async function exportConfig(req, res) {
|
async function exportConfig(req, res) {
|
||||||
try {
|
try {
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' }));
|
const filters = filterRulesStorage.listRules('global');
|
||||||
const fileContent = await streamToString(data.Body);
|
|
||||||
let filters = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
filters = JSON.parse(fileContent);
|
|
||||||
if (!Array.isArray(filters)) filters = [];
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Error parsing filters.json:', parseError);
|
|
||||||
filters = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.length === 0) {
|
if (filters.length === 0) {
|
||||||
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
|
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
|
||||||
@@ -50,10 +41,10 @@ async function exportConfig(req, res) {
|
|||||||
|
|
||||||
const config = generateMikrotikConfig(filters);
|
const config = generateMikrotikConfig(filters);
|
||||||
await writeS3TextObject('mikrotik-frouting-config.txt', config);
|
await writeS3TextObject('mikrotik-frouting-config.txt', config);
|
||||||
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
|
res.json({ success: true, message: 'Конфигурация экспортирована в локальное хранилище' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return sendError(res, 500, 'Error exporting configuration', 'E_S3');
|
return sendError(res, 500, 'Error exporting configuration', 'E_STORAGE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
* Общие роуты для JSON данных (servers, filters, billing и т.д.)
|
* Общие роуты для JSON данных (servers, filters, billing и т.д.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3JsonObject } = require('../services/s3Service');
|
const { writeS3JsonObject, headMeta, readS3TextObject } = require('../services/s3Service');
|
||||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
|
||||||
const { streamToString } = require('../services/s3Service');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создать GET эндпоинт для JSON данных
|
* Создать GET эндпоинт для JSON данных
|
||||||
* @param {string} s3Key - Ключ в S3
|
* @param {string} s3Key - Ключ объекта (логический, как раньше в S3)
|
||||||
* @param {Object} options - Опции
|
* @param {Object} options - Опции
|
||||||
* @param {boolean} options.singleObject - Если true, возвращает объект вместо массива
|
* @param {boolean} options.singleObject - Если true, возвращает объект вместо массива
|
||||||
* @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта)
|
* @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта)
|
||||||
@@ -20,24 +18,31 @@ function createJsonDataGET(s3Key, options = {}) {
|
|||||||
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
const head = await headMeta(s3Key);
|
||||||
const etag = head?.ETag || null;
|
const etag = head?.etag || null;
|
||||||
if (etag) res.set('ETag', String(etag));
|
if (etag) res.set('ETag', String(etag));
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
let fileContent;
|
||||||
const fileContent = await streamToString(data.Body);
|
try {
|
||||||
|
const data = await readS3TextObject(s3Key);
|
||||||
|
fileContent = data.body;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'NoSuchKey') {
|
||||||
|
return res.json(fallback);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
let items = fallback;
|
let items = fallback;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fileContent);
|
const parsed = JSON.parse(fileContent);
|
||||||
if (singleObject) {
|
if (singleObject) {
|
||||||
// Для одиночного объекта
|
items = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : fallback;
|
||||||
items = (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) ? parsed : fallback;
|
|
||||||
} else {
|
} else {
|
||||||
// Для массива
|
|
||||||
items = Array.isArray(parsed) ? parsed : fallback;
|
items = Array.isArray(parsed) ? parsed : fallback;
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
@@ -47,36 +52,26 @@ function createJsonDataGET(s3Key, options = {}) {
|
|||||||
|
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
console.error(error);
|
||||||
res.json(fallback);
|
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создать POST эндпоинт для JSON данных
|
* Создать POST эндпоинт для JSON данных
|
||||||
* @param {string} s3Key - Ключ в S3
|
|
||||||
* @param {Function} validateItem - Функция валидации (для массива - валидирует каждый элемент, для объекта - весь объект)
|
|
||||||
* @param {Object} options - Опции
|
|
||||||
* @param {boolean} options.singleObject - Если true, ожидает объект вместо массива
|
|
||||||
*/
|
*/
|
||||||
function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
||||||
const { singleObject = false } = options;
|
const { singleObject = false } = options;
|
||||||
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
const { domains: items } = req.body; // Используем 'domains' для обратной совместимости
|
const { domains: items } = req.body;
|
||||||
|
|
||||||
if (singleObject) {
|
if (singleObject) {
|
||||||
// Для одиночного объекта
|
|
||||||
if (typeof items !== 'object' || items === null || Array.isArray(items)) {
|
if (typeof items !== 'object' || items === null || Array.isArray(items)) {
|
||||||
return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Валидация объекта целиком
|
|
||||||
if (validateItem) {
|
if (validateItem) {
|
||||||
const error = validateItem(items);
|
const error = validateItem(items);
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -84,12 +79,10 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Для массива
|
|
||||||
if (!Array.isArray(items)) {
|
if (!Array.isArray(items)) {
|
||||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Валидация каждого элемента массива
|
|
||||||
if (validateItem) {
|
if (validateItem) {
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const error = validateItem(items[i], i);
|
const error = validateItem(items[i], i);
|
||||||
@@ -105,23 +98,15 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
|||||||
return sendOk(res, meta);
|
return sendOk(res, meta);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return sendError(res, 500, 'Error writing to S3', 'E_S3');
|
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Создать роуты для JSON данных (GET + POST)
|
|
||||||
* @param {string} s3Key - Ключ в S3
|
|
||||||
* @param {Function} validateItem - Функция валидации
|
|
||||||
* @param {Object} options - Опции
|
|
||||||
* @param {boolean} options.singleObject - Если true, работает с объектом вместо массива
|
|
||||||
* @param {any} options.defaultValue - Значение по умолчанию
|
|
||||||
*/
|
|
||||||
function createJsonDataRoutes(s3Key, validateItem = null, options = {}) {
|
function createJsonDataRoutes(s3Key, validateItem = null, options = {}) {
|
||||||
return {
|
return {
|
||||||
get: createJsonDataGET(s3Key, options),
|
get: createJsonDataGET(s3Key, options),
|
||||||
post: createJsonDataPOST(s3Key, validateItem, options)
|
post: createJsonDataPOST(s3Key, validateItem, options),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,4 +115,3 @@ module.exports = {
|
|||||||
createJsonDataGET,
|
createJsonDataGET,
|
||||||
createJsonDataPOST,
|
createJsonDataPOST,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Роуты для бэкапов конфигурации MikroTik
|
* Роуты для бэкапов конфигурации MikroTik
|
||||||
* Хранят сырый текст конфигурации (например, вывод /export) в S3.
|
* Хранят сырый текст конфигурации (например, вывод /export) на локальной ФС.
|
||||||
*
|
*
|
||||||
* Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
|
* Логические ключи: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
|
||||||
*
|
*
|
||||||
* Задачи:
|
* Задачи:
|
||||||
* - создать бэкап (ручной или из планировщика)
|
* - создать бэкап (ручной или из планировщика)
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service');
|
const { writeBackupFile, readBackupFile, listBackupObjects } = require('../services/backupFsService');
|
||||||
const { readServersFromS3 } = require('./serversRoutes');
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
|
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
|
||||||
const { encrypt, decrypt } = require('../utils/encryption');
|
const { encrypt, decrypt } = require('../utils/encryption');
|
||||||
@@ -61,7 +61,7 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
|
|||||||
|
|
||||||
let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
|
let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
|
||||||
|
|
||||||
// Шифруем содержимое на S3, если задан ENCRYPTION_KEY
|
// Шифруем содержимое файла, если задан ENCRYPTION_KEY
|
||||||
if (process.env.ENCRYPTION_KEY) {
|
if (process.env.ENCRYPTION_KEY) {
|
||||||
try {
|
try {
|
||||||
content = ENCRYPTED_PREFIX + encrypt(content);
|
content = ENCRYPTED_PREFIX + encrypt(content);
|
||||||
@@ -71,15 +71,15 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = await writeS3TextObject(key, content, 'text/plain');
|
const meta = await writeBackupFile(key, content, 'text/plain');
|
||||||
return { key, createdAt: now.toISOString(), s3: meta };
|
return { key, createdAt: now.toISOString(), storage: meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/mikrotik/backups
|
* POST /api/mikrotik/backups
|
||||||
* Body: { serverId: string, config: string, source?: string, comment?: string }
|
* Body: { serverId: string, config: string, source?: string, comment?: string }
|
||||||
*
|
*
|
||||||
* Сохраняет текст конфигурации (обычно вывод /export) в S3.
|
* Сохраняет текст конфигурации (обычно вывод /export) в каталог бэкапов на диске.
|
||||||
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
|
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
|
||||||
*/
|
*/
|
||||||
async function createBackup(req, res) {
|
async function createBackup(req, res) {
|
||||||
@@ -93,14 +93,14 @@ async function createBackup(req, res) {
|
|||||||
return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment });
|
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, { source, comment });
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
key,
|
key,
|
||||||
serverId,
|
serverId,
|
||||||
createdAt,
|
createdAt,
|
||||||
s3,
|
storage,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('createBackup error:', error);
|
console.error('createBackup error:', error);
|
||||||
@@ -120,12 +120,11 @@ async function listBackups(req, res) {
|
|||||||
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`;
|
const safeId = String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
const objects = await listS3Objects(prefix, { maxKeys: 200 });
|
const objects = await listBackupObjects(safeId, 200);
|
||||||
|
|
||||||
const items = objects
|
const items = objects
|
||||||
.filter(o => o.key && o.key.startsWith(prefix))
|
.map((o) => ({
|
||||||
.map(o => ({
|
|
||||||
key: o.key,
|
key: o.key,
|
||||||
serverId,
|
serverId,
|
||||||
createdAt: o.lastModified,
|
createdAt: o.lastModified,
|
||||||
@@ -156,7 +155,7 @@ async function getBackup(req, res) {
|
|||||||
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await readS3TextObject(key);
|
const data = await readBackupFile(key);
|
||||||
const config = decryptBackupContent(data.body || '');
|
const config = decryptBackupContent(data.body || '');
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -191,8 +190,8 @@ async function diffBackups(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [a, b] = await Promise.all([
|
const [a, b] = await Promise.all([
|
||||||
readS3TextObject(keyA),
|
readBackupFile(keyA),
|
||||||
readS3TextObject(keyB),
|
readBackupFile(keyB),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bodyA = decryptBackupContent(a.body || '');
|
const bodyA = decryptBackupContent(a.body || '');
|
||||||
@@ -265,7 +264,7 @@ async function getMikrotikClientForServerId(serverId) {
|
|||||||
* POST /api/mikrotik/backups/run
|
* POST /api/mikrotik/backups/run
|
||||||
* Body: { serverId: string }
|
* Body: { serverId: string }
|
||||||
*
|
*
|
||||||
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3.
|
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат на диск.
|
||||||
*/
|
*/
|
||||||
async function runBackupNow(req, res) {
|
async function runBackupNow(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -293,7 +292,7 @@ async function runBackupNow(req, res) {
|
|||||||
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT');
|
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, {
|
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, {
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
|
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
|
||||||
});
|
});
|
||||||
@@ -303,7 +302,7 @@ async function runBackupNow(req, res) {
|
|||||||
key,
|
key,
|
||||||
serverId,
|
serverId,
|
||||||
createdAt,
|
createdAt,
|
||||||
s3,
|
storage,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
|
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
||||||
const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
|
||||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
const { decrypt } = require('../utils/encryption');
|
const { decrypt } = require('../utils/encryption');
|
||||||
const { readServersFromS3 } = require('./serversRoutes');
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
@@ -15,7 +14,7 @@ const {
|
|||||||
buildMikrotikRecursiveRoutes,
|
buildMikrotikRecursiveRoutes,
|
||||||
getParentGateway,
|
getParentGateway,
|
||||||
} = require('../utils/mikrotikInterfaceGenerator');
|
} = require('../utils/mikrotikInterfaceGenerator');
|
||||||
const { createRosClient, applyBlock, rosPrint, rosAdd, rosRemove } = require('../services/mikrotikApplyService');
|
const { createRosClient, applyBlock, rosPrint, rosAdd, rosSet, rosRemove } = require('../services/mikrotikApplyService');
|
||||||
|
|
||||||
const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
|
const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
|
||||||
const NETWORK_CONFIG_KEY = 'network-config.json';
|
const NETWORK_CONFIG_KEY = 'network-config.json';
|
||||||
@@ -54,12 +53,11 @@ function speedTestCacheKey(serverId, interfaceName) {
|
|||||||
/** Загрузить network-config из S3 */
|
/** Загрузить network-config из S3 */
|
||||||
async function loadNetworkConfig() {
|
async function loadNetworkConfig() {
|
||||||
try {
|
try {
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: NETWORK_CONFIG_KEY }));
|
const data = await readS3TextObject(NETWORK_CONFIG_KEY);
|
||||||
const body = await streamToString(data.Body);
|
const parsed = JSON.parse(data.body || '{}');
|
||||||
const parsed = JSON.parse(body || '{}');
|
|
||||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return {};
|
if (e?.code === 'NoSuchKey') return {};
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,32 +262,44 @@ function updateUptimeCache(serverId, entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/uptime/cache
|
* Получить данные кеша Uptime Monitor (для оповещений и др.).
|
||||||
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек.
|
* Учитывает TTL из ui-settings.uptimeMonitorCacheSeconds.
|
||||||
|
* @returns {Promise<{ results: Record<string, { ok, lastCheckTs, ms }>, updatedAt: number|null }>}
|
||||||
*/
|
*/
|
||||||
async function getUptimeCache(req, res) {
|
async function getUptimeCacheData() {
|
||||||
try {
|
try {
|
||||||
const uiSettings = await loadUiSettings();
|
const uiSettings = await loadUiSettings();
|
||||||
const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120);
|
const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120);
|
||||||
if (ttlSec === 0) {
|
if (ttlSec === 0) return { results: {}, updatedAt: null };
|
||||||
return res.json({ results: {}, updatedAt: null });
|
|
||||||
}
|
|
||||||
const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null);
|
const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null);
|
||||||
if (!data?.body) {
|
if (!data?.body) return { results: {}, updatedAt: null };
|
||||||
return res.json({ results: {}, updatedAt: null });
|
|
||||||
}
|
|
||||||
let cache = { results: {}, updatedAt: null };
|
let cache = { results: {}, updatedAt: null };
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data.body);
|
const parsed = JSON.parse(data.body);
|
||||||
if (parsed && typeof parsed === 'object') cache = parsed;
|
if (parsed && typeof parsed === 'object') cache = parsed;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) {
|
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) return { results: {}, updatedAt: null };
|
||||||
return res.json({ results: {}, updatedAt: null });
|
return {
|
||||||
}
|
|
||||||
return res.json({
|
|
||||||
results: cache.results && typeof cache.results === 'object' ? cache.results : {},
|
results: cache.results && typeof cache.results === 'object' ? cache.results : {},
|
||||||
updatedAt: cache.updatedAt,
|
updatedAt: cache.updatedAt,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[uptime] getUptimeCacheData', e?.message);
|
||||||
|
return { results: {}, updatedAt: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/uptime/cache
|
||||||
|
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек.
|
||||||
|
*/
|
||||||
|
async function getUptimeCache(req, res) {
|
||||||
|
try {
|
||||||
|
const { results, updatedAt } = await getUptimeCacheData();
|
||||||
|
return res.json({
|
||||||
|
results: Object.keys(results).length ? results : {},
|
||||||
|
updatedAt,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[uptime] getUptimeCache', e);
|
console.error('[uptime] getUptimeCache', e);
|
||||||
@@ -1462,7 +1472,368 @@ async function getAddressLists(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOspfInterfaceName(entry) {
|
||||||
|
if (!entry || typeof entry !== 'object') return '';
|
||||||
|
return String(entry.interfaces ?? entry.interface ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOspfCost(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOspfInterface(a, b) {
|
||||||
|
return String(a || '').trim().toUpperCase() === String(b || '').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOspfArea(a, b) {
|
||||||
|
const left = String(a || '').trim().toUpperCase() || 'BACKBONE';
|
||||||
|
const right = String(b || '').trim().toUpperCase() || 'BACKBONE';
|
||||||
|
return left === right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mikrotik/ospf-interface-templates?serverId=
|
||||||
|
* Возвращает текущие OSPF interface-template с HOME роутеров.
|
||||||
|
*/
|
||||||
|
async function getOspfInterfaceTemplates(req, res) {
|
||||||
|
try {
|
||||||
|
const requestedServerId = String(req.query?.serverId || '').trim();
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const homeServers = servers.filter((s) => String(s?.type || '').toLowerCase() === 'home');
|
||||||
|
|
||||||
|
const targets = requestedServerId
|
||||||
|
? homeServers.filter((s) => [s.id, s.ip, s.dns].map((x) => String(x || '')).includes(requestedServerId))
|
||||||
|
: homeServers;
|
||||||
|
|
||||||
|
if (requestedServerId && targets.length === 0) {
|
||||||
|
return sendError(res, 404, 'HOME server not found', 'E_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const server of targets) {
|
||||||
|
const serverId = server.id || server.ip || server.dns;
|
||||||
|
const creds = getMikrotikCredentials(server);
|
||||||
|
if (!creds) {
|
||||||
|
errors.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
error: 'MikroTik credentials not configured for this server',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = createRosClient(creds);
|
||||||
|
const raw = await rosPrint(client, 'routing/ospf/interface-template');
|
||||||
|
const templates = (Array.isArray(raw) ? raw : [])
|
||||||
|
.map((item) => {
|
||||||
|
const interfaceName = normalizeOspfInterfaceName(item);
|
||||||
|
if (!interfaceName) return null;
|
||||||
|
return {
|
||||||
|
id: item['.id'] || null,
|
||||||
|
interfaceName,
|
||||||
|
cost: parseOspfCost(item.cost),
|
||||||
|
area: item.area || '',
|
||||||
|
networks: item.networks || '',
|
||||||
|
networkType: item['type'] || item['network-type'] || '',
|
||||||
|
disabled: String(item.disabled || '').toLowerCase() === 'true',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
templates,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to read OSPF templates',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: errors.length === 0,
|
||||||
|
results,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, 500, error.message || 'Failed to load OSPF templates', 'E_OSPF_LOAD');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/mikrotik/ospf-interface-templates/apply
|
||||||
|
* Body: { servers: [{ serverId, templates: [{ rosId?, interfaceName, area?, cost }] }] }
|
||||||
|
*/
|
||||||
|
async function applyOspfInterfaceTemplates(req, res) {
|
||||||
|
try {
|
||||||
|
const payloadServers = Array.isArray(req.body?.servers) ? req.body.servers : [];
|
||||||
|
if (payloadServers.length === 0) {
|
||||||
|
return sendError(res, 400, 'servers array is required', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
const summary = { updated: 0, skipped: 0, missing: 0, invalid: 0 };
|
||||||
|
|
||||||
|
for (const payload of payloadServers) {
|
||||||
|
const serverRef = String(payload?.serverId || '').trim();
|
||||||
|
const desiredTemplates = Array.isArray(payload?.templates) ? payload.templates : [];
|
||||||
|
const server = servers.find((s) =>
|
||||||
|
[s?.id, s?.ip, s?.dns].map((x) => String(x || '')).includes(serverRef)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!server || String(server.type || '').toLowerCase() !== 'home') {
|
||||||
|
errors.push({ serverId: serverRef, error: 'HOME server not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = server.id || server.ip || server.dns;
|
||||||
|
const creds = getMikrotikCredentials(server);
|
||||||
|
if (!creds) {
|
||||||
|
errors.push({ serverId, error: 'MikroTik credentials not configured for this server' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = createRosClient(creds);
|
||||||
|
const raw = await rosPrint(client, 'routing/ospf/interface-template');
|
||||||
|
const existing = (Array.isArray(raw) ? raw : []).filter((item) => normalizeOspfInterfaceName(item));
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let missing = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
|
||||||
|
for (const desired of desiredTemplates) {
|
||||||
|
const desiredRosId = String(desired?.rosId || '').trim();
|
||||||
|
const interfaceName = String(desired?.interfaceName || '').trim();
|
||||||
|
const desiredArea = String(desired?.area || '').trim();
|
||||||
|
const desiredCost = parseOspfCost(desired?.cost);
|
||||||
|
|
||||||
|
if ((!interfaceName && !desiredRosId) || desiredCost == null) {
|
||||||
|
invalid += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let match = null;
|
||||||
|
if (desiredRosId) {
|
||||||
|
match = existing.find((item) => String(item?.['.id'] || '') === desiredRosId);
|
||||||
|
}
|
||||||
|
if (!match && interfaceName) {
|
||||||
|
match = existing.find((item) => {
|
||||||
|
if (!sameOspfInterface(normalizeOspfInterfaceName(item), interfaceName)) return false;
|
||||||
|
if (!desiredArea) return true;
|
||||||
|
return sameOspfArea(item?.area, desiredArea);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match || !match['.id']) {
|
||||||
|
missing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCost = parseOspfCost(match.cost);
|
||||||
|
if (currentCost === desiredCost) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rosSet(client, 'routing/ospf/interface-template', match['.id'], { cost: String(desiredCost) });
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.updated += updated;
|
||||||
|
summary.skipped += skipped;
|
||||||
|
summary.missing += missing;
|
||||||
|
summary.invalid += invalid;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
missing,
|
||||||
|
invalid,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to apply OSPF templates',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: errors.length === 0,
|
||||||
|
summary,
|
||||||
|
results,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, 500, error.message || 'Failed to apply OSPF templates', 'E_OSPF_APPLY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ADDRESS_LIST_PATH = 'ip/firewall/address-list';
|
const ADDRESS_LIST_PATH = 'ip/firewall/address-list';
|
||||||
|
const ADDRESS_LIST_REMOVE_CHUNK_SIZE = 100;
|
||||||
|
const ADDRESS_LIST_ADD_CONCURRENCY = 8;
|
||||||
|
const ADDRESS_LIST_ADD_EXECUTE_CHUNK_SIZE = 80;
|
||||||
|
|
||||||
|
function chunkArray(items, size) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) return [];
|
||||||
|
const chunkSize = Math.max(1, Number(size) || 1);
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < items.length; i += chunkSize) {
|
||||||
|
chunks.push(items.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление address-list по .id:
|
||||||
|
* 1) пытаемся батчем через POST .../remove { numbers: "*1,*2" }
|
||||||
|
* 2) при ошибке откатываемся к поштучному DELETE, чтобы не ломать совместимость.
|
||||||
|
*/
|
||||||
|
async function removeAddressListIds(client, ids) {
|
||||||
|
const normalizedIds = [...new Set(
|
||||||
|
(Array.isArray(ids) ? ids : [])
|
||||||
|
.map((id) => String(id || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)];
|
||||||
|
if (normalizedIds.length === 0) return 0;
|
||||||
|
|
||||||
|
let removed = 0;
|
||||||
|
const chunks = chunkArray(normalizedIds, ADDRESS_LIST_REMOVE_CHUNK_SIZE);
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
try {
|
||||||
|
await client.command(`${ADDRESS_LIST_PATH}/remove`, { numbers: chunk.join(',') });
|
||||||
|
removed += chunk.length;
|
||||||
|
continue;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[address-lists] batch remove fallback (${chunk.length} ids):`,
|
||||||
|
err?.message || String(err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of chunk) {
|
||||||
|
try {
|
||||||
|
await rosRemove(client, ADDRESS_LIST_PATH, id);
|
||||||
|
removed++;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[address-lists] remove ${id}:`, err?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAddressListEntries(entries) {
|
||||||
|
return [...new Map(
|
||||||
|
(Array.isArray(entries) ? entries : [])
|
||||||
|
.map((entry) => {
|
||||||
|
const address = String(entry?.address || '').trim();
|
||||||
|
const list = String(entry?.list || 'ban').trim();
|
||||||
|
const comment = entry?.comment != null ? String(entry.comment).trim() : '';
|
||||||
|
if (!address || !list) return null;
|
||||||
|
const params = { address, list };
|
||||||
|
if (comment) params.comment = comment;
|
||||||
|
const key = `${list}|${address}|${comment}`;
|
||||||
|
return [key, params];
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
).values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRouterOsString(value) {
|
||||||
|
return `"${String(value || '')
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAddressListAddScript(entriesChunk) {
|
||||||
|
return entriesChunk
|
||||||
|
.map((params) => {
|
||||||
|
const parts = [
|
||||||
|
'/ip/firewall/address-list/add',
|
||||||
|
`address=${escapeRouterOsString(params.address)}`,
|
||||||
|
`list=${escapeRouterOsString(params.list)}`,
|
||||||
|
];
|
||||||
|
if (params.comment != null && String(params.comment).trim()) {
|
||||||
|
parts.push(`comment=${escapeRouterOsString(params.comment)}`);
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
})
|
||||||
|
.join(';\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAddressListEntriesParallel(client, normalized) {
|
||||||
|
if (!Array.isArray(normalized) || normalized.length === 0) return { added: 0, failed: 0 };
|
||||||
|
let index = 0;
|
||||||
|
let added = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const workersCount = Math.min(ADDRESS_LIST_ADD_CONCURRENCY, normalized.length);
|
||||||
|
const workers = Array.from({ length: workersCount }, async () => {
|
||||||
|
while (true) {
|
||||||
|
const current = index;
|
||||||
|
index += 1;
|
||||||
|
if (current >= normalized.length) break;
|
||||||
|
const params = normalized[current];
|
||||||
|
try {
|
||||||
|
await rosAdd(client, ADDRESS_LIST_PATH, params);
|
||||||
|
added++;
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
console.warn('[address-lists] add:', err?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return { added, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAddressListEntries(client, entries) {
|
||||||
|
const normalized = normalizeAddressListEntries(entries);
|
||||||
|
if (normalized.length === 0) return { added: 0, failed: 0 };
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const chunks = chunkArray(normalized, ADDRESS_LIST_ADD_EXECUTE_CHUNK_SIZE);
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const script = buildAddressListAddScript(chunk);
|
||||||
|
try {
|
||||||
|
// Один execute на пачку резко снижает HTTP overhead.
|
||||||
|
await client.command('execute', { script });
|
||||||
|
added += chunk.length;
|
||||||
|
continue;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[address-lists] execute add fallback (${chunk.length} entries):`,
|
||||||
|
err?.message || String(err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackResult = await addAddressListEntriesParallel(client, chunk);
|
||||||
|
added += fallbackResult.added;
|
||||||
|
failed += fallbackResult.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added, failed };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/mikrotik/address-lists/apply-summary
|
* POST /api/mikrotik/address-lists/apply-summary
|
||||||
@@ -1475,7 +1846,9 @@ async function applyAddressListSummary(req, res) {
|
|||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
const ids = Array.isArray(removeIds) ? removeIds.filter((id) => id != null && String(id).trim()) : [];
|
const ids = Array.isArray(removeIds)
|
||||||
|
? [...new Set(removeIds.map((id) => String(id || '').trim()).filter(Boolean))]
|
||||||
|
: [];
|
||||||
const entries = Array.isArray(addEntries)
|
const entries = Array.isArray(addEntries)
|
||||||
? addEntries.filter((e) => e && (e.address || e.list))
|
? addEntries.filter((e) => e && (e.address || e.list))
|
||||||
: [];
|
: [];
|
||||||
@@ -1495,32 +1868,10 @@ async function applyAddressListSummary(req, res) {
|
|||||||
let removed = 0;
|
let removed = 0;
|
||||||
let added = 0;
|
let added = 0;
|
||||||
|
|
||||||
for (const id of ids) {
|
removed = await removeAddressListIds(client, ids);
|
||||||
try {
|
|
||||||
const pathId = String(id).trim();
|
|
||||||
await rosRemove(client, ADDRESS_LIST_PATH, pathId);
|
|
||||||
removed++;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[address-lists] remove ${id}:`, err?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
const addResult = await addAddressListEntries(client, entries);
|
||||||
try {
|
added = addResult.added;
|
||||||
const params = {
|
|
||||||
address: String(entry.address || '').trim(),
|
|
||||||
list: String(entry.list || 'ban').trim(),
|
|
||||||
};
|
|
||||||
if (entry.comment != null && String(entry.comment).trim()) {
|
|
||||||
params.comment = String(entry.comment).trim();
|
|
||||||
}
|
|
||||||
if (!params.address || !params.list) continue;
|
|
||||||
await rosAdd(client, ADDRESS_LIST_PATH, params);
|
|
||||||
added++;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[address-lists] add:', err?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({ ok: true, removed, added });
|
return res.json({ ok: true, removed, added });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1544,7 +1895,10 @@ module.exports = {
|
|||||||
speedTestViaTunnel,
|
speedTestViaTunnel,
|
||||||
loadNetworkConfig,
|
loadNetworkConfig,
|
||||||
getAddressLists,
|
getAddressLists,
|
||||||
|
getOspfInterfaceTemplates,
|
||||||
|
applyOspfInterfaceTemplates,
|
||||||
applyAddressListSummary,
|
applyAddressListSummary,
|
||||||
uptimeCheck,
|
uptimeCheck,
|
||||||
getUptimeCache,
|
getUptimeCache,
|
||||||
|
getUptimeCacheData,
|
||||||
};
|
};
|
||||||
|
|||||||
+131
-156
@@ -2,10 +2,18 @@
|
|||||||
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
|
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, streamToString } = require('../services/s3Service');
|
const {
|
||||||
|
writeS3TextObject,
|
||||||
|
writeS3JsonObject,
|
||||||
|
readS3TextObject,
|
||||||
|
headMeta,
|
||||||
|
listBlobVersionsForKey,
|
||||||
|
rollbackBlobVersion,
|
||||||
|
} = require('../services/s3Service');
|
||||||
|
const evobgpClient = require('../services/evobgpClient');
|
||||||
|
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||||
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 validators = require('../lib/validators');
|
const validators = require('../lib/validators');
|
||||||
const net = require('net');
|
const net = require('net');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
@@ -19,66 +27,97 @@ async function getS3LastModified(req, res) {
|
|||||||
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
|
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
|
||||||
{ name: 'asns', key: 'bgp_data/asns.txt' },
|
{ name: 'asns', key: 'bgp_data/asns.txt' },
|
||||||
{ name: 'servers', key: 'servers.json' },
|
{ name: 'servers', key: 'servers.json' },
|
||||||
{ name: 'filters', key: 'filters.json' },
|
{ name: 'filters', key: null },
|
||||||
{ name: 'ipRanges', key: 'bgp_data/ips.txt' },
|
{ name: 'ipRanges', key: 'bgp_data/ips.txt' },
|
||||||
{ name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' }
|
{ name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
keys.map(k => headMeta(k.key))
|
|
||||||
);
|
|
||||||
|
|
||||||
const out = {};
|
const out = {};
|
||||||
results.forEach((r, idx) => {
|
for (const k of keys) {
|
||||||
const name = keys[idx].name;
|
if (k.name === 'filters') {
|
||||||
if (r.status === 'fulfilled' && r.value) {
|
try {
|
||||||
out[name] = r.value;
|
const head = filterRulesStorage.headMetaForScope('global');
|
||||||
} else {
|
out.filters = {
|
||||||
out[name] = null;
|
etag: head.etag,
|
||||||
|
lastModified: head.lastModified,
|
||||||
|
contentLength: head.contentLength,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
out.filters = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
});
|
if (['domainsNew', 'asns', 'ipRanges'].includes(k.name) && evobgpClient.isConfigured()) {
|
||||||
|
try {
|
||||||
|
await evobgpClient.fetchRouterListsCatalog();
|
||||||
|
out[k.name] = { etag: null, lastModified: new Date().toISOString(), contentLength: null };
|
||||||
|
} catch {
|
||||||
|
out[k.name] = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[k.name] = await headMeta(k.key).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
res.json(out);
|
res.json(out);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching last modified dates from S3:', error);
|
console.error('Error fetching last modified dates:', error);
|
||||||
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3');
|
return sendError(res, 500, 'Error fetching last modified dates', 'E_STORAGE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EVOBGP_ONLY_HISTORY_RESOURCES = new Set(['domains-new', 'asns', 'ip-ranges', 'filters']);
|
||||||
|
|
||||||
// GET /api/history/:resource
|
// GET /api/history/:resource
|
||||||
async function getHistory(req, res) {
|
async function getHistory(req, res) {
|
||||||
const { resource } = req.params;
|
const { resource } = req.params;
|
||||||
const { countOnly, format } = req.query || {};
|
const { countOnly, format } = req.query || {};
|
||||||
|
if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) {
|
||||||
|
const note =
|
||||||
|
resource === 'filters'
|
||||||
|
? 'Фильтры в SQL-таблице app_filter_rules; локальной истории отката через этот API нет'
|
||||||
|
: 'Версии хранятся в EvoBGP, локальной истории нет';
|
||||||
|
if (countOnly === 'true') {
|
||||||
|
return res.json(format === 'std' ? { items: [], total: 0, meta: { noBlobHistory: true } } : { total: 0 });
|
||||||
|
}
|
||||||
|
return res.json(
|
||||||
|
format === 'std'
|
||||||
|
? { items: [], total: 0, meta: { noBlobHistory: true, note } }
|
||||||
|
: { items: [], total: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const key = resourceToKey(resource);
|
const key = resourceToKey(resource);
|
||||||
|
|
||||||
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
|
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = await s3.send(new ListObjectVersionsCommand({
|
const rows = listBlobVersionsForKey(key, 50);
|
||||||
Bucket: BUCKET_NAME,
|
const versionsAll = rows.map((r) => ({
|
||||||
Prefix: key,
|
id: r.id,
|
||||||
MaxKeys: 50
|
object_key: r.object_key,
|
||||||
|
byte_size: r.byte_size,
|
||||||
|
etag: r.etag,
|
||||||
|
created_at: r.created_at,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const versionsAll = (out.Versions || []).filter(v => v.Key === key);
|
|
||||||
|
|
||||||
if (countOnly === 'true') {
|
if (countOnly === 'true') {
|
||||||
const total = versionsAll.length;
|
const total = versionsAll.length;
|
||||||
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
||||||
}
|
}
|
||||||
|
|
||||||
const versions = versionsAll.slice(0, 10).map(v => ({
|
const versions = versionsAll.slice(0, 10).map((v, idx) => ({
|
||||||
versionId: v.VersionId,
|
versionId: String(v.id),
|
||||||
isLatest: v.IsLatest,
|
isLatest: idx === 0,
|
||||||
lastModified: toIso(v.LastModified),
|
lastModified: toIso(v.created_at),
|
||||||
size: v.Size,
|
size: v.byte_size,
|
||||||
etag: v.ETag
|
etag: v.etag,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
|
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('history error', e);
|
console.error('history error', e);
|
||||||
return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) });
|
return sendError(res, 500, 'Error reading history', 'E_STORAGE', { error: String(e?.message || e) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +125,14 @@ async function getHistory(req, res) {
|
|||||||
async function postRollback(req, res) {
|
async function postRollback(req, res) {
|
||||||
const { resource } = req.params;
|
const { resource } = req.params;
|
||||||
const { versionId } = req.body || {};
|
const { versionId } = req.body || {};
|
||||||
|
if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) {
|
||||||
|
const msg =
|
||||||
|
resource === 'filters'
|
||||||
|
? 'Откат версий для фильтров не поддерживается (данные в SQL, не в blob_versions)'
|
||||||
|
: 'Откат версий для этого справочника не поддерживается (данные в EvoBGP)';
|
||||||
|
return sendError(res, 400, msg, resource === 'filters' ? 'E_HISTORY_FILTERS_SQL' : 'E_HISTORY_EV');
|
||||||
|
}
|
||||||
|
|
||||||
const key = resourceToKey(resource);
|
const key = resourceToKey(resource);
|
||||||
|
|
||||||
if (!key || !versionId) {
|
if (!key || !versionId) {
|
||||||
@@ -93,118 +140,61 @@ async function postRollback(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await s3.send(new CopyObjectCommand({
|
rollbackBlobVersion(key, versionId);
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`,
|
|
||||||
Key: key
|
|
||||||
}));
|
|
||||||
|
|
||||||
const meta = await headMeta(key);
|
const meta = await headMeta(key);
|
||||||
return sendOk(res, meta);
|
return sendOk(res, meta);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('rollback error', e);
|
console.error('rollback error', e);
|
||||||
return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) });
|
return sendError(res, 500, 'Error rollback', 'E_STORAGE', { error: String(e?.message || e) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/auto-urls
|
// GET /api/auto-urls
|
||||||
async function getAutoUrls(req, res) {
|
async function getAutoUrls(req, res) {
|
||||||
const { checkIfNoneMatch } = require('../middleware/errorHandler');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { HeadObjectCommand } = require('@aws-sdk/client-s3');
|
if (!evobgpClient.isConfigured()) {
|
||||||
const head = await s3.send(new HeadObjectCommand({
|
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: 'bgp_data/auto_url/urls.txt'
|
|
||||||
})).catch(() => null);
|
|
||||||
|
|
||||||
const etag = head?.ETag || null;
|
|
||||||
if (etag) res.set('ETag', String(etag));
|
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: 'bgp_data/auto_url/urls.txt'
|
|
||||||
}));
|
|
||||||
const fileContent = await streamToString(data.Body);
|
|
||||||
const urls = fileContent.split('\n').filter(line => line).map(line => {
|
|
||||||
const parts = line.trim().split(/\s+/);
|
|
||||||
return { url: parts[0] || '', community: parts[1] || '' };
|
|
||||||
});
|
|
||||||
res.json(urls);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
|
||||||
res.json([]);
|
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3');
|
|
||||||
}
|
}
|
||||||
|
const rows = await evobgpClient.fetchAutoUrlsData();
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return sendError(res, 500, 'Error reading auto URLs', 'E_EVOBGP');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/auto-urls
|
// POST /api/auto-urls
|
||||||
async function postAutoUrls(req, res) {
|
async function postAutoUrls(req, res) {
|
||||||
const { urls } = req.body;
|
const { urls } = req.body;
|
||||||
const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n');
|
if (!Array.isArray(urls)) {
|
||||||
|
return sendError(res, 400, 'urls must be an array', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent);
|
if (!evobgpClient.isConfigured()) {
|
||||||
return sendOk(res, meta);
|
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||||
|
}
|
||||||
|
await evobgpClient.saveAutoUrlsData(urls);
|
||||||
|
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3');
|
return sendError(res, 500, 'Error writing auto URLs', 'E_EVOBGP');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/auto-urls/process
|
// POST /api/auto-urls/process
|
||||||
async function processAutoUrls(req, res) {
|
async function processAutoUrls(req, res) {
|
||||||
try {
|
try {
|
||||||
// Загрузка URL списков
|
if (!evobgpClient.isConfigured()) {
|
||||||
let urls = [];
|
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||||
try {
|
|
||||||
const urlsData = await s3.send(new GetObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: 'bgp_data/auto_url/urls.txt'
|
|
||||||
}));
|
|
||||||
const urlsContent = await streamToString(urlsData.Body);
|
|
||||||
urls = urlsContent.split('\n').filter(line => line).map(line => {
|
|
||||||
const parts = line.trim().split(/\s+/);
|
|
||||||
return { url: parts[0] || '', community: parts[1] || '' };
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'NoSuchKey') throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urls = await evobgpClient.fetchAutoUrlsData();
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка текущих IP и доменов
|
const currentIps = await evobgpClient.fetchIpRangesData();
|
||||||
let currentIps = [];
|
const currentDomains = await evobgpClient.fetchDomainsData();
|
||||||
try {
|
|
||||||
const ipsData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' }));
|
|
||||||
const ipsContent = await streamToString(ipsData.Body);
|
|
||||||
currentIps = ipsContent.split('\n').filter(line => line).map(line => {
|
|
||||||
const parts = line.trim().split(/\s+/);
|
|
||||||
return { ipRange: parts[0] || '', community: parts[1] || '' };
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'NoSuchKey') throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentDomains = [];
|
|
||||||
try {
|
|
||||||
const dData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }));
|
|
||||||
const dContent = await streamToString(dData.Body);
|
|
||||||
currentDomains = dContent.split('\n').filter(line => line).map(line => {
|
|
||||||
const parts = line.trim().split(/\s+/);
|
|
||||||
return { domain: (parts[0] || '').toLowerCase(), community: parts[1] || '' };
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'NoSuchKey') throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка URL
|
// Обработка URL
|
||||||
const newIps = [];
|
const newIps = [];
|
||||||
@@ -256,22 +246,8 @@ async function processAutoUrls(req, res) {
|
|||||||
const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain));
|
const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain));
|
||||||
const allDomains = [...currentDomains, ...uniqueNewDomains];
|
const allDomains = [...currentDomains, ...uniqueNewDomains];
|
||||||
|
|
||||||
// Сохранение
|
await evobgpClient.saveIpRangesData(allIps, currentIps);
|
||||||
const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n');
|
await evobgpClient.saveDomainsData(allDomains, currentDomains);
|
||||||
await s3.send(new PutObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: 'bgp_data/ips.txt',
|
|
||||||
Body: updatedIpsContent,
|
|
||||||
ContentType: 'text/plain'
|
|
||||||
}));
|
|
||||||
|
|
||||||
const updatedDomainsContent = allDomains.map(d => `${d.domain} ${d.community}`).join('\n');
|
|
||||||
await s3.send(new PutObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: 'bgp_data/domains_community.txt',
|
|
||||||
Body: updatedDomainsContent,
|
|
||||||
ContentType: 'text/plain'
|
|
||||||
}));
|
|
||||||
|
|
||||||
const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
|
const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -285,7 +261,7 @@ async function processAutoUrls(req, res) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing auto URLs:', error);
|
console.error('Error processing auto URLs:', error);
|
||||||
return sendError(res, 500, 'Error processing auto URLs', 'E_S3');
|
return sendError(res, 500, 'Error processing auto URLs', 'E_EVOBGP');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,9 +333,8 @@ function getPingServicesListFromSettings(uiSettings) {
|
|||||||
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
|
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
|
||||||
async function loadUiSettingsSync() {
|
async function loadUiSettingsSync() {
|
||||||
try {
|
try {
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' }));
|
const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
|
||||||
const jsonText = await streamToString(data.Body);
|
const parsed = JSON.parse(data.body || '{}');
|
||||||
const parsed = JSON.parse(jsonText || '{}');
|
|
||||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
@@ -598,10 +573,10 @@ async function getServersAvailability(req, res) {
|
|||||||
return res.json({ ...availabilityCache.data, cached: true });
|
return res.json({ ...availabilityCache.data, cached: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' }));
|
|
||||||
let servers = [];
|
let servers = [];
|
||||||
try {
|
try {
|
||||||
servers = JSON.parse(await streamToString(data.Body));
|
const data = await readS3TextObject('servers.json');
|
||||||
|
servers = JSON.parse(data.body);
|
||||||
if (!Array.isArray(servers)) servers = [];
|
if (!Array.isArray(servers)) servers = [];
|
||||||
} catch {
|
} catch {
|
||||||
servers = [];
|
servers = [];
|
||||||
@@ -683,12 +658,13 @@ async function updateBgpBackground(req, res) {
|
|||||||
// GET /api/ws/url
|
// GET /api/ws/url
|
||||||
async function getWsUrl(req, res) {
|
async function getWsUrl(req, res) {
|
||||||
try {
|
try {
|
||||||
const settings = await s3.send(new GetObjectCommand({
|
let settings = {};
|
||||||
Bucket: BUCKET_NAME,
|
try {
|
||||||
Key: 'bgp_data/rt_ui_settings.json'
|
const d = await readS3TextObject('bgp_data/rt_ui_settings.json');
|
||||||
})).then(async (d) => {
|
settings = JSON.parse(d.body || '{}');
|
||||||
try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; }
|
} catch (_) {
|
||||||
}).catch(() => ({}));
|
settings = {};
|
||||||
|
}
|
||||||
|
|
||||||
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
|
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
|
||||||
return res.json({ url });
|
return res.json({ url });
|
||||||
@@ -702,23 +678,20 @@ async function getUiSettings(req, res) {
|
|||||||
const { checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { HeadObjectCommand } = require('@aws-sdk/client-s3');
|
const head = await headMeta('bgp_data/rt_ui_settings.json');
|
||||||
const head = await s3.send(new HeadObjectCommand({
|
const etag = head?.etag || null;
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: 'bgp_data/rt_ui_settings.json'
|
|
||||||
})).catch(() => null);
|
|
||||||
|
|
||||||
const etag = head?.ETag || null;
|
|
||||||
if (etag) res.set('ETag', String(etag));
|
if (etag) res.set('ETag', String(etag));
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({
|
let jsonText = '{}';
|
||||||
Bucket: BUCKET_NAME,
|
try {
|
||||||
Key: 'bgp_data/rt_ui_settings.json'
|
const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
|
||||||
}));
|
jsonText = data.body;
|
||||||
const jsonText = await streamToString(data.Body);
|
} catch (e) {
|
||||||
|
if (e?.code !== 'NoSuchKey') throw e;
|
||||||
|
}
|
||||||
let settings = {};
|
let settings = {};
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(jsonText);
|
const parsed = JSON.parse(jsonText);
|
||||||
@@ -728,11 +701,11 @@ async function getUiSettings(req, res) {
|
|||||||
}
|
}
|
||||||
return res.json(settings);
|
return res.json(settings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
if (error?.code === 'NoSuchKey') {
|
||||||
return res.json({});
|
return res.json({});
|
||||||
}
|
}
|
||||||
console.error('Error reading ui settings from S3:', error);
|
console.error('Error reading ui settings:', error);
|
||||||
return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3');
|
return sendError(res, 500, 'Error reading UI settings', 'E_STORAGE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,8 +729,8 @@ async function postUiSettings(req, res) {
|
|||||||
const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload);
|
const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload);
|
||||||
return sendOk(res, meta);
|
return sendOk(res, meta);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error writing UI settings to S3:', error);
|
console.error('Error writing UI settings:', error);
|
||||||
return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) });
|
return sendError(res, 500, 'Error writing UI settings', 'E_STORAGE', { error: String(error?.message || error) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,5 +749,7 @@ module.exports = {
|
|||||||
getPingServicesList,
|
getPingServicesList,
|
||||||
getPingServices,
|
getPingServices,
|
||||||
refreshPingServicesCache,
|
refreshPingServicesCache,
|
||||||
|
checkOneServerFast,
|
||||||
|
loadUiSettingsSync,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -74,13 +74,11 @@ function parseResource(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/resources/stats
|
* Внутренняя функция: возвращает данные по ресурсам роутеров (без HTTP).
|
||||||
* По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам.
|
|
||||||
*/
|
*/
|
||||||
async function getResourceStats(req, res) {
|
async function getResourceStatsData() {
|
||||||
try {
|
const servers = await readServersFromS3();
|
||||||
const servers = await readServersFromS3();
|
const routers = (Array.isArray(servers) ? servers : []).filter(
|
||||||
const routers = (Array.isArray(servers) ? servers : []).filter(
|
|
||||||
(s) =>
|
(s) =>
|
||||||
s &&
|
s &&
|
||||||
(String(s.type || '').toLowerCase() === 'jumphost' ||
|
(String(s.type || '').toLowerCase() === 'jumphost' ||
|
||||||
@@ -132,7 +130,17 @@ async function getResourceStats(req, res) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.json({ routers: results });
|
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) {
|
} catch (error) {
|
||||||
console.error('getResourceStats:', error);
|
console.error('getResourceStats:', error);
|
||||||
return sendError(
|
return sendError(
|
||||||
@@ -146,4 +154,5 @@ async function getResourceStats(req, res) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getResourceStats,
|
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 evobgpClient = require('../services/evobgpClient');
|
||||||
|
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 {
|
||||||
|
if (!evobgpClient.isConfigured()) return new Map();
|
||||||
|
const arr = await evobgpClient.listCommunities();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -5,10 +5,8 @@
|
|||||||
* Это нужно для EasySwitch, который работает только с jumphost серверами
|
* Это нужно для EasySwitch, который работает только с jumphost серверами
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject } = require('../services/s3Service');
|
const { writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject, readS3TextObject } = require('../services/s3Service');
|
||||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
|
||||||
const { streamToString } = require('../services/s3Service');
|
|
||||||
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||||
const { isValidServerId } = require('../lib/validators');
|
const { isValidServerId } = require('../lib/validators');
|
||||||
|
|
||||||
@@ -41,15 +39,20 @@ function validateJumphostServer(server, i) {
|
|||||||
// GET /api/server-configs (список только jumphost серверов)
|
// GET /api/server-configs (список только jumphost серверов)
|
||||||
async function getServerConfigsList(req, res) {
|
async function getServerConfigsList(req, res) {
|
||||||
try {
|
try {
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
const head = await headMeta(S3_KEY);
|
||||||
const etag = head?.ETag || null;
|
const etag = head?.etag || null;
|
||||||
if (etag) res.set('ETag', String(etag));
|
if (etag) res.set('ETag', String(etag));
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
let fileContent = '[]';
|
||||||
const fileContent = await streamToString(data.Body);
|
try {
|
||||||
|
const data = await readS3TextObject(S3_KEY);
|
||||||
|
fileContent = data.body;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code !== 'NoSuchKey') throw e;
|
||||||
|
}
|
||||||
let allServers = [];
|
let allServers = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -113,12 +116,10 @@ async function postServerConfigsList(req, res) {
|
|||||||
// Читаем текущие данные
|
// Читаем текущие данные
|
||||||
let allServers = [];
|
let allServers = [];
|
||||||
try {
|
try {
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
const data = await readS3TextObject(S3_KEY);
|
||||||
const fileContent = await streamToString(data.Body);
|
allServers = JSON.parse(data.body);
|
||||||
allServers = JSON.parse(fileContent);
|
|
||||||
if (!Array.isArray(allServers)) allServers = [];
|
if (!Array.isArray(allServers)) allServers = [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Файл не существует или пустой - это нормально
|
|
||||||
allServers = [];
|
allServers = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
|
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
const { readS3TextObject, writeS3JsonObject, headMeta } = require('../services/s3Service');
|
||||||
const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service');
|
|
||||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
const { encrypt, decrypt } = require('../utils/encryption');
|
const { encrypt, decrypt } = require('../utils/encryption');
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ function validateServer(server, i) {
|
|||||||
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
|
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
|
||||||
return `Server at index ${i} must have gateways for type ${normalizedType}`;
|
return `Server at index ${i} must have gateways for type ${normalizedType}`;
|
||||||
}
|
}
|
||||||
const primaries = server.gateways.filter(g => g && g.primary);
|
const primaries = server.gateways.filter((g) => g && g.primary);
|
||||||
if (primaries.length !== 1) {
|
if (primaries.length !== 1) {
|
||||||
return `Server at index ${i} must have exactly one primary gateway`;
|
return `Server at index ${i} must have exactly one primary gateway`;
|
||||||
}
|
}
|
||||||
@@ -45,24 +44,17 @@ function validateServer(server, i) {
|
|||||||
|
|
||||||
async function readServersFromS3() {
|
async function readServersFromS3() {
|
||||||
try {
|
try {
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
const data = await readS3TextObject(S3_KEY);
|
||||||
const body = await streamToString(data.Body);
|
const parsed = JSON.parse(data.body || '[]');
|
||||||
const parsed = JSON.parse(body || '[]');
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return [];
|
if (e?.code === 'NoSuchKey') return [];
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeServersToS3(servers) {
|
async function writeServersToS3(servers) {
|
||||||
const body = JSON.stringify(servers, null, 2);
|
await writeS3JsonObject(S3_KEY, servers);
|
||||||
await s3.send(new PutObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: S3_KEY,
|
|
||||||
Body: body,
|
|
||||||
ContentType: 'application/json',
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,15 +62,15 @@ async function writeServersToS3(servers) {
|
|||||||
*/
|
*/
|
||||||
async function getServers(req, res) {
|
async function getServers(req, res) {
|
||||||
try {
|
try {
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
const head = await headMeta(S3_KEY);
|
||||||
const etag = head?.ETag || null;
|
const etag = head?.etag || null;
|
||||||
if (etag) res.set('ETag', String(etag));
|
if (etag) res.set('ETag', String(etag));
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
const servers = await readServersFromS3();
|
const servers = await readServersFromS3();
|
||||||
const sanitized = servers.map(s => {
|
const sanitized = servers.map((s) => {
|
||||||
const out = { ...s };
|
const out = { ...s };
|
||||||
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
|
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
|
||||||
if (hasMikrotikApi && out.encryptedMikrotikPassword) {
|
if (hasMikrotikApi && out.encryptedMikrotikPassword) {
|
||||||
@@ -91,11 +83,11 @@ async function getServers(req, res) {
|
|||||||
});
|
});
|
||||||
res.json(sanitized);
|
res.json(sanitized);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
if (error?.code === 'NoSuchKey') {
|
||||||
return res.json([]);
|
return res.json([]);
|
||||||
}
|
}
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return sendError(res, 500, 'Error reading servers', 'E_S3');
|
return sendError(res, 500, 'Error reading servers', 'E_STORAGE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +107,10 @@ async function postServers(req, res) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentServers = await readServersFromS3();
|
const currentServers = await readServersFromS3();
|
||||||
const findCurrent = (srv) => currentServers.find(c =>
|
const findCurrent = (srv) =>
|
||||||
(srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip)
|
currentServers.find(
|
||||||
);
|
(c) => (srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip),
|
||||||
|
);
|
||||||
|
|
||||||
const toSave = items.map((srv) => {
|
const toSave = items.map((srv) => {
|
||||||
const current = findCurrent(srv);
|
const current = findCurrent(srv);
|
||||||
@@ -140,15 +133,15 @@ async function postServers(req, res) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await writeServersToS3(toSave);
|
await writeServersToS3(toSave);
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
const meta = await headMeta(S3_KEY);
|
||||||
return sendOk(res, {
|
return sendOk(res, {
|
||||||
etag: head?.ETag || null,
|
etag: meta?.etag || null,
|
||||||
lastModified: head?.LastModified ? head.LastModified.toISOString() : null,
|
lastModified: meta?.lastModified || null,
|
||||||
contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null,
|
contentLength: typeof meta?.contentLength === 'number' ? meta.contentLength : null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return sendError(res, 500, error.message || 'Error saving servers', 'E_S3');
|
return sendError(res, 500, error.message || 'Error saving servers', 'E_STORAGE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Общие роуты для текстовых данных (domains, asns, ip-ranges)
|
* Общие роуты для текстовых данных (domains, asns, ip-ranges)
|
||||||
* Уменьшают дублирование кода для похожих эндпоинтов
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = require('../services/s3Service');
|
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject, headMeta, readS3TextObject } = require('../services/s3Service');
|
||||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
const { splitWhitespace } = require('../utils/helpers');
|
const { splitWhitespace } = require('../utils/helpers');
|
||||||
const { s3, BUCKET_NAME } = require('../services/s3Service');
|
|
||||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
|
||||||
const { streamToString } = require('../services/s3Service');
|
|
||||||
|
|
||||||
// Кэш для countOnly запросов
|
// Кэш для countOnly запросов
|
||||||
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
|
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
|
||||||
@@ -27,13 +23,6 @@ function setCountOnlyCache(cacheKey, value) {
|
|||||||
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
|
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Создать GET эндпоинт для текстовых данных
|
|
||||||
* @param {string} s3Key - Ключ S3 файла
|
|
||||||
* @param {function} mapLine - Функция маппинга строки в объект
|
|
||||||
* @param {function} validate - Функция валидации данных
|
|
||||||
* @param {string} cachePrefix - Префикс для кэша
|
|
||||||
*/
|
|
||||||
function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
||||||
@@ -54,23 +43,31 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
|||||||
q,
|
q,
|
||||||
offset: Number(offset) || 0,
|
offset: Number(offset) || 0,
|
||||||
limit: Number(limit) || 0,
|
limit: Number(limit) || 0,
|
||||||
mapLine
|
mapLine,
|
||||||
});
|
});
|
||||||
if (!validate(items)) {
|
if (!validate(items)) {
|
||||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||||
}
|
}
|
||||||
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
|
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
|
||||||
} else {
|
} else {
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
const head = await headMeta(s3Key);
|
||||||
const etag = head?.ETag || null;
|
const etag = head?.etag || null;
|
||||||
if (etag) res.set('ETag', String(etag));
|
if (etag) res.set('ETag', String(etag));
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
let fileContent;
|
||||||
const fileContent = await streamToString(data.Body);
|
try {
|
||||||
const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim()));
|
const data = await readS3TextObject(s3Key);
|
||||||
|
fileContent = data.body;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'NoSuchKey') {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const items = fileContent.split('\n').filter((line) => line).map((line) => mapLine(line.trim()));
|
||||||
|
|
||||||
if (!validate(items)) {
|
if (!validate(items)) {
|
||||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||||
@@ -85,24 +82,16 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
|||||||
res.json([]);
|
res.json([]);
|
||||||
} else {
|
} else {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) });
|
return sendError(res, 500, 'Error reading storage', 'E_STORAGE', { error: String(error?.message || error) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Создать POST эндпоинт для текстовых данных
|
|
||||||
* @param {string} s3Key - Ключ S3 файла
|
|
||||||
* @param {function} formatLine - Функция форматирования объекта в строку
|
|
||||||
* @param {function} validate - Функция валидации данных
|
|
||||||
* @param {function} validateItem - Функция валидации отдельного элемента (опционально)
|
|
||||||
*/
|
|
||||||
function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
const { domains, ipRanges, items, etag } = req.body; // 'domains' основной ключ, но поддерживаем и альтернативные
|
const { domains, ipRanges, items, etag } = req.body;
|
||||||
|
|
||||||
// Поддержка обратной совместимости: принимаем domains | ipRanges | items
|
|
||||||
const payload = Array.isArray(domains)
|
const payload = Array.isArray(domains)
|
||||||
? domains
|
? domains
|
||||||
: Array.isArray(ipRanges)
|
: Array.isArray(ipRanges)
|
||||||
@@ -115,12 +104,10 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
|||||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Валидация с помощью AJV
|
|
||||||
if (!validate(payload || [])) {
|
if (!validate(payload || [])) {
|
||||||
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Дополнительная валидация элементов, если предоставлена
|
|
||||||
if (validateItem) {
|
if (validateItem) {
|
||||||
const validationErrors = [];
|
const validationErrors = [];
|
||||||
for (let i = 0; i < payload.length; i++) {
|
for (let i = 0; i < payload.length; i++) {
|
||||||
@@ -132,7 +119,7 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
|||||||
|
|
||||||
if (validationErrors.length > 0) {
|
if (validationErrors.length > 0) {
|
||||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||||
errors: validationErrors.slice(0, 10)
|
errors: validationErrors.slice(0, 10),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,51 +127,30 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
|||||||
const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n');
|
const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Проверка ETag для optimistic concurrency
|
|
||||||
let current = null;
|
let current = null;
|
||||||
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
|
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g, '"') : null;
|
||||||
try {
|
try {
|
||||||
current = await headS3ObjectEtag(s3Key);
|
current = await headS3ObjectEtag(s3Key);
|
||||||
} catch {}
|
} catch (_) {}
|
||||||
|
|
||||||
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
|
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
|
||||||
const { headMeta } = require('../services/s3Service');
|
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||||
const meta = await headMeta(s3Key);
|
|
||||||
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', {
|
|
||||||
currentEtag: current,
|
|
||||||
meta
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const meta = await writeS3TextObject(s3Key, fileContent);
|
const meta = await writeS3TextObject(s3Key, fileContent);
|
||||||
return sendOk(res, meta);
|
return sendOk(res, meta);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return sendError(res, 500, 'Error writing to S3', 'E_S3', {
|
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||||
error: String(error?.message || error)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Создать роуты для текстовых данных (GET + POST)
|
|
||||||
*/
|
|
||||||
function createTextDataRoutes(config) {
|
function createTextDataRoutes(config) {
|
||||||
const {
|
const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config;
|
||||||
s3Key,
|
|
||||||
mapLine,
|
|
||||||
formatLine,
|
|
||||||
validate,
|
|
||||||
validateItem,
|
|
||||||
cachePrefix
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix),
|
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix),
|
||||||
post: createTextDataPOST(s3Key, formatLine, validate, validateItem)
|
post: createTextDataPOST(s3Key, formatLine, validate, validateItem),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,4 +159,3 @@ module.exports = {
|
|||||||
createTextDataGET,
|
createTextDataGET,
|
||||||
createTextDataPOST,
|
createTextDataPOST,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+128
-95
@@ -15,9 +15,16 @@ const crypto = require('crypto');
|
|||||||
// Импорт модулей
|
// Импорт модулей
|
||||||
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
|
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
|
||||||
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
|
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
|
||||||
const { createTextDataRoutes } = require('./routes/textDataRoutes');
|
|
||||||
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes');
|
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes');
|
||||||
const { splitWhitespace } = require('./utils/helpers');
|
const { createFilterRulesRoutes } = require('./routes/filterRulesRoutes');
|
||||||
|
const evobgpClient = require('./services/evobgpClient');
|
||||||
|
const {
|
||||||
|
createEvobgpListGET,
|
||||||
|
createEvobgpDomainsNewPOST,
|
||||||
|
createEvobgpAsnsPOST,
|
||||||
|
createEvobgpIpRangesPOST,
|
||||||
|
createEvobgpLegacyDomainsGET,
|
||||||
|
} = require('./routes/evobgpReferenceRoutes');
|
||||||
const validators = require('./lib/validators');
|
const validators = require('./lib/validators');
|
||||||
const mikrotikValidator = require('./lib/mikrotik-validator');
|
const mikrotikValidator = require('./lib/mikrotik-validator');
|
||||||
|
|
||||||
@@ -31,11 +38,14 @@ const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
|
|||||||
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
||||||
const trafficRoutes = require('./routes/trafficRoutes');
|
const trafficRoutes = require('./routes/trafficRoutes');
|
||||||
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
|
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 { initNetworkMapScheduler } = require('./services/networkMapScheduler');
|
||||||
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
||||||
const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler');
|
const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler');
|
||||||
const schedulerRoutes = require('./routes/schedulerRoutes');
|
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;
|
||||||
@@ -154,6 +164,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) => {
|
||||||
@@ -211,99 +238,100 @@ const validateDomainsNew = ajv.compile(schemaDomainsNew);
|
|||||||
const validateAsns = ajv.compile(schemaAsns);
|
const validateAsns = ajv.compile(schemaAsns);
|
||||||
const validateIpRanges = ajv.compile(schemaIpRanges);
|
const validateIpRanges = ajv.compile(schemaIpRanges);
|
||||||
|
|
||||||
// === Domains (старая версия) ===
|
// === Domains (старая версия, поля domain+type — тот же источник, что ASNs) ===
|
||||||
const domainsOldRoutes = createTextDataRoutes({
|
const asnValidateItem = (asn, i) => {
|
||||||
s3Key: 'bgp_data/domains.txt',
|
const errors = [];
|
||||||
mapLine: (line) => {
|
if (!validators.isValidASN(asn.domain)) {
|
||||||
const parts = splitWhitespace(line);
|
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
||||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
}
|
||||||
},
|
if (!validators.isValidCommunity(asn.type)) {
|
||||||
formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(),
|
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
||||||
validate: validateAsns,
|
}
|
||||||
cachePrefix: 'domains'
|
return errors;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const domainsOldRoutes = {
|
||||||
|
get: createEvobgpLegacyDomainsGET({ validate: validateAsns }),
|
||||||
|
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
|
||||||
|
};
|
||||||
|
|
||||||
app.get('/api/domains', domainsOldRoutes.get);
|
app.get('/api/domains', domainsOldRoutes.get);
|
||||||
app.post('/api/domains', domainsOldRoutes.post);
|
app.post('/api/domains', writeLimiter, domainsOldRoutes.post);
|
||||||
|
|
||||||
// === ASNs ===
|
// === ASNs (EvoBGP) ===
|
||||||
const asnsRoutes = createTextDataRoutes({
|
const asnsRoutes = {
|
||||||
s3Key: 'bgp_data/asns.txt',
|
get: createEvobgpListGET({
|
||||||
mapLine: (line) => {
|
cachePrefix: 'asns',
|
||||||
const parts = splitWhitespace(line);
|
validate: validateAsns,
|
||||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
fetchItems: async () => {
|
||||||
},
|
const rows = await evobgpClient.fetchAsnsData();
|
||||||
formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(),
|
return rows.map((r) => ({ domain: r.asn, type: r.community }));
|
||||||
validate: validateAsns,
|
},
|
||||||
validateItem: (asn, i) => {
|
}),
|
||||||
const errors = [];
|
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
|
||||||
if (!validators.isValidASN(asn.domain)) {
|
};
|
||||||
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
|
||||||
}
|
|
||||||
if (!validators.isValidCommunity(asn.type)) {
|
|
||||||
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
},
|
|
||||||
cachePrefix: 'asns'
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/asns', asnsRoutes.get);
|
app.get('/api/asns', asnsRoutes.get);
|
||||||
app.post('/api/asns', writeLimiter, asnsRoutes.post);
|
app.post('/api/asns', writeLimiter, asnsRoutes.post);
|
||||||
|
|
||||||
// === Domains New ===
|
// === Domains New (EvoBGP) ===
|
||||||
const domainsNewRoutes = createTextDataRoutes({
|
const domainsNewRoutes = {
|
||||||
s3Key: 'bgp_data/domains_community.txt',
|
get: createEvobgpListGET({
|
||||||
mapLine: (line) => {
|
cachePrefix: 'domains-new',
|
||||||
const parts = splitWhitespace(line);
|
validate: validateDomainsNew,
|
||||||
return { domain: parts[0] || '', community: parts[1] || '' };
|
fetchItems: async () => {
|
||||||
},
|
const rows = await evobgpClient.fetchDomainsData();
|
||||||
formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(),
|
return rows.map(({ domain, community }) => ({ domain, community }));
|
||||||
validate: validateDomainsNew,
|
},
|
||||||
validateItem: (d, i) => {
|
}),
|
||||||
const errors = [];
|
post: createEvobgpDomainsNewPOST({
|
||||||
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
validate: validateDomainsNew,
|
||||||
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
validateItem: (d, i) => {
|
||||||
}
|
const errors = [];
|
||||||
if (!validators.isValidCommunity(d.community)) {
|
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
||||||
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
||||||
}
|
}
|
||||||
if (!validators.isSafeXSSString(d.domain)) {
|
if (!validators.isValidCommunity(d.community)) {
|
||||||
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
||||||
}
|
}
|
||||||
return errors;
|
if (!validators.isSafeXSSString(d.domain)) {
|
||||||
},
|
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
||||||
cachePrefix: 'domains-new'
|
}
|
||||||
});
|
return errors;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
app.get('/api/domains-new', domainsNewRoutes.get);
|
app.get('/api/domains-new', domainsNewRoutes.get);
|
||||||
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
|
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
|
||||||
|
|
||||||
// === IP Ranges ===
|
// === IP Ranges (EvoBGP) ===
|
||||||
const ipRangesRoutes = createTextDataRoutes({
|
const ipRangesRoutes = {
|
||||||
s3Key: 'bgp_data/ips.txt',
|
get: createEvobgpListGET({
|
||||||
mapLine: (line) => {
|
cachePrefix: 'ip-ranges',
|
||||||
const parts = splitWhitespace(line);
|
validate: validateIpRanges,
|
||||||
return { ipRange: parts[0] || '', community: parts[1] || '' };
|
fetchItems: async () => {
|
||||||
},
|
const rows = await evobgpClient.fetchIpRangesData();
|
||||||
formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(),
|
return rows.map(({ ipRange, community }) => ({ ipRange, community }));
|
||||||
validate: validateIpRanges,
|
},
|
||||||
validateItem: (ip, i) => {
|
}),
|
||||||
const errors = [];
|
post: createEvobgpIpRangesPOST({
|
||||||
const ipStr = String(ip.ipRange || '').trim();
|
validate: validateIpRanges,
|
||||||
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
|
validateItem: (ip, i) => {
|
||||||
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
|
const errors = [];
|
||||||
|
const ipStr = String(ip.ipRange || '').trim();
|
||||||
if (!isValidCIDR && !isValidIP) {
|
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
|
||||||
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
|
||||||
}
|
if (!isValidCIDR && !isValidIP) {
|
||||||
if (!validators.isValidCommunity(ip.community)) {
|
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
||||||
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
}
|
||||||
}
|
if (!validators.isValidCommunity(ip.community)) {
|
||||||
return errors;
|
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
||||||
},
|
}
|
||||||
cachePrefix: 'ip-ranges'
|
return errors;
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
app.get('/api/ip-ranges', ipRangesRoutes.get);
|
app.get('/api/ip-ranges', ipRangesRoutes.get);
|
||||||
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
|
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
|
||||||
@@ -340,24 +368,21 @@ const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) =>
|
|||||||
app.get('/api/billing', billingRoutes.get);
|
app.get('/api/billing', billingRoutes.get);
|
||||||
app.post('/api/billing', billingRoutes.post);
|
app.post('/api/billing', billingRoutes.post);
|
||||||
|
|
||||||
// Filters
|
// Filters (SQL app_filter_rules, scope global)
|
||||||
const filtersRoutes2 = createJsonDataRoutes('filters.json', (filter, i) => {
|
const filterItemValidator = (filter, i) => {
|
||||||
if (!filter.community || !filter.gateway) {
|
if (!filter.community || !filter.gateway) {
|
||||||
return `Filter at index ${i} is missing required fields`;
|
return `Filter at index ${i} is missing required fields`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const filtersRoutes2 = createFilterRulesRoutes('global', filterItemValidator);
|
||||||
|
|
||||||
app.get('/api/filters', filtersRoutes2.get);
|
app.get('/api/filters', filtersRoutes2.get);
|
||||||
app.post('/api/filters', filtersRoutes2.post);
|
app.post('/api/filters', filtersRoutes2.post);
|
||||||
|
|
||||||
// Simple Filters
|
// Simple Filters (app_filter_rules, scope simple)
|
||||||
const simpleFiltersRoutes = createJsonDataRoutes('filter-manager/simple-filters.json', (filter, i) => {
|
const simpleFiltersRoutes = createFilterRulesRoutes('simple', filterItemValidator);
|
||||||
if (!filter.community || !filter.gateway) {
|
|
||||||
return `Filter at index ${i} is missing required fields`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/simple-filters', simpleFiltersRoutes.get);
|
app.get('/api/simple-filters', simpleFiltersRoutes.get);
|
||||||
app.post('/api/simple-filters', simpleFiltersRoutes.post);
|
app.post('/api/simple-filters', simpleFiltersRoutes.post);
|
||||||
@@ -471,6 +496,8 @@ app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTes
|
|||||||
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/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);
|
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
|
||||||
|
|
||||||
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
|
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
|
||||||
@@ -483,7 +510,10 @@ app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
|||||||
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
|
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
|
||||||
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
|
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
|
||||||
|
|
||||||
// === MIKROTIK BACKUPS (S3) ===
|
// === ALERTS (агрегация проблем: серверы, ресурсы роутеров) ===
|
||||||
|
app.get('/api/alerts', alertsRoutes.getAlerts);
|
||||||
|
|
||||||
|
// === MIKROTIK BACKUPS (локальная ФС) ===
|
||||||
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
||||||
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
|
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
|
||||||
app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
|
app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
|
||||||
@@ -493,6 +523,9 @@ app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBack
|
|||||||
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
|
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
|
||||||
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
|
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
|
||||||
|
|
||||||
|
// === LOCAL AI ROUTE OPTIMIZER (rule-based) ===
|
||||||
|
app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer);
|
||||||
|
|
||||||
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
|
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
|
||||||
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
|
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
|
||||||
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
|
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Бэкапы MikroTik на локальной ФС (вместо S3).
|
||||||
|
* Логические ключи сохраняют вид backups/mikrotik/{serverId}/{ts}-{rand}.rsc
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const BACKUP_PREFIX = 'backups/mikrotik';
|
||||||
|
|
||||||
|
function backupRootDir() {
|
||||||
|
const raw = process.env.MIKROTIK_BACKUP_DIR || path.join(__dirname, '..', 'data', 'backups', 'mikrotik');
|
||||||
|
if (!fs.existsSync(raw)) {
|
||||||
|
fs.mkdirSync(raw, { recursive: true });
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBackupKey(key) {
|
||||||
|
const m = String(key || '').match(/^backups\/mikrotik\/([^/]+)\/(.+)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
return { safeId: m[1], fileName: m[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsPathForKey(key) {
|
||||||
|
const p = parseBackupKey(key);
|
||||||
|
if (!p) return null;
|
||||||
|
return path.join(backupRootDir(), p.safeId, p.fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupKey(serverId, createdAt, suffix) {
|
||||||
|
const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||||
|
const rand = suffix || crypto.randomBytes(3).toString('hex');
|
||||||
|
return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function etagForContent(content) {
|
||||||
|
const h = crypto.createHash('sha256').update(Buffer.from(String(content), 'utf8')).digest('hex');
|
||||||
|
return `"${h}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeBackupFile(key, content, contentType = 'text/plain') {
|
||||||
|
const p = parseBackupKey(key);
|
||||||
|
if (!p) throw new Error('Invalid backup key');
|
||||||
|
const dir = path.join(backupRootDir(), p.safeId);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const fp = path.join(dir, p.fileName);
|
||||||
|
await fs.promises.writeFile(fp, String(content), 'utf8');
|
||||||
|
const st = await fs.promises.stat(fp);
|
||||||
|
const etag = etagForContent(String(content));
|
||||||
|
return {
|
||||||
|
etag,
|
||||||
|
lastModified: st.mtime.toISOString(),
|
||||||
|
contentLength: st.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBackupFile(key) {
|
||||||
|
const fp = fsPathForKey(key);
|
||||||
|
if (!fp) throw new Error('Invalid backup key');
|
||||||
|
const body = await fs.promises.readFile(fp, 'utf8');
|
||||||
|
const st = await fs.promises.stat(fp);
|
||||||
|
const etag = etagForContent(body);
|
||||||
|
return {
|
||||||
|
body,
|
||||||
|
etag,
|
||||||
|
lastModified: st.mtime.toISOString(),
|
||||||
|
contentLength: st.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listBackupObjects(serverId, maxKeys = 200) {
|
||||||
|
const safeId = String(serverId || '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
const dir = path.join(backupRootDir(), safeId);
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
const names = await fs.promises.readdir(dir);
|
||||||
|
const out = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (out.length >= maxKeys) break;
|
||||||
|
const fp = path.join(dir, name);
|
||||||
|
const st = await fs.promises.stat(fp);
|
||||||
|
if (!st.isFile()) continue;
|
||||||
|
const key = `${BACKUP_PREFIX}/${safeId}/${name}`;
|
||||||
|
out.push({
|
||||||
|
key,
|
||||||
|
size: st.size,
|
||||||
|
lastModified: st.mtime.toISOString(),
|
||||||
|
etag: etagForContent(await fs.promises.readFile(fp, 'utf8')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BACKUP_PREFIX,
|
||||||
|
buildBackupKey,
|
||||||
|
writeBackupFile,
|
||||||
|
readBackupFile,
|
||||||
|
listBackupObjects,
|
||||||
|
backupRootDir,
|
||||||
|
};
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* SQLite blob store (logical S3 keys → rows in `blobs`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { openDatabase } = require('../db/sqliteDb');
|
||||||
|
|
||||||
|
function sha256Etag(buf) {
|
||||||
|
const h = crypto.createHash('sha256').update(buf).digest('hex');
|
||||||
|
return `"${h}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function namespaceFromKey(objectKey) {
|
||||||
|
const k = String(objectKey || '');
|
||||||
|
if (k.startsWith('ping-cache/') || k.startsWith('speed-test-cache/') || k.startsWith('network-map-cache/') ||
|
||||||
|
k.startsWith('uptime-monitor-cache/') || k.startsWith('ping-services/')) {
|
||||||
|
return 'cache';
|
||||||
|
}
|
||||||
|
if (k.startsWith('filter-manager/config-') || k.startsWith('filter-manager/server-filters-')) {
|
||||||
|
return 'per_server';
|
||||||
|
}
|
||||||
|
if (k === 'mikrotik-frouting-config.txt') {
|
||||||
|
return 'derived';
|
||||||
|
}
|
||||||
|
return 'config';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVersionedKey(db, objectKey) {
|
||||||
|
const row = db.prepare('SELECT 1 FROM versioned_keys WHERE object_key = ?').get(objectKey);
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimVersions(db, objectKey, keep = 50) {
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM blob_versions WHERE object_key = ? AND id NOT IN (
|
||||||
|
SELECT id FROM blob_versions WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?
|
||||||
|
)`
|
||||||
|
).run(objectKey, objectKey, keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer|string} body
|
||||||
|
*/
|
||||||
|
function writeBlobTx(db, objectKey, body, contentType) {
|
||||||
|
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
||||||
|
const ns = namespaceFromKey(objectKey);
|
||||||
|
const etag = sha256Etag(buf);
|
||||||
|
const now = Date.now();
|
||||||
|
const size = buf.length;
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT body, content_type, etag, updated_at, created_at FROM blobs WHERE object_key = ?').get(objectKey);
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (existing && isVersionedKey(db, objectKey)) {
|
||||||
|
const oldBuf = existing.body;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO blob_versions (object_key, body, byte_size, etag, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).run(objectKey, oldBuf, oldBuf.length, existing.etag, existing.updated_at);
|
||||||
|
trimVersions(db, objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE blobs SET namespace = ?, content_type = ?, body = ?, byte_size = ?, etag = ?, updated_at = ?
|
||||||
|
WHERE object_key = ?`
|
||||||
|
).run(ns, contentType, buf, size, etag, now, objectKey);
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO blobs (object_key, namespace, content_type, body, byte_size, etag, updated_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(objectKey, ns, contentType, buf, size, etag, now, now);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
db.transaction(run)();
|
||||||
|
return {
|
||||||
|
etag,
|
||||||
|
lastModified: new Date(now).toISOString(),
|
||||||
|
contentLength: size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBlob(objectKey) {
|
||||||
|
const db = openDatabase();
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT body, etag, updated_at, byte_size FROM blobs WHERE object_key = ?'
|
||||||
|
).get(objectKey);
|
||||||
|
if (!row) return null;
|
||||||
|
const bodyStr = row.body.toString('utf8');
|
||||||
|
return {
|
||||||
|
body: bodyStr,
|
||||||
|
etag: row.etag,
|
||||||
|
lastModified: new Date(row.updated_at).toISOString(),
|
||||||
|
contentLength: row.byte_size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function headMetaRow(objectKey) {
|
||||||
|
const db = openDatabase();
|
||||||
|
const row = db.prepare('SELECT etag, updated_at, byte_size FROM blobs WHERE object_key = ?').get(objectKey);
|
||||||
|
if (!row) {
|
||||||
|
return { etag: null, lastModified: null, contentLength: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
etag: row.etag,
|
||||||
|
lastModified: new Date(row.updated_at).toISOString(),
|
||||||
|
contentLength: row.byte_size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function headEtag(objectKey) {
|
||||||
|
const m = headMetaRow(objectKey);
|
||||||
|
return m.etag || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBlob(objectKey) {
|
||||||
|
const db = openDatabase();
|
||||||
|
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listByPrefix(prefix, maxKeys = 100) {
|
||||||
|
const db = openDatabase();
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT object_key AS key, byte_size AS size, updated_at, etag FROM blobs
|
||||||
|
WHERE object_key >= ? AND object_key < ? ORDER BY object_key LIMIT ?`
|
||||||
|
).all(prefix, prefix + '\uffff', maxKeys);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
key: r.key,
|
||||||
|
size: r.size,
|
||||||
|
lastModified: new Date(r.updated_at).toISOString(),
|
||||||
|
etag: r.etag,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listVersions(objectKey, maxKeys = 50) {
|
||||||
|
const db = openDatabase();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, object_key, byte_size, etag, created_at FROM blob_versions
|
||||||
|
WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||||
|
).all(objectKey, maxKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollbackToVersionId(objectKey, versionId) {
|
||||||
|
const db = openDatabase();
|
||||||
|
const vid = Number(versionId);
|
||||||
|
if (!Number.isFinite(vid)) throw new Error('Invalid versionId');
|
||||||
|
const ver = db.prepare('SELECT body, etag, created_at FROM blob_versions WHERE id = ? AND object_key = ?').get(vid, objectKey);
|
||||||
|
if (!ver) throw new Error('Version not found');
|
||||||
|
const buf = ver.body;
|
||||||
|
const contentType = objectKey.endsWith('.json') ? 'application/json' : 'text/plain';
|
||||||
|
writeBlobTx(db, objectKey, buf, contentType);
|
||||||
|
return headMetaRow(objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
namespaceFromKey,
|
||||||
|
writeBlobTx,
|
||||||
|
readBlob,
|
||||||
|
headMetaRow,
|
||||||
|
headEtag,
|
||||||
|
deleteBlob,
|
||||||
|
listByPrefix,
|
||||||
|
listVersions,
|
||||||
|
rollbackToVersionId,
|
||||||
|
sha256Etag,
|
||||||
|
};
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
/**
|
||||||
|
* Серверный клиент EvoBGP API (Bearer, base /v1). Логика выборки совместима с frontend/src/lib/evobgpData.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { normalizeEvobgpBaseUrl } = require('../routes/evobgpProxyRoutes');
|
||||||
|
|
||||||
|
const PAGE_LIMIT = 500;
|
||||||
|
const DEFAULT_TIMEOUT_MS = Number(process.env.EVOBGP_CLIENT_TIMEOUT_MS) || 30000;
|
||||||
|
|
||||||
|
function getBaseAndToken() {
|
||||||
|
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
|
||||||
|
const token = String(process.env.EVOBGP_API_TOKEN || '').trim();
|
||||||
|
return { base, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigured() {
|
||||||
|
const { base, token } = getBaseAndToken();
|
||||||
|
return Boolean(base && token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evobgpRequest(method, pathname, { query, body } = {}) {
|
||||||
|
const { base, token } = getBaseAndToken();
|
||||||
|
if (!base || !token) {
|
||||||
|
const err = new Error('EvoBGP not configured');
|
||||||
|
err.code = 'E_EVOBGP_NOT_CONFIGURED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const u = new URL(pathname.startsWith('/') ? pathname.slice(1) : pathname, base.endsWith('/') ? base : `${base}/`);
|
||||||
|
if (query && typeof query === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(u.toString(), {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch (_) {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(typeof data === 'object' && data?.message ? data.message : res.statusText);
|
||||||
|
err.code = 'E_EVOBGP_HTTP';
|
||||||
|
err.status = res.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function paginate(path, params = {}) {
|
||||||
|
const items = [];
|
||||||
|
let cursor = null;
|
||||||
|
while (true) {
|
||||||
|
const res = await evobgpRequest('GET', path, {
|
||||||
|
query: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
||||||
|
});
|
||||||
|
const chunk = Array.isArray(res?.items) ? res.items : [];
|
||||||
|
items.push(...chunk);
|
||||||
|
if (!res?.has_more || !res?.next_cursor) break;
|
||||||
|
cursor = res.next_cursor;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRouterListsCatalog() {
|
||||||
|
return evobgpRequest('GET', '/router-lists/catalog');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCatalogItems(section) {
|
||||||
|
if (!section || typeof section !== 'object') return [];
|
||||||
|
return Array.isArray(section.items) ? section.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCommunities() {
|
||||||
|
let communities = [];
|
||||||
|
try {
|
||||||
|
const catalog = await fetchRouterListsCatalog();
|
||||||
|
communities = getCatalogItems(catalog.communities);
|
||||||
|
} catch {
|
||||||
|
communities = await paginate('/communities');
|
||||||
|
}
|
||||||
|
return communities.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
value: String(c.community || ''),
|
||||||
|
name: String(c.title || ''),
|
||||||
|
description: '',
|
||||||
|
tags: [],
|
||||||
|
color: '',
|
||||||
|
icon: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateModule(moduleType, defaultName) {
|
||||||
|
const modules = await paginate('/modules', { type: moduleType });
|
||||||
|
if (modules.length > 0) return modules[0];
|
||||||
|
const created = await evobgpRequest('POST', '/modules', {
|
||||||
|
body: { type: moduleType, name: defaultName, enabled: true },
|
||||||
|
});
|
||||||
|
return created?.data || created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeList(items, keyName) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const item of items || []) {
|
||||||
|
const key = String(item?.[keyName] || '').trim().toLowerCase();
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ ...item, [keyName]: key });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCommunityMaps() {
|
||||||
|
const communities = await listCommunities();
|
||||||
|
const byId = new Map(communities.map((c) => [c.id, c.value]));
|
||||||
|
const byValue = new Map(communities.map((c) => [c.value, c.id]));
|
||||||
|
return { communities, byId, byValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCommunityToValue(communityId, byId) {
|
||||||
|
if (!communityId) return '';
|
||||||
|
return byId.get(communityId) || String(communityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncEntries({
|
||||||
|
moduleType,
|
||||||
|
moduleName,
|
||||||
|
listPath,
|
||||||
|
keyName,
|
||||||
|
createBody,
|
||||||
|
patchBody,
|
||||||
|
currentItems,
|
||||||
|
originalItems,
|
||||||
|
requireCommunity = false,
|
||||||
|
}) {
|
||||||
|
const module = await getOrCreateModule(moduleType, moduleName);
|
||||||
|
const moduleId = module.id;
|
||||||
|
const { byId, byValue } = await buildCommunityMaps();
|
||||||
|
const existing = await paginate(`/modules/${moduleId}/${listPath}`);
|
||||||
|
|
||||||
|
const existingByKey = new Map();
|
||||||
|
for (const row of existing) {
|
||||||
|
const entryKey = String(row?.[keyName] || '').trim().toLowerCase();
|
||||||
|
if (!entryKey) continue;
|
||||||
|
existingByKey.set(entryKey, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCurrent = normalizeList(currentItems, keyName);
|
||||||
|
const normalizedOriginal = normalizeList(originalItems, keyName);
|
||||||
|
const currentMap = new Map(normalizedCurrent.map((i) => [String(i[keyName]).toLowerCase(), i]));
|
||||||
|
const originalKeys = new Set(normalizedOriginal.map((i) => String(i[keyName]).toLowerCase()));
|
||||||
|
|
||||||
|
for (const key of originalKeys) {
|
||||||
|
if (!currentMap.has(key) && existingByKey.has(key)) {
|
||||||
|
const row = existingByKey.get(key);
|
||||||
|
await evobgpRequest('DELETE', `/modules/${moduleId}/${listPath}/${row.id}`);
|
||||||
|
existingByKey.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, item] of currentMap.entries()) {
|
||||||
|
const rawCommunity = String(item.community || '').trim();
|
||||||
|
const communityId = byValue.get(rawCommunity) || null;
|
||||||
|
if (requireCommunity && !communityId) {
|
||||||
|
throw new Error(`Community "${rawCommunity}" не найдена в справочнике`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRow = existingByKey.get(key);
|
||||||
|
if (!existingRow) {
|
||||||
|
await evobgpRequest('POST', `/modules/${moduleId}/${listPath}`, { body: createBody(item, communityId) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCommunity = mapCommunityToValue(existingRow.community_id, byId);
|
||||||
|
if (currentCommunity !== rawCommunity) {
|
||||||
|
await evobgpRequest('PATCH', `/modules/${moduleId}/${listPath}/${existingRow.id}`, {
|
||||||
|
body: patchBody(item, communityId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDomainsData() {
|
||||||
|
try {
|
||||||
|
const catalog = await fetchRouterListsCatalog();
|
||||||
|
const communities = getCatalogItems(catalog.communities);
|
||||||
|
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
|
||||||
|
const rows = getCatalogItems(catalog.domains);
|
||||||
|
return rows
|
||||||
|
.map((row) => ({
|
||||||
|
id: row?.entry?.id || row?.id || '',
|
||||||
|
domain: String(row?.entry?.fqdn || row?.fqdn || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.domain);
|
||||||
|
} catch {
|
||||||
|
const module = await getOrCreateModule('DOMAINS', 'Domains');
|
||||||
|
const { byId } = await buildCommunityMaps();
|
||||||
|
const rows = await paginate(`/modules/${module.id}/domain-entries`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
domain: String(row.fqdn || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
community: mapCommunityToValue(row.community_id, byId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIpRangesData() {
|
||||||
|
try {
|
||||||
|
const catalog = await fetchRouterListsCatalog();
|
||||||
|
const communities = getCatalogItems(catalog.communities);
|
||||||
|
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
|
||||||
|
const rows = getCatalogItems(catalog.ip_ranges);
|
||||||
|
return rows
|
||||||
|
.map((row) => ({
|
||||||
|
id: row?.entry?.id || row?.id || '',
|
||||||
|
ipRange: String(row?.entry?.prefix || row?.prefix || '').trim(),
|
||||||
|
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.ipRange);
|
||||||
|
} catch {
|
||||||
|
const module = await getOrCreateModule('IP_RANGES', 'IP ranges');
|
||||||
|
const { byId } = await buildCommunityMaps();
|
||||||
|
const rows = await paginate(`/modules/${module.id}/ip-range-entries`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
ipRange: String(row.prefix || '').trim(),
|
||||||
|
community: mapCommunityToValue(row.community_id, byId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAsnsData() {
|
||||||
|
try {
|
||||||
|
const catalog = await fetchRouterListsCatalog();
|
||||||
|
const communities = getCatalogItems(catalog.communities);
|
||||||
|
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
|
||||||
|
const rows = getCatalogItems(catalog.asns);
|
||||||
|
return rows
|
||||||
|
.map((row) => ({
|
||||||
|
id: row?.entry?.id || row?.id || '',
|
||||||
|
asn: String(row?.entry?.asn || row?.asn || '').trim(),
|
||||||
|
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.asn);
|
||||||
|
} catch {
|
||||||
|
const module = await getOrCreateModule('AS_PREFIXES', 'ASNs');
|
||||||
|
const { byId } = await buildCommunityMaps();
|
||||||
|
const rows = await paginate(`/modules/${module.id}/as-entries`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
asn: String(row.asn || '').trim(),
|
||||||
|
community: mapCommunityToValue(row.community_id, byId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-URL: секция каталога или модуль AUTO_URLS / URL_LIST (настраивается env). */
|
||||||
|
async function fetchAutoUrlsData() {
|
||||||
|
try {
|
||||||
|
const catalog = await fetchRouterListsCatalog();
|
||||||
|
for (const name of ['auto_urls', 'auto_url', 'urls', 'url_list']) {
|
||||||
|
const sec = catalog[name];
|
||||||
|
const items = getCatalogItems(sec);
|
||||||
|
if (items.length) {
|
||||||
|
const communities = getCatalogItems(catalog.communities);
|
||||||
|
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
|
||||||
|
return items.map((row) => {
|
||||||
|
const url = String(row?.entry?.url || row?.entry?.fqdn || row?.url || row?.fqdn || '').trim();
|
||||||
|
const community = mapCommunityToValue(
|
||||||
|
row?.entry?.community_id || row?.community_id || null,
|
||||||
|
byId,
|
||||||
|
);
|
||||||
|
return { url, community };
|
||||||
|
}).filter((r) => r.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
|
||||||
|
try {
|
||||||
|
const module = await getOrCreateModule(modType, 'Auto URLs');
|
||||||
|
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
|
||||||
|
const rows = await paginate(`/modules/${module.id}/${listPath}`);
|
||||||
|
const { byId } = await buildCommunityMaps();
|
||||||
|
return rows.map((row) => ({
|
||||||
|
url: String(row.url || row.fqdn || '').trim(),
|
||||||
|
community: mapCommunityToValue(row.community_id, byId),
|
||||||
|
})).filter((r) => r.url);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDomainsData(currentItems, originalItems) {
|
||||||
|
await syncEntries({
|
||||||
|
moduleType: 'DOMAINS',
|
||||||
|
moduleName: 'Domains',
|
||||||
|
listPath: 'domain-entries',
|
||||||
|
keyName: 'domain',
|
||||||
|
currentItems,
|
||||||
|
originalItems,
|
||||||
|
createBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
|
||||||
|
patchBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveIpRangesData(currentItems, originalItems) {
|
||||||
|
await syncEntries({
|
||||||
|
moduleType: 'IP_RANGES',
|
||||||
|
moduleName: 'IP ranges',
|
||||||
|
listPath: 'ip-range-entries',
|
||||||
|
keyName: 'ipRange',
|
||||||
|
currentItems,
|
||||||
|
originalItems,
|
||||||
|
requireCommunity: true,
|
||||||
|
createBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
|
||||||
|
patchBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAsnsData(currentItems, originalItems) {
|
||||||
|
await syncEntries({
|
||||||
|
moduleType: 'AS_PREFIXES',
|
||||||
|
moduleName: 'ASNs',
|
||||||
|
listPath: 'as-entries',
|
||||||
|
keyName: 'asn',
|
||||||
|
currentItems,
|
||||||
|
originalItems,
|
||||||
|
createBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
|
||||||
|
patchBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCommunitiesData(currentItems) {
|
||||||
|
const existing = await paginate('/communities');
|
||||||
|
const existingByValue = new Map(existing.map((c) => [String(c.community || ''), c]));
|
||||||
|
const nextByValue = new Map();
|
||||||
|
for (const item of currentItems || []) {
|
||||||
|
const value = String(item?.value || '').trim();
|
||||||
|
if (!value) continue;
|
||||||
|
nextByValue.set(value, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [value, row] of existingByValue.entries()) {
|
||||||
|
if (!nextByValue.has(value)) {
|
||||||
|
await evobgpRequest('DELETE', `/communities/${row.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [value, item] of nextByValue.entries()) {
|
||||||
|
const title = String(item?.name || '').trim();
|
||||||
|
const row = existingByValue.get(value);
|
||||||
|
if (!row) {
|
||||||
|
await evobgpRequest('POST', '/communities', { body: { community: value, title } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (String(row.title || '') !== title) {
|
||||||
|
await evobgpRequest('PATCH', `/communities/${row.id}`, { body: { community: value, title } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAutoUrlsData(urls) {
|
||||||
|
const originalItems = await fetchAutoUrlsData();
|
||||||
|
const list = (urls || []).map((u) => ({
|
||||||
|
url: String(u?.url || '').trim(),
|
||||||
|
community: String(u?.community || '').trim(),
|
||||||
|
})).filter((u) => u.url && u.community);
|
||||||
|
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
|
||||||
|
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
|
||||||
|
const keyName = process.env.EVOBGP_AUTO_URL_KEY_FIELD || 'url';
|
||||||
|
const currentItems = list.map((u) => ({ [keyName]: u.url, community: u.community }));
|
||||||
|
const origMapped = originalItems.map((u) => ({ [keyName]: u.url, community: u.community }));
|
||||||
|
await syncEntries({
|
||||||
|
moduleType: modType,
|
||||||
|
moduleName: 'Auto URLs',
|
||||||
|
listPath,
|
||||||
|
keyName,
|
||||||
|
currentItems,
|
||||||
|
originalItems: origMapped,
|
||||||
|
requireCommunity: true,
|
||||||
|
createBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
|
||||||
|
patchBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isConfigured,
|
||||||
|
evobgpRequest,
|
||||||
|
paginate,
|
||||||
|
fetchRouterListsCatalog,
|
||||||
|
listCommunities,
|
||||||
|
fetchDomainsData,
|
||||||
|
fetchIpRangesData,
|
||||||
|
fetchAsnsData,
|
||||||
|
fetchAutoUrlsData,
|
||||||
|
saveCommunitiesData,
|
||||||
|
saveDomainsData,
|
||||||
|
saveIpRangesData,
|
||||||
|
saveAsnsData,
|
||||||
|
saveAutoUrlsData,
|
||||||
|
buildCommunityMaps,
|
||||||
|
mapCommunityToValue,
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Глобальные и simple-фильтры в таблице app_filter_rules (не в blobs JSON).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { openDatabase } = require('../db/sqliteDb');
|
||||||
|
|
||||||
|
/** @typedef {'global'|'simple'} FilterScope */
|
||||||
|
|
||||||
|
function assertScope(scope) {
|
||||||
|
if (scope !== 'global' && scope !== 'simple') throw new Error('Invalid filter scope');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mtimeMetaKey(scope) {
|
||||||
|
return `filter_rules_mtime_${scope}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256EtagFromString(s) {
|
||||||
|
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
|
||||||
|
return `"${h}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToItem(row) {
|
||||||
|
let extra = {};
|
||||||
|
try {
|
||||||
|
extra = JSON.parse(row.extra_json || '{}');
|
||||||
|
if (!extra || typeof extra !== 'object' || Array.isArray(extra)) extra = {};
|
||||||
|
} catch {
|
||||||
|
extra = {};
|
||||||
|
}
|
||||||
|
const out = {
|
||||||
|
community: row.community,
|
||||||
|
gateway: row.gateway,
|
||||||
|
description: row.description || '',
|
||||||
|
};
|
||||||
|
for (const k of Object.keys(extra).sort()) {
|
||||||
|
if (k === 'community' || k === 'gateway' || k === 'description') continue;
|
||||||
|
out[k] = extra[k];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FilterScope} scope
|
||||||
|
*/
|
||||||
|
function listRules(scope) {
|
||||||
|
assertScope(scope);
|
||||||
|
const db = openDatabase();
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT community, gateway, description, extra_json FROM app_filter_rules WHERE scope = ? ORDER BY position ASC`,
|
||||||
|
)
|
||||||
|
.all(scope);
|
||||||
|
return rows.map(rowToItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FilterScope} scope
|
||||||
|
* @returns {{ etag: string, lastModified: string|null, contentLength: number }}
|
||||||
|
*/
|
||||||
|
function headMetaForScope(scope) {
|
||||||
|
assertScope(scope);
|
||||||
|
const items = listRules(scope);
|
||||||
|
const body = JSON.stringify(items);
|
||||||
|
const etag = sha256EtagFromString(body);
|
||||||
|
const contentLength = Buffer.byteLength(body, 'utf8');
|
||||||
|
const db = openDatabase();
|
||||||
|
const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(mtimeMetaKey(scope));
|
||||||
|
const lastModified = row?.value ? new Date(Number(row.value)).toISOString() : null;
|
||||||
|
return { etag, lastModified, contentLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FilterScope} scope
|
||||||
|
* @param {any[]} items
|
||||||
|
* @param {{ validateItem?: (item: any, i: number) => string|null }} [opts]
|
||||||
|
*/
|
||||||
|
function replaceRules(scope, items, opts = {}) {
|
||||||
|
assertScope(scope);
|
||||||
|
const { validateItem } = opts;
|
||||||
|
if (!Array.isArray(items)) throw new Error('items must be an array');
|
||||||
|
if (validateItem) {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const err = validateItem(items[i], i);
|
||||||
|
if (err) {
|
||||||
|
const e = new Error(err);
|
||||||
|
e.code = 'E_SCHEMA';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const db = openDatabase();
|
||||||
|
const now = Date.now();
|
||||||
|
const del = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
|
||||||
|
const ins = db.prepare(
|
||||||
|
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
const metaUpsert = db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)');
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
del.run(scope);
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i] || {};
|
||||||
|
const community = String(it.community || '').trim();
|
||||||
|
const gateway = String(it.gateway || '').trim();
|
||||||
|
const description = String(it.description || '').trim();
|
||||||
|
const extra = { ...it };
|
||||||
|
delete extra.community;
|
||||||
|
delete extra.gateway;
|
||||||
|
delete extra.description;
|
||||||
|
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
|
||||||
|
}
|
||||||
|
metaUpsert.run(mtimeMetaKey(scope), String(now));
|
||||||
|
};
|
||||||
|
db.transaction(run)();
|
||||||
|
return headMetaForScope(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listRules,
|
||||||
|
headMetaForScope,
|
||||||
|
replaceRules,
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Работает внутри Node-процесса backend:
|
* Работает внутри Node-процесса backend:
|
||||||
* - периодически обходит jumphost-сервера из servers.json
|
* - периодически обходит jumphost-сервера из servers.json
|
||||||
* - вызывает RouterOS REST API /rest/export (compact)
|
* - вызывает RouterOS REST API /rest/export (compact)
|
||||||
* - сохраняет конфиг в S3 через saveBackupForServer
|
* - сохраняет конфиг на диск через saveBackupForServer
|
||||||
*
|
*
|
||||||
* Управляется через переменные окружения:
|
* Управляется через переменные окружения:
|
||||||
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
|
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
|
||||||
@@ -131,7 +131,7 @@ async function runBackupOnce(logger) {
|
|||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{ component: 'mikrotik-backup', serverId: id, key: result.key },
|
{ component: 'mikrotik-backup', serverId: id, key: result.key },
|
||||||
'Automatic backup saved to S3',
|
'Automatic backup saved to disk',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(
|
log.error(
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ const { readS3TextObject, writeS3JsonObject } = require('./s3Service');
|
|||||||
|
|
||||||
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
||||||
|
const NEXT_RUN_AT_KEY = 'networkMapSchedulerNextRunAt';
|
||||||
|
|
||||||
const DEFAULT_INTERVAL_MINUTES = 5;
|
const DEFAULT_INTERVAL_MINUTES = 5;
|
||||||
const DEFAULT_LOG_MAX_LINES = 500;
|
const DEFAULT_LOG_MAX_LINES = 500;
|
||||||
|
const RESTART_RECOVERY_DELAY_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
/** Кольцевой буфер логов */
|
/** Кольцевой буфер логов */
|
||||||
class LogBuffer {
|
class LogBuffer {
|
||||||
@@ -329,16 +331,34 @@ async function writeCache(pingMap, speedMap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let schedulerTimer = null;
|
let schedulerTimer = null;
|
||||||
|
let schedulerStartTimeout = null;
|
||||||
/** Время последнего запуска по расписанию (для расчёта следующего) */
|
/** Время последнего запуска по расписанию (для расчёта следующего) */
|
||||||
let lastScheduledRunAt = 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) {
|
function getSettingsFromUiSettings(uiSettings) {
|
||||||
|
const persistedNextRunAtRaw = uiSettings?.[NEXT_RUN_AT_KEY];
|
||||||
|
const persistedNextRunAt = Number(persistedNextRunAtRaw);
|
||||||
return {
|
return {
|
||||||
enabled: Boolean(uiSettings.networkMapSchedulerEnabled),
|
enabled: Boolean(uiSettings.networkMapSchedulerEnabled),
|
||||||
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
|
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
|
||||||
runPing: uiSettings.networkMapSchedulerRunPing !== false,
|
runPing: uiSettings.networkMapSchedulerRunPing !== false,
|
||||||
runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false,
|
runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false,
|
||||||
logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)),
|
logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)),
|
||||||
|
persistedNextRunAt: Number.isFinite(persistedNextRunAt) ? persistedNextRunAt : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,8 +371,14 @@ function initNetworkMapScheduler(logger, port) {
|
|||||||
const log = logger || console;
|
const log = logger || console;
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
async function tick() {
|
async function tick(source = 'interval') {
|
||||||
lastScheduledRunAt = Date.now();
|
if (source === 'interval') {
|
||||||
|
lastScheduledRunAt = Date.now();
|
||||||
|
if (activeIntervalMs) {
|
||||||
|
nextScheduledRunAt = lastScheduledRunAt + activeIntervalMs;
|
||||||
|
persistNextRunAt(nextScheduledRunAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
let uiSettings;
|
let uiSettings;
|
||||||
try {
|
try {
|
||||||
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
@@ -369,6 +395,7 @@ function initNetworkMapScheduler(logger, port) {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
runPing: settings.runPing,
|
runPing: settings.runPing,
|
||||||
runSpeedTest: settings.runSpeedTest,
|
runSpeedTest: settings.runSpeedTest,
|
||||||
|
trigger: source === 'startup-delay' ? 'startup-delay' : 'scheduled',
|
||||||
log: (msg) => {
|
log: (msg) => {
|
||||||
logBuffer.push(msg);
|
logBuffer.push(msg);
|
||||||
log.info({ component: 'network-map-scheduler' }, msg);
|
log.info({ component: 'network-map-scheduler' }, msg);
|
||||||
@@ -389,29 +416,62 @@ function initNetworkMapScheduler(logger, port) {
|
|||||||
}
|
}
|
||||||
const settings = getSettingsFromUiSettings(uiSettings);
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
|
activeIntervalMs = null;
|
||||||
|
nextScheduledRunAt = null;
|
||||||
|
persistNextRunAt(null);
|
||||||
log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings');
|
log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logBuffer.setMaxLines(settings.logMaxLines);
|
logBuffer.setMaxLines(settings.logMaxLines);
|
||||||
const intervalMs = settings.intervalMinutes * 60 * 1000;
|
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(
|
log.info(
|
||||||
{ component: 'network-map-scheduler', intervalMinutes: settings.intervalMinutes },
|
{
|
||||||
'Network map scheduler started (first run in 10 s, then every %d min)',
|
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
|
settings.intervalMinutes
|
||||||
);
|
);
|
||||||
schedulerTimer = setInterval(tick, intervalMs);
|
schedulerStartTimeout = setTimeout(() => {
|
||||||
// Первый запуск через 10 сек, чтобы кеш появился вскоре после старта сервера
|
tick('interval');
|
||||||
setTimeout(() => tick(), 10 * 1000);
|
schedulerTimer = setInterval(() => tick('interval'), intervalMs);
|
||||||
|
}, firstDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule();
|
schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopNetworkMapScheduler() {
|
function stopNetworkMapScheduler() {
|
||||||
|
if (schedulerStartTimeout) {
|
||||||
|
clearTimeout(schedulerStartTimeout);
|
||||||
|
schedulerStartTimeout = null;
|
||||||
|
}
|
||||||
if (schedulerTimer) {
|
if (schedulerTimer) {
|
||||||
clearInterval(schedulerTimer);
|
clearInterval(schedulerTimer);
|
||||||
schedulerTimer = null;
|
schedulerTimer = null;
|
||||||
}
|
}
|
||||||
|
activeIntervalMs = null;
|
||||||
|
nextScheduledRunAt = null;
|
||||||
|
persistNextRunAt(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLogs() {
|
function getLogs() {
|
||||||
@@ -439,9 +499,19 @@ function pushLog(msg) {
|
|||||||
*/
|
*/
|
||||||
function getNextRunAt(settings) {
|
function getNextRunAt(settings) {
|
||||||
if (!settings || !settings.enabled) return null;
|
if (!settings || !settings.enabled) return null;
|
||||||
const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000;
|
if (typeof nextScheduledRunAt === 'number' && Number.isFinite(nextScheduledRunAt)) {
|
||||||
const from = lastScheduledRunAt || Date.now();
|
return nextScheduledRunAt;
|
||||||
return from + intervalMs;
|
}
|
||||||
|
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 = {
|
module.exports = {
|
||||||
|
|||||||
+120
-243
@@ -1,58 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* Сервис для работы с S3 (Yandex Object Storage)
|
* Локальное хранилище объектов (SQLite). Сохранены имена экспортов readS3TextObject / writeS3JsonObject и т.д. для совместимости с роутами.
|
||||||
* Централизованные операции чтения/записи/кэширования
|
* Справочники EvoBGP не хранятся здесь — см. evobgpClient.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
const { openDatabase } = require('../db/sqliteDb');
|
||||||
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
const blobStorage = require('./blobStorage');
|
||||||
const http = require('http');
|
|
||||||
const https = require('https');
|
|
||||||
|
|
||||||
// S3 Client setup
|
openDatabase();
|
||||||
const s3 = new S3Client({
|
|
||||||
endpoint: 'https://storage.yandexcloud.net',
|
|
||||||
region: process.env.AWS_REGION,
|
|
||||||
forcePathStyle: true,
|
|
||||||
maxAttempts: 3,
|
|
||||||
requestHandler: new NodeHttpHandler({
|
|
||||||
httpAgent: new http.Agent({ keepAlive: true }),
|
|
||||||
httpsAgent: new https.Agent({ keepAlive: true })
|
|
||||||
}),
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const BUCKET_NAME = process.env.S3_BUCKET_NAME;
|
const BUCKET_NAME = process.env.SQLITE_BUCKET_LABEL || 'sqlite';
|
||||||
|
|
||||||
// In-memory cache
|
|
||||||
const cache = {
|
const cache = {
|
||||||
text: new Map(),
|
text: new Map(),
|
||||||
head: new Map(),
|
head: new Map(),
|
||||||
max: 100,
|
max: 100,
|
||||||
ttlMs: 30_000
|
ttlMs: 30_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Конвертирует stream в строку
|
|
||||||
*/
|
|
||||||
async function streamToString(stream) {
|
async function streamToString(stream) {
|
||||||
if (!stream) return '';
|
if (!stream) return '';
|
||||||
if (typeof stream.transformToString === 'function') {
|
if (typeof stream.transformToString === 'function') {
|
||||||
return await stream.transformToString();
|
return await stream.transformToString();
|
||||||
}
|
}
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
let chunks = [];
|
const chunks = [];
|
||||||
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
|
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
|
||||||
stream.once('error', reject);
|
stream.once('error', reject);
|
||||||
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить значение из кэша
|
|
||||||
*/
|
|
||||||
function getCache(map, key) {
|
function getCache(map, key) {
|
||||||
const v = map.get(key);
|
const v = map.get(key);
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
@@ -63,9 +40,6 @@ function getCache(map, key) {
|
|||||||
return v.value;
|
return v.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Установить значение в кэш
|
|
||||||
*/
|
|
||||||
function setCache(map, key, value) {
|
function setCache(map, key, value) {
|
||||||
if (map.size >= cache.max) {
|
if (map.size >= cache.max) {
|
||||||
const firstKey = map.keys().next().value;
|
const firstKey = map.keys().next().value;
|
||||||
@@ -74,213 +48,115 @@ function setCache(map, key, value) {
|
|||||||
map.set(key, { value, at: Date.now() });
|
map.set(key, { value, at: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Инвалидировать кэш для ключа
|
|
||||||
*/
|
|
||||||
function invalidateCacheForKey(key) {
|
function invalidateCacheForKey(key) {
|
||||||
try { cache.text.delete(key); } catch {}
|
|
||||||
try { cache.head.delete(key); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Прочитать текстовый объект из S3
|
|
||||||
*/
|
|
||||||
async function readS3TextObject(key, s3Duration = null) {
|
|
||||||
const cached = getCache(cache.text, key);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const s3Start = Date.now();
|
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
|
||||||
if (s3Duration) {
|
|
||||||
try { s3Duration.labels('getObject').observe((Date.now() - s3Start)/1000); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = {
|
|
||||||
body: await streamToString(data.Body),
|
|
||||||
etag: data.ETag || undefined,
|
|
||||||
lastModified: data.LastModified ? data.LastModified.toISOString() : undefined,
|
|
||||||
contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined
|
|
||||||
};
|
|
||||||
setCache(cache.text, key, out);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить ETag объекта из S3
|
|
||||||
*/
|
|
||||||
async function headS3ObjectEtag(key, s3Duration = null) {
|
|
||||||
const cached = getCache(cache.head, key);
|
|
||||||
if (cached && cached.etag) return cached.etag;
|
|
||||||
|
|
||||||
const s3Start = Date.now();
|
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
|
||||||
if (s3Duration) {
|
|
||||||
try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
setCache(cache.head, key, { etag: head.ETag || undefined });
|
|
||||||
return head.ETag || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить метаданные объекта (etag, lastModified, contentLength)
|
|
||||||
*/
|
|
||||||
async function headMeta(key) {
|
|
||||||
try {
|
try {
|
||||||
const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
cache.text.delete(key);
|
||||||
return {
|
} catch (_) {}
|
||||||
etag: h.ETag || null,
|
try {
|
||||||
lastModified: h.LastModified ? new Date(h.LastModified).toISOString() : null,
|
cache.head.delete(key);
|
||||||
contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null,
|
} catch (_) {}
|
||||||
};
|
}
|
||||||
} catch (e) {
|
|
||||||
return { etag: null, lastModified: null, contentLength: null };
|
async function readS3TextObject(key) {
|
||||||
|
const cached = getCache(cache.text, key);
|
||||||
|
if (cached) return cached;
|
||||||
|
const row = blobStorage.readBlob(key);
|
||||||
|
if (!row) {
|
||||||
|
const err = new Error('NoSuchKey');
|
||||||
|
err.code = 'NoSuchKey';
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
const out = {
|
||||||
|
body: row.body,
|
||||||
/**
|
etag: row.etag,
|
||||||
* Записать текстовый объект в S3
|
lastModified: row.lastModified,
|
||||||
*/
|
contentLength: row.contentLength,
|
||||||
async function writeS3TextObject(key, content, contentType = 'text/plain') {
|
};
|
||||||
await s3.send(new PutObjectCommand({
|
setCache(cache.text, key, out);
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: key,
|
|
||||||
Body: content,
|
|
||||||
ContentType: contentType,
|
|
||||||
}));
|
|
||||||
invalidateCacheForKey(key);
|
|
||||||
return await headMeta(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Записать JSON объект в S3
|
|
||||||
*/
|
|
||||||
async function writeS3JsonObject(key, data) {
|
|
||||||
return await writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Удалить объект из S3
|
|
||||||
*/
|
|
||||||
async function deleteS3Object(key) {
|
|
||||||
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
|
||||||
invalidateCacheForKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Список объектов в S3 по префиксу
|
|
||||||
* Используется для истории/бэкапов (количество ограничено для безопасности)
|
|
||||||
*/
|
|
||||||
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
|
|
||||||
const out = [];
|
|
||||||
let continuationToken = undefined;
|
|
||||||
|
|
||||||
while (out.length < maxKeys) {
|
|
||||||
const resp = await s3.send(new ListObjectsV2Command({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Prefix: prefix,
|
|
||||||
ContinuationToken: continuationToken,
|
|
||||||
MaxKeys: Math.min(1000, maxKeys - out.length),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const contents = resp.Contents || [];
|
|
||||||
for (const obj of contents) {
|
|
||||||
out.push({
|
|
||||||
key: obj.Key,
|
|
||||||
size: typeof obj.Size === 'number' ? obj.Size : null,
|
|
||||||
lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
|
|
||||||
etag: obj.ETag || null,
|
|
||||||
});
|
|
||||||
if (out.length >= maxKeys) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continuationToken = resp.NextContinuationToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function headS3ObjectEtag(key) {
|
||||||
* Потоковое чтение с пагинацией больших текстовых файлов
|
const cached = getCache(cache.head, key);
|
||||||
*/
|
if (cached && cached.etag) return cached.etag;
|
||||||
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
|
const etag = blobStorage.headEtag(key);
|
||||||
return new Promise(async (resolve, reject) => {
|
if (etag) setCache(cache.head, key, { etag });
|
||||||
let total = 0;
|
return etag;
|
||||||
const items = [];
|
|
||||||
let sent = 0;
|
|
||||||
let buffered = '';
|
|
||||||
|
|
||||||
const matchesQuery = (line) => {
|
|
||||||
if (!q) return true;
|
|
||||||
return line.toLowerCase().includes(String(q).toLowerCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
|
||||||
const stream = resp.Body;
|
|
||||||
|
|
||||||
if (!stream || typeof stream.on !== 'function') {
|
|
||||||
const text = await streamToString(resp.Body);
|
|
||||||
const lines = text.split('\n');
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = String(lines[i] || '').trim();
|
|
||||||
if (!line) continue;
|
|
||||||
if (!matchesQuery(line)) continue;
|
|
||||||
total++;
|
|
||||||
const pos = total - 1;
|
|
||||||
if (limit > 0) {
|
|
||||||
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
|
|
||||||
} else {
|
|
||||||
items.push(mapLine(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolve({ items, total });
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on('data', (chunk) => {
|
|
||||||
buffered += chunk.toString('utf-8');
|
|
||||||
let lines = buffered.split('\n');
|
|
||||||
buffered = lines.pop();
|
|
||||||
for (const lnRaw of lines) {
|
|
||||||
const line = lnRaw.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
if (!matchesQuery(line)) continue;
|
|
||||||
total++;
|
|
||||||
const pos = total - 1;
|
|
||||||
if (limit > 0) {
|
|
||||||
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
|
|
||||||
} else {
|
|
||||||
items.push(mapLine(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('end', () => {
|
|
||||||
const last = (buffered || '').trim();
|
|
||||||
if (last) {
|
|
||||||
if (!q || last.toLowerCase().includes(String(q).toLowerCase())) {
|
|
||||||
total++;
|
|
||||||
if (limit > 0) {
|
|
||||||
const pos = total - 1;
|
|
||||||
if (pos >= offset && items.length < limit) items.push(mapLine(last));
|
|
||||||
} else {
|
|
||||||
items.push(mapLine(last));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolve({ items, total });
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', reject);
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function headMeta(key) {
|
||||||
|
return blobStorage.headMetaRow(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeS3TextObject(key, content, contentType = 'text/plain') {
|
||||||
|
const db = openDatabase();
|
||||||
|
blobStorage.writeBlobTx(db, key, content, contentType);
|
||||||
|
invalidateCacheForKey(key);
|
||||||
|
return headMeta(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeS3JsonObject(key, data) {
|
||||||
|
return writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteS3Object(key) {
|
||||||
|
blobStorage.deleteBlob(key);
|
||||||
|
invalidateCacheForKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
|
||||||
|
return blobStorage.listByPrefix(prefix, maxKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
const row = await readS3TextObject(key);
|
||||||
|
text = row.body || '';
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'NoSuchKey') {
|
||||||
|
return { items: [], total: 0 };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = text.split('\n');
|
||||||
|
let total = 0;
|
||||||
|
const items = [];
|
||||||
|
const qstr = q ? String(q).toLowerCase() : '';
|
||||||
|
const matchesQuery = (line) => !qstr || line.toLowerCase().includes(qstr);
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = String(lines[i] || '').trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (!matchesQuery(line)) continue;
|
||||||
|
total++;
|
||||||
|
const pos = total - 1;
|
||||||
|
if (limit > 0) {
|
||||||
|
if (pos >= offset && items.length < limit) items.push(mapLine(line));
|
||||||
|
} else {
|
||||||
|
items.push(mapLine(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBlobVersionsForKey(key, maxKeys = 50) {
|
||||||
|
return blobStorage.listVersions(key, maxKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollbackBlobVersion(key, versionId) {
|
||||||
|
openDatabase();
|
||||||
|
return blobStorage.rollbackToVersionId(key, versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заглушка: прямой вызов S3 SDK больше не используется. */
|
||||||
|
const s3 = {
|
||||||
|
send() {
|
||||||
|
throw new Error('S3 SDK removed: use readS3TextObject / writeS3TextObject or evobgpClient');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
s3,
|
s3,
|
||||||
BUCKET_NAME,
|
BUCKET_NAME,
|
||||||
@@ -294,5 +170,6 @@ module.exports = {
|
|||||||
streamPaginatedText,
|
streamPaginatedText,
|
||||||
invalidateCacheForKey,
|
invalidateCacheForKey,
|
||||||
listS3Objects,
|
listS3Objects,
|
||||||
|
listBlobVersionsForKey,
|
||||||
|
rollbackBlobVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,14 +39,15 @@ function mapAjvErrors(errors) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Маппинг UI ресурсов на S3 ключи (для истории версий)
|
* Маппинг UI ресурсов на ключи в SQLite blobs (только локально версионируемые объекты).
|
||||||
|
* Справочники EvoBGP (domains-new / asns / ip-ranges) в истории не участвуют.
|
||||||
*/
|
*/
|
||||||
function resourceToKey(resource) {
|
function resourceToKey(resource) {
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
case 'domains-new': return 'bgp_data/domains_community.txt';
|
case 'servers':
|
||||||
case 'ip-ranges': return 'bgp_data/ips.txt';
|
return 'servers.json';
|
||||||
case 'asns': return 'bgp_data/asns.txt';
|
default:
|
||||||
default: return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-50
@@ -1,41 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* Универсальные хелперы для работы с S3
|
* Универсальные хелперы для JSON/текста из локального storage (SQLite)
|
||||||
* Устраняют дублирование кода в routes
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME } = require('../services/s3Service');
|
const { headMeta, readS3TextObject } = require('../services/s3Service');
|
||||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
|
||||||
const { streamToString } = require('../services/s3Service');
|
|
||||||
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Универсальный GET для JSON объектов из S3 с заголовками
|
* Универсальный GET для JSON объектов с заголовками
|
||||||
* @param {string} s3Key - Ключ S3
|
|
||||||
* @param {object} req - Express request
|
|
||||||
* @param {object} res - Express response
|
|
||||||
* @param {object} options - Опции
|
|
||||||
* @param {function} options.transform - Функция трансформации данных
|
|
||||||
* @param {*} options.defaultValue - Значение по умолчанию при отсутствии файла
|
|
||||||
*/
|
*/
|
||||||
async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
|
async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
|
||||||
const { transform = (data) => data, defaultValue = [] } = options;
|
const { transform = (data) => data, defaultValue = [] } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Получаем HEAD для установки заголовков
|
const head = await headMeta(s3Key);
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
const etag = head?.etag || null;
|
||||||
const etag = head?.ETag || null;
|
|
||||||
|
|
||||||
// Устанавливаем заголовки
|
|
||||||
if (etag) res.set('ETag', String(etag));
|
if (etag) res.set('ETag', String(etag));
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||||
|
|
||||||
// Проверяем If-None-Match
|
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
// Получаем объект
|
let fileContent;
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
try {
|
||||||
const fileContent = await streamToString(data.Body);
|
const data = await readS3TextObject(s3Key);
|
||||||
|
fileContent = data.body;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'NoSuchKey') {
|
||||||
|
return res.json(transform(defaultValue));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
@@ -48,54 +43,46 @@ async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
|
|||||||
parsed = defaultValue;
|
parsed = defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Применяем трансформацию
|
|
||||||
const result = transform(parsed);
|
const result = transform(parsed);
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
console.error(error);
|
||||||
res.json(defaultValue);
|
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Универсальный GET для текстовых объектов из S3 с заголовками
|
* Универсальный GET для текстовых объектов с заголовками
|
||||||
* @param {string} s3Key - Ключ S3
|
|
||||||
* @param {object} req - Express request
|
|
||||||
* @param {object} res - Express response
|
|
||||||
* @param {object} options - Опции
|
|
||||||
* @param {function} options.transform - Функция трансформации текста
|
|
||||||
* @param {string} options.defaultValue - Значение по умолчанию при отсутствии файла
|
|
||||||
*/
|
*/
|
||||||
async function getS3TextWithHeaders(s3Key, req, res, options = {}) {
|
async function getS3TextWithHeaders(s3Key, req, res, options = {}) {
|
||||||
const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
|
const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
const head = await headMeta(s3Key);
|
||||||
const etag = head?.ETag || null;
|
const etag = head?.etag || null;
|
||||||
|
|
||||||
if (etag) res.set('ETag', String(etag));
|
if (etag) res.set('ETag', String(etag));
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||||
|
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
let text;
|
||||||
const text = await streamToString(data.Body);
|
try {
|
||||||
|
const data = await readS3TextObject(s3Key);
|
||||||
|
text = data.body;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'NoSuchKey') {
|
||||||
|
return res.json(transform(defaultValue));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
const result = transform(text);
|
const result = transform(text);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
console.error(error);
|
||||||
res.json(transform(defaultValue));
|
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,4 +90,3 @@ module.exports = {
|
|||||||
getS3JsonWithHeaders,
|
getS3JsonWithHeaders,
|
||||||
getS3TextWithHeaders,
|
getS3TextWithHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+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.
|
||||||
|
|
||||||
## 📋 Содержание
|
## 📋 Содержание
|
||||||
|
|
||||||
|
|||||||
@@ -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
+10
@@ -20,6 +20,7 @@
|
|||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
@@ -2798,6 +2799,15 @@
|
|||||||
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -43,21 +43,24 @@ import AddItemModal from './components/AddItemModal.jsx';
|
|||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
||||||
import LastSaved from './components/LastSaved.jsx';
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
|
import {
|
||||||
const API_URL = '/api';
|
listCommunities,
|
||||||
|
fetchAsnsData,
|
||||||
|
saveAsnsData,
|
||||||
|
} from './lib/evobgpData.js';
|
||||||
|
|
||||||
function ASNsNewManager() {
|
function ASNsNewManager() {
|
||||||
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({ asn: '', community: '' });
|
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
||||||
const [newInvalid, setNewInvalid] = useState({ asn: false, community: false });
|
const [, setNewInvalid] = useState({ asn: 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 [editingValue, setEditingValue] = useState('');
|
const [editingValue, setEditingValue] = useState('');
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -80,9 +83,9 @@ function ASNsNewManager() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(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 (_e) {
|
||||||
// тихо игнорируем
|
// тихо игнорируем
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -112,21 +115,17 @@ function ASNsNewManager() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchItems = async (abortSignal) => {
|
const fetchItems = useCallback(async (_abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
const mapped = await fetchAsnsData();
|
||||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
|
||||||
const mapped = payload.map(item => ({ asn: String(item.domain), community: String(item.type) }));
|
|
||||||
const total = mapped.length;
|
const total = mapped.length;
|
||||||
setItems(mapped);
|
setItems(mapped);
|
||||||
setOriginalItems(mapped);
|
setOriginalItems(mapped);
|
||||||
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;
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
@@ -135,7 +134,7 @@ function ASNsNewManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!didInit.current) {
|
if (!didInit.current) {
|
||||||
@@ -144,7 +143,7 @@ function ASNsNewManager() {
|
|||||||
fetchItems(controller.signal);
|
fetchItems(controller.signal);
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [fetchItems]);
|
||||||
|
|
||||||
const handleAddItem = (item) => {
|
const handleAddItem = (item) => {
|
||||||
const asn = item?.asn || newItem.asn;
|
const asn = item?.asn || newItem.asn;
|
||||||
@@ -298,7 +297,7 @@ function ASNsNewManager() {
|
|||||||
return { added, removed, changed };
|
return { added, removed, changed };
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [_showDiff, setShowDiff] = useState(false);
|
||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
@@ -312,7 +311,29 @@ function ASNsNewManager() {
|
|||||||
setShowDiff(true);
|
setShowDiff(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const performSave = useCallback(async () => {
|
||||||
|
setConfirmSaveOpen(false);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||||
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||||
|
const unique = deduplicate(valid);
|
||||||
|
|
||||||
|
await saveAsnsData(unique, originalItems);
|
||||||
|
setEtag('');
|
||||||
|
await fetchItems();
|
||||||
|
setSuccess('Изменения успешно сохранены!');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
notifyMutationSuccess('Изменения сохранены');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving changes:', error);
|
||||||
|
setError('Не удалось сохранить изменения.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [items, originalItems, fetchItems]);
|
||||||
|
|
||||||
|
const handleSaveChanges = useCallback(async () => {
|
||||||
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
||||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||||
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||||
@@ -330,49 +351,7 @@ function ASNsNewManager() {
|
|||||||
// Малое изменение - сохраняем сразу
|
// Малое изменение - сохраняем сразу
|
||||||
performSave();
|
performSave();
|
||||||
}
|
}
|
||||||
};
|
}, [items, originalItems, performSave]);
|
||||||
|
|
||||||
const performSave = async () => {
|
|
||||||
setConfirmSaveOpen(false);
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
|
||||||
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
|
||||||
const unique = deduplicate(valid);
|
|
||||||
|
|
||||||
// Загрузим полный список, совместим и сохраним
|
|
||||||
const fullRes = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
|
|
||||||
const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
|
|
||||||
const full = fullPayload.map(item => ({ asn: String(item.domain).trim(), community: String(item.type || '').trim() }));
|
|
||||||
const fullMap = new Map(full.map(i => [i.asn, { asn: i.asn, community: i.community }]));
|
|
||||||
const originalPageMap = new Map(originalItems.map(i => [String(i.asn).trim(), true]));
|
|
||||||
const uniqueMap = new Map(unique.map(i => [String(i.asn).trim(), { asn: i.asn, community: i.community }]));
|
|
||||||
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()).map(i => ({ domain: i.asn, type: i.community }));
|
|
||||||
|
|
||||||
const et = fullRes?.headers?.etag || etag;
|
|
||||||
const payload = { domains: fullToSave, etag: et };
|
|
||||||
const response = await api.post(`/asns`, payload, { 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();
|
|
||||||
setSuccess('Изменения успешно сохранены!');
|
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
|
||||||
notifyMutationSuccess('Изменения сохранены');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving changes:', error);
|
|
||||||
setError('Не удалось сохранить изменения.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
setImportOpen(true);
|
setImportOpen(true);
|
||||||
@@ -380,10 +359,7 @@ function ASNsNewManager() {
|
|||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
|
const all = filtered;
|
||||||
const response = await api.get(`/asns`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
|
|
||||||
const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
|
|
||||||
const all = payload.map(item => ({ asn: item.domain, community: item.type }));
|
|
||||||
const header = ['asn', 'community'];
|
const header = ['asn', 'community'];
|
||||||
const csv = [header, ...all.map(i => [i.asn, i.community])]
|
const csv = [header, ...all.map(i => [i.asn, i.community])]
|
||||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
@@ -401,7 +377,7 @@ function ASNsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -469,6 +445,7 @@ function ASNsNewManager() {
|
|||||||
});
|
});
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||||
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
|
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
|
||||||
|
const paginatedAsns = paginatedItems.map(i => i?.asn).filter(Boolean).join(',');
|
||||||
|
|
||||||
// Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems)
|
// Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -482,7 +459,7 @@ function ASNsNewManager() {
|
|||||||
const name = await getAsnName(asn);
|
const name = await getAsnName(asn);
|
||||||
if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name }));
|
if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name }));
|
||||||
});
|
});
|
||||||
}, [JSON.stringify(paginatedItems)]);
|
}, [paginatedAsns, paginatedItems]);
|
||||||
|
|
||||||
// Для фильтра - список всех уникальных community
|
// Для фильтра - список всех уникальных community
|
||||||
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => String(i.community || '')).filter(c => c !== '')));
|
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => String(i.community || '')).filter(c => c !== '')));
|
||||||
@@ -507,7 +484,7 @@ function ASNsNewManager() {
|
|||||||
};
|
};
|
||||||
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) => {
|
const handleEditKeyDown = (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
|
|||||||
+216
-7
@@ -1,4 +1,4 @@
|
|||||||
/* Custom styles for S3 Lists Manager */
|
/* Custom styles for RouterOS Manager */
|
||||||
/* Layout: Tabler Fluid Vertical (https://preview.tabler.io/layout-fluid-vertical.html) */
|
/* Layout: Tabler Fluid Vertical (https://preview.tabler.io/layout-fluid-vertical.html) */
|
||||||
|
|
||||||
/* --- Fluid Vertical: page structure --- */
|
/* --- Fluid Vertical: page structure --- */
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
.navbar-vertical .navbar-sidebar-footer {
|
.navbar-vertical .navbar-sidebar-footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-top-color: rgba(255, 255, 255, 0.1) !important;
|
border-top-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon {
|
.navbar-vertical .navbar-sidebar-footer .nav-link.btn-icon {
|
||||||
padding: 0.35rem;
|
padding: 0.35rem;
|
||||||
@@ -92,6 +93,23 @@
|
|||||||
.navbar-vertical .navbar-sidebar-footer .dropdown-toggle::after {
|
.navbar-vertical .navbar-sidebar-footer .dropdown-toggle::after {
|
||||||
display: none;
|
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), не у обычных ссылок и не у бренда */
|
/* Стрелка только у пунктов с подменю (dropdown-toggle), не у обычных ссылок и не у бренда */
|
||||||
.navbar-vertical .nav-link:not(.dropdown-toggle)::after {
|
.navbar-vertical .nav-link:not(.dropdown-toggle)::after {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -115,8 +133,9 @@
|
|||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
/* Sidebar dropdown: inline submenu (no popup) */
|
/* Sidebar dropdown: inline submenu (no popup) — только для основного списка меню,
|
||||||
.navbar-vertical .nav-item.dropdown .dropdown-menu {
|
не затрагиваем иконки в нижнем футере сайдбара (язык, тема, колокол и т.д.) */
|
||||||
|
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-menu {
|
||||||
position: static;
|
position: static;
|
||||||
float: none;
|
float: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -125,12 +144,12 @@
|
|||||||
padding: 0 0 0.5rem 0;
|
padding: 0 0 0.5rem 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.navbar-vertical .nav-item.dropdown .dropdown-item {
|
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item {
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-top: 0.35rem;
|
padding-top: 0.35rem;
|
||||||
padding-bottom: 0.35rem;
|
padding-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
.navbar-vertical .nav-item.dropdown .dropdown-item .nav-link-icon {
|
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon {
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -138,7 +157,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.navbar-vertical .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
|
.navbar-vertical .navbar-nav > .nav-item.dropdown .dropdown-item .nav-link-icon .icon {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
@@ -347,6 +366,96 @@
|
|||||||
padding-left: 0;
|
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 {
|
||||||
@@ -461,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;
|
||||||
@@ -474,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 {
|
||||||
@@ -564,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 {
|
||||||
|
|||||||
+99
-26
@@ -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,
|
||||||
@@ -27,7 +27,8 @@ import {
|
|||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconClock,
|
IconClock,
|
||||||
IconClockPlay,
|
IconClockPlay,
|
||||||
IconShield
|
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';
|
||||||
@@ -51,6 +52,8 @@ import PingServicesManager from './PingServicesManager.jsx';
|
|||||||
import FirewallPage from './FirewallPage.jsx';
|
import FirewallPage from './FirewallPage.jsx';
|
||||||
import SettingsPage from './SettingsPage.jsx';
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
import ResourceStatsPage from './ResourceStatsPage.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 ToastContainer from './components/ToastContainer.jsx';
|
import ToastContainer from './components/ToastContainer.jsx';
|
||||||
@@ -58,6 +61,8 @@ import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPal
|
|||||||
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 { 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 });
|
||||||
@@ -69,16 +74,18 @@ function LanguageProvider({ children }) {
|
|||||||
ru: {
|
ru: {
|
||||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||||
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', 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 Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
|
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', ospfTools: 'Инструменты OSPF',
|
||||||
light: 'Светлая', dark: 'Тёмная',
|
light: 'Светлая', dark: 'Тёмная',
|
||||||
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
|
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||||
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', 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', pingServices: 'Ping Services', firewall: 'Firewall',
|
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'
|
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
|
||||||
}
|
}
|
||||||
@@ -124,12 +131,14 @@ function App() {
|
|||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<PingProvider>
|
<PingProvider>
|
||||||
<ToastContainer>
|
<AlertsProvider>
|
||||||
<NotifyProvider>
|
<ToastContainer>
|
||||||
<NetworkErrorHandler />
|
<NotifyProvider>
|
||||||
<MainLayout />
|
<NetworkErrorHandler />
|
||||||
</NotifyProvider>
|
<MainLayout />
|
||||||
</ToastContainer>
|
</NotifyProvider>
|
||||||
|
</ToastContainer>
|
||||||
|
</AlertsProvider>
|
||||||
</PingProvider>
|
</PingProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
@@ -142,16 +151,58 @@ function App() {
|
|||||||
const LAYOUT_FLUID = 'fluid';
|
const LAYOUT_FLUID = 'fluid';
|
||||||
const LAYOUT_HORIZONTAL = 'horizontal';
|
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 [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
|
||||||
|
const [sidebarBackdrop, setSidebarBackdrop] = useState(false);
|
||||||
|
const sidebarTogglerRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('layout', layout);
|
localStorage.setItem('layout', 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({
|
||||||
data: false,
|
data: false,
|
||||||
@@ -205,6 +256,7 @@ function MainLayout() {
|
|||||||
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
|
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
|
||||||
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
|
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
|
||||||
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
|
{ 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: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -238,6 +290,7 @@ function MainLayout() {
|
|||||||
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: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
|
||||||
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
|
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
|
||||||
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
|
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
|
||||||
@@ -284,7 +337,7 @@ function MainLayout() {
|
|||||||
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
<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">
|
<Link to="/" className="navbar-brand d-flex align-items-center">
|
||||||
<IconBrandTabler className="navbar-brand-icon me-2" />
|
<IconBrandTabler className="navbar-brand-icon me-2" />
|
||||||
<span className="fw-bold">S3 Lists Manager</span>
|
<span className="fw-bold">RouterOS Manager</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -365,12 +418,12 @@ function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-item dropdown me-1">
|
<div className="nav-item dropdown me-1">
|
||||||
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
|
<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="badge">{lang.toUpperCase()}</span>
|
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-menu dropdown-menu-end">
|
<div className="dropdown-menu dropdown-menu-end">
|
||||||
<a href="#" className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}>Русский</a>
|
<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 ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}>English</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>
|
</div>
|
||||||
<div className="nav-item dropdown me-1">
|
<div className="nav-item dropdown me-1">
|
||||||
@@ -382,6 +435,9 @@ function MainLayout() {
|
|||||||
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
|
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="nav-item me-1">
|
||||||
|
<AlertsBell />
|
||||||
|
</div>
|
||||||
<div className="nav-item me-1">
|
<div className="nav-item me-1">
|
||||||
<KeyboardShortcutsButton className="nav-link px-2" />
|
<KeyboardShortcutsButton className="nav-link px-2" />
|
||||||
</div>
|
</div>
|
||||||
@@ -399,6 +455,7 @@ function MainLayout() {
|
|||||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||||
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||||
|
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
|
||||||
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||||
<Route path="/domains" element={<DomainsNewManager />} />
|
<Route path="/domains" element={<DomainsNewManager />} />
|
||||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||||
@@ -411,6 +468,7 @@ function MainLayout() {
|
|||||||
<Route path="/network-config" element={<NetworkConfigManager />} />
|
<Route path="/network-config" element={<NetworkConfigManager />} />
|
||||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
|
<Route path="/ospf-tools" element={<OspfToolsPage />} />
|
||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
<Route path="/firewall" element={<FirewallPage />} />
|
<Route path="/firewall" element={<FirewallPage />} />
|
||||||
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
@@ -441,22 +499,23 @@ function MainLayout() {
|
|||||||
<div className="container-fluid">
|
<div className="container-fluid">
|
||||||
<Link to="/" className="navbar-brand navbar-brand-autodark">
|
<Link to="/" className="navbar-brand navbar-brand-autodark">
|
||||||
<IconBrandTabler className="navbar-brand-icon" />
|
<IconBrandTabler className="navbar-brand-icon" />
|
||||||
<span className="fw-bold">S3 Lists Manager</span>
|
<span className="fw-bold">RouterOS Manager</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="navbar-nav flex-row d-md-none">
|
<div className="navbar-nav flex-row d-md-none">
|
||||||
<button
|
<button
|
||||||
|
ref={sidebarTogglerRef}
|
||||||
className="navbar-toggler"
|
className="navbar-toggler"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
data-bs-target="#sidebar-menu"
|
data-bs-target={`#${SIDEBAR_COLLAPSE_ID}`}
|
||||||
aria-controls="sidebar-menu"
|
aria-controls={SIDEBAR_COLLAPSE_ID}
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-label="Открыть меню"
|
aria-label="Открыть меню"
|
||||||
>
|
>
|
||||||
<IconMenu2 />
|
<IconMenu2 />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="collapse navbar-collapse" id="sidebar-menu">
|
<div className="collapse navbar-collapse" id={SIDEBAR_COLLAPSE_ID}>
|
||||||
<ul className="navbar-nav pt-lg-3">
|
<ul className="navbar-nav pt-lg-3">
|
||||||
{navCategories.map(category => (
|
{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' : ''}`}>
|
<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' : ''}`}>
|
||||||
@@ -509,19 +568,19 @@ function MainLayout() {
|
|||||||
<div className="navbar-sidebar-footer border-top mt-auto">
|
<div className="navbar-sidebar-footer border-top mt-auto">
|
||||||
<div className="d-flex align-items-center justify-content-center gap-1 py-2 px-2">
|
<div className="d-flex align-items-center justify-content-center gap-1 py-2 px-2">
|
||||||
<div className="nav-item dropdown dropup">
|
<div className="nav-item dropdown dropup">
|
||||||
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
|
<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="Язык">
|
||||||
<span className="badge bg-secondary">{lang.toUpperCase()}</span>
|
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-menu dropdown-menu-end">
|
<div className="dropdown-menu dropdown-menu-start">
|
||||||
<a href="#" className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}>Русский</a>
|
<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 ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}>English</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>
|
</div>
|
||||||
<div className="nav-item dropdown dropup">
|
<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')}>
|
<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} />}
|
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-menu dropdown-menu-end">
|
<div className="dropdown-menu dropdown-menu-start">
|
||||||
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
|
<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')}
|
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
|
||||||
</a>
|
</a>
|
||||||
@@ -534,11 +593,14 @@ function MainLayout() {
|
|||||||
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('light') + ' / ' + t('dark')}>
|
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('light') + ' / ' + t('dark')}>
|
||||||
{theme === 'dark' ? '🌙' : '☀️'}
|
{theme === 'dark' ? '🌙' : '☀️'}
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-menu dropdown-menu-end">
|
<div className="dropdown-menu dropdown-menu-start">
|
||||||
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}>☀️ {t('light')}</a>
|
<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>
|
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="nav-item">
|
||||||
|
<AlertsBell dropup />
|
||||||
|
</div>
|
||||||
<div className="nav-item">
|
<div className="nav-item">
|
||||||
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
|
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
|
||||||
</div>
|
</div>
|
||||||
@@ -553,6 +615,15 @@ function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Бэкдроп мобильного меню: закрытие по клику */}
|
||||||
|
{sidebarBackdrop && (
|
||||||
|
<div
|
||||||
|
className="sidebar-backdrop d-md-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => sidebarTogglerRef.current?.click?.()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="page-wrapper">
|
<div className="page-wrapper">
|
||||||
<div className="page-body">
|
<div className="page-body">
|
||||||
<div className="container-fluid">
|
<div className="container-fluid">
|
||||||
@@ -562,6 +633,7 @@ function MainLayout() {
|
|||||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||||
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||||
|
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
|
||||||
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||||
<Route path="/domains" element={<DomainsNewManager />} />
|
<Route path="/domains" element={<DomainsNewManager />} />
|
||||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||||
@@ -574,6 +646,7 @@ function MainLayout() {
|
|||||||
<Route path="/network-config" element={<NetworkConfigManager />} />
|
<Route path="/network-config" element={<NetworkConfigManager />} />
|
||||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
|
<Route path="/ospf-tools" element={<OspfToolsPage />} />
|
||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
<Route path="/firewall" element={<FirewallPage />} />
|
<Route path="/firewall" element={<FirewallPage />} />
|
||||||
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
+128
-124
@@ -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,7 +86,7 @@ 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 [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
|
||||||
@@ -76,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;
|
||||||
|
|
||||||
// Курсы валют
|
// Курсы валют
|
||||||
@@ -92,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({
|
||||||
@@ -112,6 +147,73 @@ function BillingManager() {
|
|||||||
serverId: '',
|
serverId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fetchExchangeRates = useCallback(async (abortSignal) => {
|
||||||
|
setRatesLoading(true);
|
||||||
|
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const signal = controller.signal;
|
const signal = controller.signal;
|
||||||
@@ -119,13 +221,19 @@ function BillingManager() {
|
|||||||
fetchExchangeRates(signal);
|
fetchExchangeRates(signal);
|
||||||
fetchServers(signal);
|
fetchServers(signal);
|
||||||
return () => controller.abort();
|
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)
|
||||||
);
|
);
|
||||||
@@ -165,118 +273,14 @@ function BillingManager() {
|
|||||||
}, [servers, loading]);
|
}, [servers, loading]);
|
||||||
|
|
||||||
|
|
||||||
const fetchExchangeRates = async (abortSignal) => {
|
|
||||||
setRatesLoading(true);
|
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
|
||||||
try {
|
|
||||||
// Основной источник: ЦБ РФ (base = RUB)
|
|
||||||
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, // сколько EUR за 1 USD
|
|
||||||
RUB: rubPerUsd // сколько RUB за 1 USD
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
|
||||||
console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Резервный источник: open.er-api
|
|
||||||
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 = 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 = async (abortSignal) => {
|
|
||||||
setLoading(true);
|
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/billing`, opts);
|
|
||||||
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) {
|
|
||||||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
|
||||||
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) {
|
||||||
@@ -466,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);
|
||||||
}
|
}
|
||||||
@@ -479,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);
|
||||||
@@ -492,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);
|
||||||
@@ -504,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);
|
||||||
@@ -1151,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] = {
|
||||||
@@ -1166,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,
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import {
|
|||||||
IconFilter,
|
IconFilter,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconList,
|
IconList,
|
||||||
IconLink,
|
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconSortAscending,
|
IconSortAscending,
|
||||||
IconSortDescending,
|
IconSortDescending,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconDeviceFloppy
|
IconDeviceFloppy,
|
||||||
|
IconPlugConnected
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import PortalDropdown from './components/PortalDropdown.jsx';
|
import PortalDropdown from './components/PortalDropdown.jsx';
|
||||||
@@ -29,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();
|
||||||
@@ -53,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);
|
||||||
@@ -72,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) => {
|
||||||
@@ -79,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());
|
||||||
@@ -89,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);
|
||||||
@@ -188,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) {
|
||||||
@@ -215,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);
|
||||||
@@ -231,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);
|
||||||
};
|
};
|
||||||
@@ -247,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);
|
||||||
@@ -278,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);
|
||||||
@@ -295,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.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -318,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) => {
|
||||||
@@ -359,6 +370,61 @@ function CommunitiesManager() {
|
|||||||
icon={<IconFilter size={24} />}
|
icon={<IconFilter size={24} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header py-2 d-flex align-items-center justify-content-between">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<IconPlugConnected size={18} />
|
||||||
|
<span className="fw-semibold">Подключение к EvoBGP API</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
|
||||||
{/* Уведомления */}
|
{/* Уведомления */}
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
|
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
|
||||||
@@ -553,6 +619,7 @@ function CommunitiesManager() {
|
|||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style={{ width: '8%' }}>Иконка</th>
|
||||||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
|
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<IconHash size={16} className="me-1 text-muted" />
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
@@ -577,7 +644,6 @@ function CommunitiesManager() {
|
|||||||
</th>
|
</th>
|
||||||
<th>Описание</th>
|
<th>Описание</th>
|
||||||
<th>Теги</th>
|
<th>Теги</th>
|
||||||
<th>Gateway</th>
|
|
||||||
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
||||||
<th className="text-end">Действия</th>
|
<th className="text-end">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -600,8 +666,18 @@ function CommunitiesManager() {
|
|||||||
</td></tr>
|
</td></tr>
|
||||||
) : paginated.map((item) => {
|
) : paginated.map((item) => {
|
||||||
const usageCount = filterUsage[item.value] || 0;
|
const usageCount = filterUsage[item.value] || 0;
|
||||||
|
const iconInfo = item.icon ? getIconById(item.icon) : null;
|
||||||
return (
|
return (
|
||||||
<tr key={item.value}>
|
<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>
|
<td>
|
||||||
<code className="text-blue">{item.value}</code>
|
<code className="text-blue">{item.value}</code>
|
||||||
{item._external && (
|
{item._external && (
|
||||||
@@ -617,9 +693,6 @@ function CommunitiesManager() {
|
|||||||
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
||||||
))}
|
))}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span className="text-muted">{item.gatewayDefault || '—'}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{usageCount > 0 ? (
|
{usageCount > 0 ? (
|
||||||
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
|
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
|
||||||
@@ -629,13 +702,6 @@ function CommunitiesManager() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="btn-list">
|
<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
|
<button
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
onClick={()=>startEdit(item)}
|
onClick={()=>startEdit(item)}
|
||||||
@@ -681,7 +747,7 @@ function CommunitiesManager() {
|
|||||||
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}
|
||||||
@@ -742,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"
|
||||||
@@ -758,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>
|
||||||
|
|
||||||
{/* Модальное окно редактирования */}
|
{/* Модальное окно редактирования */}
|
||||||
@@ -824,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"
|
||||||
@@ -840,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>
|
||||||
|
|
||||||
{/* Модальное окно подтверждения удаления */}
|
{/* Модальное окно подтверждения удаления */}
|
||||||
|
|||||||
+87
-19
@@ -18,6 +18,11 @@ import {
|
|||||||
IconSearch,
|
IconSearch,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
|
IconBell,
|
||||||
|
IconCpu,
|
||||||
|
IconDeviceDesktop,
|
||||||
|
IconDatabase,
|
||||||
|
IconServerOff,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { getIconById } from './lib/brandIcons.js';
|
import { getIconById } from './lib/brandIcons.js';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
@@ -26,13 +31,14 @@ 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 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">
|
||||||
@@ -58,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">
|
||||||
@@ -177,7 +183,16 @@ function PingServiceCard({ config, ms, previousMs, history = [], loading, isExpa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -357,12 +372,12 @@ function Dashboard() {
|
|||||||
title="Панель"
|
title="Панель"
|
||||||
icon={<IconCloud size={24} />}
|
icon={<IconCloud size={24} />}
|
||||||
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>
|
||||||
@@ -371,12 +386,12 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
|
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
|
||||||
<div className="d-flex align-items-center justify-content-between mb-2">
|
<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>
|
<h3 className="mb-0">Пинг сервисов</h3>
|
||||||
<Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются">
|
<Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-primary btn-sm"
|
className="btn btn-outline-primary btn-sm w-100 w-sm-auto"
|
||||||
onClick={() => fetchPingServices(true)}
|
onClick={() => fetchPingServices(true)}
|
||||||
disabled={pingLoading}
|
disabled={pingLoading}
|
||||||
>
|
>
|
||||||
@@ -389,7 +404,7 @@ function Dashboard() {
|
|||||||
{pingServicesConfig.map((config) => (
|
{pingServicesConfig.map((config) => (
|
||||||
<div
|
<div
|
||||||
key={config.id}
|
key={config.id}
|
||||||
className={expandedPingId === config.id ? 'col-12' : 'col-6 col-md-3'}
|
className={expandedPingId === config.id ? 'col-12' : 'col-12 col-sm-6 col-md-3'}
|
||||||
>
|
>
|
||||||
<PingServiceCard
|
<PingServiceCard
|
||||||
config={config}
|
config={config}
|
||||||
@@ -407,9 +422,62 @@ function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</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-3 mb-4">
|
<div className="row g-2 g-md-3 mb-4">
|
||||||
<div className="col-md-3">
|
<div className="col-6 col-lg-3">
|
||||||
<div className="animate-in">
|
<div className="animate-in">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconWorld}
|
icon={IconWorld}
|
||||||
@@ -423,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}
|
||||||
@@ -437,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}
|
||||||
@@ -451,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}
|
||||||
@@ -468,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 ?? '—'}
|
||||||
@@ -478,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 ?? '—'}
|
||||||
@@ -487,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 ?? '—'}`}
|
||||||
@@ -496,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>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -49,22 +49,25 @@ import AddItemModal from './components/AddItemModal.jsx';
|
|||||||
import LastSaved from './components/LastSaved.jsx';
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
import FormModal from './components/FormModal.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 [editingValue, setEditingValue] = useState('');
|
const [editingValue, setEditingValue] = useState('');
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -90,18 +93,18 @@ function DomainsNewManager() {
|
|||||||
const signal = controller.signal;
|
const signal = controller.signal;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`, { signal });
|
const list = await listCommunities();
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
setCommunities(list);
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.get('/ws/url', { signal });
|
const r = await api.get('/ws/url', { signal });
|
||||||
setWsUrl(String(r.data?.url || ''));
|
setWsUrl(String(r.data?.url || ''));
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
@@ -121,20 +124,17 @@ function DomainsNewManager() {
|
|||||||
const [wsOpen, setWsOpen] = useState(false);
|
const [wsOpen, setWsOpen] = useState(false);
|
||||||
const [wsUrl, setWsUrl] = useState('');
|
const [wsUrl, setWsUrl] = useState('');
|
||||||
|
|
||||||
const fetchItems = async (abortSignal) => {
|
const fetchItems = useCallback(async (_abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
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;
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
@@ -143,7 +143,7 @@ function DomainsNewManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!didInit.current) {
|
if (!didInit.current) {
|
||||||
@@ -152,7 +152,7 @@ function DomainsNewManager() {
|
|||||||
fetchItems(controller.signal);
|
fetchItems(controller.signal);
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [fetchItems]);
|
||||||
|
|
||||||
const handleAddItem = (item) => {
|
const handleAddItem = (item) => {
|
||||||
// Если item передан (из модалки), используем его, иначе из state (старый способ)
|
// Если item передан (из модалки), используем его, иначе из state (старый способ)
|
||||||
@@ -273,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 {
|
||||||
@@ -301,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('Изменения успешно сохранены!');
|
||||||
@@ -336,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;
|
||||||
@@ -366,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(','))
|
||||||
@@ -456,7 +426,7 @@ 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) => {
|
const handleEditKeyDown = (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -950,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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
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 { usePing } from './contexts/PingContext.jsx';
|
||||||
@@ -42,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); // Показывать только настроенные
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ function EasySwitchManager() {
|
|||||||
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
|
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
|
||||||
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
|
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
|
||||||
// Единый источник пингов (кеш + API) — общий с страницей фильтров
|
// Единый источник пингов (кеш + API) — общий с страницей фильтров
|
||||||
const { pingMap, requestPings } = usePing();
|
const { pingMap, requestPings, cancelScope } = usePing();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,7 +97,7 @@ function EasySwitchManager() {
|
|||||||
initData();
|
initData();
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, [loadDataWithParams]);
|
||||||
|
|
||||||
// При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся)
|
// При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,10 +115,21 @@ function EasySwitchManager() {
|
|||||||
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
|
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
requestPings(pingTasks);
|
requestPings(pingTasks, { scope: 'easy-switch' });
|
||||||
}, [location.pathname, servers, inventoryServers, requestPings]);
|
}, [location.pathname, servers, inventoryServers, requestPings]);
|
||||||
|
|
||||||
const loadDataWithParams = async (communities, inventory, ncGatewaysParam, signal) => {
|
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('');
|
||||||
|
|
||||||
@@ -258,13 +269,13 @@ function EasySwitchManager() {
|
|||||||
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
|
if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
requestPings(pingTasks);
|
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) {
|
||||||
@@ -289,7 +300,7 @@ 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;
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
@@ -298,7 +309,8 @@ function EasySwitchManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, deps would cause unnecessary re-fetches
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleGatewaySelect = (serverId, community, gateway) => {
|
const handleGatewaySelect = (serverId, community, gateway) => {
|
||||||
const key = `${serverId}:${community}`;
|
const key = `${serverId}:${community}`;
|
||||||
@@ -370,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 = [];
|
||||||
@@ -505,7 +517,7 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
if (loading && servers.length === 0) {
|
if (loading && servers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="container-xl">
|
<div className="easy-switch-page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Easy Switch"
|
title="Easy Switch"
|
||||||
icon={<IconWorld size={24} />}
|
icon={<IconWorld size={24} />}
|
||||||
@@ -525,7 +537,7 @@ function EasySwitchManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-xl">
|
<div className="easy-switch-page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Easy Switch"
|
title="Easy Switch"
|
||||||
icon={<IconWorld size={24} />}
|
icon={<IconWorld size={24} />}
|
||||||
@@ -554,7 +566,7 @@ function EasySwitchManager() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Вкладки */}
|
{/* Вкладки */}
|
||||||
<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
|
||||||
@@ -599,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">
|
||||||
@@ -679,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 ? (
|
||||||
@@ -726,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">
|
||||||
@@ -742,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 && (
|
||||||
@@ -793,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">
|
||||||
@@ -813,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>
|
||||||
)}
|
)}
|
||||||
@@ -842,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>
|
||||||
@@ -895,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>
|
||||||
@@ -919,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>
|
||||||
@@ -952,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">
|
||||||
@@ -972,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>
|
||||||
)}
|
)}
|
||||||
@@ -1001,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>
|
||||||
@@ -1054,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>
|
||||||
@@ -1078,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>
|
||||||
@@ -1120,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>
|
||||||
)}
|
)}
|
||||||
@@ -1149,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>
|
||||||
@@ -1202,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>
|
||||||
@@ -1226,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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Link, useLocation } 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';
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
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 { usePing } from './contexts/PingContext.jsx';
|
import { usePing } from './contexts/PingContext.jsx';
|
||||||
|
import { listCommunities } from './lib/evobgpData.js';
|
||||||
|
|
||||||
// Компонент автокомплита для выбора 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' }) {
|
||||||
@@ -79,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];
|
||||||
|
|
||||||
// Точное совпадение числовой части
|
// Точное совпадение числовой части
|
||||||
@@ -246,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
|
||||||
@@ -773,7 +774,7 @@ function FilterManager() {
|
|||||||
const { pingMap, requestPings } = usePing();
|
const { pingMap, requestPings } = usePing();
|
||||||
|
|
||||||
// Получить metadata сервера из inventory по имени (dns/hostName/ip или короткое имя типа "ihor")
|
// Получить metadata сервера из inventory по имени (dns/hostName/ip или короткое имя типа "ihor")
|
||||||
const getInventoryMetaForServer = (serverName) => {
|
const getInventoryMetaForServer = useCallback((serverName) => {
|
||||||
if (!serverName) return null;
|
if (!serverName) return null;
|
||||||
const name = String(serverName).trim();
|
const name = String(serverName).trim();
|
||||||
return inventoryServers.find((srv) => {
|
return inventoryServers.find((srv) => {
|
||||||
@@ -781,20 +782,18 @@ function FilterManager() {
|
|||||||
const hostName = String(srv.hostName || '').trim();
|
const hostName = String(srv.hostName || '').trim();
|
||||||
const ip = String(srv.ip || '').trim();
|
const ip = String(srv.ip || '').trim();
|
||||||
if (dns === name || hostName === name || ip === name) return true;
|
if (dns === name || hostName === name || ip === name) return true;
|
||||||
// На странице Фильтров selectedServer.name — короткое имя (первая часть dns), например "ihor"
|
|
||||||
if (dns && dns.split('.')[0] === name) return true;
|
if (dns && dns.split('.')[0] === name) return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
};
|
}, [inventoryServers]);
|
||||||
|
|
||||||
// Получить routerId для MikroTik (id/dns/ip), как на странице Easy Switch
|
// Получить routerId для MikroTik (id/dns/ip), как на странице Easy Switch
|
||||||
const getRouterIdForServer = (server) => {
|
const getRouterIdForServer = useCallback((server) => {
|
||||||
if (!server) return null;
|
if (!server) return null;
|
||||||
// Сначала ищем по полному имени (fullName/dns), затем по короткому (name)
|
|
||||||
const meta = getInventoryMetaForServer(server.fullName || server.dns || server.name) || getInventoryMetaForServer(server.name);
|
const meta = getInventoryMetaForServer(server.fullName || server.dns || server.name) || getInventoryMetaForServer(server.name);
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
return meta.id || meta.dns || meta.ip || null;
|
return meta.id || meta.dns || meta.ip || null;
|
||||||
};
|
}, [getInventoryMetaForServer]);
|
||||||
|
|
||||||
// Собираем gateways только для выбранного сервера
|
// Собираем gateways только для выбранного сервера
|
||||||
const selectedServerGateways = (() => {
|
const selectedServerGateways = (() => {
|
||||||
@@ -893,7 +892,7 @@ function FilterManager() {
|
|||||||
.filter((gw) => gw.ip)
|
.filter((gw) => gw.ip)
|
||||||
.map((gw) => ({ routerId, gatewayIp: gw.ip }));
|
.map((gw) => ({ routerId, gatewayIp: gw.ip }));
|
||||||
requestPings(tasks);
|
requestPings(tasks);
|
||||||
}, [location.pathname, selectedServer, selectedServerGateways, requestPings]);
|
}, [location.pathname, selectedServer, selectedServerGateways, requestPings, getRouterIdForServer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -913,10 +912,11 @@ function FilterManager() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`, opts);
|
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 (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
if (signal.aborted) return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -990,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');
|
||||||
}
|
}
|
||||||
@@ -1165,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;
|
||||||
@@ -1192,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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1299,7 +1299,7 @@ function FilterManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeployToAllServers = async () => {
|
const _handleDeployToAllServers = async () => {
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
setError('Сначала выберите сервер с фильтрами для развертывания.');
|
setError('Сначала выберите сервер с фильтрами для развертывания.');
|
||||||
return;
|
return;
|
||||||
@@ -1521,7 +1521,7 @@ function FilterManager() {
|
|||||||
setEditServerModalOpen(false);
|
setEditServerModalOpen(false);
|
||||||
setSuccess('Сервер успешно обновлён!');
|
setSuccess('Сервер успешно обновлён!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setEditServerError('Не удалось сохранить изменения.');
|
setEditServerError('Не удалось сохранить изменения.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1576,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 {
|
||||||
@@ -1590,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);
|
||||||
@@ -1598,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`);
|
||||||
@@ -2561,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('Не удалось обновить статус сервера');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
@@ -250,7 +250,7 @@ export default function FirewallPage() {
|
|||||||
const res = await api.get('/servers');
|
const res = await api.get('/servers');
|
||||||
const list = Array.isArray(res.data) ? res.data : [];
|
const list = Array.isArray(res.data) ? res.data : [];
|
||||||
if (!cancelled) setServers(list);
|
if (!cancelled) setServers(list);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
if (!cancelled) setError('Не удалось загрузить список серверов.');
|
if (!cancelled) setError('Не удалось загрузить список серверов.');
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingServers(false);
|
if (!cancelled) setLoadingServers(false);
|
||||||
@@ -259,7 +259,7 @@ export default function FirewallPage() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchAddressLists = async () => {
|
const fetchAddressLists = useCallback(async () => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
setData(null);
|
setData(null);
|
||||||
return;
|
return;
|
||||||
@@ -281,12 +281,12 @@ export default function FirewallPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (serverId) fetchAddressLists();
|
if (serverId) fetchAddressLists();
|
||||||
else setData(null);
|
else setData(null);
|
||||||
}, [serverId]);
|
}, [serverId, fetchAddressLists]);
|
||||||
|
|
||||||
const runAnalysis = () => {
|
const runAnalysis = () => {
|
||||||
if (!data?.ban?.length) return;
|
if (!data?.ban?.length) return;
|
||||||
@@ -319,7 +319,7 @@ export default function FirewallPage() {
|
|||||||
[s.subnet]: { org: info.org, country: info.country, city: info.city },
|
[s.subnet]: { org: info.org, country: info.country, city: info.city },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch {
|
||||||
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
|
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
|
||||||
}
|
}
|
||||||
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
|
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
MiniMap,
|
MiniMap,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
addEdge,
|
|
||||||
MarkerType,
|
MarkerType,
|
||||||
Handle,
|
Handle,
|
||||||
Position,
|
Position,
|
||||||
@@ -224,7 +223,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
});
|
});
|
||||||
setSelectedNodeId(nodeId);
|
setSelectedNodeId(nodeId);
|
||||||
setTimeout(() => setSelectedNodeId(null), 2000);
|
setTimeout(() => setSelectedNodeId(null), 2000);
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
|
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
|
||||||
@@ -232,7 +231,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
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) => ({
|
||||||
@@ -244,7 +243,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
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]);
|
||||||
@@ -378,7 +377,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
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 */ }
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -389,7 +388,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
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]);
|
||||||
@@ -403,7 +402,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
instanceRef.current.fitView({ padding: 0.2 });
|
instanceRef.current.fitView({ padding: 0.2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, 60);
|
}, 60);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -44,21 +44,41 @@ import Pagination from './components/Pagination.jsx';
|
|||||||
import AddItemModal from './components/AddItemModal.jsx';
|
import AddItemModal from './components/AddItemModal.jsx';
|
||||||
import LastSaved from './components/LastSaved.jsx';
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
|
import {
|
||||||
|
listCommunities,
|
||||||
|
fetchIpRangesData,
|
||||||
|
saveIpRangesData,
|
||||||
|
} from './lib/evobgpData.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const isValidIPv4 = (ip) => {
|
||||||
|
const octets = ip.split('.');
|
||||||
|
if (octets.length !== 4) return false;
|
||||||
|
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidCidr = (value) => {
|
||||||
|
const v = String(value).trim();
|
||||||
|
const parts = v.split('/');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
const [ip, mask] = parts;
|
||||||
|
if (!isValidIPv4(ip)) return false;
|
||||||
|
if (!/^\d{1,2}$/.test(mask)) return false;
|
||||||
|
const m = Number(mask);
|
||||||
|
return m >= 0 && m <= 32;
|
||||||
|
};
|
||||||
|
|
||||||
function IPRangesManager() {
|
function IPRangesManager() {
|
||||||
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({ ipRange: '', community: '' });
|
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
|
||||||
const [newInvalid, setNewInvalid] = useState({ ipRange: false, community: false });
|
const [, setNewInvalid] = useState({ ipRange: 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 [editingValue, setEditingValue] = useState('');
|
const [editingValue, setEditingValue] = useState('');
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -80,9 +100,9 @@ function IPRangesManager() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(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 (_e) {
|
||||||
// тихо игнорируем
|
// тихо игнорируем
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -90,23 +110,6 @@ function IPRangesManager() {
|
|||||||
|
|
||||||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||||||
|
|
||||||
const isValidIPv4 = (ip) => {
|
|
||||||
const octets = ip.split('.');
|
|
||||||
if (octets.length !== 4) return false;
|
|
||||||
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidCidr = (value) => {
|
|
||||||
const v = String(value).trim();
|
|
||||||
const parts = v.split('/');
|
|
||||||
if (parts.length !== 2) return false;
|
|
||||||
const [ip, mask] = parts;
|
|
||||||
if (!isValidIPv4(ip)) return false;
|
|
||||||
if (!/^\d{1,2}$/.test(mask)) return false;
|
|
||||||
const m = Number(mask);
|
|
||||||
return m >= 0 && m <= 32;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Допускаем как числовые, так и строковые (AS:NNN) community
|
// Допускаем как числовые, так и строковые (AS:NNN) community
|
||||||
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
||||||
|
|
||||||
@@ -126,20 +129,17 @@ function IPRangesManager() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchItems = async (abortSignal) => {
|
const fetchItems = useCallback(async (_abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
const payload = await fetchIpRangesData();
|
||||||
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;
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
@@ -148,7 +148,7 @@ function IPRangesManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!didInit.current) {
|
if (!didInit.current) {
|
||||||
@@ -157,7 +157,7 @@ function IPRangesManager() {
|
|||||||
fetchItems(controller.signal);
|
fetchItems(controller.signal);
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [fetchItems]);
|
||||||
|
|
||||||
const handleAddItem = (item) => {
|
const handleAddItem = (item) => {
|
||||||
const ipRange = item?.ipRange || newItem.ipRange;
|
const ipRange = item?.ipRange || newItem.ipRange;
|
||||||
@@ -268,7 +268,7 @@ function IPRangesManager() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicateItem = (item) => {
|
const _duplicateItem = (item) => {
|
||||||
setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]);
|
setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]);
|
||||||
window.notify?.success?.('Запись продублирована');
|
window.notify?.success?.('Запись продублирована');
|
||||||
};
|
};
|
||||||
@@ -316,19 +316,19 @@ function IPRangesManager() {
|
|||||||
return { added, removed, changed };
|
return { added, removed, changed };
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [_showDiff, setShowDiff] = useState(false);
|
||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
||||||
|
|
||||||
// Аналитика
|
// Аналитика
|
||||||
const [analyzeOpen, setAnalyzeOpen] = useState(false);
|
const [_analyzeOpen, _setAnalyzeOpen] = useState(false);
|
||||||
const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false);
|
const [_analyzeConfirmOpen, _setAnalyzeConfirmOpen] = useState(false);
|
||||||
const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
|
const [_analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
|
||||||
const [analysisPreview, setAnalysisPreview] = useState(null);
|
const [analysisPreview, setAnalysisPreview] = useState(null);
|
||||||
const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
|
const [_overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
|
||||||
const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
|
const [analyzeFilters, _setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
|
||||||
|
|
||||||
const handlePreviewDiff = () => {
|
const handlePreviewDiff = () => {
|
||||||
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
@@ -338,27 +338,7 @@ function IPRangesManager() {
|
|||||||
setShowDiff(true);
|
setShowDiff(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const performSave = useCallback(async () => {
|
||||||
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
|
||||||
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
|
||||||
.map(i => ({ ipRange: i.ipRange.trim(), 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 {
|
||||||
@@ -366,28 +346,8 @@ function IPRangesManager() {
|
|||||||
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
|
|
||||||
// Загрузим полный список, совместим с изменениями текущей страницы и сохраним
|
await saveIpRangesData(unique, originalItems);
|
||||||
const fullRes = await api.get(`/ip-ranges`, { 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.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), community: String(i.community || '').trim() }]));
|
|
||||||
const originalPageMap = new Map(originalItems.map(i => [String(i.ipRange).trim(), true]));
|
|
||||||
const uniqueMap = new Map(unique.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), 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(`/ip-ranges`, { ipRanges: 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('Изменения успешно сохранены!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
@@ -398,7 +358,21 @@ function IPRangesManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [items, originalItems, fetchItems]);
|
||||||
|
|
||||||
|
const handleSaveChanges = useCallback(async () => {
|
||||||
|
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
|
.map(i => ({ ipRange: i.ipRange.trim(), 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]);
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
setImportOpen(true);
|
setImportOpen(true);
|
||||||
@@ -406,9 +380,7 @@ function IPRangesManager() {
|
|||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
|
const all = filtered;
|
||||||
const response = await api.get(`/ip-ranges`, { 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 = ['ipRange', 'community'];
|
const header = ['ipRange', 'community'];
|
||||||
const csv = [header, ...all.map(i => [i.ipRange, i.community])]
|
const csv = [header, ...all.map(i => [i.ipRange, i.community])]
|
||||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
@@ -426,7 +398,7 @@ function IPRangesManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -532,7 +504,7 @@ function IPRangesManager() {
|
|||||||
};
|
};
|
||||||
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) => {
|
const handleEditKeyDown = (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -582,7 +554,7 @@ function IPRangesManager() {
|
|||||||
return 'public';
|
return 'public';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBigInt = (n) => {
|
const _formatBigInt = (n) => {
|
||||||
try {
|
try {
|
||||||
const s = (n ?? 0n).toString();
|
const s = (n ?? 0n).toString();
|
||||||
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||||
@@ -591,7 +563,7 @@ function IPRangesManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildAnalysisConfirmText = () => {
|
const _buildAnalysisConfirmText = () => {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
|
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
|
||||||
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
|
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
|
||||||
@@ -705,7 +677,7 @@ function IPRangesManager() {
|
|||||||
return Array.from(new Set(out));
|
return Array.from(new Set(out));
|
||||||
};
|
};
|
||||||
|
|
||||||
const runAnalysis = () => {
|
const _runAnalysis = () => {
|
||||||
const community = String(analyzeFilters.community || '').trim();
|
const community = String(analyzeFilters.community || '').trim();
|
||||||
if (!community) { window.notify?.error?.('Выберите community'); return; }
|
if (!community) { window.notify?.error?.('Выберите community'); return; }
|
||||||
// исходные для community
|
// исходные для community
|
||||||
@@ -744,7 +716,7 @@ function IPRangesManager() {
|
|||||||
setAnalysisResultsOpen(true);
|
setAnalysisResultsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyAnalysisToItems = () => {
|
const _applyAnalysisToItems = () => {
|
||||||
if (!analysisPreview) return;
|
if (!analysisPreview) return;
|
||||||
const { community, afterCidrs, unchangedCidrs } = analysisPreview;
|
const { community, afterCidrs, unchangedCidrs } = analysisPreview;
|
||||||
const keptOthers = items.filter(i => i != null && i.community !== community);
|
const keptOthers = items.filter(i => i != null && i.community !== community);
|
||||||
@@ -770,10 +742,14 @@ function IPRangesManager() {
|
|||||||
window.notify?.success?.('Диапазоны перезаписаны по результатам анализа. Не забудьте сохранить изменения.');
|
window.notify?.success?.('Диапазоны перезаписаны по результатам анализа. Не забудьте сохранить изменения.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportAnalysisCsv = () => {
|
const _exportAnalysisCsv = () => {
|
||||||
|
if (!analysisPreview) return;
|
||||||
|
const { community, afterCidrs } = analysisPreview;
|
||||||
|
const totalAddr = afterCidrs?.reduce((s, c) => s + countAddresses(parseMask(c)), 0n) ?? 0n;
|
||||||
const header = ['community', 'ranges', 'totalAddresses'];
|
const header = ['community', 'ranges', 'totalAddresses'];
|
||||||
const lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])]
|
const rows = [[community, String(afterCidrs?.length ?? 0), totalAddr.toString()]];
|
||||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
const lines = [header, ...rows]
|
||||||
|
.map(r => (Array.isArray(r) ? r : []).map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function InterfaceSpeedTest() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [notify]);
|
}, [notify]);
|
||||||
|
|
||||||
const gateways = useMemo(
|
const _gateways = useMemo(
|
||||||
() =>
|
() =>
|
||||||
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
|
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
|
||||||
? networkConfig.gateways
|
? networkConfig.gateways
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import * as Diff from 'diff';
|
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';
|
||||||
@@ -156,13 +156,70 @@ function MikrotikBackupsManager() {
|
|||||||
const [backupPage, setBackupPage] = useState(1);
|
const [backupPage, setBackupPage] = useState(1);
|
||||||
const backupPageSize = 10;
|
const backupPageSize = 10;
|
||||||
|
|
||||||
|
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const signal = controller.signal;
|
const signal = controller.signal;
|
||||||
fetchServers(signal);
|
fetchServers(signal);
|
||||||
fetchUiSettings(signal);
|
fetchUiSettings(signal);
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, [fetchServers, fetchUiSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedServerId) {
|
if (selectedServerId) {
|
||||||
@@ -173,15 +230,13 @@ function MikrotikBackupsManager() {
|
|||||||
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],
|
||||||
@@ -319,61 +374,6 @@ function MikrotikBackupsManager() {
|
|||||||
setBackupServerIds(new Set());
|
setBackupServerIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchServers = 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchUiSettings = 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 = 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectKey = (key) => {
|
const handleSelectKey = (key) => {
|
||||||
setSelectedKeys((prev) => {
|
setSelectedKeys((prev) => {
|
||||||
if (prev.includes(key)) {
|
if (prev.includes(key)) {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function MikrotikTools() {
|
|||||||
[networkConfig]
|
[networkConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectGatewayMeta = (meta) => {
|
const _handleSelectGatewayMeta = (meta) => {
|
||||||
setGatewayMeta(meta);
|
setGatewayMeta(meta);
|
||||||
// Если цель не задана — подставляем IP gateway как target
|
// Если цель не задана — подставляем IP gateway как target
|
||||||
if (!target && meta && meta.ip) {
|
if (!target && meta && meta.ip) {
|
||||||
@@ -214,7 +214,7 @@ function MikrotikTools() {
|
|||||||
[jumphostServers, serverId]
|
[jumphostServers, serverId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const interfacesForServer = useMemo(() => {
|
const _interfacesForServer = useMemo(() => {
|
||||||
if (!serverId || !interfaces.length) return [];
|
if (!serverId || !interfaces.length) return [];
|
||||||
const ids = new Set(
|
const ids = new Set(
|
||||||
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
|
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
|
||||||
@@ -391,7 +391,7 @@ function MikrotikTools() {
|
|||||||
|
|
||||||
{/* Цели — карточки в стиле сервера/gateway */}
|
{/* Цели — карточки в стиле сервера/gateway */}
|
||||||
<div className="row row-cards g-1 mt-2">
|
<div className="row row-cards g-1 mt-2">
|
||||||
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon, color }) => {
|
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon: _Icon, color }) => {
|
||||||
const isSelected = target === tgt;
|
const isSelected = target === tgt;
|
||||||
const bgClass = color === 'red' ? 'bg-red-lt' : color === 'blue' ? 'bg-blue-lt' : 'bg-orange-lt';
|
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';
|
const textClass = color === 'red' ? 'text-red' : color === 'blue' ? 'text-blue' : 'text-orange';
|
||||||
@@ -404,7 +404,7 @@ function MikrotikTools() {
|
|||||||
>
|
>
|
||||||
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
||||||
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
|
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
|
||||||
<Icon size={20} stroke={1.5} />
|
<_Icon size={20} stroke={1.5} />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1 min-w-0">
|
<div className="flex-grow-1 min-w-0">
|
||||||
<div className="fw-semibold text-truncate small">{name}</div>
|
<div className="fw-semibold text-truncate small">{name}</div>
|
||||||
|
|||||||
@@ -249,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 () => {
|
||||||
@@ -393,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]);
|
||||||
|
|
||||||
// === Фильтрация ===
|
// === Фильтрация ===
|
||||||
@@ -431,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(() => {
|
||||||
@@ -456,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(() => {
|
||||||
@@ -468,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) ||
|
||||||
@@ -479,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]);
|
||||||
|
|
||||||
// === Группировка по серверам ===
|
// === Группировка по серверам ===
|
||||||
@@ -528,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(() => {
|
||||||
@@ -547,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]);
|
||||||
|
|
||||||
// === Сброс фильтров ===
|
// === Сброс фильтров ===
|
||||||
@@ -913,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;
|
||||||
}
|
}
|
||||||
@@ -1274,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) {
|
||||||
@@ -1590,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 ===
|
||||||
@@ -1650,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);
|
||||||
@@ -2021,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 = [];
|
||||||
@@ -2073,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) {
|
||||||
@@ -2264,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()) {
|
||||||
@@ -2272,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 {
|
||||||
@@ -2403,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) {
|
||||||
@@ -2542,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);
|
||||||
@@ -2589,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 'Рекурсивные маршруты';
|
||||||
@@ -2967,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'
|
||||||
@@ -3135,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%' }}>
|
||||||
@@ -3665,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">
|
||||||
@@ -5520,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);
|
||||||
@@ -5698,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">
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default function NetworkMapDashboard() {
|
|||||||
const lastSuccessfulPingRef = useRef({});
|
const lastSuccessfulPingRef = useRef({});
|
||||||
const lastSuccessfulSpeedRef = useRef({});
|
const lastSuccessfulSpeedRef = useRef({});
|
||||||
const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
|
const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
|
||||||
|
const [alertSettings, setAlertSettings] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pingLoading, setPingLoading] = useState(false);
|
const [pingLoading, setPingLoading] = useState(false);
|
||||||
const [speedLoading, setSpeedLoading] = useState(false);
|
const [speedLoading, setSpeedLoading] = useState(false);
|
||||||
@@ -102,12 +103,17 @@ export default function NetworkMapDashboard() {
|
|||||||
const config = configRes?.data || {};
|
const config = configRes?.data || {};
|
||||||
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||||||
const uiSettings = uiSettingsRes?.data || {};
|
const uiSettings = uiSettingsRes?.data || {};
|
||||||
|
const uiAlertSettings =
|
||||||
|
uiSettings.alertSettings && typeof uiSettings.alertSettings === 'object'
|
||||||
|
? uiSettings.alertSettings
|
||||||
|
: {};
|
||||||
const cache = cacheRes?.data || {};
|
const cache = cacheRes?.data || {};
|
||||||
const ttlSeconds = Math.max(
|
const ttlSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
|
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
|
||||||
);
|
);
|
||||||
setPingCacheSeconds(ttlSeconds);
|
setPingCacheSeconds(ttlSeconds);
|
||||||
|
setAlertSettings(uiAlertSettings);
|
||||||
|
|
||||||
const getServer = (serverId) =>
|
const getServer = (serverId) =>
|
||||||
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
|
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
|
||||||
@@ -195,14 +201,21 @@ export default function NetworkMapDashboard() {
|
|||||||
/** Один ключ пинга на ребро (маршруты статичны — одна сторона) */
|
/** Один ключ пинга на ребро (маршруты статичны — одна сторона) */
|
||||||
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
|
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
|
||||||
|
|
||||||
const requestPings = useCallback(async () => {
|
/**
|
||||||
if (connections.length === 0) return;
|
* Запросить пинг для всех соединений или для конкретного подмножества.
|
||||||
|
* Если передать 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;
|
pingAbortRef.current = false;
|
||||||
setPingLoading(true);
|
setPingLoading(true);
|
||||||
const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000);
|
const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000);
|
||||||
|
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
connections.forEach((c) => {
|
sourceConnections.forEach((c) => {
|
||||||
const from = String(c.from);
|
const from = String(c.from);
|
||||||
const to = String(c.to);
|
const to = String(c.to);
|
||||||
if (!from || !to || from === to) return;
|
if (!from || !to || from === to) return;
|
||||||
@@ -214,7 +227,7 @@ export default function NetworkMapDashboard() {
|
|||||||
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
|
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
|
||||||
|
|
||||||
const ekey = edgePingKey(from, to);
|
const ekey = edgePingKey(from, to);
|
||||||
if (ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
|
if (!force && ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +304,15 @@ export default function NetworkMapDashboard() {
|
|||||||
const speedKey = (key1, key2) =>
|
const speedKey = (key1, key2) =>
|
||||||
[String(key1), String(key2)].sort().join(':');
|
[String(key1), String(key2)].sort().join(':');
|
||||||
|
|
||||||
const requestSpeeds = useCallback(async () => {
|
/**
|
||||||
const withSpeed = connections.filter(
|
* Запросить скорость для всех соединений или подмножества.
|
||||||
|
* Если передать 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
|
(c) => c.speedTestServerId && c.interfaceName
|
||||||
);
|
);
|
||||||
if (withSpeed.length === 0) return;
|
if (withSpeed.length === 0) return;
|
||||||
@@ -307,6 +327,7 @@ export default function NetworkMapDashboard() {
|
|||||||
{
|
{
|
||||||
serverId: c.speedTestServerId,
|
serverId: c.speedTestServerId,
|
||||||
interfaceName: c.interfaceName,
|
interfaceName: c.interfaceName,
|
||||||
|
forceRefresh: true, // ручной запуск со страницы карты сети всегда должен делать новый замер
|
||||||
},
|
},
|
||||||
{ timeout: 120000 }
|
{ timeout: 120000 }
|
||||||
);
|
);
|
||||||
@@ -343,6 +364,22 @@ export default function NetworkMapDashboard() {
|
|||||||
setSpeedLoading(false);
|
setSpeedLoading(false);
|
||||||
}, [connections]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
fetchData(controller.signal);
|
fetchData(controller.signal);
|
||||||
@@ -357,7 +394,7 @@ export default function NetworkMapDashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshSpeeds = () => {
|
const handleRefreshSpeeds = () => {
|
||||||
requestSpeeds();
|
requestSpeeds({ force: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -427,6 +464,11 @@ export default function NetworkMapDashboard() {
|
|||||||
speedMap={speedMap}
|
speedMap={speedMap}
|
||||||
pingStaleMap={pingStaleMap}
|
pingStaleMap={pingStaleMap}
|
||||||
speedStaleMap={speedStaleMap}
|
speedStaleMap={speedStaleMap}
|
||||||
|
onRefreshPingForConnection={handleRefreshPingForConnection}
|
||||||
|
onRefreshSpeedForConnection={handleRefreshSpeedForConnection}
|
||||||
|
pingLoading={pingLoading}
|
||||||
|
speedLoading={speedLoading}
|
||||||
|
alertSettings={alertSettings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+642
-127
@@ -4,13 +4,17 @@ import { IconRefresh, IconMaximize, IconMinimize, IconClock } from '@tabler/icon
|
|||||||
const FLAG_CDN = 'https://flagcdn.com';
|
const FLAG_CDN = 'https://flagcdn.com';
|
||||||
|
|
||||||
const UNIFI_BG = '#0f172a';
|
const UNIFI_BG = '#0f172a';
|
||||||
|
const UNIFI_BG_DEEP = '#0b1220';
|
||||||
const UNIFI_CARD_BG = '#1e293b';
|
const UNIFI_CARD_BG = '#1e293b';
|
||||||
const UNIFI_CARD_BORDER = '#334155';
|
const UNIFI_CARD_BORDER = '#334155';
|
||||||
const UNIFI_GREEN = '#22c55e';
|
const UNIFI_GREEN = '#22c55e';
|
||||||
|
const UNIFI_GREEN_SOFT = '#34d399';
|
||||||
|
const UNIFI_BLUE_SOFT = '#38bdf8';
|
||||||
|
const UNIFI_WARNING = '#f59e0b';
|
||||||
const UNIFI_TEXT = '#f1f5f9';
|
const UNIFI_TEXT = '#f1f5f9';
|
||||||
const UNIFI_TEXT_MUTED = '#94a3b8';
|
const UNIFI_TEXT_MUTED = '#94a3b8';
|
||||||
const NODE_WIDTH = 88;
|
const NODE_WIDTH = 124;
|
||||||
const NODE_HEIGHT = 52;
|
const NODE_HEIGHT = 64;
|
||||||
const STORAGE_KEY = 'network-map-unifi-positions';
|
const STORAGE_KEY = 'network-map-unifi-positions';
|
||||||
|
|
||||||
function computeInitialLayout(servers, size) {
|
function computeInitialLayout(servers, size) {
|
||||||
@@ -34,7 +38,7 @@ function computeInitialLayout(servers, size) {
|
|||||||
const baseX = paddingX;
|
const baseX = paddingX;
|
||||||
|
|
||||||
columns.forEach((col, colIdx) => {
|
columns.forEach((col, colIdx) => {
|
||||||
// В пределах колонки сортируем по dns/ip для стабильности
|
// В пределах колонки сортируем по dns/ip для стабильности/
|
||||||
const sorted = [...col].sort((a, b) => {
|
const sorted = [...col].sort((a, b) => {
|
||||||
const la = (a.dns || a.ip || '').toString();
|
const la = (a.dns || a.ip || '').toString();
|
||||||
const lb = (b.dns || b.ip || '').toString();
|
const lb = (b.dns || b.ip || '').toString();
|
||||||
@@ -86,7 +90,62 @@ function speedKey(key1, key2) {
|
|||||||
return [String(key1), String(key2)].sort().join(':');
|
return [String(key1), String(key2)].sort().join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {}, speedMap = {}, pingStaleMap = {}, speedStaleMap = {} }) {
|
function getNodeTypeMeta(type) {
|
||||||
|
const t = String(type || '').toLowerCase();
|
||||||
|
if (t === 'home') return { label: 'HOME', color: '#60a5fa' };
|
||||||
|
if (t === 'jumphost') return { label: 'JUMP', color: '#34d399' };
|
||||||
|
if (t === 'exit') return { label: 'EXIT', color: '#f59e0b' };
|
||||||
|
return { label: 'NODE', color: '#94a3b8' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTunnelColor(tunnelType) {
|
||||||
|
const t = String(tunnelType || '').toLowerCase();
|
||||||
|
if (t.includes('wireguard') || t === 'wg') return UNIFI_BLUE_SOFT;
|
||||||
|
if (t.includes('ipsec')) return UNIFI_GREEN;
|
||||||
|
return UNIFI_GREEN_SOFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sideVector(side) {
|
||||||
|
if (side === 'right') return { x: 1, y: 0 };
|
||||||
|
if (side === 'left') return { x: -1, y: 0 };
|
||||||
|
if (side === 'bottom') return { x: 0, y: 1 };
|
||||||
|
return { x: 0, y: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorPoint(pos, side, slotOffset = 0) {
|
||||||
|
if (side === 'right') return { x: pos.x + NODE_WIDTH / 2, y: pos.y + slotOffset };
|
||||||
|
if (side === 'left') return { x: pos.x - NODE_WIDTH / 2, y: pos.y + slotOffset };
|
||||||
|
if (side === 'bottom') return { x: pos.x + slotOffset, y: pos.y + NODE_HEIGHT / 2 };
|
||||||
|
return { x: pos.x + slotOffset, y: pos.y - NODE_HEIGHT / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(val, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, val));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash01(str) {
|
||||||
|
let h = 2166136261;
|
||||||
|
const s = String(str || '');
|
||||||
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return (h >>> 0) / 4294967295;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NetworkMapUnifi({
|
||||||
|
servers = [],
|
||||||
|
connections = [],
|
||||||
|
pingMap = {},
|
||||||
|
speedMap = {},
|
||||||
|
pingStaleMap = {},
|
||||||
|
speedStaleMap = {},
|
||||||
|
onRefreshPingForConnection,
|
||||||
|
onRefreshSpeedForConnection,
|
||||||
|
pingLoading = false,
|
||||||
|
speedLoading = false,
|
||||||
|
alertSettings = {},
|
||||||
|
}) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const [size, setSize] = useState({ w: 800, h: 520 });
|
const [size, setSize] = useState({ w: 800, h: 520 });
|
||||||
@@ -96,6 +155,33 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
const [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);
|
const [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);
|
||||||
const [selectedEdge, setSelectedEdge] = useState(null);
|
const [selectedEdge, setSelectedEdge] = useState(null);
|
||||||
|
|
||||||
|
const mapSpeedThresholds = useMemo(() => {
|
||||||
|
const raw =
|
||||||
|
alertSettings?.networkMapNodeThresholds &&
|
||||||
|
typeof alertSettings.networkMapNodeThresholds === 'object'
|
||||||
|
? alertSettings.networkMapNodeThresholds
|
||||||
|
: {};
|
||||||
|
const low = Math.max(1, Number(raw.lowSpeedMbps) || 40);
|
||||||
|
const belowNorm = Math.max(low, Number(raw.belowNormSpeedMbps) || 80);
|
||||||
|
const normal = Math.max(belowNorm, Number(raw.normalSpeedMbps) || 120);
|
||||||
|
return { low, belowNorm, normal };
|
||||||
|
}, [alertSettings]);
|
||||||
|
|
||||||
|
const nodeMetaByIp = useMemo(() => {
|
||||||
|
const byIp = new Map();
|
||||||
|
servers.forEach((s) => {
|
||||||
|
const ip = String(s.ip || '');
|
||||||
|
if (!ip) return;
|
||||||
|
byIp.set(ip, {
|
||||||
|
ip,
|
||||||
|
dns: s.dns || '',
|
||||||
|
type: String(s.type || '').toLowerCase(),
|
||||||
|
nodeLabel: s.dns ? String(s.dns).split('.')[0] : ip,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return byIp;
|
||||||
|
}, [servers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -127,10 +213,10 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) saved = JSON.parse(raw) || {};
|
if (raw) saved = JSON.parse(raw) || {};
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
const initial = computeInitialLayout(servers, size);
|
const initial = computeInitialLayout(servers, size);
|
||||||
const merged = {};
|
const merged = {};
|
||||||
servers.forEach((s, i) => {
|
servers.forEach((s, _i) => {
|
||||||
const ip = String(s.ip);
|
const ip = String(s.ip);
|
||||||
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
|
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
|
||||||
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
|
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
|
||||||
@@ -139,13 +225,13 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setPositions(merged);
|
setPositions(merged);
|
||||||
}, [servers.length, size.w, size.h]);
|
}, [servers, size]);
|
||||||
|
|
||||||
const savePositions = useCallback((next) => {
|
const _savePositions = useCallback((next) => {
|
||||||
setPositions(next);
|
setPositions(next);
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getSvgCoords = useCallback((clientX, clientY) => {
|
const getSvgCoords = useCallback((clientX, clientY) => {
|
||||||
@@ -169,7 +255,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
const next = { ...prev, [drag.nodeId]: { x: drag.startX + (p.x - drag.mouseX), y: drag.startY + (p.y - drag.mouseY) } };
|
const next = { ...prev, [drag.nodeId]: { x: drag.startX + (p.x - drag.mouseX), y: drag.startY + (p.y - drag.mouseY) } };
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -187,7 +273,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
setPositions(initial);
|
setPositions(initial);
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, [servers, size]);
|
}, [servers, size]);
|
||||||
|
|
||||||
const onNodeMouseDown = useCallback(
|
const onNodeMouseDown = useCallback(
|
||||||
@@ -220,16 +306,23 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
|
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
|
||||||
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
|
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
|
||||||
: null;
|
: null;
|
||||||
|
const compactEdgeLabel = pingLabel || speedLabel || null;
|
||||||
|
const edgeLabel =
|
||||||
|
pingLabel && speedLabel ? `${pingLabel} • ${speedLabel}` : (pingLabel || speedLabel);
|
||||||
return {
|
return {
|
||||||
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
|
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
p1,
|
p1,
|
||||||
p2,
|
p2,
|
||||||
|
pingMs: typeof ms === 'number' ? ms : null,
|
||||||
pingLabel,
|
pingLabel,
|
||||||
speedLabel,
|
speedLabel,
|
||||||
|
compactEdgeLabel,
|
||||||
|
edgeLabel,
|
||||||
pingStale,
|
pingStale,
|
||||||
speedStale,
|
speedStale,
|
||||||
|
stale: pingStale || speedStale,
|
||||||
speed,
|
speed,
|
||||||
tunnelType: c.tunnelType,
|
tunnelType: c.tunnelType,
|
||||||
raw: c,
|
raw: c,
|
||||||
@@ -237,77 +330,308 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
}).filter((e) => e.p1 && e.p2);
|
}).filter((e) => e.p1 && e.p2);
|
||||||
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
|
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
|
||||||
|
|
||||||
// Вычисляем старт/финиш и одну контрольную точку:
|
// Роутинг рёбер: раскладываем рёбра по "портам" на сторонах узла (эффект трезубца),
|
||||||
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
|
// а также даём параллельным связям разный изгиб.
|
||||||
|
const routedEdges = useMemo(() => {
|
||||||
|
const nodePortBuckets = new Map();
|
||||||
|
const edgeRoutes = new Map();
|
||||||
|
const pairBuckets = new Map();
|
||||||
|
const slotStep = 9;
|
||||||
|
|
||||||
|
const getEdgeSides = (e) => {
|
||||||
|
const dx = e.p2.x - e.p1.x;
|
||||||
|
const dy = e.p2.y - e.p1.y;
|
||||||
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||||
|
return dx >= 0
|
||||||
|
? { fromSide: 'right', toSide: 'left' }
|
||||||
|
: { fromSide: 'left', toSide: 'right' };
|
||||||
|
}
|
||||||
|
return dy >= 0
|
||||||
|
? { fromSide: 'bottom', toSide: 'top' }
|
||||||
|
: { fromSide: 'top', toSide: 'bottom' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortValue = (side, targetPos) =>
|
||||||
|
side === 'left' || side === 'right' ? targetPos.y : targetPos.x;
|
||||||
|
|
||||||
|
edgesWithPing.forEach((e, idx) => {
|
||||||
|
const { fromSide, toSide } = getEdgeSides(e);
|
||||||
|
|
||||||
|
if (!nodePortBuckets.has(e.from)) nodePortBuckets.set(e.from, {});
|
||||||
|
if (!nodePortBuckets.has(e.to)) nodePortBuckets.set(e.to, {});
|
||||||
|
|
||||||
|
const fromBuckets = nodePortBuckets.get(e.from);
|
||||||
|
const toBuckets = nodePortBuckets.get(e.to);
|
||||||
|
|
||||||
|
fromBuckets[fromSide] = fromBuckets[fromSide] || [];
|
||||||
|
toBuckets[toSide] = toBuckets[toSide] || [];
|
||||||
|
|
||||||
|
fromBuckets[fromSide].push({
|
||||||
|
idx,
|
||||||
|
role: 'from',
|
||||||
|
sortValue: getSortValue(fromSide, e.p2),
|
||||||
|
});
|
||||||
|
toBuckets[toSide].push({
|
||||||
|
idx,
|
||||||
|
role: 'to',
|
||||||
|
sortValue: getSortValue(toSide, e.p1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pairKey = [e.from, e.to].sort().join(':');
|
||||||
|
if (!pairBuckets.has(pairKey)) pairBuckets.set(pairKey, []);
|
||||||
|
pairBuckets.get(pairKey).push(idx);
|
||||||
|
|
||||||
|
edgeRoutes.set(idx, {
|
||||||
|
fromSide,
|
||||||
|
toSide,
|
||||||
|
fromSlot: 0,
|
||||||
|
toSlot: 0,
|
||||||
|
bundleOffset: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nodePortBuckets.forEach((sides) => {
|
||||||
|
Object.keys(sides).forEach((side) => {
|
||||||
|
const list = sides[side];
|
||||||
|
list.sort((a, b) => a.sortValue - b.sortValue);
|
||||||
|
const center = (list.length - 1) / 2;
|
||||||
|
list.forEach((item, order) => {
|
||||||
|
const slotOffset = (order - center) * slotStep;
|
||||||
|
const route = edgeRoutes.get(item.idx);
|
||||||
|
if (!route) return;
|
||||||
|
if (item.role === 'from') route.fromSlot = slotOffset;
|
||||||
|
else route.toSlot = slotOffset;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pairBuckets.forEach((idxList) => {
|
||||||
|
idxList.sort((a, b) => {
|
||||||
|
const ea = edgesWithPing[a];
|
||||||
|
const eb = edgesWithPing[b];
|
||||||
|
return String(ea.tunnelType || '').localeCompare(String(eb.tunnelType || '')) || ea.edgeKey.localeCompare(eb.edgeKey);
|
||||||
|
});
|
||||||
|
const center = (idxList.length - 1) / 2;
|
||||||
|
idxList.forEach((edgeIdx, order) => {
|
||||||
|
const route = edgeRoutes.get(edgeIdx);
|
||||||
|
if (!route) return;
|
||||||
|
route.bundleOffset = (order - center) * 14;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return edgesWithPing.map((e, idx) => ({
|
||||||
|
...e,
|
||||||
|
route: edgeRoutes.get(idx),
|
||||||
|
}));
|
||||||
|
}, [edgesWithPing]);
|
||||||
|
|
||||||
|
// Вычисляем геометрию ребра: выход из "порта" узла + короткий stem (трезубец) + плавная дуга.
|
||||||
const computeCurvePoints = useCallback((e) => {
|
const computeCurvePoints = useCallback((e) => {
|
||||||
const { p1, p2 } = e;
|
const { p1, p2 } = e;
|
||||||
const dx = p2.x - p1.x;
|
const route = e.route || { fromSide: 'right', toSide: 'left', fromSlot: 0, toSlot: 0, bundleOffset: 0 };
|
||||||
const dy = p2.y - p1.y;
|
const stem = 14;
|
||||||
const adx = Math.abs(dx);
|
|
||||||
const ady = Math.abs(dy);
|
|
||||||
|
|
||||||
let sx = p1.x;
|
const startAnchor = anchorPoint(p1, route.fromSide, route.fromSlot);
|
||||||
let sy = p1.y;
|
const endAnchor = anchorPoint(p2, route.toSide, route.toSlot);
|
||||||
let ex = p2.x;
|
const fromVec = sideVector(route.fromSide);
|
||||||
let ey = p2.y;
|
const toVec = sideVector(route.toSide);
|
||||||
|
|
||||||
if (adx >= ady) {
|
const sx = startAnchor.x;
|
||||||
const sign = dx >= 0 ? 1 : -1;
|
const sy = startAnchor.y;
|
||||||
sx = p1.x + sign * (NODE_WIDTH / 2);
|
const ex = endAnchor.x;
|
||||||
sy = p1.y;
|
const ey = endAnchor.y;
|
||||||
ex = p2.x - sign * (NODE_WIDTH / 2);
|
const sOutX = sx + fromVec.x * stem;
|
||||||
ey = p2.y;
|
const sOutY = sy + fromVec.y * stem;
|
||||||
} else {
|
const eOutX = ex + toVec.x * stem;
|
||||||
const sign = dy >= 0 ? 1 : -1;
|
const eOutY = ey + toVec.y * stem;
|
||||||
sx = p1.x;
|
|
||||||
sy = p1.y + sign * (NODE_HEIGHT / 2);
|
|
||||||
ex = p2.x;
|
|
||||||
ey = p2.y - sign * (NODE_HEIGHT / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ddx = ex - sx;
|
const ddx = eOutX - sOutX;
|
||||||
const ddy = ey - sy;
|
const ddy = eOutY - sOutY;
|
||||||
const len = Math.hypot(ddx, ddy) || 1;
|
const len = Math.hypot(ddx, ddy) || 1;
|
||||||
|
|
||||||
const mx = (sx + ex) / 2;
|
const baseOffset = Math.min(44, len / 2);
|
||||||
const my = (sy + ey) / 2;
|
const bendOffset = baseOffset + (route.bundleOffset || 0);
|
||||||
const baseOffset = Math.min(40, len / 2);
|
const bendX = (-ddy / len) * bendOffset;
|
||||||
const perpX = (-ddy / len) * baseOffset;
|
const bendY = (ddx / len) * bendOffset;
|
||||||
const perpY = (ddx / len) * baseOffset;
|
|
||||||
const cpx = mx + perpX;
|
|
||||||
const cpy = my + perpY;
|
|
||||||
const perpLen = Math.hypot(perpX, perpY) || 1;
|
|
||||||
const ux = perpX / perpLen;
|
|
||||||
const uy = perpY / perpLen;
|
|
||||||
|
|
||||||
return { sx, sy, ex, ey, cpx, cpy, mx, my, perpX: ux, perpY: uy };
|
const cpx = (sOutX + eOutX) / 2 + bendX;
|
||||||
|
const cpy = (sOutY + eOutY) / 2 + bendY;
|
||||||
|
|
||||||
|
// Середина квадратичной кривой при t=0.5
|
||||||
|
const mx = 0.25 * sOutX + 0.5 * cpx + 0.25 * eOutX;
|
||||||
|
const my = 0.25 * sOutY + 0.5 * cpy + 0.25 * eOutY;
|
||||||
|
const perpLen = Math.hypot(-ddy, ddx) || 1;
|
||||||
|
const ux = (-ddy) / perpLen;
|
||||||
|
const uy = (ddx) / perpLen;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
ex,
|
||||||
|
ey,
|
||||||
|
sOutX,
|
||||||
|
sOutY,
|
||||||
|
eOutX,
|
||||||
|
eOutY,
|
||||||
|
cpx,
|
||||||
|
cpy,
|
||||||
|
mx,
|
||||||
|
my,
|
||||||
|
perpX: ux,
|
||||||
|
perpY: uy,
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pathD = useCallback((e) => {
|
const pathD = useCallback((e) => {
|
||||||
const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e);
|
const { sx, sy, ex, ey, sOutX, sOutY, eOutX, eOutY, cpx, cpy } = computeCurvePoints(e);
|
||||||
return `M ${sx} ${sy} Q ${cpx} ${cpy} ${ex} ${ey}`;
|
return `M ${sx} ${sy} L ${sOutX} ${sOutY} Q ${cpx} ${cpy} ${eOutX} ${eOutY} L ${ex} ${ey}`;
|
||||||
}, [computeCurvePoints]);
|
}, [computeCurvePoints]);
|
||||||
|
|
||||||
/** Точка для подписи: середина ребра + смещение по перпендикуляру (чтобы подпись была у ребра, а не уезжала) */
|
/** Точка подписи на кривой с небольшим jitter и ограничением в пределах карты */
|
||||||
const labelPlacement = useCallback((e, level = 0) => {
|
const labelPlacement = useCallback((e, level = 0) => {
|
||||||
const { mx, my, perpX, perpY } = computeCurvePoints(e);
|
const geom = computeCurvePoints(e);
|
||||||
const baseOffset = 18;
|
const jitter = (hash01(e.edgeKey) - 0.5) * 0.24;
|
||||||
const stepOffset = 26;
|
const t = clamp(0.42 + jitter, 0.26, 0.74);
|
||||||
const x = mx + perpX * (baseOffset + level * stepOffset);
|
const omt = 1 - t;
|
||||||
const y = my + perpY * (baseOffset + level * stepOffset);
|
const xCurve = omt * omt * geom.sOutX + 2 * omt * t * geom.cpx + t * t * geom.eOutX;
|
||||||
return { x, y };
|
const yCurve = omt * omt * geom.sOutY + 2 * omt * t * geom.cpy + t * t * geom.eOutY;
|
||||||
}, [computeCurvePoints]);
|
const baseOffset = 16;
|
||||||
|
const stepOffset = 18;
|
||||||
|
const x = xCurve + geom.perpX * (baseOffset + level * stepOffset);
|
||||||
|
const y = yCurve + geom.perpY * (baseOffset + level * stepOffset);
|
||||||
|
const margin = 22;
|
||||||
|
return {
|
||||||
|
x: clamp(x, margin, Math.max(margin, size.w - margin)),
|
||||||
|
y: clamp(y, margin, Math.max(margin, size.h - margin)),
|
||||||
|
};
|
||||||
|
}, [computeCurvePoints, size.w, size.h]);
|
||||||
|
|
||||||
|
const problematicNodes = useMemo(() => {
|
||||||
|
const byIp = new Map();
|
||||||
|
servers.forEach((s) => {
|
||||||
|
byIp.set(String(s.ip), {
|
||||||
|
ip: String(s.ip),
|
||||||
|
dns: s.dns || '',
|
||||||
|
type: String(s.type || '').toLowerCase(),
|
||||||
|
linkCount: 0,
|
||||||
|
staleCount: 0,
|
||||||
|
maxPingMs: null,
|
||||||
|
maxPingPeerLabel: null,
|
||||||
|
minSpeedMbps: null,
|
||||||
|
minSpeedPeerLabel: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(connections || []).forEach((c) => {
|
||||||
|
const from = byIp.get(String(c.from));
|
||||||
|
const to = byIp.get(String(c.to));
|
||||||
|
if (from) from.linkCount += 1;
|
||||||
|
if (to) to.linkCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
routedEdges.forEach((e) => {
|
||||||
|
[e.from, e.to].forEach((nodeIp) => {
|
||||||
|
const node = byIp.get(String(nodeIp));
|
||||||
|
if (!node) return;
|
||||||
|
const peerIp = String(nodeIp) === String(e.from) ? String(e.to) : String(e.from);
|
||||||
|
const peerMeta = nodeMetaByIp.get(peerIp);
|
||||||
|
const peerLabel = peerMeta?.nodeLabel || peerIp;
|
||||||
|
if (e.stale) node.staleCount += 1;
|
||||||
|
if (typeof e.pingMs === 'number') {
|
||||||
|
if (node.maxPingMs == null || e.pingMs > node.maxPingMs) {
|
||||||
|
node.maxPingMs = e.pingMs;
|
||||||
|
node.maxPingPeerLabel = peerLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const down = e.speed?.tcpDownloadBps != null ? e.speed.tcpDownloadBps / 1_000_000 : null;
|
||||||
|
const up = e.speed?.tcpUploadBps != null ? e.speed.tcpUploadBps / 1_000_000 : null;
|
||||||
|
const edgeMin = down != null && up != null ? Math.min(down, up) : (down ?? up);
|
||||||
|
if (edgeMin != null && Number.isFinite(edgeMin)) {
|
||||||
|
if (node.minSpeedMbps == null || edgeMin < node.minSpeedMbps) {
|
||||||
|
node.minSpeedMbps = edgeMin;
|
||||||
|
node.minSpeedPeerLabel = peerLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(byIp.values())
|
||||||
|
.map((n) => {
|
||||||
|
const issues = [];
|
||||||
|
let riskScore = 0;
|
||||||
|
|
||||||
|
if (n.maxPingMs != null) {
|
||||||
|
if (n.maxPingMs >= 120) {
|
||||||
|
issues.push(`Очень высокий ping${n.maxPingPeerLabel ? ` (с ${n.maxPingPeerLabel})` : ''}`);
|
||||||
|
riskScore += 3;
|
||||||
|
} else if (n.maxPingMs >= 80) {
|
||||||
|
issues.push(`Повышенный ping${n.maxPingPeerLabel ? ` (с ${n.maxPingPeerLabel})` : ''}`);
|
||||||
|
riskScore += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.minSpeedMbps != null) {
|
||||||
|
if (n.minSpeedMbps < mapSpeedThresholds.low) {
|
||||||
|
issues.push(`Низкая скорость${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
|
||||||
|
riskScore += 3;
|
||||||
|
} else if (n.minSpeedMbps < mapSpeedThresholds.belowNorm) {
|
||||||
|
issues.push(`Скорость ниже нормы${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
|
||||||
|
riskScore += 2;
|
||||||
|
} else if (n.minSpeedMbps < mapSpeedThresholds.normal) {
|
||||||
|
issues.push(`Норма скорости не достигнута${n.minSpeedPeerLabel ? ` (с ${n.minSpeedPeerLabel})` : ''}`);
|
||||||
|
riskScore += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.staleCount > 0) {
|
||||||
|
issues.push('Есть устаревшие метрики');
|
||||||
|
riskScore += n.staleCount >= 2 ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.linkCount >= 7) {
|
||||||
|
issues.push('Высокая связанность');
|
||||||
|
riskScore += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
issues,
|
||||||
|
riskScore,
|
||||||
|
nodeLabel: n.dns ? String(n.dns).split('.')[0] : n.ip,
|
||||||
|
affectedLink:
|
||||||
|
(n.maxPingPeerLabel && `${n.dns ? String(n.dns).split('.')[0] : n.ip} ↔ ${n.maxPingPeerLabel}`) ||
|
||||||
|
(n.minSpeedPeerLabel && `${n.dns ? String(n.dns).split('.')[0] : n.ip} ↔ ${n.minSpeedPeerLabel}`) ||
|
||||||
|
'—',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((n) => n.issues.length > 0)
|
||||||
|
.sort((a, b) =>
|
||||||
|
b.riskScore - a.riskScore ||
|
||||||
|
(b.maxPingMs ?? -1) - (a.maxPingMs ?? -1) ||
|
||||||
|
(a.minSpeedMbps ?? Infinity) - (b.minSpeedMbps ?? Infinity)
|
||||||
|
);
|
||||||
|
}, [servers, connections, routedEdges, mapSpeedThresholds, nodeMetaByIp]);
|
||||||
|
|
||||||
|
const criticalNodes = useMemo(
|
||||||
|
() => problematicNodes.filter((n) => n.riskScore >= 4).slice(0, 8),
|
||||||
|
[problematicNodes]
|
||||||
|
);
|
||||||
|
const warningNodes = useMemo(
|
||||||
|
() => problematicNodes.filter((n) => n.riskScore >= 1 && n.riskScore < 4).slice(0, 12),
|
||||||
|
[problematicNodes]
|
||||||
|
);
|
||||||
|
|
||||||
if (servers.length === 0) return null;
|
if (servers.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="network-map-unifi"
|
className="network-map-unifi"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: 520,
|
height: isFullscreen ? '100%' : '68vh',
|
||||||
minHeight: 400,
|
minHeight: isFullscreen ? 400 : 620,
|
||||||
background: UNIFI_BG,
|
background: UNIFI_BG,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -323,17 +647,38 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
style={{ display: 'block' }}
|
style={{ display: 'block' }}
|
||||||
>
|
>
|
||||||
{/* Рёбра: зелёные кривые (стиль UNIFI — проводные соединения) */}
|
<defs>
|
||||||
|
<linearGradient id="unifiBg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={UNIFI_BG} />
|
||||||
|
<stop offset="100%" stopColor={UNIFI_BG_DEEP} />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="edgeGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="2.4" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="nodeShadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="#020617" floodOpacity="0.55" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width={size.w} height={size.h} fill="url(#unifiBg)" />
|
||||||
|
{/* Рёбра: сглаженные линии с цветовой дифференциацией туннеля */}
|
||||||
<g>
|
<g>
|
||||||
{(() => {
|
{(() => {
|
||||||
const used = [];
|
const used = [];
|
||||||
const LABEL_W = 100;
|
const LABEL_W = 118;
|
||||||
const LABEL_H = 18;
|
const LABEL_H = 22;
|
||||||
const LABEL_GAP = 6;
|
const denseMode = routedEdges.length > 14;
|
||||||
return edgesWithPing.map((e, idx) => {
|
return routedEdges.map((e, idx) => {
|
||||||
const dimFactor =
|
const dimFactor =
|
||||||
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1;
|
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.16 : 1;
|
||||||
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3.2 : 2.5;
|
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3 : 1.8;
|
||||||
|
const edgeColor = getTunnelColor(e.tunnelType);
|
||||||
|
const isFocused =
|
||||||
|
hoveredEdgeKey === e.edgeKey || selectedEdge?.edgeKey === e.edgeKey;
|
||||||
|
const baseOpacity = denseMode ? 0.62 : 0.82;
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
|
key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
|
||||||
@@ -347,19 +692,19 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
<path
|
<path
|
||||||
d={pathD(e)}
|
d={pathD(e)}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={UNIFI_GREEN}
|
stroke={edgeColor}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
opacity={dimFactor}
|
opacity={baseOpacity * dimFactor}
|
||||||
|
filter="url(#edgeGlow)"
|
||||||
/>
|
/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const labels = [
|
const compactOnly = denseMode && !isFocused && !e.stale;
|
||||||
e.pingLabel ? { text: e.pingLabel, stale: e.pingStale } : null,
|
const labelText = compactOnly
|
||||||
e.speedLabel ? { text: e.speedLabel, stale: e.speedStale } : null,
|
? (e.compactEdgeLabel || e.edgeLabel)
|
||||||
].filter(Boolean);
|
: e.edgeLabel;
|
||||||
if (labels.length === 0) return null;
|
if (!labelText) return null;
|
||||||
const totalH = labels.length * LABEL_H + (labels.length - 1) * LABEL_GAP;
|
const minDist = denseMode ? 52 : 70;
|
||||||
const minDist = 70;
|
|
||||||
let level = 0;
|
let level = 0;
|
||||||
let x; let y;
|
let x; let y;
|
||||||
while (level < 10) {
|
while (level < 10) {
|
||||||
@@ -374,54 +719,51 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
}
|
}
|
||||||
used.push({ x, y });
|
used.push({ x, y });
|
||||||
|
|
||||||
const pad = 8;
|
const pad = 10;
|
||||||
const staleColor = '#f59e0b';
|
const isStale = e.stale === true;
|
||||||
|
const staleColor = UNIFI_WARNING;
|
||||||
|
const textWidth = labelText.length * (compactOnly ? 5.9 : 6.2);
|
||||||
|
const staleDotRadius = 4.2;
|
||||||
|
const staleDotSpace = isStale ? 18 : 0;
|
||||||
|
const minW = compactOnly ? 76 : LABEL_W;
|
||||||
|
const maxW = compactOnly ? 160 : 210;
|
||||||
|
const w = Math.max(minW, Math.min(textWidth + pad * 2 + staleDotSpace, maxW));
|
||||||
|
const h = isStale ? LABEL_H + 2 : LABEL_H;
|
||||||
|
const textAnchor = isStale ? 'start' : 'middle';
|
||||||
|
const textX = isStale ? (x - w / 2 + pad + staleDotSpace) : x;
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
{labels.map((label, i) => {
|
<rect
|
||||||
const yy = y + i * (LABEL_H + LABEL_GAP) + LABEL_H / 2;
|
x={x - w / 2}
|
||||||
const isStale = label.stale === true;
|
y={y - h / 2}
|
||||||
const textWidth = label.text.length * 6;
|
width={w}
|
||||||
const iconWidth = isStale ? 14 : 0;
|
height={h}
|
||||||
const w = Math.max(LABEL_W, Math.min(textWidth + pad * 2 + iconWidth, 180));
|
rx={7}
|
||||||
const h = LABEL_H + 4;
|
fill={isFocused ? 'rgba(2,6,23,0.94)' : 'rgba(2,6,23,0.85)'}
|
||||||
const textX = x - (iconWidth / 2);
|
stroke={isStale ? staleColor : edgeColor}
|
||||||
return (
|
strokeWidth={isFocused ? 1.3 : 1}
|
||||||
<g key={i}>
|
strokeDasharray={isStale ? '2,2' : 'none'}
|
||||||
<rect
|
/>
|
||||||
x={x - w / 2}
|
{isStale && (
|
||||||
y={yy - h / 2}
|
<circle
|
||||||
width={w}
|
cx={x - w / 2 + pad + staleDotRadius}
|
||||||
height={h}
|
cy={y}
|
||||||
rx={4}
|
r={staleDotRadius}
|
||||||
fill="rgba(15,23,42,0.92)"
|
fill={staleColor}
|
||||||
stroke={isStale ? staleColor : UNIFI_GREEN}
|
opacity={0.95}
|
||||||
strokeWidth={1}
|
/>
|
||||||
strokeDasharray={isStale ? '2,2' : 'none'}
|
)}
|
||||||
/>
|
<text
|
||||||
{isStale && (
|
x={textX}
|
||||||
<circle
|
y={y}
|
||||||
cx={x - w / 2 + 10}
|
textAnchor={textAnchor}
|
||||||
cy={yy}
|
dominantBaseline="middle"
|
||||||
r={5}
|
fill={isStale ? staleColor : edgeColor}
|
||||||
fill={staleColor}
|
fontSize={compactOnly ? 10.2 : 11}
|
||||||
opacity={0.9}
|
fontWeight={600}
|
||||||
/>
|
>
|
||||||
)}
|
{labelText}
|
||||||
<text
|
</text>
|
||||||
x={textX}
|
|
||||||
y={yy}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fill={isStale ? staleColor : UNIFI_GREEN}
|
|
||||||
fontSize={i === 0 ? 12 : 11}
|
|
||||||
fontWeight={600}
|
|
||||||
>
|
|
||||||
{label.text}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -436,6 +778,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
const pos = positions[String(s.ip)];
|
const pos = positions[String(s.ip)];
|
||||||
if (!pos) return null;
|
if (!pos) return null;
|
||||||
const label = s.dns?.split('.')[0] || s.ip || '?';
|
const label = s.dns?.split('.')[0] || s.ip || '?';
|
||||||
|
const typeMeta = getNodeTypeMeta(s.type);
|
||||||
const isDragging = drag?.nodeId === s.ip;
|
const isDragging = drag?.nodeId === s.ip;
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -452,6 +795,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
fill={UNIFI_CARD_BG}
|
fill={UNIFI_CARD_BG}
|
||||||
stroke={UNIFI_CARD_BORDER}
|
stroke={UNIFI_CARD_BORDER}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
|
filter="url(#nodeShadow)"
|
||||||
/>
|
/>
|
||||||
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
|
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
|
||||||
{/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */}
|
{/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */}
|
||||||
@@ -477,29 +821,50 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
})()}
|
})()}
|
||||||
{/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */}
|
{/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */}
|
||||||
<g transform="translate(-10, -16)">
|
<g transform="translate(-10, -16)">
|
||||||
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={UNIFI_GREEN} strokeWidth={1.5} />
|
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={typeMeta.color} strokeWidth={1.5} />
|
||||||
<line x1={5} y1={4} x2={15} y2={4} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} />
|
<line x1={5} y1={4} x2={15} y2={4} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
|
||||||
<line x1={5} y1={8} x2={15} y2={8} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} />
|
<line x1={5} y1={8} x2={15} y2={8} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
|
||||||
</g>
|
</g>
|
||||||
<text
|
<text
|
||||||
x={0}
|
x={0}
|
||||||
y={8}
|
y={8}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill={UNIFI_TEXT}
|
fill={UNIFI_TEXT}
|
||||||
fontSize={12}
|
fontSize={11.5}
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
>
|
>
|
||||||
{label.length > 12 ? label.slice(0, 11) + '…' : label}
|
{label.length > 14 ? `${label.slice(0, 13)}…` : label}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
x={0}
|
x={0}
|
||||||
y={22}
|
y={22}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill={UNIFI_TEXT_MUTED}
|
fill={UNIFI_TEXT_MUTED}
|
||||||
fontSize={10}
|
fontSize={9.5}
|
||||||
>
|
>
|
||||||
{s.ip}
|
{s.ip}
|
||||||
</text>
|
</text>
|
||||||
|
<rect
|
||||||
|
x={NODE_WIDTH / 2 - 42}
|
||||||
|
y={-NODE_HEIGHT / 2 + 5}
|
||||||
|
width={36}
|
||||||
|
height={14}
|
||||||
|
rx={7}
|
||||||
|
fill="rgba(15,23,42,0.9)"
|
||||||
|
stroke={typeMeta.color}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={NODE_WIDTH / 2 - 24}
|
||||||
|
y={-NODE_HEIGHT / 2 + 15}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={typeMeta.color}
|
||||||
|
fontSize={8}
|
||||||
|
fontWeight={700}
|
||||||
|
letterSpacing={0.5}
|
||||||
|
>
|
||||||
|
{typeMeta.label}
|
||||||
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
@@ -514,6 +879,17 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
|
const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
|
||||||
const speed = speedMap[sk] ?? selectedEdge.speed;
|
const speed = speedMap[sk] ?? selectedEdge.speed;
|
||||||
const speedStale = speedStaleMap[sk] === true;
|
const speedStale = speedStaleMap[sk] === true;
|
||||||
|
const canPingFrom =
|
||||||
|
fromServer &&
|
||||||
|
['jumphost', 'home'].includes(String(fromServer.type || '').toLowerCase()) &&
|
||||||
|
selectedEdge.raw?.internalFromTo;
|
||||||
|
const canPingTo =
|
||||||
|
toServer &&
|
||||||
|
['jumphost', 'home'].includes(String(toServer.type || '').toLowerCase()) &&
|
||||||
|
selectedEdge.raw?.internalToFrom;
|
||||||
|
const canPing = Boolean(canPingFrom || canPingTo);
|
||||||
|
const canSpeed =
|
||||||
|
Boolean(selectedEdge.raw?.speedTestServerId && selectedEdge.raw?.interfaceName);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="network-map-edge-modal-backdrop"
|
className="network-map-edge-modal-backdrop"
|
||||||
@@ -635,6 +1011,32 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-3 d-flex flex-wrap gap-2 justify-content-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
disabled={!canPing || pingLoading || !onRefreshPingForConnection}
|
||||||
|
onClick={() => onRefreshPingForConnection && onRefreshPingForConnection(selectedEdge.raw)}
|
||||||
|
>
|
||||||
|
<IconRefresh
|
||||||
|
size={14}
|
||||||
|
className={pingLoading ? 'spin me-1' : 'me-1'}
|
||||||
|
/>
|
||||||
|
{pingLoading ? 'Пинг…' : 'Проверить пинг'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
disabled={!canSpeed || speedLoading || !onRefreshSpeedForConnection}
|
||||||
|
onClick={() => onRefreshSpeedForConnection && onRefreshSpeedForConnection(selectedEdge.raw)}
|
||||||
|
>
|
||||||
|
<IconRefresh
|
||||||
|
size={14}
|
||||||
|
className={speedLoading ? 'spin me-1' : 'me-1'}
|
||||||
|
/>
|
||||||
|
{speedLoading ? 'Скорость…' : 'Проверить скорость'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -688,5 +1090,118 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row g-3 mt-2">
|
||||||
|
<div className="col-12 col-xl-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex align-items-center">
|
||||||
|
<h3 className="card-title mb-0">Критичные узлы</h3>
|
||||||
|
<div className="card-actions ms-auto">
|
||||||
|
<span className="badge bg-red-lt text-red">{criticalNodes.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter table-nowrap mb-0 table-sm table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Узел</th>
|
||||||
|
<th scope="col">Тип</th>
|
||||||
|
<th scope="col">Ping max</th>
|
||||||
|
<th scope="col">Speed min</th>
|
||||||
|
<th scope="col">Связи</th>
|
||||||
|
<th scope="col">Проблемная связь</th>
|
||||||
|
<th scope="col">Проблемы</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{criticalNodes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="text-muted">Критичных узлов не найдено</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
criticalNodes.map((n) => (
|
||||||
|
<tr key={`critical-${n.ip}`}>
|
||||||
|
<td>
|
||||||
|
<div className="fw-semibold">{n.nodeLabel}</div>
|
||||||
|
<div className="text-muted small">{n.ip}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-secondary-lt text-secondary">
|
||||||
|
{String(n.type || 'node').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'}</td>
|
||||||
|
<td>{n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'}</td>
|
||||||
|
<td>{n.linkCount}</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-muted">{n.affectedLink}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-orange-lt text-orange">
|
||||||
|
{n.issues.slice(0, 2).join(', ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-xl-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex align-items-center">
|
||||||
|
<h3 className="card-title mb-0">Требуют внимания</h3>
|
||||||
|
<div className="card-actions ms-auto">
|
||||||
|
<span className="badge bg-yellow-lt text-yellow">{warningNodes.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter table-nowrap mb-0 table-sm table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Узел</th>
|
||||||
|
<th scope="col">Ping max</th>
|
||||||
|
<th scope="col">Speed min</th>
|
||||||
|
<th scope="col">Stale</th>
|
||||||
|
<th scope="col">Проблемная связь</th>
|
||||||
|
<th scope="col">Проблемы</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{warningNodes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-muted">Узлов с предупреждениями не найдено</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
warningNodes.map((n) => (
|
||||||
|
<tr key={`warn-${n.ip}`}>
|
||||||
|
<td>
|
||||||
|
<div className="fw-semibold">{n.nodeLabel}</div>
|
||||||
|
<div className="text-muted small">{n.ip}</div>
|
||||||
|
</td>
|
||||||
|
<td>{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'}</td>
|
||||||
|
<td>{n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={n.staleCount > 0 ? 'badge bg-yellow-lt text-yellow' : 'text-muted'}>
|
||||||
|
{n.staleCount}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-muted">{n.affectedLink}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-muted">{n.issues.slice(0, 2).join(', ')}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -308,12 +308,12 @@ export default function PingServicesManager() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((item) => {
|
filtered.map((item) => {
|
||||||
const iconInfo = getIconById(item.icon);
|
const { Icon } = getIconById(item.icon);
|
||||||
return (
|
return (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td>
|
<td>
|
||||||
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}>
|
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}>
|
||||||
<iconInfo.Icon size={18} stroke={1.5} />
|
<Icon size={18} stroke={1.5} />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><code className="small">{item.id}</code></td>
|
<td><code className="small">{item.id}</code></td>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes == null || bytes === 0) return '0 B';
|
if (bytes == null || bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -149,7 +151,7 @@ export default function ResourceStatsPage() {
|
|||||||
const fetchStats = useCallback(async (abortSignal) => {
|
const fetchStats = useCallback(async (abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
const opts = { timeout: REQUEST_TIMEOUT_MS, noRetry: true, ...(abortSignal ? { signal: abortSignal } : {}) };
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/resources/stats', opts);
|
const res = await api.get('/resources/stats', opts);
|
||||||
const data = res?.data;
|
const data = res?.data;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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';
|
||||||
@@ -147,6 +147,7 @@ function ServerManager() {
|
|||||||
fetchConnections(signal);
|
fetchConnections(signal);
|
||||||
fetchNetworkConfig(signal);
|
fetchNetworkConfig(signal);
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -303,7 +304,7 @@ function ServerManager() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1);
|
if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1);
|
||||||
}, [viewMode, totalPages]);
|
}, [viewMode, totalPages, currentPage]);
|
||||||
|
|
||||||
// === Handlers ===
|
// === Handlers ===
|
||||||
const handleSort = (field) => {
|
const handleSort = (field) => {
|
||||||
@@ -501,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);
|
||||||
};
|
};
|
||||||
@@ -511,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 }));
|
||||||
@@ -529,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);
|
||||||
@@ -541,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);
|
||||||
};
|
};
|
||||||
@@ -800,8 +801,8 @@ function ServerManager() {
|
|||||||
{/* 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) */}
|
{/* Открыть фильтры (offcanvas) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1087,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>
|
||||||
@@ -1111,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>
|
||||||
@@ -1255,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"
|
||||||
@@ -1272,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"
|
||||||
@@ -1282,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"
|
||||||
|
|||||||
+1255
-17
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ function formatBytes(bytes) {
|
|||||||
const DONUT_SIZE = 200;
|
const DONUT_SIZE = 200;
|
||||||
const DONUT_OUTER = 88;
|
const DONUT_OUTER = 88;
|
||||||
const DONUT_INNER = 52;
|
const DONUT_INNER = 52;
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
function JumphostCard({ jumphost }) {
|
function JumphostCard({ jumphost }) {
|
||||||
const { name, host, error, interfaces } = jumphost;
|
const { name, host, error, interfaces } = jumphost;
|
||||||
@@ -184,7 +185,7 @@ export default function TrafficDashboard() {
|
|||||||
const fetchStats = useCallback(async (abortSignal) => {
|
const fetchStats = useCallback(async (abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
const opts = { timeout: REQUEST_TIMEOUT_MS, ...(abortSignal ? { signal: abortSignal } : {}) };
|
||||||
try {
|
try {
|
||||||
const [statsRes, settingsRes] = await Promise.all([
|
const [statsRes, settingsRes] = await Promise.all([
|
||||||
api.get('/traffic/interface-stats', opts),
|
api.get('/traffic/interface-stats', opts),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import {
|
import {
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -33,14 +34,14 @@ function loadHistoryFromStorage() {
|
|||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (parsed && typeof parsed === 'object') return parsed;
|
if (parsed && typeof parsed === 'object') return parsed;
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveHistoryToStorage(historyMap) {
|
function saveHistoryToStorage(historyMap) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
|
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
|
||||||
@@ -81,6 +82,7 @@ function historyToResponseTimeData(history) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UptimeMonitorPage() {
|
export default function UptimeMonitorPage() {
|
||||||
|
const location = useLocation();
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
@@ -94,6 +96,8 @@ export default function UptimeMonitorPage() {
|
|||||||
const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]);
|
const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
const checkingRef = useRef(false);
|
const checkingRef = useRef(false);
|
||||||
|
const activeRunIdRef = useRef(0);
|
||||||
|
const checkControllersRef = useRef(new Set());
|
||||||
|
|
||||||
const jumphosts = useMemo(
|
const jumphosts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -160,6 +164,7 @@ export default function UptimeMonitorPage() {
|
|||||||
const runChecks = useCallback(async () => {
|
const runChecks = useCallback(async () => {
|
||||||
if (jumphosts.length === 0) return;
|
if (jumphosts.length === 0) return;
|
||||||
if (checkingRef.current) return;
|
if (checkingRef.current) return;
|
||||||
|
const runId = ++activeRunIdRef.current;
|
||||||
checkingRef.current = true;
|
checkingRef.current = true;
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setCurrentCheckingServerIds([]);
|
setCurrentCheckingServerIds([]);
|
||||||
@@ -171,18 +176,28 @@ export default function UptimeMonitorPage() {
|
|||||||
const checkOne = async () => {
|
const checkOne = async () => {
|
||||||
let server;
|
let server;
|
||||||
while ((server = getNext()) != null) {
|
while ((server = getNext()) != null) {
|
||||||
|
if (activeRunIdRef.current !== runId) break;
|
||||||
const serverId = server.id || server.dns || server.ip;
|
const serverId = server.id || server.dns || server.ip;
|
||||||
if (!serverId) continue;
|
if (!serverId) continue;
|
||||||
setCurrentCheckingServerIds((prev) => [...prev, serverId]);
|
setCurrentCheckingServerIds((prev) => [...prev, serverId]);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
let ok = false;
|
let ok = false;
|
||||||
let ms = null;
|
let ms = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
checkControllersRef.current.add(controller);
|
||||||
try {
|
try {
|
||||||
const { data: checkData } = await api.post('/uptime/check', { serverId });
|
const { data: checkData } = await api.post('/uptime/check', { serverId }, { signal: controller.signal });
|
||||||
ok = checkData?.ok === true;
|
ok = checkData?.ok === true;
|
||||||
ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0;
|
ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0;
|
||||||
} catch (_) {
|
} 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;
|
ms = Date.now() - t0;
|
||||||
|
} finally {
|
||||||
|
checkControllersRef.current.delete(controller);
|
||||||
}
|
}
|
||||||
const lastCheckTs = Date.now();
|
const lastCheckTs = Date.now();
|
||||||
const entry = { ts: lastCheckTs, ok, ms };
|
const entry = { ts: lastCheckTs, ok, ms };
|
||||||
@@ -196,6 +211,7 @@ export default function UptimeMonitorPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne));
|
await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne));
|
||||||
|
if (activeRunIdRef.current !== runId) return;
|
||||||
setCurrentCheckingServerIds([]);
|
setCurrentCheckingServerIds([]);
|
||||||
checkingRef.current = false;
|
checkingRef.current = false;
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
@@ -217,6 +233,30 @@ export default function UptimeMonitorPage() {
|
|||||||
};
|
};
|
||||||
}, [jumphosts, runChecks, checkIntervalMs]);
|
}, [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 при изменении
|
// Сохраняем историю в localStorage при изменении
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveHistoryToStorage(historyMap);
|
saveHistoryToStorage(historyMap);
|
||||||
@@ -225,7 +265,10 @@ export default function UptimeMonitorPage() {
|
|||||||
const selectedServer = selectedServerId
|
const selectedServer = selectedServerId
|
||||||
? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId)
|
? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId)
|
||||||
: null;
|
: null;
|
||||||
const selectedHistory = selectedServerId ? historyMap[selectedServerId] || [] : [];
|
const selectedHistory = useMemo(
|
||||||
|
() => (selectedServerId ? historyMap[selectedServerId] || [] : []),
|
||||||
|
[selectedServerId, historyMap]
|
||||||
|
);
|
||||||
const responseTimeData = useMemo(
|
const responseTimeData = useMemo(
|
||||||
() => historyToResponseTimeData(selectedHistory),
|
() => historyToResponseTimeData(selectedHistory),
|
||||||
[selectedHistory]
|
[selectedHistory]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,14 +8,14 @@ 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 = [],
|
customActions = [],
|
||||||
className = '',
|
className: _className = '',
|
||||||
}) {
|
}) {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class ErrorBoundary extends Component {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error) {
|
static getDerivedStateFromError(_error) {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
|
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch, IconMinus } from '@tabler/icons-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выбор иконки бренда с поиском и предпросмотром.
|
* Выбор иконки бренда с поиском и предпросмотром.
|
||||||
* value — id иконки (например IconBrandGoogle), onChange(id) — при выборе.
|
* value — id иконки (например IconBrandGoogle), onChange(id) — при выборе.
|
||||||
*/
|
*/
|
||||||
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда' }) {
|
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда', allowNone = false }) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const selected = getIconById(value);
|
const selected = allowNone && !value
|
||||||
const filtered = useMemo(() => {
|
? { id: '', label: 'Без иконки', Icon: IconMinus }
|
||||||
|
: getIconById(value);
|
||||||
|
const filteredIcons = useMemo(() => {
|
||||||
const q = (search || '').trim().toLowerCase();
|
const q = (search || '').trim().toLowerCase();
|
||||||
if (!q) return BRAND_ICONS;
|
if (!q) return BRAND_ICONS;
|
||||||
return BRAND_ICONS.filter(
|
return BRAND_ICONS.filter(
|
||||||
@@ -19,6 +22,13 @@ export default function IconPicker({ value, onChange, disabled = false, label =
|
|||||||
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
|
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}, [search]);
|
}, [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) => {
|
const handleSelect = (item) => {
|
||||||
onChange?.(item.id);
|
onChange?.(item.id);
|
||||||
@@ -44,19 +54,19 @@ export default function IconPicker({ value, onChange, disabled = false, label =
|
|||||||
<div className="d-flex align-items-center gap-2 flex-wrap" ref={panelRef}>
|
<div className="d-flex align-items-center gap-2 flex-wrap" ref={panelRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-2"
|
className="btn btn-outline-secondary d-flex align-items-center gap-2"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
>
|
>
|
||||||
<span className="avatar avatar-sm bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center">
|
<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={18} stroke={1.5} />
|
<selected.Icon size={20} stroke={1.5} />
|
||||||
</span>
|
</span>
|
||||||
<span>{selected.label}</span>
|
<span>{selected.label}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="border rounded p-2 shadow-sm bg-body" style={{ minWidth: 280, maxWidth: 360 }}>
|
<div className="border rounded p-3 shadow-sm bg-body" style={{ minWidth: 560, maxWidth: 640 }}>
|
||||||
<div className="input-group input-group-sm mb-2">
|
<div className="input-group input-group-sm mb-2">
|
||||||
<span className="input-group-text">
|
<span className="input-group-text">
|
||||||
<IconSearch size={14} />
|
<IconSearch size={14} />
|
||||||
@@ -72,7 +82,7 @@ export default function IconPicker({ value, onChange, disabled = false, label =
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="d-flex flex-wrap gap-1 overflow-auto"
|
className="d-flex flex-wrap gap-1 overflow-auto"
|
||||||
style={{ maxHeight: 200 }}
|
style={{ maxHeight: 320 }}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
>
|
>
|
||||||
{filtered.map((item) => (
|
{filtered.map((item) => (
|
||||||
@@ -82,13 +92,13 @@ export default function IconPicker({ value, onChange, disabled = false, label =
|
|||||||
className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
|
className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
|
||||||
value === item.id ? 'btn-primary' : 'btn-outline-secondary'
|
value === item.id ? 'btn-primary' : 'btn-outline-secondary'
|
||||||
}`}
|
}`}
|
||||||
style={{ width: 40, height: 40 }}
|
style={{ width: 56, height: 56 }}
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={value === item.id}
|
aria-selected={value === item.id}
|
||||||
>
|
>
|
||||||
<item.Icon size={22} stroke={1.5} />
|
<item.Icon size={26} stroke={1.5} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function PageHeader({ title, icon, pretitle, actions, meta }) {
|
|||||||
</div>
|
</div>
|
||||||
{actions && (
|
{actions && (
|
||||||
<div className="row mt-2">
|
<div className="row mt-2">
|
||||||
<div className="col d-print-none">
|
<div className="col d-print-none overflow-auto">
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
||||||
setEtag(e ? String(e) : '');
|
setEtag(e ? String(e) : '');
|
||||||
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setError('Не удалось загрузить настройки');
|
setError('Не удалось загрузить настройки');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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,
|
||||||
@@ -32,17 +33,17 @@ const DEFAULT_TITLES = {
|
|||||||
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(() => handleClose(), toast.duration)
|
const timer = setTimeout(() => handleClose(), toast.duration)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [toast.duration])
|
}, [toast.duration, handleClose])
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsLeaving(true)
|
|
||||||
setTimeout(() => onClose(toast.id), 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (toast.type) {
|
switch (toast.type) {
|
||||||
@@ -121,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,
|
||||||
@@ -136,23 +137,23 @@ 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}>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user