Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5efd0339fb | ||
|
|
e461dc21bd | ||
|
|
2852b11234 | ||
|
|
0abdac82de | ||
|
|
db81cf0f97 | ||
|
|
fcca92706b | ||
|
|
4881306f0b | ||
|
|
ef229ca9c4 | ||
|
|
25ae76dc3f | ||
|
|
552d5f831f | ||
|
|
e6a2e4009d | ||
|
|
eb24a08981 | ||
|
|
1c11a133f3 | ||
|
|
0f0d3aa060 | ||
|
|
042407f03e | ||
|
|
b2cf71fcc7 | ||
|
|
fda7f353ad | ||
|
|
72bf648ca8 | ||
|
|
8613749de1 | ||
|
|
6210f34740 | ||
|
|
481688e72a | ||
|
|
c88d31fa45 | ||
|
|
3d51bba86d | ||
|
|
3c9096804b | ||
|
|
9b0d7bb6e8 | ||
|
|
8c5cfa0706 | ||
|
|
c683e1b2c1 | ||
|
|
9178ff2ccb | ||
|
|
0e86628d58 | ||
|
|
e78bc2d7cb | ||
|
|
37a9c828d3 | ||
|
|
3d23ae4be4 | ||
|
|
0563dd7cbd | ||
|
|
0374b57a30 | ||
|
|
dc7b22940a | ||
|
|
74151176d7 | ||
|
|
b3d11276dd | ||
|
|
0cc8fe5572 | ||
|
|
61e6671685 | ||
|
|
4f6de4b08e | ||
|
|
044002e7eb | ||
|
|
48cc65ec2b | ||
|
|
3c3d152c2d | ||
|
|
026a358ca6 | ||
|
|
4b028be241 | ||
|
|
0216415953 | ||
|
|
6617b776bb | ||
|
|
e452e528e1 | ||
|
|
ab48973eb9 | ||
|
|
659e658f18 | ||
|
|
faf452d149 | ||
|
|
7cef0cc133 | ||
|
|
41a76f1163 | ||
|
|
c3fb66dcbf | ||
|
|
03b0896369 | ||
|
|
2277a76196 | ||
|
|
cdb528276b | ||
|
|
af07c59ffa | ||
|
|
baf81c0c7d | ||
|
|
0d08c21bb9 | ||
|
|
cbb4f9f0fc | ||
|
|
c21deba804 | ||
|
|
19ad294ded | ||
|
|
7a38bb36aa | ||
|
|
51a579d370 | ||
|
|
9c4a0992d7 | ||
|
|
8b60ab040a | ||
|
|
d91cc77cda | ||
|
|
4f084222ad | ||
|
|
5ac5a25dfc | ||
|
|
eaf724d6cc | ||
|
|
0c5d7d3207 | ||
|
|
1be4a490b5 | ||
|
|
7d86af7e8b | ||
|
|
727e35f985 | ||
|
|
3f3f06f147 | ||
|
|
a404da768d | ||
|
|
c8f9c83f45 | ||
|
|
5ce20e8892 | ||
|
|
b9000b16e4 | ||
|
|
6cc0094591 | ||
|
|
7a58f472b3 | ||
|
|
3c30c5c7cb | ||
|
|
bf39b3de18 | ||
|
|
cbe44e8072 | ||
|
|
46de87e213 | ||
|
|
199910eceb | ||
|
|
f8d2b21cf6 | ||
|
|
608f743003 | ||
|
|
a15917d08b | ||
|
|
efdfc6f224 | ||
|
|
b525e70655 | ||
|
|
a68bd61e58 | ||
|
|
7b88416415 | ||
|
|
dbc3b2c947 | ||
|
|
a9f19e1f4b | ||
|
|
7c667f8036 | ||
|
|
504f62fc0b | ||
|
|
5d19b6a258 | ||
|
|
a8b80d9914 | ||
|
|
b57ae316ba | ||
|
|
493c30bebc | ||
|
|
9603048bd8 | ||
|
|
a40b26c3c5 | ||
|
|
c69d99ba80 | ||
|
|
d8f8d3c7a4 | ||
|
|
b4d0733cfa | ||
|
|
2b3d583e3b | ||
|
|
2358ecef60 | ||
|
|
1275dd7b9b | ||
|
|
ebe62ae2ef | ||
|
|
ca2b634743 | ||
|
|
737e04a6a9 | ||
|
|
4527e5537c | ||
|
|
8fb46b1552 | ||
|
|
ca23284486 | ||
|
|
d3f9a120e6 | ||
|
|
db7105119d | ||
|
|
8ffb8b5f00 | ||
|
|
65d4a17882 | ||
|
|
13618e7efe | ||
|
|
7ad66f178b | ||
|
|
fdddc52019 | ||
|
|
9fbe27c1b0 | ||
|
|
c557a8c8cb | ||
|
|
22b0e8ae8e | ||
|
|
fef7bc5520 | ||
|
|
8cb0fbd195 | ||
|
|
cf7f4edf78 | ||
|
|
e1617f1472 | ||
|
|
1b63e3ec43 | ||
|
|
d3d8afaa0c | ||
|
|
90e6c7be25 | ||
|
|
f6b9c6941d | ||
|
|
12ead0b6c2 | ||
|
|
4d3cb659c1 | ||
|
|
f3cbc32720 | ||
|
|
655ea9727f | ||
|
|
555bd31596 | ||
|
|
0c14da190b | ||
|
|
64df472202 | ||
|
|
979dc3cfb0 | ||
|
|
d569932b2a | ||
|
|
11813179a7 | ||
|
|
c718654bc7 | ||
|
|
af66faaae4 | ||
|
|
59938118bd | ||
|
|
b619ef208e | ||
|
|
f3156b44cf | ||
|
|
c242b611e2 | ||
|
|
c331558e31 | ||
|
|
8af5c7a080 | ||
|
|
7c0cd50334 | ||
|
|
07499cd340 | ||
|
|
bd023b8aaa | ||
|
|
1e4d3b4678 | ||
|
|
3b908a9e4e | ||
|
|
63ee8a4549 | ||
|
|
e17ddfdc20 | ||
|
|
b190412af1 | ||
|
|
7867914cbc | ||
|
|
a31a65fbe6 | ||
|
|
aed1963eef | ||
|
|
07396d9385 | ||
|
|
cad01853e1 | ||
|
|
67644c50cb | ||
|
|
d4f5746325 | ||
|
|
b4e56d1998 | ||
|
|
d379045d20 | ||
|
|
08fcebd549 | ||
|
|
9da5e55367 | ||
|
|
057b530364 | ||
|
|
d52c48e5ed | ||
|
|
58f5c48a97 | ||
|
|
60b2e099f0 | ||
|
|
cb6ec09312 | ||
|
|
7815e00a1c | ||
|
|
2aa93ad365 | ||
|
|
1d61b50591 | ||
|
|
2a804876b0 | ||
|
|
e7aaec926d | ||
|
|
a58756c421 | ||
|
|
78b0c07b88 | ||
|
|
beb72ceea1 | ||
|
|
2d4e8baa13 | ||
|
|
cd8613fab6 | ||
|
|
89bde27f19 | ||
|
|
94bdb2a8a1 | ||
|
|
7f0c2aad0f | ||
|
|
5944e5e751 | ||
|
|
dbe1e7c917 | ||
|
|
64bf0b4f6e | ||
|
|
890a5cba09 | ||
|
|
9d6cd000ef | ||
|
|
e23038d409 | ||
|
|
40f730b5be | ||
|
|
f319eb947a | ||
|
|
c9bf5abe55 | ||
|
|
ee704a86f2 | ||
|
|
8b7bff165a | ||
|
|
5b3133ce73 | ||
|
|
36c6a21ca5 | ||
|
|
3bfd4ae6cf | ||
|
|
2e1586e4d4 | ||
|
|
c35272273b | ||
|
|
8795969871 | ||
|
|
89de7e134b | ||
|
|
43b00e0740 | ||
|
|
a83e4734df | ||
|
|
5d9ef9dcf3 | ||
|
|
b9a8615b8b | ||
|
|
f80d9b7637 | ||
|
|
e2d81f9657 | ||
|
|
6b068ac39a | ||
|
|
0067c2eeff | ||
|
|
8eaf78b686 | ||
|
|
91ee424b42 | ||
|
|
e01454b720 | ||
|
|
f9980d7d7e | ||
|
|
479f2adc78 | ||
|
|
7ce43adc07 | ||
|
|
d13c773856 | ||
|
|
c16fc0c5b5 | ||
|
|
c82e8fa12b | ||
|
|
206255b856 | ||
|
|
a1183f2ec9 | ||
|
|
bf43263e43 | ||
|
|
ae0162db53 | ||
|
|
6a46fb1e40 | ||
|
|
4c4ba02b44 | ||
|
|
3722fad329 | ||
|
|
fe0ca3fa46 | ||
|
|
02cdfd869a | ||
|
|
fd521f6ef8 | ||
|
|
ed80aef195 | ||
|
|
731803bade | ||
|
|
26e8225815 | ||
|
|
bd3f996f55 | ||
|
|
56f29a60d1 | ||
|
|
37bc356ff7 | ||
|
|
2ead73c30a | ||
|
|
edc459382b | ||
|
|
a4ae10d7f5 | ||
|
|
bb7b940bfc | ||
|
|
1d6f2d598c | ||
|
|
b55588f29b | ||
|
|
e80cfeb4a9 | ||
|
|
ca8eafa1ff | ||
|
|
22a4e2f36e | ||
|
|
693cce2535 | ||
|
|
ffe44dc938 | ||
|
|
507c7010ad | ||
|
|
d44511da8e | ||
|
|
7c4b21be19 | ||
|
|
87fc1be657 | ||
|
|
06d73c50cc | ||
|
|
49af17178e | ||
|
|
800c6f69ae | ||
|
|
e1457fbc77 | ||
|
|
8a1c36f358 | ||
|
|
34096cbfd5 | ||
|
|
a178becdcd | ||
|
|
f194e7269b | ||
|
|
47f747586c | ||
|
|
aa7dbdf608 | ||
|
|
fc1a60d413 | ||
|
|
c0a32e8ed2 | ||
|
|
01061b4406 | ||
|
|
9ccfc7f2b6 | ||
|
|
8782054078 | ||
|
|
96fa910804 | ||
|
|
f408afba20 | ||
|
|
c337fddac9 | ||
|
|
1adb82c9a2 | ||
|
|
4c12c55c9d | ||
|
|
f3e205950f | ||
|
|
c24ad7810c | ||
|
|
f1aac49dfc | ||
|
|
2943032462 | ||
|
|
8682f2a7a5 | ||
|
|
9264b6636a | ||
|
|
f8da3e08a0 | ||
|
|
1b89d56ea3 | ||
|
|
6720ad3539 | ||
|
|
66bed28382 | ||
|
|
476dbec204 | ||
|
|
83593756ce | ||
|
|
65d04baf2f | ||
|
|
673e5ba444 | ||
|
|
4d88b97d49 | ||
|
|
6255f44975 | ||
|
|
683148d620 | ||
|
|
9680a6422b | ||
|
|
30cc99c68e | ||
|
|
7f18fcb6db | ||
|
|
090206c893 | ||
|
|
b59944b573 | ||
|
|
b0e114b56d | ||
|
|
a56f555c25 | ||
|
|
65c63e2860 | ||
|
|
e4f951175c | ||
|
|
8627459bfa | ||
|
|
a3fcba0d43 | ||
|
|
08ea416989 | ||
|
|
5fedf7ae68 | ||
|
|
579e816bbb | ||
|
|
5919af7f9b | ||
|
|
fb32f87d33 | ||
|
|
9d5959601f | ||
|
|
1904c86657 | ||
|
|
4ba7f82ccf | ||
|
|
7d331aff41 | ||
|
|
4fee9e371f | ||
|
|
057f0d352a | ||
|
|
53e36f2a03 | ||
|
|
52d8a05480 | ||
|
|
c6dfa5267b | ||
|
|
8e3b7a65ce | ||
|
|
0355343f9f | ||
|
|
ba27d2ade0 | ||
|
|
b1f8326304 | ||
|
|
55df77296a | ||
|
|
991f2b3871 | ||
|
|
53603f17c1 | ||
|
|
833225d169 | ||
|
|
1ad6b4a6b6 | ||
|
|
b3da4216e6 | ||
|
|
28e2d38ee6 | ||
|
|
a16bfae805 | ||
|
|
82f620ac0b | ||
|
|
059fd93520 | ||
|
|
7265709b1c | ||
|
|
be54666661 | ||
|
|
9f74cfebfe | ||
|
|
086f118204 | ||
|
|
8d86c5cbb0 | ||
|
|
1a82c1b41f | ||
|
|
2554c3572a | ||
|
|
ab56225a1a | ||
|
|
21dc646553 | ||
|
|
9231597d88 | ||
|
|
2e648dcf1c | ||
|
|
1e9889f892 | ||
|
|
4a5ae8baa1 | ||
|
|
06850169ba | ||
|
|
9124d451a5 | ||
|
|
f515818254 | ||
|
|
adfb2fc4c3 | ||
|
|
9d4d9f308f | ||
|
|
9bbecfbafe | ||
|
|
97936aa81e | ||
|
|
dcc8550482 | ||
|
|
c7aec1500c | ||
|
|
e51964b4d2 | ||
|
|
c6702efcc4 | ||
|
|
837298a171 | ||
|
|
747f544462 | ||
|
|
a6be18c1d6 | ||
|
|
7693b32372 | ||
|
|
53c1bfbd3b | ||
|
|
9c22fa8a68 | ||
|
|
21859be580 | ||
|
|
5b2a2917c1 | ||
|
|
586da55c24 | ||
|
|
908974302e | ||
|
|
d4f2961f76 | ||
|
|
f30319ac55 | ||
|
|
a558412a27 | ||
|
|
e5ff69e243 | ||
|
|
611e542ea7 | ||
|
|
5d9a6f3757 | ||
|
|
e0f83bdf42 | ||
|
|
5fbfc752fb | ||
|
|
5488d86b12 | ||
|
|
e5cf05759e | ||
|
|
488f2fe482 | ||
|
|
8d6d82082c | ||
|
|
91c1194550 | ||
|
|
c4110fa323 | ||
|
|
1cce712833 | ||
|
|
2bdcc1621f | ||
|
|
ab334764cf | ||
|
|
2762f073a0 | ||
|
|
76a5e81fd4 | ||
|
|
057281c246 | ||
|
|
ef457b471a | ||
|
|
160b39186a | ||
|
|
b658f7127f | ||
|
|
39789765cb | ||
|
|
604b2b0b51 | ||
|
|
5e5cf70980 | ||
|
|
12c376b4c5 | ||
|
|
c3a9c00aed | ||
|
|
d4ae1c3127 | ||
|
|
5fdd6ef58c | ||
|
|
55c9349677 | ||
|
|
9d3129372b | ||
|
|
8f65d1620d | ||
|
|
9e0f695684 | ||
|
|
a28f871fd4 | ||
|
|
f0eaa94e02 | ||
|
|
c343ce62c8 | ||
|
|
1a9966ec95 | ||
|
|
271291a910 | ||
|
|
1d27127b52 | ||
|
|
d5edfe5ef6 | ||
|
|
c7e1f75e2e | ||
|
|
f3ec6b31ce | ||
|
|
1deccf862c | ||
|
|
587dd94f82 | ||
|
|
1999e8160e | ||
|
|
b109532f33 | ||
|
|
7d0608bbce | ||
|
|
89c31289bd | ||
|
|
b1ad9515fa | ||
|
|
46be201d10 | ||
|
|
382e318b97 | ||
|
|
c5c1c2812d | ||
|
|
dc5594ca7c | ||
|
|
9c1d14635a | ||
|
|
875819c577 | ||
|
|
a5c8e614bd | ||
|
|
526f305aa6 | ||
|
|
a8a3eb59fb | ||
|
|
3710fc6aab | ||
|
|
9bc3a5db77 | ||
|
|
6093442bcf | ||
|
|
3556a378f2 | ||
|
|
98319c9d50 | ||
|
|
0bcac655ea | ||
|
|
596fea4814 | ||
|
|
57293479d1 | ||
|
|
92b65a30ce | ||
|
|
738c98b9d3 | ||
|
|
af5dbb0519 | ||
|
|
ff8b04bddf | ||
|
|
e5842b415b | ||
|
|
b6f1b72500 | ||
|
|
2b6d3ab2e0 | ||
|
|
d272ef57f0 | ||
|
|
118cd31d65 | ||
|
|
7e3cb754b0 | ||
|
|
9db1471b2c | ||
|
|
554dba55ec | ||
|
|
2b62e0e956 | ||
|
|
2215007c91 | ||
|
|
d9e09281e8 | ||
|
|
b80fa042d5 | ||
|
|
32b7812169 | ||
|
|
a575795ff5 | ||
|
|
fcd8de2a96 | ||
|
|
c9b0f9266e | ||
|
|
e2aae3897d | ||
|
|
8b017bab3f | ||
|
|
4af4fdc544 | ||
|
|
52cb93bf24 | ||
|
|
57e7dcf990 | ||
|
|
f0d9f488e6 | ||
|
|
65aeedd3b0 | ||
|
|
5df337f6d7 | ||
|
|
cb377fb60c | ||
|
|
117aa9a21a | ||
|
|
d453f2aec3 | ||
|
|
dd387cf379 | ||
|
|
0d2affaf6b | ||
|
|
a0263c22a5 | ||
|
|
5d5ea680aa | ||
|
|
1f75bc5cea | ||
|
|
933a4eaf57 | ||
|
|
1b51d2af01 | ||
|
|
638c17caef | ||
|
|
92b7399ddb | ||
|
|
63b2f75012 | ||
|
|
49b8a1a19c | ||
|
|
5ce080baf3 | ||
|
|
98b4c39a00 | ||
|
|
ad457b90a9 | ||
|
|
b74084b67c | ||
|
|
11e357cc94 | ||
|
|
6f152c8dfe | ||
|
|
39570656c9 | ||
|
|
99ff5aebaf | ||
|
|
af86d2c3cd | ||
|
|
4c5604bf87 | ||
|
|
714af7e872 | ||
|
|
4aee2a34f1 | ||
|
|
30bd479a6f | ||
|
|
8e01666e0e | ||
|
|
5ce21e9a47 | ||
|
|
3dcc7c9ea6 | ||
|
|
b460997cbf | ||
|
|
4ae2954f97 | ||
|
|
46fe38321a | ||
|
|
16fa6eb5d8 | ||
|
|
f5a71728ac | ||
|
|
97526bf06f | ||
|
|
299f1da90b | ||
|
|
1d50ec8d17 | ||
|
|
594e60a1f4 | ||
|
|
1c407b77de | ||
|
|
df89e88f4e | ||
|
|
adb6589816 | ||
|
|
b184922f61 | ||
|
|
17f03e7af1 | ||
|
|
bb74eae64c | ||
|
|
f99612f6aa | ||
|
|
c7b4981dff | ||
|
|
26869632a0 | ||
|
|
71cc6fa3a7 | ||
|
|
3ceb8c038c | ||
|
|
c419a46955 | ||
|
|
860add8edf | ||
|
|
644ddb64b3 | ||
|
|
1e0fc4cd2d | ||
|
|
9320c11d58 | ||
|
|
c37498512f | ||
|
|
ecd72d9750 | ||
|
|
4896d8ef6f | ||
|
|
02ca48ef9c | ||
|
|
2d5fb3598f | ||
|
|
0a332ce341 | ||
|
|
517e0bca67 | ||
|
|
3c41de136e | ||
|
|
d28d7a2cdb | ||
|
|
440e10c646 | ||
|
|
9d2101d700 | ||
|
|
33185fddae | ||
|
|
68ccc49135 | ||
|
|
b60b7ac8e8 | ||
|
|
d0c715e93c | ||
|
|
1b91ba4e1d | ||
|
|
155dfc5716 | ||
|
|
b856215c03 | ||
|
|
cba3db1508 | ||
|
|
6fa8e5bfd1 | ||
|
|
8bda85129f | ||
|
|
ea4095d1f1 | ||
|
|
a2eae189be | ||
|
|
bc20e5b9f4 | ||
|
|
bc83b8dbbf | ||
|
|
89e48c2424 | ||
|
|
8bc4f22af7 | ||
|
|
0cdb43a29d | ||
|
|
6cb27b2aab | ||
|
|
1bce4e1ea2 | ||
|
|
e47e2b4029 | ||
|
|
329d6b82f7 | ||
|
|
1215cbe8ae | ||
|
|
66fde3fc62 | ||
|
|
5a23dfcfe6 | ||
|
|
6e6558bde9 | ||
|
|
338335ddc0 | ||
|
|
7077844655 | ||
|
|
255f272fab | ||
|
|
9404bd5136 | ||
|
|
d68c11e58a | ||
|
|
c82434c435 | ||
|
|
2cdfa2cb13 | ||
|
|
fb70d80220 | ||
|
|
0ee7f02e87 | ||
|
|
c88e1400bf | ||
|
|
bf2815b5a4 | ||
|
|
f93dfe34c2 | ||
|
|
59f0f82176 | ||
|
|
e58604c3c0 | ||
|
|
54ef752a5b | ||
|
|
50fad3ac80 | ||
|
|
52493debc2 | ||
|
|
f5fc9282e2 | ||
|
|
79a8d95e7d | ||
|
|
259c5c3014 | ||
|
|
7a68ae8184 | ||
|
|
1d522527b4 | ||
|
|
af05ad5cda | ||
|
|
e3791f3496 | ||
|
|
47dcd89b3c | ||
|
|
b1080b0a05 | ||
|
|
2199950681 | ||
|
|
748a3c00ad | ||
|
|
3dc25628af | ||
|
|
c18c148a23 | ||
|
|
baa43b7ad4 | ||
|
|
4254904c3a | ||
|
|
a89cefcbf2 | ||
|
|
72a63b4a4f | ||
|
|
aa678b9d5d | ||
|
|
5c307a5b6c | ||
|
|
2e17cbe926 | ||
|
|
8fdc4b917b | ||
|
|
7046808429 | ||
|
|
85e701960e | ||
|
|
f9cfb4e532 | ||
|
|
919d0c4714 | ||
|
|
a29ee31052 | ||
|
|
fc3d588cec | ||
|
|
337d14abdf | ||
|
|
a21252fb02 | ||
|
|
ceb2113209 | ||
|
|
fb009344e1 | ||
|
|
6ea3cdad0e | ||
|
|
6f00596e33 | ||
|
|
d112935c73 | ||
|
|
0e0f98e0f4 | ||
|
|
d200e87e3d | ||
|
|
6fc342cd2c | ||
|
|
ce43b2d762 | ||
|
|
921aba3054 | ||
|
|
f0071a495c | ||
|
|
b8923d075d | ||
|
|
8fab2f7fd2 | ||
|
|
29a4ea2e5b | ||
|
|
4ae5f2edcd | ||
|
|
0ab5a23eb3 | ||
|
|
860e88ed3e | ||
|
|
365ab2173c | ||
|
|
24cc66d8d2 | ||
|
|
1c7ffa07a8 | ||
|
|
1fba16e1f2 | ||
|
|
7554f1804b | ||
|
|
c4011cfaff | ||
|
|
8412ba9bbe | ||
|
|
1cf7297d64 | ||
|
|
4b01f20558 | ||
|
|
c2159508d5 | ||
|
|
5e6057842f | ||
|
|
59440555cd | ||
|
|
92ba474ca9 | ||
|
|
14f8b1ee9a | ||
|
|
b47c5645d5 | ||
|
|
14a9e6d26c | ||
|
|
0c460d4767 | ||
|
|
131ecb8668 | ||
|
|
14be83a869 | ||
|
|
e163a9ed9d | ||
|
|
4dc2ee2129 | ||
|
|
3d518ff902 | ||
|
|
66b439cc2e | ||
|
|
780d5e9237 | ||
|
|
f5b64ea154 | ||
|
|
611f31875e | ||
|
|
74e7cd257d | ||
|
|
86ecf764ff | ||
|
|
f9413d5100 | ||
|
|
4a8d696e4e | ||
|
|
46d62c98b8 | ||
|
|
3dd4ad34ee | ||
|
|
d0f5649725 | ||
|
|
1d1de12c58 | ||
|
|
221d6db10c | ||
|
|
425a77a1ca | ||
|
|
4aa4726c6b | ||
|
|
d3ae2598fb | ||
|
|
8fff3d7fff | ||
|
|
ec6ec52965 | ||
|
|
efc6da1249 | ||
|
|
f980619046 | ||
|
|
165f40acd7 | ||
|
|
48b7a061c1 | ||
|
|
460cf67313 | ||
|
|
7dd377d7c3 | ||
|
|
7a01e8d545 | ||
|
|
88a8b5f02f | ||
|
|
ed491c313f | ||
|
|
f50a29f116 | ||
|
|
5734340db2 | ||
|
|
c6d146d38f | ||
|
|
65241c94e9 | ||
|
|
7318c5286b | ||
|
|
53bca894bb | ||
|
|
c3ce626b47 | ||
|
|
ee3dcdacf2 | ||
|
|
2c2a3ef078 | ||
|
|
c563583ccb | ||
|
|
1d98a2a66e | ||
|
|
f3568d836b | ||
|
|
ccbc1aa029 | ||
|
|
ec8e28d15d | ||
|
|
7c6f937cec | ||
|
|
a03909e504 | ||
|
|
094c812c37 | ||
|
|
cb16c03e01 | ||
|
|
3dc85a22e9 | ||
|
|
8ac40ba78a | ||
|
|
24313340d5 | ||
|
|
94dd453b33 | ||
|
|
480aa82f78 | ||
|
|
9eb6a3fad1 | ||
|
|
e05b3a38c8 | ||
|
|
94ad73212b | ||
|
|
167e19cd91 | ||
|
|
3898881d8a | ||
|
|
ddb47a0ee4 | ||
|
|
27f8323624 | ||
|
|
ca9474ac83 | ||
|
|
9cd15528c9 | ||
|
|
a9ed459481 | ||
|
|
0734679fd6 | ||
|
|
fd7a08d5dc | ||
|
|
f7e0e20673 | ||
|
|
cc40a5e33d | ||
|
|
ce14f5de3e | ||
|
|
4f235058be | ||
|
|
2f5b617e8c | ||
|
|
bb9278fe58 | ||
|
|
61950638bc | ||
|
|
e9e5030047 | ||
|
|
dfb993e1a3 | ||
|
|
b64e6834a9 | ||
|
|
d25afa4b9e | ||
|
|
af84f0135d | ||
|
|
67a152b7c8 | ||
|
|
6c8e66693b | ||
|
|
668c7c6823 | ||
|
|
4ec32765df | ||
|
|
fe9df167ec | ||
|
|
9b3196363c | ||
|
|
6a726b13b4 | ||
|
|
0d418e2e39 | ||
|
|
4fd844f977 | ||
|
|
52af82af42 | ||
|
|
fca42aaa81 | ||
|
|
a475b5c6df | ||
|
|
bdba83b2b6 | ||
|
|
1ea07f65b2 | ||
|
|
ddf1851e92 | ||
|
|
f81b35308b | ||
|
|
45530cdb99 | ||
|
|
d47f7ab162 | ||
|
|
583b23ded1 | ||
|
|
8b9b584513 | ||
|
|
e7d45d194d | ||
|
|
d3db82fd1c | ||
|
|
e66762ca2e | ||
|
|
3d3c45e4d2 | ||
|
|
f9a4a07996 | ||
|
|
e7755a4d16 | ||
|
|
c30f817926 | ||
|
|
88326d9851 | ||
|
|
f8e7437210 | ||
|
|
e0364d45df | ||
|
|
bbff586da2 | ||
|
|
fbd1be8952 | ||
|
|
0bf5d6065c | ||
|
|
67c3a4fd73 | ||
|
|
9beb24273b | ||
|
|
5cc6128086 | ||
|
|
569b6be2b4 | ||
|
|
752256f12e | ||
|
|
a3502b9755 | ||
|
|
0e1524c600 | ||
|
|
11ae7593f4 | ||
|
|
3eb75ea0b8 | ||
|
|
7365d8d8fb | ||
|
|
7755d77340 | ||
|
|
564aae21f0 | ||
|
|
0fdd2fc1e1 | ||
|
|
f15a7348db | ||
|
|
f3c846201c | ||
|
|
ee9804f1bb | ||
|
|
4ce6169d14 | ||
|
|
60a0970d73 | ||
|
|
fc29dcede7 | ||
|
|
5f774ce26e | ||
|
|
25b82997b6 | ||
|
|
b4a3c3a925 | ||
|
|
9c0ee7940e | ||
|
|
5750590b68 | ||
|
|
3c42c114f5 | ||
|
|
5aef419582 | ||
|
|
0c0dfa1df7 | ||
|
|
c162a41bc0 | ||
|
|
97e43b2335 | ||
|
|
db21e1217c | ||
|
|
5bb9066be8 | ||
|
|
b1fd259f10 |
No files matched your search
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -31,7 +31,7 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
|
|
||||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## code-block
|
||||||
|
|
||||||
|
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CodeBlock code={code} language="tsx" />
|
||||||
|
|
||||||
|
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||||
|
<CodeBlockLanguage />
|
||||||
|
<CodeBlockCopyButton className="ml-auto" />
|
||||||
|
</CodeBlockHeader>
|
||||||
|
<CodeBlockExpandButton />
|
||||||
|
</CodeBlock>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 22 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
|
|
||||||
## Error playbook
|
## Error playbook
|
||||||
|
|
||||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing?ref=skill (upgrade). Free accounts still get all components + examples.
|
||||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||||
|
|
||||||
@@ -55,4 +55,4 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||||
@@ -54,6 +54,12 @@ function formatSemver(parts) {
|
|||||||
|
|
||||||
function bumpVersion(version, bump) {
|
function bumpVersion(version, bump) {
|
||||||
const parts = parseSemver(version)
|
const parts = parseSemver(version)
|
||||||
|
if (bump === "major") {
|
||||||
|
parts.major += 1
|
||||||
|
parts.minor = 0
|
||||||
|
parts.patch = 0
|
||||||
|
return formatSemver(parts)
|
||||||
|
}
|
||||||
if (bump === "minor") {
|
if (bump === "minor") {
|
||||||
parts.minor += 1
|
parts.minor += 1
|
||||||
parts.patch = 0
|
parts.patch = 0
|
||||||
@@ -66,8 +72,8 @@ function bumpVersion(version, bump) {
|
|||||||
function parseCommit(subject, body = "") {
|
function parseCommit(subject, body = "") {
|
||||||
const match = CONVENTIONAL_RE.exec(subject.trim())
|
const match = CONVENTIONAL_RE.exec(subject.trim())
|
||||||
const breaking =
|
const breaking =
|
||||||
/BREAKING CHANGE/i.test(body) ||
|
/BREAKING[ -]CHANGE:/i.test(body) ||
|
||||||
(match?.[1] && subject.includes(`${match[1]}!:`))
|
(match !== null && /^[^:]+!:\s/.test(subject.trim()))
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
||||||
@@ -78,7 +84,9 @@ function parseCommit(subject, body = "") {
|
|||||||
const description = match[3].trim()
|
const description = match[3].trim()
|
||||||
let bump = "patch"
|
let bump = "patch"
|
||||||
|
|
||||||
if (breaking || MINOR_TYPES.has(type)) {
|
if (breaking) {
|
||||||
|
bump = "major"
|
||||||
|
} else if (MINOR_TYPES.has(type)) {
|
||||||
bump = "minor"
|
bump = "minor"
|
||||||
} else if (PATCH_TYPES.has(type)) {
|
} else if (PATCH_TYPES.has(type)) {
|
||||||
bump = "patch"
|
bump = "patch"
|
||||||
@@ -251,7 +259,9 @@ function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
const bump = commits.some((commit) => commit.bump === "major")
|
||||||
|
? "major"
|
||||||
|
: commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
||||||
const version = bumpVersion(baseFromTag, bump)
|
const version = bumpVersion(baseFromTag, bump)
|
||||||
const tag = `v${version}`
|
const tag = `v${version}`
|
||||||
const publishedAt = new Date().toISOString()
|
const publishedAt = new Date().toISOString()
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { execFileSync } from "node:child_process"
|
||||||
|
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { basename, dirname, join, resolve } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
const source = fileURLToPath(new URL("./compute-release.mjs", import.meta.url))
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{ name: "исправление повышает patch", commits: [["fix(ui): исправить отображение"]], version: "1.47.7", bump: "patch" },
|
||||||
|
{ name: "новая возможность повышает minor", commits: [["feat(ui): добавить раздел"]], version: "1.48.0", bump: "minor" },
|
||||||
|
{ name: "breaking со scope повышает major", commits: [["chore(release)!: подготовить вторую основную версию"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "breaking без scope повышает major", commits: [["feat!: заменить контракт"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "footer со пробелом повышает major", commits: [["fix(api): заменить контракт", "BREAKING CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "footer с дефисом повышает major", commits: [["fix(api): заменить контракт", "BREAKING-CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "major имеет приоритет в смешанной истории", commits: [["chore(release)!: подготовить выпуск"], ["feat(ui): добавить раздел"], ["fix(ui): исправить отображение"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "после второй версии сохраняется patch", base: "2.0.0", commits: [["fix(ui): исправить отображение"]], version: "2.0.1", bump: "patch" },
|
||||||
|
{ name: "после второй версии сохраняется minor", base: "2.0.0", commits: [["feat(ui): добавить раздел"]], version: "2.1.0", bump: "minor" },
|
||||||
|
{ name: "готовый тег не выпускается повторно", base: "2.0.0", commits: [], version: "2.0.0", bump: "none" },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const scenario of cases) test(scenario.name, () => {
|
||||||
|
const parent = resolve(tmpdir())
|
||||||
|
const root = mkdtempSync(join(parent, "mm-release-"))
|
||||||
|
const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" })
|
||||||
|
try {
|
||||||
|
mkdirSync(join(root, ".ci/scripts"), { recursive: true })
|
||||||
|
copyFileSync(source, join(root, ".ci/scripts/compute-release.mjs"))
|
||||||
|
git("init", "--initial-branch=main")
|
||||||
|
git("config", "user.name", "Release Test")
|
||||||
|
git("config", "user.email", "[email protected]")
|
||||||
|
git("config", "core.hooksPath", ".git/hooks")
|
||||||
|
writeFileSync(join(root, "fixture"), "baseline")
|
||||||
|
git("add", "fixture")
|
||||||
|
git("commit", "-m", "chore(test): создать исходную базу")
|
||||||
|
git("tag", `v${scenario.base ?? "1.47.6"}`)
|
||||||
|
for (const [index, [subject, body]] of scenario.commits.entries()) {
|
||||||
|
writeFileSync(join(root, "fixture"), `change ${index}`)
|
||||||
|
git("add", "fixture")
|
||||||
|
git("commit", "-m", subject, ...(body ? ["-m", body] : []))
|
||||||
|
}
|
||||||
|
execFileSync(process.execPath, [join(root, ".ci/scripts/compute-release.mjs")], {
|
||||||
|
cwd: root, stdio: "pipe",
|
||||||
|
env: { ...process.env, BASE_VERSION: "1.0.0", RELEASE_OUTPUT_DIR: ".ci/release", GITHUB_OUTPUT: join(root, "outputs"), GITEA_REPOSITORY: "test/repo", GITEA_SERVER_URL: "https://example.invalid" },
|
||||||
|
})
|
||||||
|
const manifest = JSON.parse(readFileSync(join(root, ".ci/release/release-manifest.json"), "utf8"))
|
||||||
|
const output = readFileSync(join(root, "outputs"), "utf8")
|
||||||
|
assert.equal(manifest.version, scenario.version)
|
||||||
|
assert.equal(manifest.tag, `v${scenario.version}`)
|
||||||
|
assert.equal(manifest.releaseUrl, `https://example.invalid/test/repo/releases/tag/v${scenario.version}`)
|
||||||
|
assert.match(output, new RegExp(`^bump=${scenario.bump}$`, "m"))
|
||||||
|
assert.match(output, new RegExp(`^should_release=${scenario.bump !== "none"}$`, "m"))
|
||||||
|
if (scenario.bump !== "none") assert.match(readFileSync(join(root, ".ci/release/release_notes.md"), "utf8"), new RegExp(`# v${scenario.version.replaceAll(".", "\\.")}`))
|
||||||
|
} finally {
|
||||||
|
assert.equal(dirname(resolve(root)), parent)
|
||||||
|
assert.ok(basename(root).startsWith("mm-release-"))
|
||||||
|
rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -31,7 +31,7 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
|
|
||||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## code-block
|
||||||
|
|
||||||
|
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CodeBlock code={code} language="tsx" />
|
||||||
|
|
||||||
|
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||||
|
<CodeBlockLanguage />
|
||||||
|
<CodeBlockCopyButton className="ml-auto" />
|
||||||
|
</CodeBlockHeader>
|
||||||
|
<CodeBlockExpandButton />
|
||||||
|
</CodeBlock>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 22 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
|
|
||||||
## Error playbook
|
## Error playbook
|
||||||
|
|
||||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing?ref=skill (upgrade). Free accounts still get all components + examples.
|
||||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||||
|
|
||||||
@@ -55,4 +55,4 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[mcp_servers.reui]
|
||||||
|
url = "https://mcp.reui.io/api/mcp?style=base-nova"
|
||||||
|
bearer_token_env_var = "REUI_LICENSE_KEY"
|
||||||
@@ -29,7 +29,11 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Minor (`1.x.0`)
|
## Minor (`1.x.0`)
|
||||||
|
|
||||||
`feat:`, `feat!`, footer `BREAKING CHANGE` — только для заметного core-функционала.
|
`feat:` — новая пользовательская возможность.
|
||||||
|
|
||||||
|
## Major (`2.0.0` после `1.x`)
|
||||||
|
|
||||||
|
`тип!:`, `тип(scope)!:` или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:` повышают major, обнуляя minor и patch. При смешанной истории major имеет приоритет над minor и patch. Версия, тег, manifest и Docker tags вычисляются CI; package.json вручную не меняется. Для согласованного перехода на вторую основную версию используется `chore(release)!:` с русским описанием.
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
|||||||
|
|
||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -31,7 +31,7 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
|
|
||||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## code-block
|
||||||
|
|
||||||
|
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CodeBlock code={code} language="tsx" />
|
||||||
|
|
||||||
|
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||||
|
<CodeBlockLanguage />
|
||||||
|
<CodeBlockCopyButton className="ml-auto" />
|
||||||
|
</CodeBlockHeader>
|
||||||
|
<CodeBlockExpandButton />
|
||||||
|
</CodeBlock>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 22 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
|
|
||||||
## Error playbook
|
## Error playbook
|
||||||
|
|
||||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing?ref=skill (upgrade). Free accounts still get all components + examples.
|
||||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||||
|
|
||||||
@@ -55,4 +55,4 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
deploy/netflow-agent/*.sh text eol=lf
|
||||||
|
deploy/netflow-agent/*.py text eol=lf
|
||||||
@@ -25,6 +25,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Test release version calculation
|
||||||
|
run: node --test .ci/scripts/compute-release.test.mjs
|
||||||
|
|
||||||
- name: Compute release metadata
|
- name: Compute release metadata
|
||||||
id: release
|
id: release
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -54,16 +57,31 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
docker rm -f "$NAME" mm-pg 2>/dev/null || true
|
docker rm -f "$NAME" 2>/dev/null || true
|
||||||
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
|
# Docker-backed Gitea runners execute this step in a separate container.
|
||||||
|
# Join its network; a published host loopback port is not reachable here.
|
||||||
|
NETWORKS=$(docker inspect --format '{{range $name, $network := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$(cat /etc/hostname)" 2>/dev/null || true)
|
||||||
|
if [ -n "$NETWORKS" ]; then
|
||||||
|
test "$(printf '%s\n' "$NETWORKS" | wc -l)" -eq 1
|
||||||
|
NETWORK_ARGS=(--network "$NETWORKS" --network-alias "$NAME")
|
||||||
|
PG_HOST="$NAME"
|
||||||
|
PG_PORT=5432
|
||||||
|
else
|
||||||
|
# Host-based runners can use a Docker-assigned ephemeral host port.
|
||||||
|
NETWORK_ARGS=(-p 127.0.0.1::5432)
|
||||||
|
PG_HOST=127.0.0.1
|
||||||
|
fi
|
||||||
docker run -d --name "$NAME" --rm \
|
docker run -d --name "$NAME" --rm \
|
||||||
-e POSTGRES_USER=mmapp \
|
-e POSTGRES_USER=mmapp \
|
||||||
-e POSTGRES_PASSWORD=mmapp \
|
-e POSTGRES_PASSWORD=mmapp \
|
||||||
-e POSTGRES_DB=mmapp \
|
-e POSTGRES_DB=mmapp \
|
||||||
-p "127.0.0.1:${HOST_PORT}:5432" \
|
"${NETWORK_ARGS[@]}" \
|
||||||
postgres:18-alpine
|
postgres:18-alpine
|
||||||
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
||||||
echo "DATABASE_URL=postgres://mmapp:[email protected]:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}"
|
if [ "$PG_HOST" = 127.0.0.1 ]; then
|
||||||
|
PG_PORT=$(docker port "$NAME" 5432/tcp | awk -F: '{print $NF}')
|
||||||
|
fi
|
||||||
|
echo "DATABASE_URL=postgres://mmapp:mmapp@${PG_HOST}:${PG_PORT}/mmapp" >> "${GITHUB_ENV}"
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
||||||
exit 0
|
exit 0
|
||||||
@@ -76,15 +94,27 @@ jobs:
|
|||||||
|
|
||||||
- name: Install and test backend
|
- name: Install and test backend
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUIRE_TEST_POSTGRES: "1"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
npm ci
|
npm ci
|
||||||
|
node --input-type=module <<'NODE'
|
||||||
|
import pg from 'pg';
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 8000 });
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
console.log('PostgreSQL reachable from the test process');
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
NODE
|
||||||
npm run test --prefix backend
|
npm run test --prefix backend
|
||||||
|
|
||||||
- name: Stop PostgreSQL
|
- name: Stop PostgreSQL
|
||||||
if: always()
|
if: always()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true
|
run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
|
||||||
|
|
||||||
backend-image:
|
backend-image:
|
||||||
needs:
|
needs:
|
||||||
@@ -125,6 +155,8 @@ jobs:
|
|||||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||||
cp -R backend/src "$STAGING/backend/"
|
cp -R backend/src "$STAGING/backend/"
|
||||||
|
mkdir -p "$STAGING/deploy/netflow-agent"
|
||||||
|
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
||||||
if [ -d backend/drizzle ]; then
|
if [ -d backend/drizzle ]; then
|
||||||
cp -R backend/drizzle "$STAGING/backend/"
|
cp -R backend/drizzle "$STAGING/backend/"
|
||||||
fi
|
fi
|
||||||
@@ -299,12 +331,69 @@ jobs:
|
|||||||
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 }}
|
||||||
|
|
||||||
|
netflow-agent-image:
|
||||||
|
needs: prepare-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure netflow-agent image name
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
||||||
|
name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
||||||
|
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
||||||
|
echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
- name: Test controller and installer
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
|
||||||
|
bash -n deploy/netflow-agent/install.sh
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
|
- name: Verify image with real WireGuard
|
||||||
|
run: |
|
||||||
|
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
|
||||||
|
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
|
||||||
|
|
||||||
|
- name: Build and push netflow-agent image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: deploy/netflow-agent
|
||||||
|
file: deploy/netflow-agent/Dockerfile
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
|
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
|
||||||
|
cache-from: type=gha,scope=netflow-agent
|
||||||
|
cache-to: type=gha,mode=max,scope=netflow-agent
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=mmapp-netflow-agent
|
||||||
|
org.opencontainers.image.description=MikroTik Manager local WireGuard controller
|
||||||
|
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
|
||||||
|
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||||
|
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
- backend-image
|
- backend-image
|
||||||
- frontend-image
|
- frontend-image
|
||||||
- updater-image
|
- updater-image
|
||||||
|
- netflow-agent-image
|
||||||
if: needs.prepare-release.outputs.should_release == 'true'
|
if: needs.prepare-release.outputs.should_release == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -386,17 +475,18 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
notify-webhook:
|
notify-webhook:
|
||||||
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }}
|
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
- backend-image
|
- backend-image
|
||||||
- frontend-image
|
- frontend-image
|
||||||
|
- netflow-agent-image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||||
steps:
|
steps:
|
||||||
- name: Notify deploy webhook
|
- name: Notify deploy webhook
|
||||||
|
if: env.WEBHOOK_URL != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl --fail --silent --show-error --request POST \
|
curl --fail --silent --show-error --request POST \
|
||||||
|
|||||||
+15
@@ -31,6 +31,10 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# coverage
|
# coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
@@ -55,3 +59,14 @@ temp/
|
|||||||
.cursor/mcp.json
|
.cursor/mcp.json
|
||||||
.mcp.json
|
.mcp.json
|
||||||
opencode.json
|
opencode.json
|
||||||
|
|
||||||
|
# Local codegraph process
|
||||||
|
.codegraph/daemon.pid
|
||||||
|
|
||||||
|
# Python controller tests
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# NetFlow agent host state (when Compose is installed in a checkout)
|
||||||
|
/data/netflow-wireguard/
|
||||||
|
/data/netflow-agent-socket/
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -31,7 +31,7 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
|
|
||||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## code-block
|
||||||
|
|
||||||
|
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CodeBlock code={code} language="tsx" />
|
||||||
|
|
||||||
|
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||||
|
<CodeBlockLanguage />
|
||||||
|
<CodeBlockCopyButton className="ml-auto" />
|
||||||
|
</CodeBlockHeader>
|
||||||
|
<CodeBlockExpandButton />
|
||||||
|
</CodeBlock>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 22 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
|
|
||||||
## Error playbook
|
## Error playbook
|
||||||
|
|
||||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing?ref=skill (upgrade). Free accounts still get all components + examples.
|
||||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||||
|
|
||||||
@@ -55,4 +55,4 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Локальные GeoLite2-базы (Country + ASN) для потоков по странам и ASN
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Сейчас страна и ASN для netflow-потоков резолвятся через внешний RIPEstat API (`backend/src/services/traffic-flow-ripe.ts`): лимит 30 новых префиксов/мин, очередь на 90, кэш в PG `flow_ip_meta`. Новые IP «дозревают» с задержкой, IPv6 не покрывается (кэш индексируется только по IPv4). Локальные mmdb-базы дают мгновенный синхронный lookup всех IP без внешних вызовов.
|
||||||
|
|
||||||
|
Решения (подтверждены):
|
||||||
|
- Источник — **MaxMind GeoLite2 через P3TERX-зеркало**: `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb` и `.../GeoLite2-ASN.mmdb`. Без регистрации, ключей и tar-распаковки. Точность по стране у GeoLite2 и IPinfo паритетная (<1% ошибок у обоих, arXiv 2026); выбран P3TERX за надёжность зеркала (5.2k звёзд) и преемственность: текущий RIPE-путь и так читает GeoLite (`maxmind-geo-lite`), история в кэше остаётся консистентной.
|
||||||
|
- **RIPEstat остаётся fallback** (до первой загрузки баз / если lookup не дал результата).
|
||||||
|
- City-базу не качаем (lat/lng фронтенд не использует).
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### 1. Зависимость
|
||||||
|
- `npm install -w mikrotik-manager-backend maxmind` — sync-чтение mmdb, встроенные TS-типы, без транзитивных зависимостей, Node 22 ок.
|
||||||
|
|
||||||
|
### 2. Новый сервис `backend/src/services/traffic-flow-geoip.ts`
|
||||||
|
(по конвенциям окружения traffic-flow-*: контракт → маршрут → сервис, без БД-логики в маршрутах)
|
||||||
|
- Каталог: `backend/storage/geoip/` (конвенция `storage/backups`), файлы `GeoLite2-Country.mmdb`, `GeoLite2-ASN.mmdb`.
|
||||||
|
- `initGeoip()` — открыть ридеры best-effort при старте (из `index.ts` рядом с `startTrafficFlowListener`), независимо от настроек автообновления: файлы есть — работают.
|
||||||
|
- `lookupGeoip(ip): FlowIpMeta | null` — синхронно: `country.iso_code` (fallback `registered_country.iso_code`) с валидацией `isIsoCountry`, ASN = `autonomous_system_number`, holder = `autonomous_system_organization`; приватные IP → negative-запись как в RIPE (`isNonPublicIp`); в PG не пишем (lookup и так быстрый). IPv6 поддержан ридером.
|
||||||
|
- `resolveFlowIp(ip)` — фасад: `lookupGeoip(ip) ?? lookupRipeCached(ip)`; главный экспорт для потребителей.
|
||||||
|
- `geoipStatus()` — loaded, даты сборки баз (метаданные mmdb). Тест-хук `setGeoipReadersForTests`. Смена ридеров после обновления — атомарная замена ссылок.
|
||||||
|
|
||||||
|
### 3. Коллектор `backend/src/services/geoip-update-collector.ts`
|
||||||
|
`collectGeoipUpdateOnce()` по образцу `certificate-renew-collector.ts`:
|
||||||
|
1. Conditional GET с ETag/If-None-Match из настроек → 304 = skip (фолбэк-сравнение: размер/содержимое).
|
||||||
|
2. Скачивание в `*.tmp` через глобальный `fetch` + AbortController с таймаутом (внешний HTTP из service-слоя — по правилу fastify-backend-drizzle).
|
||||||
|
3. Валидация: открыть ридер из tmp-файла, пробой 8.8.8.8 (страна US, ASN 15169).
|
||||||
|
4. `fs.rename` атомарная подмена, старый файл → `*.prev` (откат, если новый ридер не открылся).
|
||||||
|
5. Перезагрузка ридеров, статус в настройках; snapshot для `scheduler_runs` (checked/downloaded/skipped/bytes/error).
|
||||||
|
|
||||||
|
### 4. Планировщик (`backend/src/services/scheduler.ts`)
|
||||||
|
- `JOB_KEYS` += `geoip_update`; case в `runSchedulerJobBody`; блок в `refreshScheduler()` по образцу `certificates_renew`: интервал `Math.max(6ч, updateIntervalSec*1000)`, по умолчанию 7 дней (upstream обновляется еженедельно) + немедленный первый запуск при включённой настройке.
|
||||||
|
|
||||||
|
### 5. Схема и миграция
|
||||||
|
- `backend/src/db/schema.ts`: singleton `geoip_settings` — `enabled` (default true), `updateIntervalSec` (default 604800), `lastCheckAt`, `lastSuccessAt`, `lastError`, `countryBuildAt`, `asnBuildAt`, `etagsJson` (jsonb), `createdAt/updatedAt`.
|
||||||
|
- Миграция: `npm run db:generate` → файл в `backend/drizzle/`.
|
||||||
|
|
||||||
|
### 6. API + контракты
|
||||||
|
- `packages/contracts/src/geoip.ts`: zod-схемы настроек/статуса (все входы — Zod, по правилам проекта).
|
||||||
|
- Новый `backend/src/routes/geoip.ts`, регистрация в `index.ts` с prefix `/api`:
|
||||||
|
- `GET /api/geoip` — настройки + статус (ready, даты сборки, последняя проверка/ошибка);
|
||||||
|
- `PUT /api/geoip` — сохранить настройки, затем `refreshScheduler()`;
|
||||||
|
- `POST /api/geoip/update` — запустить загрузку сейчас (409, если уже идёт; флаг-гард как в коллекторах).
|
||||||
|
|
||||||
|
### 7. Интеграция в пайплайн (geoip-first, RIPE-fallback)
|
||||||
|
- `traffic-flow-engine.ts` (`queueParsedFlows`, ~строка 336): `lookupRipeCached` → `resolveFlowIp`. Логика misses не меняется: при готовом mmdb публичные IP (v4+v6) резолвятся сразу, очередь RIPE пустеет; до скачивания баз — прежнее поведение.
|
||||||
|
- Остальные вызовы `lookupRipeCached` → `resolveFlowIp` (grep: как минимум `traffic-flow-analytics.ts` ~258–271).
|
||||||
|
- `classifyFlowDst`/бренды не трогаем: holder из mmdb (org name) встаёт в существующие `HOLDER_BRANDS`-регулярки как есть.
|
||||||
|
|
||||||
|
### 8. Frontend (по next-shadcn-production / ui-guardian: только переиспользование)
|
||||||
|
- Секция «GeoIP-базы (GeoLite2)» внутри существующей `components/traffic/netflow-settings-panel.tsx`: статус (готово/не скачано, даты сборки Country/ASN, последняя проверка, ошибка), тумблер автообновления, интервал, кнопка «Обновить сейчас» с индикатором. Только уже используемые в панели примитивы (Switch/Button/поля) — никаких новых визуальных паттернов и Card-shell. API-клиент через существующие http-хелперы.
|
||||||
|
|
||||||
|
### 9. Хаускипинг, тесты, проверки
|
||||||
|
- `backend/.gitignore`: `storage/geoip/`.
|
||||||
|
- Тесты `backend/src/services/traffic-flow-geoip.test.ts` + скрипт `test:geoip` (по образцу `test:traffic-flow`): приоритет фасада (geoip hit → RIPE не зовётся; miss → fallback), negative на приватных IP, фильтрация EU/ZZ через `isIsoCountry`, коллектор с мокнутым fetch (304-skip, битый файл → подмены нет, `.prev` сохранён), dims по стране/ASN с засеянным ридером.
|
||||||
|
- Проверки после реализации (обязательно по правилам): типы/сборка бэка (`npm run build -w mikrotik-manager-backend`), типы фронта при правке UI (`npx tsc --noEmit`), `npm run test:geoip` и `test:traffic-flow`; предупреждения не игнорировать.
|
||||||
|
- Коммит: `feat(netflow): <subject по-русски>` — новая пользовательская фича (мгновенные страна/ASN в потоках), по commit-messages-ru.
|
||||||
|
- README: короткий раздел о GeoIP; примечание, что в Docker `storage/geoip` ephemeral без тома — базы перекачаются после пересоздания контейнера (~17 МБ); при желании смонтировать volume.
|
||||||
|
|
||||||
|
## Что это даёт
|
||||||
|
Страна и ASN появляются у потока мгновенно при ingest (включая IPv6), без ограничения скорости RIPE; dims `country`/`asn` в `flow_daily_dims`, аналитика (карта, топы, monthly) становятся полными сразу. Внешняя зависимость от stat.ripe.net остаётся только как fallback до первой загрузки баз.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# IKEv2/IPsec VPN: клиенты, IP-адреса и сертификаты — по образцу WireGuard
|
||||||
|
|
||||||
|
## Архитектура (как у WireGuard)
|
||||||
|
|
||||||
|
Роутер — источник истины: live-чтение через существующий REST-клиент (`mikrotik.ts`), мутации прямыми REST-вызовами, история через `config_revisions` (новая секция `"ipsec"`; `section` — TEXT без CHECK → **SQL-миграция не нужна**). Новых таблиц нет. Объекты, созданные мастером/при создании клиентов, помечаются managed-комментарием `mm-ipsec` (по образцу managed-markers).
|
||||||
|
|
||||||
|
## Модель RouterOS (что создаём/читаем)
|
||||||
|
|
||||||
|
Мастер инициализации сервера (per router): CA-сертификат (`/certificate add` + `sign`, key-usage=key-cert-sign,crl-sign), серверный сертификат (CN=адрес/домен, SAN, tls-server), `/ip/ipsec/peer` (passive, exchange-mode=ike2, certificate=серверный cert), `/ip/ipsec/profile`+`proposal` (aes-256/sha256/modp2048, pfs), `/ip pool` + `/ip/ipsec/mode-config` «VPN» (address-pool, dns), policy-template, и опциональное (default on, чекбокс) managed srcnat masquerade-правило «интернет клиентам».
|
||||||
|
|
||||||
|
Клиент = `/ip/ipsec/identity`: **Сертификат** (`/certificate add` CN=<имя> → `sign` ca=<CA> → `export-certificate type=pkcs12` → файл `.p12` скачивается с роутера → identity match-by=certificate) или **PSK** (auth-method=pre-shared-key, remote-id+secret). IP: «из пула» (общий mode-config) или статический (персональный `mc-<user>` mode-config с `address=x.x.x.x/32`). Онлайн — `/ip/ipsec/active-peers`.
|
||||||
|
|
||||||
|
Клиентские загрузки: `.p12` (cert+key+CA, passphrase) + strongSwan `.sswan` (генерация на бэкенде) + текстовая инструкция. Перекачка `.p12` — повторный export с новой passphrase (ключ остаётся на роутере).
|
||||||
|
|
||||||
|
## Бэкенд
|
||||||
|
|
||||||
|
1. **`mikrotik.ts`**: `downloadFile(name): Promise<Buffer>` — бинарно-безопасный GET `/rest/file/<name>` (с fallback `flash/<name>`, как у `uploadTextFile`; текущий конвейер парсит JSON как utf8 — нужен Buffer-режим); хелперы `signCertificate` (с поллингом готовности, sign небыстрый), `exportCertificatePkcs12(name, passphrase)`.
|
||||||
|
2. **`services/ipsec-config.ts`** (чистые, тестируемые): генератор `.sswan` и инструкции, поиск свободного IP в пуле, naming-конвенции (`ipsec-ca`, `ipsec-server`, `mc-<user>`, `ipsec-user-<name>`).
|
||||||
|
3. **`services/ipsec-ca.ts`**: `ensureCa/ensureServerCert/issueClientCert/exportClientP12` — add → sign (poll) → export → download.
|
||||||
|
4. **`services/ipsec-ros.ts`**: put/patch/delete для `/ip/ipsec/{peer,identity,mode-config,profile,proposal,policy}`, `/ip/pool`, NAT managed-правило.
|
||||||
|
5. **`services/ipsec-live.ts`**: `fetchIpsecState(server)` — параллельные GET peer/identity/mode-config/pool/active-peers/certificate (+какие из них наши по маркеру/имени) → DTO; `listIpsec()` fan-out с failures; `countIpsecClients()` для сайдбара.
|
||||||
|
6. **`entity-snapshots.ts`**: `canonicalIpsecSnapshot/parseIpsecSnapshot/planIpsecRestore` (секреты через `isHiddenSecret`); `CONFIG_SECTIONS` += `"ipsec"`; enum в `schema.ts` (миграции нет).
|
||||||
|
7. **`routes/ipsec.ts`**: `GET /ipsec` (+observed revision), `POST /ipsec/server/init`, `DELETE /ipsec/server/:serverId` (только managed-объекты), `POST /ipsec/users` (ответ включает одноразовый p12 base64), `PATCH /ipsec/users/:serverId/:rosId` (имя/статический IP/psk), `DELETE /ipsec/users/...` (identity + mc + опционально клиентский cert), `POST /ipsec/users/:serverId/:rosId/cert` (перекачка p12), `GET /ipsec/revisions` + `POST /ipsec/revisions/:id/restore` — всё по образцу `routes/wireguard.ts` (`captureAndAppendRevision`). Регистрация в `index.ts`; `/api/ipsec` в обе группы network-правил `permissions.ts`.
|
||||||
|
8. **Модуль Пользователи**: `InterfaceType` += `"ipsec"` (iface-type.ts, schema.ts enum — TEXT, без миграции); каталог интерфейсов дополняется IPsec-клиентами (identity name + CN в peerPublicKey/peerName) — привязка app-пользователя к VPN-клиенту.
|
||||||
|
|
||||||
|
## Контракт
|
||||||
|
|
||||||
|
`packages/contracts/src/ipsec.ts` (+`package.json` exports, `index.ts`): `ipsecPeerDto/identityDto/modeConfigDto/poolDto/activePeerDto/certInfoDto/serverSummaryDto`, list-response с failures, `initRequest` (CN/SAN, пул, DNS, NAT-чекбокс), `userCreateRequest` (имя, auth: certificate|psk, psk?, ip: static|pool, passphrase, daysValid), `certDownloadResponse {filename, contentB64, mime}`.
|
||||||
|
|
||||||
|
## Фронт
|
||||||
|
|
||||||
|
`app/(main)/ipsec/page.tsx` — «одно окно» по образцу WG-страницы: ServerRail + KPI (серверы IKEv2 / клиенты / онлайн / CA) + Tabs **«Клиенты»** (grid: имя, сервер, аутентификация, IP, онлайн, действия — скачать .p12/.sswan, изменить, удалить; Sheet создания клиента), **«Сервер»** (peer/pool/mode-config/certs + мастер инициализации Stepper + статус NAT-правила), **«CLI»** (шпаргалка). История/restore — `ConfigHistorySheet`. Компоненты `components/ipsec/*` на существующих DataGridShell/form-kit/Sheet; скачивание бинарного p12 — Blob из base64 (по образцу `downloadText`), `.sswan`/инструкция — через `CodeExportSheet`. Сайдбар «IPsec / IKEv2» в «Управление» + бейдж (`sidebar-counts.ts` + mock в `sidebar-badges.ts`) + command-palette. Mock-данные в `lib/data.ts` для демо-режима.
|
||||||
|
|
||||||
|
## Тесты и проверка
|
||||||
|
|
||||||
|
- `ipsec-config.test.ts` (чистые функции: sswan, свободный IP, naming); `entity-snapshots.test.ts` — `planIpsecRestore` + `opsTouchOnly(["/ip/ipsec", ...])`.
|
||||||
|
- `npm run build -w @mmapp/contracts`; `tsc` backend/root; `npm --prefix backend run test:config-sync && test:wireguard` + новые; eslint без новых ошибок.
|
||||||
|
- Демо-режим: визуальная проверка страницы в браузере (как в прошлый раз).
|
||||||
|
|
||||||
|
## Риски (проверить на живом роутере при внедрении)
|
||||||
|
|
||||||
|
- REST-скачивание файла `GET /rest/file/<name>` — еслиRouterOS отдаёт только метаданные, fallback: чтение `contents` маленьких PEM-файлов или сборка p12 из PEM-частей.
|
||||||
|
- Точные имена полей mode-config (`address` vs `address-pool`) сверить с живым GET и адаптировать.
|
||||||
|
- `/certificate/sign` — поллинг готовности с таймаутом ~30–60 с.
|
||||||
|
|
||||||
|
Объём большой; коммит/пуш — по готовности, отдельным `feat(ipsec): …`.
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
## ReUI
|
## ReUI
|
||||||
|
|
||||||
Skill: `.claude/skills/reui` / `.cursor/skills/reui` — версия `668fb463eb` (20 free components).
|
Skill: `.agents/skills/reui` (Codex), `.claude/skills/reui`, `.cursor/skills/reui`, `.opencode/skills/reui` — версия `fe6742e29c` (22 free components).
|
||||||
|
Codex MCP: `.codex/config.toml`, стиль `base-nova`, авторизация через переменную окружения `REUI_LICENSE_KEY`. Для PRO-реестра shadcn тот же ключ хранится в `.env.local` (исключён из Git); реестр настроен в `components.json`. После изменения переменной окружения перезапустить Codex.
|
||||||
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
|
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
|
||||||
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||||
@@ -219,9 +219,9 @@ Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country
|
|||||||
|
|
||||||
### Версионирование и релизы
|
### Версионирование и релизы
|
||||||
|
|
||||||
- Базовая версия: **`v1.0.0`**. Линия semver: `1.x.y` (major `2.x` вне scope).
|
- Базовая версия: **`v1.0.0`**. Поддерживаются patch, minor и major; выпуск `2.0.0` инициируется breaking-коммитом, версия вычисляется CI.
|
||||||
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
|
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
|
||||||
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
|
- **`!` после типа/scope или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:`** → major (`2.0.0` после `1.x`); **`feat`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
|
||||||
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
|
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
|
||||||
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
|
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
|
||||||
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
||||||
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
|||||||
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
||||||
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
||||||
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
||||||
|
|
||||||
|
### Автоматизация NetFlow через Docker-агент
|
||||||
|
|
||||||
|
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
|
||||||
+318
-683
File diff suppressed because it is too large.
Load diff
+13
-28
@@ -8,25 +8,22 @@ import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
|
|||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { asns as mockAsns } from "@/lib/data"
|
import { asns as mockAsns } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function AsnsPage() {
|
export default function AsnsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live"
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!useEvoCatalog) return mockAsns
|
if (mode === "mock") return mockAsns
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.asns ?? []
|
return snapshot?.asns ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return rows
|
if (!search) return rows
|
||||||
@@ -35,7 +32,7 @@ export default function AsnsPage() {
|
|||||||
(r) =>
|
(r) =>
|
||||||
r.asn.toLowerCase().includes(q) ||
|
r.asn.toLowerCase().includes(q) ||
|
||||||
r.org.toLowerCase().includes(q) ||
|
r.org.toLowerCase().includes(q) ||
|
||||||
String(r.prefixes).includes(q),
|
(r.prefixes === null ? "" : String(r.prefixes)).includes(q),
|
||||||
)
|
)
|
||||||
}, [rows, search])
|
}, [rows, search])
|
||||||
|
|
||||||
@@ -43,8 +40,7 @@ export default function AsnsPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||||
actions={
|
actions={mode === "live" ? null : <>
|
||||||
<>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<UploadIcon className="size-4" />Импорт
|
<UploadIcon className="size-4" />Импорт
|
||||||
</Button>
|
</Button>
|
||||||
@@ -60,32 +56,21 @@ export default function AsnsPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Автономные системы — источники маршрутов для BGP-фильтров
|
Автономные системы — источники маршрутов для BGP-фильтров
|
||||||
</p>
|
</p>
|
||||||
{useEvoCatalog && (
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
<p className={cn(
|
|
||||||
"text-xs mt-2 flex items-center gap-1.5",
|
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
||||||
{error
|
|
||||||
? `EvoBGP: ${error}`
|
|
||||||
: snapshot?.fetchedAt
|
|
||||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
||||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||||
countLabel={`${filtered.length} ASN`}
|
countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
|
||||||
/>
|
/>
|
||||||
<AsnsDataGrid
|
{known || loading ? <AsnsDataGrid
|
||||||
asns={filtered}
|
asns={filtered}
|
||||||
isLoading={useEvoCatalog && loading && !snapshot}
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
pagination={useEvoCatalog}
|
pagination={useEvoCatalog}
|
||||||
/>
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+331
-250
@@ -1,10 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import type { Backup, Server } from "@/lib/data"
|
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
|
||||||
import type { Filter } from "@/components/reui/filters"
|
import { useBackupsUrlState } from "@/hooks/use-backups-url-state"
|
||||||
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
|
import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -17,12 +18,13 @@ import {
|
|||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { listServers } from "@/shared/api/servers"
|
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { userApiError } from "@/shared/api/users"
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import {
|
import {
|
||||||
createBackupsAsync,
|
|
||||||
deleteBackup,
|
deleteBackup,
|
||||||
getBackupJob,
|
downloadBackup,
|
||||||
getBackupScheduleSettings,
|
getBackupScheduleSettings,
|
||||||
getBackupStorageSettings,
|
getBackupStorageSettings,
|
||||||
listBackups,
|
listBackups,
|
||||||
@@ -33,9 +35,16 @@ import {
|
|||||||
testBackupStorage,
|
testBackupStorage,
|
||||||
type BackupItem,
|
type BackupItem,
|
||||||
} from "@/shared/api/backups"
|
} from "@/shared/api/backups"
|
||||||
import { requestBlob } from "@/shared/api/http-client"
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard"
|
||||||
|
import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { useBackupJob } from "@/lib/use-backup-job"
|
||||||
|
import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll"
|
||||||
|
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
|
||||||
import { BackupsHistory } from "@/components/backups/backups-history"
|
import { BackupsHistory } from "@/components/backups/backups-history"
|
||||||
|
import { backupFileMissing } from "@/lib/backup-file-state"
|
||||||
import {
|
import {
|
||||||
BackupsSettings,
|
BackupsSettings,
|
||||||
WEEK_DAYS,
|
WEEK_DAYS,
|
||||||
@@ -48,27 +57,73 @@ import { BackupCreateSheet } from "@/components/backups/backup-create-sheet"
|
|||||||
import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
|
import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
|
||||||
|
|
||||||
type PageTab = "history" | "settings"
|
type PageTab = "history" | "settings"
|
||||||
type KindFilter = "all" | "auto" | "manual"
|
|
||||||
|
|
||||||
export default function BackupsPage() {
|
type Resource = "backups" | "servers" | "schedule" | "storage"
|
||||||
const { backendUrl } = useDataSource()
|
type ResourceState = { known: boolean; error: string | null; receivedAt: string | null }
|
||||||
const [tab, setTab] = useState<PageTab>("history")
|
const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null })
|
||||||
const [backupList, setBackupList] = useState<Backup[]>([])
|
|
||||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
export default function BackupsPage() { return <Suspense fallback={<p role="status" className="p-5">Загрузка контекста резервных копий…</p>}><BackupsSource /></Suspense> }
|
||||||
const [search, setSearch] = useState("")
|
function BackupsSource() {
|
||||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
|
||||||
|
return <BackupsContent key={mode + ":" + backendUrl} />
|
||||||
|
}
|
||||||
|
function BackupsContent() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
const canRead = can("mm:backups:read")
|
||||||
|
const canWrite = isLive && can("mm:backups:write")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
const [resources, setResources] = useState<Record<Resource, ResourceState>>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
|
||||||
|
const dialogTrigger = useRef<{ element: HTMLElement; rowId: string | null; label: string | null } | null>(null)
|
||||||
|
const refreshTrigger = useRef<HTMLButtonElement | null>(null)
|
||||||
|
const captureDialogFocus = () => {
|
||||||
|
const element = document.activeElement
|
||||||
|
dialogTrigger.current = element instanceof HTMLElement
|
||||||
|
? { element, rowId: element.closest("[data-row-id]")?.getAttribute("data-row-id") ?? null, label: element.getAttribute("aria-label") }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
const returnDialogFocus = () => {
|
||||||
|
const trigger = dialogTrigger.current
|
||||||
|
if (trigger?.element.isConnected) return trigger.element
|
||||||
|
// Cell renderers can remount when operation state changes; resolve the same row/action.
|
||||||
|
if (trigger?.rowId && trigger.label) {
|
||||||
|
const replacement = document.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(trigger.rowId)}"] button[aria-label="${CSS.escape(trigger.label)}"]`)
|
||||||
|
if (replacement) return replacement
|
||||||
|
}
|
||||||
|
return refreshTrigger.current
|
||||||
|
}
|
||||||
|
const reader = useRef<AbortController | null>(null)
|
||||||
|
const revision = useRef(0)
|
||||||
|
const alive = useRef(true)
|
||||||
|
const scheduleDirty = useRef(false)
|
||||||
|
const storageDirty = useRef(false)
|
||||||
|
const operationLock = useRef(false)
|
||||||
|
const operationController = useRef<AbortController | null>(null)
|
||||||
|
const downloadControllers = useRef(new Map<string, AbortController>())
|
||||||
|
const settingsLock = useRef(false)
|
||||||
|
const settingsController = useRef<AbortController | null>(null)
|
||||||
|
const known = (resource: Resource) => !isLive || resources[resource].known
|
||||||
|
const current = (resource: Resource) => known(resource) && !resources[resource].error
|
||||||
|
const { tab, setTab, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState()
|
||||||
|
const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups)
|
||||||
|
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
|
||||||
|
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const restoreGuard = useBackupRestoreGuard(backendUrl, isLive)
|
||||||
|
const [restoreReviewed, setRestoreReviewed] = useState(false)
|
||||||
const [opBusy, setOpBusy] = useState(false)
|
const [opBusy, setOpBusy] = useState(false)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
|
||||||
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||||
const [testBusy, setTestBusy] = useState(false)
|
const [testBusy, setTestBusy] = useState(false)
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
const [syncBusy, setSyncBusy] = useState(false)
|
||||||
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
|
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
|
||||||
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
|
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
|
||||||
|
const [storageIsDirty, setStorageIsDirty] = useState(false)
|
||||||
|
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
|
||||||
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
||||||
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
||||||
|
const [allEnabled, setAllEnabled] = useState(true)
|
||||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
||||||
const [manualOpen, setManualOpen] = useState(false)
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
const [manualStep, setManualStep] = useState(1)
|
const [manualStep, setManualStep] = useState(1)
|
||||||
@@ -77,256 +132,224 @@ export default function BackupsPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
|
||||||
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
||||||
|
|
||||||
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) =>
|
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
|
||||||
setSchedule((s) => ({ ...s, [k]: v }))
|
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => { if (k !== "showPassword") { storageDirty.current = true; setStorageIsDirty(true) } setStorage(s => ({ ...s, [k]: v })) }
|
||||||
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) =>
|
|
||||||
setStorage((s) => ({ ...s, [k]: v }))
|
|
||||||
|
|
||||||
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
|
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
|
||||||
const kb = Math.max(1, Math.round(b.sizeBytes / 1024))
|
const kb = Math.round(b.sizeBytes / 1024)
|
||||||
return {
|
return {
|
||||||
id: b.id,
|
id: b.id,
|
||||||
server: b.serverName,
|
server: b.serverName,
|
||||||
serverId: b.serverId == null ? null : String(b.serverId),
|
serverId: b.serverId == null ? null : String(b.serverId),
|
||||||
filename: b.filename,
|
filename: b.filename,
|
||||||
size: `${kb} КБ`,
|
size: b.sizeBytes < 1024 ? `${b.sizeBytes} Б` : `${kb} КБ`,
|
||||||
|
sizeBytes: b.sizeBytes,
|
||||||
created: new Date(b.createdAt).toLocaleString("ru-RU"),
|
created: new Date(b.createdAt).toLocaleString("ru-RU"),
|
||||||
kind: b.kind,
|
kind: b.kind,
|
||||||
notes: b.notes ?? "",
|
notes: b.notes ?? "",
|
||||||
storage: b.storage ?? "local",
|
storage: b.storage,
|
||||||
uploadError: b.uploadError ?? undefined,
|
uploadError: b.uploadError ?? undefined,
|
||||||
|
localFileStatus: b.localFileStatus,
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadLive = useCallback(async () => {
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive || !canRead || !alive.current) return
|
||||||
|
reader.current?.abort()
|
||||||
|
const abort = new AbortController(), version = ++revision.current
|
||||||
|
reader.current = abort
|
||||||
|
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)])
|
||||||
|
const active = () => alive.current && !abort.signal.aborted && revision.current === version
|
||||||
|
const success = (resource: Resource) => { if (active()) setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) }
|
||||||
|
const failure = (resource: Resource, error: unknown) => { if (active()) setResources(prev => ({ ...prev, [resource]: { ...prev[resource], error: userApiError(error) } })) }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setOpError(null)
|
await Promise.allSettled([
|
||||||
try {
|
listBackups(backendUrl, { signal }).then(rows => { if (!active()) return; setBackupList(rows.map(mapApiBackupToUi)); success("backups") }).catch(error => failure("backups", error)),
|
||||||
const [serversRows, backupsRows, scheduleRow, storageRow] = await Promise.all([
|
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
||||||
listServers(backendUrl),
|
const rows = serverListSchema.parse(payload)
|
||||||
listBackups(backendUrl),
|
if (!active()) return
|
||||||
getBackupScheduleSettings(backendUrl),
|
setLiveServers(rows.map(row => ({ ...toFrontendServer(row), status: row.status ?? "unknown" }))); success("servers")
|
||||||
getBackupStorageSettings(backendUrl),
|
}).catch(error => failure("servers", error)) : Promise.resolve(),
|
||||||
])
|
getBackupScheduleSettings(backendUrl, { signal }).then(row => {
|
||||||
const mappedServers = serversRows.map((s) => toFrontendServer(s))
|
if (!active()) return
|
||||||
setLiveServers(mappedServers)
|
if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)); setAllEnabled(row.serverIds.length === 0) }
|
||||||
setBackupList(backupsRows.map(mapApiBackupToUi))
|
setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule")
|
||||||
setSchedule({
|
}).catch(error => failure("schedule", error)),
|
||||||
enabled: scheduleRow.enabled,
|
getBackupStorageSettings(backendUrl, { signal }).then(row => {
|
||||||
frequency: scheduleRow.frequency,
|
if (!active()) return
|
||||||
hour: scheduleRow.hour,
|
setSavedStorage(row)
|
||||||
minute: scheduleRow.minute,
|
if (!storageDirty.current) setStorage(prev => ({ ...prev, ...row, s3SecretAccessKey: "" }))
|
||||||
weekDay: scheduleRow.weekDay,
|
success("storage")
|
||||||
monthDay: scheduleRow.monthDay,
|
}).catch(error => failure("storage", error)),
|
||||||
keepCount: scheduleRow.keepCount,
|
])
|
||||||
format: scheduleRow.format,
|
if (active()) setLoading(false)
|
||||||
})
|
}, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi])
|
||||||
setSavedStorage(storageRow)
|
|
||||||
setScheduleLastError(scheduleRow.lastError ?? null)
|
|
||||||
setStorage((prev) => ({
|
|
||||||
...prev,
|
|
||||||
provider: storageRow.provider,
|
|
||||||
s3Endpoint: storageRow.s3Endpoint,
|
|
||||||
s3Region: storageRow.s3Region,
|
|
||||||
s3Bucket: storageRow.s3Bucket,
|
|
||||||
s3Prefix: storageRow.s3Prefix,
|
|
||||||
s3AccessKeyId: storageRow.s3AccessKeyId,
|
|
||||||
s3SecretAccessKey: "",
|
|
||||||
s3ForcePathStyle: storageRow.s3ForcePathStyle,
|
|
||||||
keepLocalCopy: storageRow.keepLocalCopy,
|
|
||||||
}))
|
|
||||||
const serverIds = scheduleRow.serverIds.length > 0
|
|
||||||
? scheduleRow.serverIds
|
|
||||||
: mappedServers.map((s) => s.id)
|
|
||||||
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
|
|
||||||
} catch (e) {
|
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [backendUrl, mapApiBackupToUi])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadLive()
|
alive.current = true
|
||||||
|
const downloads = downloadControllers.current
|
||||||
|
queueMicrotask(() => { if (alive.current) void loadLive() })
|
||||||
|
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort(); operationController.current?.abort(); downloads.forEach(controller => controller.abort()) }
|
||||||
}, [loadLive])
|
}, [loadLive])
|
||||||
|
|
||||||
async function handleSave() {
|
const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive)
|
||||||
if (scheduleSaveBusy) return
|
const checkpoint = job.checkpoint
|
||||||
setScheduleSaveBusy(true)
|
const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status)
|
||||||
setOpError(null)
|
const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null
|
||||||
try {
|
const persistedJobError = checkpoint?.job && jobTerminal ? backupJobResultError(checkpoint.job) : null
|
||||||
await putBackupScheduleSettings(backendUrl, {
|
const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError
|
||||||
enabled: schedule.enabled,
|
|
||||||
frequency: schedule.frequency,
|
|
||||||
hour: schedule.hour,
|
|
||||||
minute: schedule.minute,
|
|
||||||
weekDay: schedule.weekDay,
|
|
||||||
monthDay: schedule.monthDay,
|
|
||||||
keepCount: schedule.keepCount,
|
|
||||||
format: "rsc",
|
|
||||||
serverIds: [...selectedServers],
|
|
||||||
})
|
|
||||||
const saved = await putBackupStorageSettings(backendUrl, {
|
|
||||||
provider: storage.provider,
|
|
||||||
s3Endpoint: storage.s3Endpoint,
|
|
||||||
s3Region: storage.s3Region,
|
|
||||||
s3Bucket: storage.s3Bucket,
|
|
||||||
s3Prefix: storage.s3Prefix,
|
|
||||||
s3AccessKeyId: storage.s3AccessKeyId,
|
|
||||||
s3SecretAccessKey: storage.s3SecretAccessKey || undefined,
|
|
||||||
s3ForcePathStyle: storage.s3ForcePathStyle,
|
|
||||||
keepLocalCopy: storage.keepLocalCopy,
|
|
||||||
})
|
|
||||||
setSavedStorage(saved)
|
|
||||||
setStorage((prev) => ({ ...prev, s3SecretAccessKey: "" }))
|
|
||||||
toast.success("Настройки сохранены")
|
|
||||||
} catch (e) {
|
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка сохранения настроек")
|
|
||||||
} finally {
|
|
||||||
setScheduleSaveBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function startSettingsMutation() {
|
||||||
|
if (!alive.current || settingsLock.current || operationLock.current || backupJobId) return null
|
||||||
|
settingsLock.current = true
|
||||||
|
const abort = new AbortController()
|
||||||
|
settingsController.current = abort
|
||||||
|
reader.current?.abort(); revision.current += 1; setLoading(false)
|
||||||
|
setScheduleSaveBusy(true); setOpError(null)
|
||||||
|
return abort
|
||||||
|
}
|
||||||
|
function finishSettingsMutation(abort: AbortController) {
|
||||||
|
if (settingsController.current !== abort) return
|
||||||
|
settingsLock.current = false; settingsController.current = null
|
||||||
|
if (alive.current) { setScheduleSaveBusy(false); setTestBusy(false); setSyncBusy(false) }
|
||||||
|
}
|
||||||
|
function settingsConfirmed(resource: Resource) {
|
||||||
|
setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
|
||||||
|
}
|
||||||
|
async function handleSave() {
|
||||||
|
if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return
|
||||||
|
const abort = startSettingsMutation(); if (!abort) return
|
||||||
|
try {
|
||||||
|
const payload = backupSchedulePayload(schedule, [...selectedServers], allEnabled)
|
||||||
|
if (payload.serverIds?.some(id => !liveServers.some(server => server.id === id))) throw new Error("Выбранный сервер отсутствует; обновите или измените выбор")
|
||||||
|
const result = await putBackupScheduleSettings(backendUrl, payload, { signal: abort.signal })
|
||||||
|
if (!alive.current || abort.signal.aborted) return
|
||||||
|
setSavedSchedule(result); setSchedule(result); setScheduleLastError(result.lastError ?? null); scheduleDirty.current = false
|
||||||
|
setSelectedServers(new Set(result.serverIds)); setAllEnabled(result.serverIds.length === 0); settingsConfirmed("schedule")
|
||||||
|
toast.success("Расписание сохранено")
|
||||||
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
|
finally { finishSettingsMutation(abort) }
|
||||||
|
}
|
||||||
|
async function handleSaveStorage() {
|
||||||
|
if (!canWrite || !current("storage")) return
|
||||||
|
const abort = startSettingsMutation(); if (!abort) return
|
||||||
|
try {
|
||||||
|
const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage, savedStorage?.secretConfigured), { signal: abort.signal })
|
||||||
|
if (!alive.current || abort.signal.aborted) return
|
||||||
|
setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; setStorageIsDirty(false); settingsConfirmed("storage")
|
||||||
|
toast.success("Хранилище сохранено")
|
||||||
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
|
finally { finishSettingsMutation(abort) }
|
||||||
|
}
|
||||||
async function handleTestStorage() {
|
async function handleTestStorage() {
|
||||||
|
if (!canWrite || !current("storage")) return
|
||||||
|
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; проверка использует подтверждённые настройки"); return }
|
||||||
|
if (savedStorage?.provider !== "s3") { setOpError("Проверка доступна для сохранённого хранилища S3"); return }
|
||||||
|
const abort = startSettingsMutation(); if (!abort) return
|
||||||
setTestBusy(true)
|
setTestBusy(true)
|
||||||
try {
|
try {
|
||||||
await putBackupStorageSettings(backendUrl, {
|
const result = await testBackupStorage(backendUrl, { signal: abort.signal })
|
||||||
provider: storage.provider,
|
if (!alive.current || abort.signal.aborted) return
|
||||||
s3Endpoint: storage.s3Endpoint,
|
setSavedStorage(result); settingsConfirmed("storage")
|
||||||
s3Region: storage.s3Region,
|
if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена")
|
||||||
s3Bucket: storage.s3Bucket,
|
toast.success("Доступ к S3 подтверждён")
|
||||||
s3Prefix: storage.s3Prefix,
|
} catch (error) {
|
||||||
s3AccessKeyId: storage.s3AccessKeyId,
|
if (alive.current && !abort.signal.aborted) {
|
||||||
s3SecretAccessKey: storage.s3SecretAccessKey || undefined,
|
if (error instanceof ApiClientError && error.payload && typeof error.payload === "object" && "settings" in error.payload) {
|
||||||
s3ForcePathStyle: storage.s3ForcePathStyle,
|
const parsed = backupStorageSettingsDtoSchema.safeParse(error.payload.settings)
|
||||||
keepLocalCopy: storage.keepLocalCopy,
|
if (parsed.success) setSavedStorage(parsed.data)
|
||||||
})
|
}
|
||||||
const result = await testBackupStorage(backendUrl)
|
setOpError(userApiError(error))
|
||||||
setSavedStorage(result)
|
|
||||||
toast.success("S3 доступен")
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка проверки S3")
|
|
||||||
try {
|
|
||||||
setSavedStorage(await getBackupStorageSettings(backendUrl))
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setTestBusy(false)
|
|
||||||
}
|
}
|
||||||
|
finally { finishSettingsMutation(abort) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncStorage() {
|
async function handleSyncStorage() {
|
||||||
|
if (!canWrite || !current("storage")) return
|
||||||
|
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; синхронизация использует подтверждённые настройки"); return }
|
||||||
|
if (savedStorage?.provider !== "s3") return
|
||||||
|
const abort = startSettingsMutation(); if (!abort) return
|
||||||
setSyncBusy(true)
|
setSyncBusy(true)
|
||||||
try {
|
try {
|
||||||
const result = await syncBackupsFromStorage(backendUrl)
|
const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal })
|
||||||
toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`)
|
if (!alive.current || abort.signal.aborted) return
|
||||||
|
toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка синхронизации")
|
finally { finishSettingsMutation(abort) }
|
||||||
} finally {
|
|
||||||
setSyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleManualBackup() {
|
async function handleManualBackup() {
|
||||||
if (manualServers.size === 0 || opBusy) return
|
if (checkpoint) { await job.run(); return }
|
||||||
setOpError(null)
|
if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return
|
||||||
try {
|
if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return }
|
||||||
const res = await createBackupsAsync(backendUrl, {
|
await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined })
|
||||||
serverIds: [...manualServers],
|
|
||||||
notes: manualNotes || undefined,
|
|
||||||
})
|
|
||||||
setBackupJobId(res.jobId)
|
|
||||||
setManualOpen(false)
|
|
||||||
setManualServers(new Set())
|
|
||||||
setManualNotes("")
|
|
||||||
setTab("history")
|
|
||||||
} catch (e) {
|
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!backupJobId) return
|
|
||||||
toast.info("Бэкап выполняется в фоне...")
|
|
||||||
let cancelled = false
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const job = await getBackupJob(backendUrl, backupJobId)
|
|
||||||
if (cancelled) return
|
|
||||||
if (job.status === "done") {
|
|
||||||
clearInterval(timer)
|
|
||||||
setBackupJobId(null)
|
|
||||||
if (job.failures.length > 0) {
|
|
||||||
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
|
|
||||||
} else {
|
|
||||||
toast.success("Бэкап успешно завершён")
|
|
||||||
}
|
|
||||||
await loadLive()
|
|
||||||
} else if (job.status === "failed") {
|
|
||||||
clearInterval(timer)
|
|
||||||
setBackupJobId(null)
|
|
||||||
setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа")
|
|
||||||
await loadLive()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
clearInterval(timer)
|
|
||||||
setBackupJobId(null)
|
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа")
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, 1200)
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
clearInterval(timer)
|
|
||||||
}
|
|
||||||
}, [backendUrl, backupJobId, loadLive])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opError) toast.error(opError)
|
if (opError) toast.error(opError)
|
||||||
}, [opError])
|
}, [opError])
|
||||||
|
|
||||||
|
function startOperation() {
|
||||||
|
if (!canWrite || !alive.current || operationLock.current || settingsLock.current || backupJobId) return null
|
||||||
|
operationLock.current = true
|
||||||
|
const abort = new AbortController(); operationController.current = abort
|
||||||
|
reader.current?.abort(); revision.current += 1; setLoading(false)
|
||||||
|
setOpBusy(true); setOpError(null)
|
||||||
|
return abort
|
||||||
|
}
|
||||||
|
function finishOperation(abort: AbortController) {
|
||||||
|
if (operationController.current !== abort) return
|
||||||
|
operationLock.current = false; operationController.current = null
|
||||||
|
if (alive.current) setOpBusy(false)
|
||||||
|
}
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
setOpBusy(true)
|
const target = deleteTarget, abort = startOperation(); if (!abort) return
|
||||||
setOpError(null)
|
|
||||||
try {
|
try {
|
||||||
await deleteBackup(backendUrl, deleteTarget.id)
|
await deleteBackup(backendUrl, target.id, { signal: abort.signal }, backupFileMissing(target))
|
||||||
setDeleteTarget(null)
|
if (!alive.current || abort.signal.aborted) return
|
||||||
|
setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null)
|
||||||
|
toast.success("API подтвердил удаление резервной копии")
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа")
|
finally { finishOperation(abort) }
|
||||||
} finally {
|
|
||||||
setOpBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestore() {
|
async function handleRestore() {
|
||||||
if (!restoreTarget) return
|
if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return
|
||||||
setOpBusy(true)
|
if (backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget)) {
|
||||||
setOpError(null)
|
setOpError("Локальный файл отсутствует. Создайте новую резервную копию.")
|
||||||
try {
|
return
|
||||||
await restoreBackup(backendUrl, restoreTarget.id)
|
|
||||||
toast.success(`Конфигурация восстановлена на ${restoreTarget.server}`)
|
|
||||||
setRestoreTarget(null)
|
|
||||||
} catch (e) {
|
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка восстановления")
|
|
||||||
} finally {
|
|
||||||
setOpBusy(false)
|
|
||||||
}
|
}
|
||||||
|
const target = restoreTarget, abort = startOperation(); if (!abort) return
|
||||||
|
let sent = false
|
||||||
|
try {
|
||||||
|
if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return
|
||||||
|
sent = true
|
||||||
|
const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId))
|
||||||
|
if (!alive.current || abort.signal.aborted) return
|
||||||
|
if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу")
|
||||||
|
toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName)
|
||||||
|
restoreGuard.clear(); setRestoreTarget(null)
|
||||||
|
} catch (error) {
|
||||||
|
if (alive.current && !abort.signal.aborted) {
|
||||||
|
if (sent && restoreWasNotApplied(error)) restoreGuard.clear()
|
||||||
|
setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { finishOperation(abort) }
|
||||||
}
|
}
|
||||||
|
async function handleDownload(id: string, filename: string) {
|
||||||
async function handleDownload(id: string, fallbackFilename: string) {
|
if (!isLive || !canRead || !alive.current || downloadControllers.current.has(id)) return
|
||||||
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
const abort = new AbortController(); downloadControllers.current.set(id, abort)
|
||||||
const blob = await res.blob()
|
try {
|
||||||
const url = URL.createObjectURL(blob)
|
const blob = await downloadBackup(backendUrl, id, { signal: abort.signal })
|
||||||
const a = document.createElement("a")
|
if (!alive.current || abort.signal.aborted) return
|
||||||
a.href = url
|
const url = URL.createObjectURL(blob)
|
||||||
a.download = fallbackFilename
|
try {
|
||||||
a.click()
|
const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
} finally { URL.revokeObjectURL(url) }
|
||||||
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
|
finally { if (downloadControllers.current.get(id) === abort) downloadControllers.current.delete(id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoCount = backupList.filter((b) => b.kind === "auto").length
|
const autoCount = backupList.filter((b) => b.kind === "auto").length
|
||||||
@@ -334,19 +357,21 @@ export default function BackupsPage() {
|
|||||||
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
|
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
|
||||||
|
|
||||||
const schedSummary = (() => {
|
const schedSummary = (() => {
|
||||||
if (!schedule.enabled) return "Расписание отключено"
|
const confirmed = isLive ? savedSchedule : defaultSchedule
|
||||||
const t = `${String(schedule.hour).padStart(2, "0")}:${String(schedule.minute).padStart(2, "0")}`
|
if (!confirmed) return "Расписание пока неизвестно"
|
||||||
if (schedule.frequency === "daily") return `Каждый день в ${t}`
|
if (!confirmed.enabled) return "Расписание отключено"
|
||||||
if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}`
|
const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0")
|
||||||
return `${schedule.monthDay}-го числа каждого месяца в ${t}`
|
if (confirmed.frequency === "daily") return "Каждый день в " + time
|
||||||
|
if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[Number(confirmed.weekDay)] + ") в " + time
|
||||||
|
return confirmed.monthDay + "-го числа каждого месяца в " + time
|
||||||
})()
|
})()
|
||||||
|
const confirmedStorage = isLive ? savedStorage : defaultStorageForm
|
||||||
const destinationLabel = storage.provider === "s3"
|
const destinationLabel = !confirmedStorage ? "Хранилище пока неизвестно" : confirmedStorage.provider === "s3"
|
||||||
? (storage.keepLocalCopy ? "локально и в S3" : "в S3")
|
? (confirmedStorage.keepLocalCopy ? "локально и в S3" : "в S3")
|
||||||
: "локально (storage/backups)"
|
: "локально (storage/backups)"
|
||||||
|
|
||||||
const statusDetail = [
|
const statusDetail = [
|
||||||
backupJobId ? null : `${destinationLabel} · ${selectedServers.size} из ${liveServers.length} серверов`,
|
backupJobId ? null : `${destinationLabel} · ${known("servers") ? liveServers.length + " серверов в каталоге" : "Серверы пока неизвестны"}`,
|
||||||
scheduleLastError,
|
scheduleLastError,
|
||||||
savedStorage?.lastTestError,
|
savedStorage?.lastTestError,
|
||||||
].filter(Boolean).join(" · ")
|
].filter(Boolean).join(" · ")
|
||||||
@@ -358,6 +383,8 @@ export default function BackupsPage() {
|
|||||||
: "default"
|
: "default"
|
||||||
|
|
||||||
function openCreate(all: boolean) {
|
function openCreate(all: boolean) {
|
||||||
|
if (checkpoint) { setManualStep(3); setManualOpen(true); return }
|
||||||
|
if (!canCreate) return
|
||||||
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
|
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
|
||||||
setManualNotes("")
|
setManualNotes("")
|
||||||
setManualStep(1)
|
setManualStep(1)
|
||||||
@@ -365,7 +392,7 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex min-h-0 min-w-0 flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
||||||
actions={
|
actions={
|
||||||
@@ -375,7 +402,7 @@ export default function BackupsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openCreate(true)}
|
onClick={() => openCreate(true)}
|
||||||
disabled={loading}
|
disabled={!canCreate || loading || job.busy}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-4" />
|
<RefreshCwIcon className="size-4" />
|
||||||
Снять со всех
|
Снять со всех
|
||||||
@@ -384,45 +411,69 @@ export default function BackupsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openCreate(false)}
|
onClick={() => openCreate(false)}
|
||||||
disabled={loading}
|
disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый бэкап
|
{checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
{urlError && <Alert variant="warning" aria-label="Контекст резервных копий"><AlertTitle>Не удалось прочитать ссылку</AlertTitle><AlertDescription>{urlError}</AlertDescription><AlertAction><Button type="button" variant="outline" onClick={resetContext}>Сбросить контекст</Button></AlertAction></Alert>}
|
||||||
|
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
|
||||||
|
{!canRead && <p role="status">Нет права mm:backups:read</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
|
||||||
|
{isLive && canRead && <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<Button type="button" variant="outline" size="sm" ref={refreshTrigger} onClick={() => void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить</Button>
|
||||||
|
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
|
||||||
|
</div>}
|
||||||
|
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
|
||||||
|
<AlertTitle>{resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"}</AlertTitle>
|
||||||
|
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
|
||||||
|
</Alert>)}
|
||||||
|
|
||||||
|
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
|
||||||
|
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col gap-3">
|
||||||
|
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
|
||||||
|
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
|
||||||
|
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>}
|
||||||
|
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
||||||
|
{(job.error || job.storageError || persistedJobError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError || persistedJobError}</AlertDescription></Alert>}
|
||||||
|
<div><h1 className="text-xl font-semibold">Резервные копии</h1><p className="text-sm text-muted-foreground">История файлов, расписание и подтверждения операций</p></div>
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка бэкапов"
|
aria-label="Сводка бэкапов"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего бэкапов",
|
label: "Всего бэкапов",
|
||||||
value: backupList.length,
|
value: known("backups") ? backupList.length : "—",
|
||||||
icon: <HardDriveIcon className="size-4" />,
|
icon: <HardDriveIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "auto",
|
id: "auto",
|
||||||
label: "Авто",
|
label: "Авто",
|
||||||
value: autoCount,
|
value: known("backups") ? autoCount : "—",
|
||||||
icon: <ClockIcon className="size-4" />,
|
icon: <ClockIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "manual",
|
id: "manual",
|
||||||
label: "Вручную",
|
label: "Вручную",
|
||||||
value: manualCount,
|
value: known("backups") ? manualCount : "—",
|
||||||
icon: <PlusIcon className="size-4" />,
|
icon: <PlusIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "s3",
|
id: "s3",
|
||||||
label: "В S3",
|
label: "В S3",
|
||||||
value: s3Count,
|
value: known("backups") ? s3Count : "—",
|
||||||
icon: <CloudIcon className="size-4" />,
|
icon: <CloudIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
@@ -432,11 +483,11 @@ export default function BackupsPage() {
|
|||||||
<Alert variant={alertVariant}>
|
<Alert variant={alertVariant}>
|
||||||
<ClockIcon />
|
<ClockIcon />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{backupJobId ? "Задача выполняется" : schedSummary}
|
{backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{backupJobId
|
{backupJobId
|
||||||
? "Снимок конфигурации снимается в фоне. Список обновится после завершения."
|
? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
|
||||||
: statusDetail}
|
: statusDetail}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
<AlertAction>
|
<AlertAction>
|
||||||
@@ -446,6 +497,7 @@ export default function BackupsPage() {
|
|||||||
</AlertAction>
|
</AlertAction>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
<Frame dense className="min-w-0"><FramePanel className="min-w-0 p-3 sm:p-5">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(v) => setTab(String(v) as PageTab)}
|
onValueChange={(v) => setTab(String(v) as PageTab)}
|
||||||
@@ -456,7 +508,7 @@ export default function BackupsPage() {
|
|||||||
<TabsTrigger value="settings">Настройки</TabsTrigger>
|
<TabsTrigger value="settings">Настройки</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="history" className="flex flex-col gap-5">
|
<TabsContent value="history" className="flex flex-col gap-5">
|
||||||
<BackupsHistory
|
{urlError ? null : !canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
|
||||||
backups={backupList}
|
backups={backupList}
|
||||||
kindFilter={kindFilter}
|
kindFilter={kindFilter}
|
||||||
onKindFilterChange={setKindFilter}
|
onKindFilterChange={setKindFilter}
|
||||||
@@ -465,12 +517,17 @@ export default function BackupsPage() {
|
|||||||
filters={advancedFilters}
|
filters={advancedFilters}
|
||||||
onFiltersChange={setAdvancedFilters}
|
onFiltersChange={setAdvancedFilters}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onRestore={setRestoreTarget}
|
onRestore={backup => { captureDialogFocus(); setOpError(null); setRestoreTarget(backup) }}
|
||||||
onDelete={setDeleteTarget}
|
onDelete={backup => { captureDialogFocus(); setOpError(null); setDeleteTarget(backup) }}
|
||||||
onCreate={() => openCreate(false)}
|
onCreate={() => openCreate(false)}
|
||||||
/>
|
canCreate={canCreate}
|
||||||
|
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
|
||||||
|
canRestore={restoreGuard.canStart}
|
||||||
|
canDownload={isLive && canRead}
|
||||||
|
/>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="settings">
|
<TabsContent value="settings">
|
||||||
|
|
||||||
<BackupsSettings
|
<BackupsSettings
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
onScheduleChange={setSched}
|
onScheduleChange={setSched}
|
||||||
@@ -480,6 +537,7 @@ export default function BackupsPage() {
|
|||||||
servers={liveServers}
|
servers={liveServers}
|
||||||
selectedServers={selectedServers}
|
selectedServers={selectedServers}
|
||||||
onToggleServer={(id) => {
|
onToggleServer={(id) => {
|
||||||
|
scheduleDirty.current = true
|
||||||
setSelectedServers((prev) => {
|
setSelectedServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(id)) next.delete(id)
|
||||||
@@ -487,17 +545,27 @@ export default function BackupsPage() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onSelectAll={() => setSelectedServers(new Set(liveServers.map((s) => s.id)))}
|
onSelectAll={() => { scheduleDirty.current = true; setAllEnabled(false); setSelectedServers(new Set(liveServers.map(s => s.id))) }}
|
||||||
onClearServers={() => setSelectedServers(new Set())}
|
onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
onSaveStorage={handleSaveStorage}
|
||||||
|
scheduleKnown={known("schedule")}
|
||||||
|
storageKnown={known("storage")}
|
||||||
|
storageDirty={storageIsDirty}
|
||||||
|
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId}
|
||||||
|
storageWritable={canWrite && current("storage") && !backupJobId}
|
||||||
|
allEnabled={allEnabled}
|
||||||
|
onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
|
||||||
onTest={handleTestStorage}
|
onTest={handleTestStorage}
|
||||||
onSync={handleSyncStorage}
|
onSync={handleSyncStorage}
|
||||||
saveBusy={scheduleSaveBusy}
|
saveBusy={scheduleSaveBusy}
|
||||||
testBusy={testBusy}
|
testBusy={testBusy}
|
||||||
syncBusy={syncBusy}
|
syncBusy={syncBusy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</FramePanel></Frame>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -507,7 +575,7 @@ export default function BackupsPage() {
|
|||||||
step={manualStep}
|
step={manualStep}
|
||||||
onStepChange={setManualStep}
|
onStepChange={setManualStep}
|
||||||
servers={liveServers}
|
servers={liveServers}
|
||||||
selected={manualServers}
|
selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : manualServers}
|
||||||
onToggle={(id) => {
|
onToggle={(id) => {
|
||||||
setManualServers((prev) => {
|
setManualServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -518,22 +586,35 @@ export default function BackupsPage() {
|
|||||||
}}
|
}}
|
||||||
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
||||||
onClear={() => setManualServers(new Set())}
|
onClear={() => setManualServers(new Set())}
|
||||||
notes={manualNotes}
|
notes={checkpoint?.request.notes ?? manualNotes}
|
||||||
onNotesChange={setManualNotes}
|
onNotesChange={setManualNotes}
|
||||||
destinationLabel={destinationLabel}
|
destinationLabel={destinationLabel}
|
||||||
busy={backupJobId !== null}
|
busy={job.busy}
|
||||||
|
locked={!!checkpoint}
|
||||||
|
terminal={jobTerminal}
|
||||||
|
canObserve={canRead}
|
||||||
|
jobId={checkpoint?.request.requestId}
|
||||||
|
jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: checkpoint?.job && backupJobOutcome(checkpoint.job).fullSuccess ? "Завершена" : "Завершена с ограничениями", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"}
|
||||||
|
error={job.error || job.storageError || persistedJobError}
|
||||||
|
result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined}
|
||||||
|
onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }}
|
||||||
onSubmit={handleManualBackup}
|
onSubmit={handleManualBackup}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BackupDeleteDialog
|
<BackupDeleteDialog
|
||||||
|
returnFocus={returnDialogFocus}
|
||||||
backup={deleteTarget}
|
backup={deleteTarget}
|
||||||
busy={opBusy}
|
busy={opBusy}
|
||||||
|
error={opError}
|
||||||
onConfirm={() => void handleDelete()}
|
onConfirm={() => void handleDelete()}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
<BackupRestoreDialog
|
<BackupRestoreDialog
|
||||||
|
returnFocus={returnDialogFocus}
|
||||||
backup={restoreTarget}
|
backup={restoreTarget}
|
||||||
busy={opBusy}
|
busy={opBusy}
|
||||||
|
blocked={!restoreGuard.canStart || Boolean(restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget))}
|
||||||
|
error={restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget) ? "Локальный файл отсутствует. Создайте новую резервную копию." : opError}
|
||||||
onConfirm={() => void handleRestore()}
|
onConfirm={() => void handleRestore()}
|
||||||
onCancel={() => setRestoreTarget(null)}
|
onCancel={() => setRestoreTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+231
-161
@@ -1,37 +1,41 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Fragment, useState, useMemo, useEffect } from "react"
|
import { Suspense, useState, useMemo, useEffect } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
||||||
import type { Filter } from "@/components/reui/filters"
|
import { useBgpUrlState } from "@/hooks/use-bgp-url-state"
|
||||||
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
import { QueryFilters } from "@/components/data-grids/query-filters"
|
||||||
|
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
|
||||||
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||||
|
import { bgpCatalogSchema, bgpServersSchema, type BackendBgpSession, type BgpFailure } from "@/lib/bgp/response"
|
||||||
|
import { fmtBgpNum, sumBgpCounters } from "@/lib/bgp/helpers"
|
||||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
RefreshCwIcon, DownloadIcon,
|
||||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
ActivityIcon, BarChart3Icon,
|
||||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
ArrowDownIcon, ServerIcon,
|
||||||
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { servers as mockServers, type Server } from "@/lib/data"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type BgpSession = BgpSessionRow
|
type BgpSession = BgpSessionRow
|
||||||
type BgpTab = "sessions" | "routers" | "analytics"
|
type BgpTab = "sessions" | "routers" | "analytics"
|
||||||
type StateFilter = "all" | BgpState
|
|
||||||
type TypeFilter = "all" | BgpType
|
|
||||||
|
|
||||||
const AS_NAMES = BGP_AS_NAMES
|
const AS_NAMES = BGP_AS_NAMES
|
||||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||||
@@ -181,6 +185,7 @@ const SESSIONS: BgpSession[] = [
|
|||||||
// ─── visual helpers ───────────────────────────────────────────────────────────
|
// ─── visual helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
||||||
|
Unknown: { bg: "bg-muted", text: "text-muted-foreground", dot: "bg-muted-foreground", label: "Неизвестно" },
|
||||||
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
|
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
|
||||||
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
|
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
|
||||||
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
|
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
|
||||||
@@ -189,48 +194,32 @@ const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; lab
|
|||||||
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
|
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
|
||||||
}
|
}
|
||||||
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
|
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
|
||||||
|
Unknown: { bg: "bg-muted", text: "text-muted-foreground" },
|
||||||
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
|
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
|
||||||
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function StateBadge({ state }: { state: BgpState }) {
|
|
||||||
const s = STATE_STYLE[state]
|
|
||||||
return (
|
|
||||||
<span className={cn("inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
|
|
||||||
s.bg, s.text, "border-current/20")}>
|
|
||||||
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: BgpType }) {
|
function TypeBadge({ type }: { type: BgpType }) {
|
||||||
const s = TYPE_STYLE[type]
|
const s = TYPE_STYLE[type]
|
||||||
return (
|
return (
|
||||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
|
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
|
||||||
s.bg, s.text, "border-current/20")}>
|
s.bg, s.text, "border-current/20")}>
|
||||||
{type}
|
{type === "Unknown" ? "Тип неизвестен" : type}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function fmtNum(n: number) {
|
const fmtNum = fmtBgpNum
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
|
|
||||||
return String(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── backend mapping ──────────────────────────────────────────────────────────
|
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BackendBgpSession {
|
type BackendServer = ReturnType<typeof bgpServersSchema.parse>[number]
|
||||||
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
|
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||||||
name: string; peerIp: string; remoteAs: number; localAs: number
|
function mapBackendServer(s: BackendServer): InventoryServer {
|
||||||
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP"
|
return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "",
|
||||||
uptime: string | null; holdTime: number; keepalive: number
|
country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled,
|
||||||
prefixesRx: number; prefixesTx: number
|
status: s.status ?? "offline", monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0 }
|
||||||
inputMessages: number; outputMessages: number
|
|
||||||
capabilities: string[]; lastError: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||||
@@ -244,15 +233,15 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
|
|||||||
localAs: b.localAs,
|
localAs: b.localAs,
|
||||||
routerId: b.remoteId || b.localId,
|
routerId: b.remoteId || b.localId,
|
||||||
description: b.name,
|
description: b.name,
|
||||||
state: (b.state as BgpState) || "Idle",
|
state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown",
|
||||||
type: b.type,
|
type: b.type,
|
||||||
afi: "IPv4 Unicast",
|
afi: b.afi ?? "Неизвестно",
|
||||||
uptime: b.uptime,
|
uptime: b.uptime,
|
||||||
holdTime: b.holdTime,
|
holdTime: b.holdTime,
|
||||||
keepalive: b.keepalive,
|
keepalive: b.keepalive,
|
||||||
prefixesRx: b.prefixesRx,
|
prefixesRx: b.prefixesRx,
|
||||||
prefixesTx: b.prefixesTx,
|
prefixesTx: b.prefixesTx,
|
||||||
prefixesActive: b.prefixesRx,
|
prefixesActive: null,
|
||||||
inputMessages: b.inputMessages,
|
inputMessages: b.inputMessages,
|
||||||
outputMessages: b.outputMessages,
|
outputMessages: b.outputMessages,
|
||||||
capabilities: b.capabilities,
|
capabilities: b.capabilities,
|
||||||
@@ -268,14 +257,12 @@ const BGP_FILTER_ACCESSORS = {
|
|||||||
afi: (s: BgpSession) => s.afi,
|
afi: (s: BgpSession) => s.afi,
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
|
||||||
const [search, setSearch] = useState("")
|
const { search, setSearch, stateFilter, setStateFilter, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset } = useBgpUrlState()
|
||||||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
const fields = useMemo(() => BGP_FILTER_FIELDS.map(field => field.id === "afi" ? { ...field, options: Array.from(new Set(sessions.map(s => s.afi))).sort().map(value => ({ value, label: value })) } : field), [sessions])
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
|
||||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
|
||||||
|
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
|
if (urlError) return []
|
||||||
const base = sessions.filter((s) => {
|
const base = sessions.filter((s) => {
|
||||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||||
@@ -283,46 +270,48 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
q &&
|
q &&
|
||||||
!s.peerIp.includes(q) &&
|
!s.peerIp.includes(q) &&
|
||||||
!s.description.toLowerCase().includes(q) &&
|
!s.description.toLowerCase().includes(q) &&
|
||||||
!s.serverLabel.includes(q) &&
|
!s.serverLabel.toLowerCase().includes(q) &&
|
||||||
!String(s.remoteAs).includes(q) &&
|
!String(s.remoteAs).includes(q) &&
|
||||||
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
|
!(s.remoteAs === null ? "" : AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS)
|
return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS)
|
||||||
}, [sessions, q, stateFilter, typeFilter, advancedFilters])
|
}, [sessions, q, stateFilter, typeFilter, advancedFilters, urlError])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{urlError && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>{urlError}<Button variant="outline" size="sm" onClick={reset}>Сбросить фильтры BGP</Button></AlertDescription></Alert>}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
|
className="[&>div:first-child]:max-w-full [&>div:first-child]:overflow-x-auto [&>div:first-child_button]:shrink-0 [&>div:first-child_button]:whitespace-nowrap"
|
||||||
segmented={{
|
segmented={{
|
||||||
value: stateFilter,
|
value: stateFilter,
|
||||||
onChange: setStateFilter,
|
onChange: setStateFilter,
|
||||||
options: [
|
options: [
|
||||||
{ value: "all", label: "Все", count: sessions.length },
|
{ value: "all", label: "Все", count: sessions.length },
|
||||||
|
{ value: "Unknown", label: "Неизвестно", count: sessions.filter(s => s.state === "Unknown").length },
|
||||||
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
|
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
|
||||||
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
|
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
|
||||||
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
|
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
|
||||||
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
|
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
filters={advancedFilters}
|
filterControl={<QueryFilters fields={fields} query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
||||||
onFiltersChange={setAdvancedFilters}
|
|
||||||
filterFields={BGP_FILTER_FIELDS}
|
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="IP, AS, описание…"
|
searchPlaceholder="IP, AS, описание…"
|
||||||
countLabel={`${filtered.length} из ${sessions.length}`}
|
countLabel={`${filtered.length} из ${sessions.length}`}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||||
{(["all", "eBGP", "iBGP"] as const).map((t) => (
|
{(["all", "eBGP", "iBGP", "Unknown"] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTypeFilter(t)}
|
onClick={() => setTypeFilter(t)}
|
||||||
|
aria-pressed={typeFilter === t}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
||||||
typeFilter === t
|
typeFilter === t
|
||||||
@@ -330,13 +319,15 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t === "all" ? "Все типы" : t}
|
{t === "all" ? "Все типы" : t === "Unknown" ? "Неизвестно" : t}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<BgpSessionsDataGrid sessions={filtered} />
|
{selectedSessionId && sessions.some(s => s.id === selectedSessionId) && !filtered.some(s => s.id === selectedSessionId) && <p role="status" className="px-5 py-2 text-xs text-muted-foreground">Сессия из ссылки скрыта текущими фильтрами.</p>}
|
||||||
|
{hasFilters && !urlError && <Button variant="ghost" size="sm" onClick={reset}>Сбросить фильтры BGP</Button>}
|
||||||
|
<BgpSessionsDataGrid sessions={filtered} emptyTitle={urlError ? "Некорректные фильтры BGP" : emptyTitle} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -358,8 +349,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
{byRouter.map(router => {
|
{byRouter.map(router => {
|
||||||
const estCnt = router.sessions.filter(s => s.state === "Established").length
|
const estCnt = router.sessions.filter(s => s.state === "Established").length
|
||||||
const downCnt = router.sessions.length - estCnt
|
const downCnt = router.sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
|
||||||
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
const totalRx = sumBgpCounters(router.sessions.map(s => s.prefixesRx))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame key={router.id} dense className="w-full overflow-hidden">
|
<Frame key={router.id} dense className="w-full overflow-hidden">
|
||||||
@@ -369,7 +360,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
|
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium leading-none">{router.label}</p>
|
<p className="text-sm font-medium leading-none">{router.label}</p>
|
||||||
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · AS65001</p>
|
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{estCnt > 0 && (
|
{estCnt > 0 && (
|
||||||
@@ -400,19 +391,19 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<TypeBadge type={s.type} />
|
<TypeBadge type={s.type} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span>
|
<span className="text-[10px] text-muted-foreground">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
|
||||||
{AS_NAMES[s.remoteAs] && (
|
{s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
|
||||||
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
|
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
{s.prefixesRx > 0 && (
|
{s.prefixesRx !== null && s.prefixesRx > 0 && (
|
||||||
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
|
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
|
||||||
↓ {fmtNum(s.prefixesRx)}
|
↓ {fmtNum(s.prefixesRx)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p>
|
<p className={cn("text-[10px] font-semibold", ss.text)}>{ss.label}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -420,7 +411,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* footer summary */}
|
{/* footer summary */}
|
||||||
{totalRx > 0 && (
|
{totalRx !== null && totalRx > 0 && (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
|
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
|
||||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||||
<span className="text-[11px] font-mono text-muted-foreground">
|
<span className="text-[11px] font-mono text-muted-foreground">
|
||||||
@@ -441,9 +432,9 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||||
// top peers by prefixes received (eBGP only)
|
// top peers by prefixes received (eBGP only)
|
||||||
const topPeers = useMemo(() =>
|
const topPeers = useMemo(() =>
|
||||||
[...sessions]
|
(sessions.some(s => s.prefixesRx === null) ? [] : [...sessions])
|
||||||
.filter(s => s.prefixesRx > 0)
|
.filter(s => s.prefixesRx !== null && s.prefixesRx > 0)
|
||||||
.sort((a, b) => b.prefixesRx - a.prefixesRx)
|
.sort((a, b) => (b.prefixesRx ?? 0) - (a.prefixesRx ?? 0))
|
||||||
.slice(0, 8),
|
.slice(0, 8),
|
||||||
[sessions]
|
[sessions]
|
||||||
)
|
)
|
||||||
@@ -457,8 +448,8 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
// total prefix stats
|
// total prefix stats
|
||||||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
|
||||||
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0)
|
const totalActive = sumBgpCounters(sessions.map(s => s.prefixesActive))
|
||||||
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
|
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
|
||||||
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
|
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
|
||||||
|
|
||||||
@@ -469,29 +460,30 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "rx",
|
id: "rx",
|
||||||
label: "Всего префиксов",
|
label: "Префиксы",
|
||||||
value: fmtNum(totalRx),
|
value: fmtNum(totalRx),
|
||||||
icon: <DownloadIcon className="size-4" />,
|
icon: <DownloadIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "active",
|
id: "active",
|
||||||
label: "Активных маршрутов",
|
label: "Маршруты",
|
||||||
|
hint: totalActive === null ? "нет данных API" : undefined,
|
||||||
value: fmtNum(totalActive),
|
value: fmtNum(totalActive),
|
||||||
icon: <GitMergeIcon className="size-4" />,
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ebgp",
|
id: "ebgp",
|
||||||
label: "eBGP сессий",
|
label: "eBGP",
|
||||||
value: ebgpSessions,
|
value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
|
||||||
icon: <ActivityIcon className="size-4" />,
|
icon: <ActivityIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ibgp",
|
id: "ibgp",
|
||||||
label: "iBGP сессий",
|
label: "iBGP",
|
||||||
value: ibgpSessions,
|
value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
|
||||||
icon: <ServerIcon className="size-4" />,
|
icon: <ServerIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
@@ -503,7 +495,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
|
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{topPeers.map((s, i) => {
|
{topPeers.map((s, i) => {
|
||||||
const pct = (s.prefixesRx / maxRx) * 100
|
const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
|
||||||
const ss = STATE_STYLE[s.state]
|
const ss = STATE_STYLE[s.state]
|
||||||
return (
|
return (
|
||||||
<div key={s.id} className="flex items-center gap-3">
|
<div key={s.id} className="flex items-center gap-3">
|
||||||
@@ -514,7 +506,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<div className="flex items-center justify-between mb-1 gap-2">
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-xs font-mono truncate">{s.peerIp}</span>
|
<span className="text-xs font-mono truncate">{s.peerIp}</span>
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span>
|
<span className="text-[10px] text-muted-foreground shrink-0">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
|
||||||
<TypeBadge type={s.type} />
|
<TypeBadge type={s.type} />
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
|
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
|
||||||
@@ -533,7 +525,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{topPeers.length === 0 && (
|
{topPeers.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
|
<p className="text-sm text-muted-foreground text-center py-4">Недостаточно данных о префиксах для сравнения</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
@@ -552,7 +544,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
return (
|
return (
|
||||||
<div key={state} className="flex items-center gap-2">
|
<div key={state} className="flex items-center gap-2">
|
||||||
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
||||||
<span className="text-xs w-24">{state}</span>
|
<span className="text-xs w-24">{ss.label}</span>
|
||||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||||
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
|
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -566,7 +558,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
|
|
||||||
{/* eBGP vs iBGP */}
|
{/* eBGP vs iBGP */}
|
||||||
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
|
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
|
||||||
<p className="text-xs text-muted-foreground">Нет данных</p>
|
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -593,7 +585,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{Object.entries(
|
{Object.entries(
|
||||||
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
|
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
|
||||||
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
|
if (s.remoteAs !== null) acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
|
||||||
}, {})
|
}, {})
|
||||||
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
|
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
|
||||||
<div key={as} className="flex items-center justify-between text-[11px]">
|
<div key={as} className="flex items-center justify-between text-[11px]">
|
||||||
@@ -622,29 +614,78 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function BgpPage() {
|
export default function BgpPage() {
|
||||||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
return <Suspense fallback={<div role="status">Загрузка BGP…</div>}><BgpRoute /></Suspense>
|
||||||
|
}
|
||||||
|
function BgpRoute() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const params = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
|
||||||
|
const selectedSessionId = params.get("session")
|
||||||
|
const tab = params.get("tab")
|
||||||
|
const activeTab: BgpTab = tab === "routers" || tab === "analytics" ? tab : "sessions"
|
||||||
|
function navigate(values: Record<string, string | null>) {
|
||||||
|
const next = new URLSearchParams(params.toString())
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (value === null) next.delete(key)
|
||||||
|
else next.set(key, value)
|
||||||
|
}
|
||||||
|
router.push("/bgp" + (next.size ? "?" + next.toString() : ""))
|
||||||
|
}
|
||||||
|
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} selectedSessionId={selectedSessionId} activeTab={activeTab}
|
||||||
|
onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, session: null })}
|
||||||
|
onSelectSession={id => navigate({ session: id })}
|
||||||
|
onSelectTab={tab => navigate({ tab: tab === "sessions" ? null : tab })} />
|
||||||
|
}
|
||||||
|
function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSelectServer, onSelectSession, onSelectTab }: {
|
||||||
|
selectedServerId: string; selectedSessionId: string | null; activeTab: BgpTab;
|
||||||
|
onSelectServer: (id: string) => void; onSelectSession: (id: string | null) => void; onSelectTab: (tab: BgpTab) => void;
|
||||||
|
}) {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
||||||
|
const [loading, setLoading] = useState(isLive)
|
||||||
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
||||||
const [liveError, setLiveError] = useState<string | null>(null)
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
const [failures, setFailures] = useState<BgpFailure[]>([])
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false)
|
||||||
|
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
|
||||||
const [fetchTick, setFetchTick] = useState(0)
|
const [fetchTick, setFetchTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLiveSessions([])
|
||||||
|
setLiveServers([])
|
||||||
|
setLiveError(null)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLiveError(null)
|
void Promise.all([
|
||||||
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
|
requestJson<unknown>(backendUrl, "/api/bgp/catalog"),
|
||||||
.then(data => {
|
requestJson<unknown>(backendUrl, "/api/servers"),
|
||||||
|
])
|
||||||
|
.then(([data, servers]) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLiveSessions(data.map(backendToFrontend))
|
const catalog = bgpCatalogSchema.parse(data)
|
||||||
setFetchedAt(new Date())
|
const inventory = bgpServersSchema.parse(servers)
|
||||||
|
const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id))
|
||||||
|
if (catalog.sessions.some(s => !enabled.has(s.serverId)) || catalog.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст BGP-каталога")
|
||||||
|
const failed = new Set(catalog.failures.map(f => String(f.serverId)))
|
||||||
|
setLiveSessions(previous => [...catalog.sessions.map(backendToFrontend), ...previous.filter(s => enabled.has(Number(s.serverId)) && failed.has(s.serverId))])
|
||||||
|
setLiveServers(inventory.filter(s => s.enabled).map(mapBackendServer))
|
||||||
|
setFailures(catalog.failures)
|
||||||
|
setLiveError(null)
|
||||||
|
setHasLoaded(true)
|
||||||
|
const receivedAt = new Date()
|
||||||
|
setFetchedAt(receivedAt)
|
||||||
|
setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(String(id))).map(id => [String(id), receivedAt.toISOString()])) }))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
@@ -656,52 +697,89 @@ export default function BgpPage() {
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [isLive, backendUrl, fetchTick])
|
}, [isLive, backendUrl, fetchTick])
|
||||||
|
|
||||||
// Use live or mock data for all tabs and KPI
|
const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
|
||||||
const sessions = isLive ? liveSessions : SESSIONS
|
const allSessions: BgpSession[] = isLive ? liveSessions.map(s => unknownIds.has(s.serverId) ? { ...s, state: "Unknown" } : s) : SESSIONS
|
||||||
|
const unavailableContext = isLive && hasLoaded && selectedServerId !== ALL_SERVERS_ID && !liveServers.some(s => s.id === selectedServerId)
|
||||||
|
const incomplete = isLive && (unavailableContext || !hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
|
const effectiveServerId = selectedServerId
|
||||||
|
|
||||||
|
const sessions = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
|
||||||
|
return allSessions.filter((s) => s.serverId === effectiveServerId)
|
||||||
|
}, [allSessions, effectiveServerId])
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const s of allSessions) {
|
||||||
|
counts.set(s.serverId, (counts.get(s.serverId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: "monitoringUnknown" in s && s.monitoringUnknown ? undefined : s.status,
|
||||||
|
meta: "monitoringUnknown" in s && s.monitoringUnknown ? "Статус неизвестен" : undefined,
|
||||||
|
type: s.type,
|
||||||
|
count: isLive && (!hasLoaded || unknownIds.has(s.id)) ? undefined : counts.get(s.id) ?? 0,
|
||||||
|
enabled: s.enabled,
|
||||||
|
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||||
|
}))
|
||||||
|
}, [displayServers, allSessions, hasLoaded, isLive, unknownIds])
|
||||||
|
|
||||||
const established = sessions.filter(s => s.state === "Established").length
|
const established = sessions.filter(s => s.state === "Established").length
|
||||||
const notEstab = sessions.length - established
|
const notEstab = sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
|
||||||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
|
||||||
const serverCount = useMemo(
|
|
||||||
() => new Set(liveSessions.map(s => s.serverId)).size,
|
|
||||||
[liveSessions],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<ServerRailLayout
|
||||||
<PageHeader
|
preserveSelection
|
||||||
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
items={railItems}
|
||||||
actions={
|
selectedId={effectiveServerId}
|
||||||
<>
|
onSelect={onSelectServer}
|
||||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
showAll
|
||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
allCount={displayServers.length}
|
||||||
Обновить
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
</Button>
|
header={
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<PageHeader
|
||||||
</>
|
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||||||
}
|
actions={
|
||||||
/>
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
{/* tab bar */}
|
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
|
||||||
<div className="border-b bg-background shrink-0">
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
<div className="flex items-center px-6">
|
Обновить
|
||||||
{TABS.map(t => (
|
</Button>
|
||||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
</>
|
||||||
className={cn(
|
}
|
||||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
/>
|
||||||
activeTab === t.id
|
}
|
||||||
? "border-primary text-foreground"
|
banner={
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
<div className="border-b bg-background shrink-0">
|
||||||
)}>
|
<div className="flex min-w-0 items-center overflow-x-auto px-4 sm:px-6">
|
||||||
{t.icon}{t.label}
|
{TABS.map(t => (
|
||||||
</button>
|
<button key={t.id} type="button" aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
|
||||||
))}
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
|
activeTab === t.id
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||||
|
)}>
|
||||||
|
{t.icon}{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{unavailableContext && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Устройство BGP недоступно в этом каталоге</AlertTitle><AlertDescription>Контекст {selectedServerId} сохранён. Выберите доступное устройство.<Button variant="outline" size="sm" onClick={() => onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP</Button></AlertDescription></Alert>}
|
||||||
|
{selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.<Button variant="outline" size="sm" onClick={() => onSelectSession(null)}>Все сессии BGP</Button></AlertDescription></Alert>}
|
||||||
{/* data source banner */}
|
{/* data source banner */}
|
||||||
{isLive && loading && (
|
{isLive && loading && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
@@ -709,31 +787,23 @@ export default function BgpPage() {
|
|||||||
Загрузка BGP-сессий…
|
Загрузка BGP-сессий…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLive && !loading && fetchedAt && !liveError && (
|
{isLive && fetchedAt && (
|
||||||
<div className="flex items-center gap-2">
|
<div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
|
||||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
Это время получения браузером, не время измерения RouterOS.
|
||||||
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")}
|
{displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-bgp-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setFetchTick(t => t + 1)}
|
|
||||||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
<RefreshCwIcon className="size-3" />
|
|
||||||
Обновить
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLive && liveError && (
|
{isLive && (liveError || failures.length > 0) && (
|
||||||
<Alert variant="warning" className="py-2">
|
<Alert variant="warning">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
<AlertTitle>{liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."}
|
||||||
|
<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку BGP">Повторить</Button>
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
|
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
|
||||||
BGP не настроен ни на одном сервере
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mode === "mock" && (
|
{mode === "mock" && (
|
||||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
Моковые данные
|
Моковые данные
|
||||||
@@ -745,30 +815,31 @@ export default function BgpPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "sessions",
|
id: "sessions",
|
||||||
label: "Сессий всего",
|
label: "Сессий",
|
||||||
value: sessions.length,
|
value: incomplete ? "—" : sessions.length,
|
||||||
|
hint: incomplete ? "неизвестно" : undefined,
|
||||||
icon: <GitMergeIcon className="size-4" />,
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "established",
|
id: "established",
|
||||||
label: "Established",
|
label: "Established",
|
||||||
value: established,
|
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
|
||||||
icon: <CheckCircleIcon className="size-4" />,
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "not-estab",
|
id: "not-estab",
|
||||||
label: "Не установлено",
|
label: "Не поднято",
|
||||||
value: notEstab,
|
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
|
||||||
icon: <AlertCircleIcon className="size-4" />,
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
variant: notEstab > 0 ? "warning" : "default",
|
variant: notEstab > 0 ? "warning" : "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prefixes",
|
id: "prefixes",
|
||||||
label: "Получено префиксов",
|
label: "Префиксы",
|
||||||
value: fmtNum(totalRx),
|
value: incomplete ? "—" : fmtNum(totalRx),
|
||||||
icon: <DownloadIcon className="size-4" />,
|
icon: <DownloadIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
@@ -776,25 +847,24 @@ export default function BgpPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* alert: not-established sessions */}
|
{/* alert: not-established sessions */}
|
||||||
{notEstab > 0 && (
|
{!incomplete && notEstab > 0 && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
Проверьте {sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").map(s => s.peerIp).join(", ")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* tab content */}
|
{/* tab content */}
|
||||||
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
|
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
|
||||||
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
|
{activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
|
||||||
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
{activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+157
-342
@@ -1,16 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { Suspense, useMemo, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
|
||||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||||
import type { CertStatus, Server } from "@/lib/data"
|
import type { Server } from "@/lib/data"
|
||||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||||
|
import { CertificateAcmeSettings } from "@/components/certificates/certificate-acme-settings"
|
||||||
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
|
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
@@ -28,17 +30,11 @@ import {
|
|||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { listServers } from "@/shared/api/servers"
|
import { can } from "@/lib/auth"
|
||||||
|
import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
|
||||||
|
import type { CertificateView } from "@/lib/certificate-view"
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import {
|
import { useCertificateIssue } from "@/lib/use-certificate-issue"
|
||||||
createCertificateIssueJob,
|
|
||||||
getAcmeSettings,
|
|
||||||
getCertificateIssueJob,
|
|
||||||
listCertificates,
|
|
||||||
putAcmeSettings,
|
|
||||||
refreshCertificates,
|
|
||||||
testAcmeSettings,
|
|
||||||
} from "@/shared/api/certificates"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
@@ -59,7 +55,7 @@ import {
|
|||||||
StepperSeparator,
|
StepperSeparator,
|
||||||
StepperTitle,
|
StepperTitle,
|
||||||
StepperTrigger,
|
StepperTrigger,
|
||||||
} from "@/components/reui/stepper"
|
} from "@/components/reui-kit/accessible-stepper"
|
||||||
|
|
||||||
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||||
return {
|
return {
|
||||||
@@ -79,12 +75,12 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-4 py-3 text-sm">
|
||||||
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
|
<AlertCircleIcon className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-red-600 dark:text-red-400">
|
<p className="font-medium text-destructive">
|
||||||
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
@@ -95,12 +91,12 @@ function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) {
|
function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-warning/5 border border-warning/20 px-4 py-3 text-sm">
|
||||||
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
<AlertTriangleIcon className="size-5 text-warning shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
<p className="font-medium text-warning">
|
||||||
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
@@ -115,10 +111,14 @@ function CertPartKpi({
|
|||||||
displayCerts,
|
displayCerts,
|
||||||
expiring,
|
expiring,
|
||||||
expired,
|
expired,
|
||||||
|
complete,
|
||||||
|
totalKnown,
|
||||||
}: {
|
}: {
|
||||||
displayCerts: CertificateDto[]
|
displayCerts: CertificateView[]
|
||||||
expiring: CertificateDto[]
|
expiring: CertificateView[]
|
||||||
expired: CertificateDto[]
|
expired: CertificateView[]
|
||||||
|
complete: boolean
|
||||||
|
totalKnown: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
@@ -127,21 +127,21 @@ function CertPartKpi({
|
|||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего",
|
label: "Всего",
|
||||||
value: displayCerts.length,
|
value: totalKnown ? displayCerts.length : "—",
|
||||||
icon: <ShieldCheckIcon className="size-4" />,
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "valid",
|
id: "valid",
|
||||||
label: "Действующих",
|
label: "Действующих",
|
||||||
value: displayCerts.filter((c) => c.status === "valid").length,
|
value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
|
||||||
icon: <BadgeCheckIcon className="size-4" />,
|
icon: <BadgeCheckIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "expiring",
|
id: "expiring",
|
||||||
label: "Истекают",
|
label: "Истекают",
|
||||||
value: expiring.length,
|
value: complete ? expiring.length : "—",
|
||||||
icon: <AlertTriangleIcon className="size-4" />,
|
icon: <AlertTriangleIcon className="size-4" />,
|
||||||
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
variant: expiring.length > 0 ? "warning" : "default",
|
variant: expiring.length > 0 ? "warning" : "default",
|
||||||
@@ -149,7 +149,7 @@ function CertPartKpi({
|
|||||||
{
|
{
|
||||||
id: "expired",
|
id: "expired",
|
||||||
label: "Истёкших",
|
label: "Истёкших",
|
||||||
value: expired.length,
|
value: complete ? expired.length : "—",
|
||||||
icon: <AlertCircleIcon className="size-4" />,
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
variant: expired.length > 0 ? "destructive" : "default",
|
variant: expired.length > 0 ? "destructive" : "default",
|
||||||
@@ -159,87 +159,6 @@ function CertPartKpi({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertPartAcmeSettings({
|
|
||||||
acmeDirectoryUrl,
|
|
||||||
setAcmeDirectoryUrl,
|
|
||||||
acmeZoneId,
|
|
||||||
setAcmeZoneId,
|
|
||||||
acmeTokenDraft,
|
|
||||||
setAcmeTokenDraft,
|
|
||||||
acmeTokenConfigured,
|
|
||||||
acmeSaveBusy,
|
|
||||||
onTest,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
acmeDirectoryUrl: string
|
|
||||||
setAcmeDirectoryUrl: (v: string) => void
|
|
||||||
acmeZoneId: string
|
|
||||||
setAcmeZoneId: (v: string) => void
|
|
||||||
acmeTokenDraft: string
|
|
||||||
setAcmeTokenDraft: (v: string) => void
|
|
||||||
acmeTokenConfigured: boolean
|
|
||||||
acmeSaveBusy: boolean
|
|
||||||
onTest: () => void
|
|
||||||
onSave: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<OpsPanel
|
|
||||||
title="ACME · Cloudflare DNS-01"
|
|
||||||
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
|
|
||||||
contentClassName="px-5 py-4 flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
className="grid gap-3 md:grid-cols-2"
|
|
||||||
autoComplete="off"
|
|
||||||
onSubmit={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">ACME directory URL</label>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
name="acme-directory-url"
|
|
||||||
value={acmeDirectoryUrl}
|
|
||||||
onChange={(e) => setAcmeDirectoryUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">Cloudflare zone id (опционально)</label>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
name="cloudflare-zone-id"
|
|
||||||
value={acmeZoneId}
|
|
||||||
onChange={(e) => setAcmeZoneId(e.target.value)}
|
|
||||||
placeholder="Авто по домену"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5 md:col-span-2">
|
|
||||||
<label className="text-sm font-medium">Cloudflare API token</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
name="cloudflare-api-token"
|
|
||||||
value={acmeTokenDraft}
|
|
||||||
onChange={(e) => setAcmeTokenDraft(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
acmeTokenConfigured
|
|
||||||
? "Токен сохранён — введите новый для замены"
|
|
||||||
: "API token с правом DNS"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button variant="outline" size="sm" disabled={acmeSaveBusy} onClick={onTest}>
|
|
||||||
Проверить Cloudflare
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" disabled={acmeSaveBusy} onClick={onSave}>
|
|
||||||
Сохранить настройки
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</OpsPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CertPartReference() {
|
function CertPartReference() {
|
||||||
return (
|
return (
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
@@ -280,7 +199,7 @@ function CertPartReference() {
|
|||||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
||||||
{b.title}
|
{b.title}
|
||||||
</p>
|
</p>
|
||||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
<pre tabIndex={0} role="region" aria-label={"Команды RouterOS: " + b.title} className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2">
|
||||||
{b.lines.join("\n")}
|
{b.lines.join("\n")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,6 +248,7 @@ function CertPartIssueForm({
|
|||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||||
<select
|
<select
|
||||||
|
aria-label="Сервер выпуска"
|
||||||
value={issueServerId}
|
value={issueServerId}
|
||||||
onChange={(e) => setIssueServerId(e.target.value)}
|
onChange={(e) => setIssueServerId(e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
@@ -346,6 +266,7 @@ function CertPartIssueForm({
|
|||||||
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
aria-label="Имя сертификата"
|
||||||
value={issueCertName}
|
value={issueCertName}
|
||||||
onChange={(e) => setIssueCertName(e.target.value)}
|
onChange={(e) => setIssueCertName(e.target.value)}
|
||||||
placeholder="router-le"
|
placeholder="router-le"
|
||||||
@@ -360,6 +281,7 @@ function CertPartIssueForm({
|
|||||||
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
aria-label="Основной домен"
|
||||||
value={issueCommonName}
|
value={issueCommonName}
|
||||||
onChange={(e) => setIssueCommonName(e.target.value)}
|
onChange={(e) => setIssueCommonName(e.target.value)}
|
||||||
placeholder="vpn.example.com"
|
placeholder="vpn.example.com"
|
||||||
@@ -368,6 +290,7 @@ function CertPartIssueForm({
|
|||||||
<FormField label="SAN" hint="По одному имени в строке">
|
<FormField label="SAN" hint="По одному имени в строке">
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Дополнительные домены"
|
||||||
value={issueSans}
|
value={issueSans}
|
||||||
onChange={(e) => setIssueSans(e.target.value)}
|
onChange={(e) => setIssueSans(e.target.value)}
|
||||||
placeholder="www.example.com"
|
placeholder="www.example.com"
|
||||||
@@ -388,15 +311,16 @@ function CertPartIssueForm({
|
|||||||
<p className="text-sm font-medium">Trust store · www</p>
|
<p className="text-sm font-medium">Trust store · www</p>
|
||||||
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
|
<FormToggle ariaLabel="Trust store www" checked={issueTrustWww} onChange={setIssueTrustWww} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Trust store · api</p>
|
<p className="text-sm font-medium">Trust store · api</p>
|
||||||
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
<FormToggle ariaLabel="Trust store api" checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -408,6 +332,8 @@ function CertPartIssueForm({
|
|||||||
<li>Имя: {issueCertName || "—"}</li>
|
<li>Имя: {issueCertName || "—"}</li>
|
||||||
<li>CN: {issueCommonName || "—"}</li>
|
<li>CN: {issueCommonName || "—"}</li>
|
||||||
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
||||||
|
<li>Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.</li>
|
||||||
|
<li>DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -416,21 +342,42 @@ function CertPartIssueForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CertificatesPage() {
|
export default function CertificatesPage() {
|
||||||
|
return <Suspense fallback={<p role="status">Загрузка контекста сертификатов…</p>}><CertificatesSource /></Suspense>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertificatesSource() {
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
|
||||||
|
return <CertificatesContent key={mode + ":" + backendUrl} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertificatesContent() {
|
||||||
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
|
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
|
||||||
const isLive = prefsHydrated && mode === "live"
|
const isLive = prefsHydrated && mode === "live"
|
||||||
const liveReady = isLive && backendStatus === true
|
const liveReady = isLive && backendStatus === true
|
||||||
|
|
||||||
const [search, setSearch] = useState("")
|
const params = useSearchParams()
|
||||||
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
const context = readCertificatesUrl(params)
|
||||||
const [certificates, setCertificates] = useState<CertificateDto[]>([])
|
const search = context.search
|
||||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
|
const statusFilter = context.status
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const selectedServerId = context.server
|
||||||
const [serverList, setServerList] = useState<Server[]>([])
|
const urlError = context.error
|
||||||
|
function writeContext(changes: Record<string, string | null>, replace = false) {
|
||||||
|
const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||||
|
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
||||||
|
}
|
||||||
|
const setSearch = (value: string) => writeContext({ q: value }, true)
|
||||||
|
const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
||||||
|
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
||||||
|
const canRead = can("mm:certificates:read")
|
||||||
|
const canWrite = can("mm:certificates:write")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
|
||||||
|
const { snapshots, loading, error: loadError, serverError } = catalog
|
||||||
|
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
|
||||||
|
|
||||||
const [issueOpen, setIssueOpen] = useState(false)
|
const [issueOpen, setIssueOpen] = useState(false)
|
||||||
const [issueStep, setIssueStep] = useState(1)
|
const [issueStep, setIssueStep] = useState(1)
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
|
||||||
const [issueBusy, setIssueBusy] = useState(false)
|
|
||||||
const [issueServerId, setIssueServerId] = useState("")
|
const [issueServerId, setIssueServerId] = useState("")
|
||||||
const [issueCertName, setIssueCertName] = useState("")
|
const [issueCertName, setIssueCertName] = useState("")
|
||||||
const [issueCommonName, setIssueCommonName] = useState("")
|
const [issueCommonName, setIssueCommonName] = useState("")
|
||||||
@@ -438,28 +385,20 @@ export default function CertificatesPage() {
|
|||||||
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
||||||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||||
|
|
||||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
|
||||||
|
|
||||||
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
|
||||||
"https://acme-v02.api.letsencrypt.org/directory",
|
|
||||||
)
|
|
||||||
const [acmeZoneId, setAcmeZoneId] = useState("")
|
|
||||||
const [acmeTokenDraft, setAcmeTokenDraft] = useState("")
|
|
||||||
const [acmeTokenConfigured, setAcmeTokenConfigured] = useState(false)
|
|
||||||
const [acmeSaveBusy, setAcmeSaveBusy] = useState(false)
|
|
||||||
|
|
||||||
const displayCerts = useMemo(() => {
|
const displayCerts = useMemo(() => {
|
||||||
if (!prefsHydrated) return []
|
if (!prefsHydrated) return []
|
||||||
if (isLive) return certificates
|
if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
|
||||||
return routerCertificates.map(mockToDto)
|
return routerCertificates.map(mockToDto)
|
||||||
}, [prefsHydrated, isLive, certificates])
|
}, [prefsHydrated, isLive, snapshots])
|
||||||
|
|
||||||
const displayServers = isLive ? serverList : mockServers
|
const displayServers = isLive ? serverList : mockServers
|
||||||
|
|
||||||
const scopedCerts = useMemo(() => {
|
const scopedCerts = useMemo(() => {
|
||||||
|
if (urlError) return []
|
||||||
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
||||||
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
||||||
}, [displayCerts, selectedServerId])
|
}, [displayCerts, selectedServerId, urlError])
|
||||||
|
|
||||||
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
displayServers.map((s) => ({
|
displayServers.map((s) => ({
|
||||||
@@ -468,12 +407,12 @@ export default function CertificatesPage() {
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status,
|
status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
|
meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||||||
}))
|
}))
|
||||||
), [displayServers, displayCerts])
|
), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
|
||||||
|
|
||||||
const serverById = useMemo(() => {
|
const serverById = useMemo(() => {
|
||||||
const map = new Map<string, Server>()
|
const map = new Map<string, Server>()
|
||||||
@@ -481,69 +420,21 @@ export default function CertificatesPage() {
|
|||||||
return map
|
return map
|
||||||
}, [isLive, serverList])
|
}, [isLive, serverList])
|
||||||
|
|
||||||
const loadLive = useCallback(
|
const loadLive = catalog.reload
|
||||||
async (silent = false) => {
|
const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
|
||||||
if (!isLive) return
|
const issueBusy = issue.busy
|
||||||
if (!silent) setLoadState("loading")
|
const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
|
||||||
setLoadError(null)
|
|
||||||
try {
|
|
||||||
const [certRes, serversRes] = await Promise.all([
|
|
||||||
listCertificates(backendUrl),
|
|
||||||
listServers(backendUrl),
|
|
||||||
])
|
|
||||||
setCertificates(certRes.certificates)
|
|
||||||
setServerList(serversRes.map(toFrontendServer))
|
|
||||||
if (certRes.failures.length > 0 && certRes.certificates.length === 0) {
|
|
||||||
setLoadError(
|
|
||||||
certRes.failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; "),
|
|
||||||
)
|
|
||||||
} else if (certRes.failures.length > 0) {
|
|
||||||
toast.warning(`Часть серверов недоступна: ${certRes.failures.length}`)
|
|
||||||
}
|
|
||||||
setLoadState("idle")
|
|
||||||
} catch (e) {
|
|
||||||
setCertificates([])
|
|
||||||
setLoadError(e instanceof Error ? e.message : "Не удалось загрузить сертификаты")
|
|
||||||
setLoadState("error")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[backendUrl, isLive],
|
|
||||||
)
|
|
||||||
|
|
||||||
const loadAcmeSettings = useCallback(async () => {
|
|
||||||
if (!liveReady) return
|
|
||||||
try {
|
|
||||||
const s = await getAcmeSettings(backendUrl)
|
|
||||||
setAcmeDirectoryUrl(s.directoryUrl)
|
|
||||||
setAcmeZoneId(s.defaultZoneId ?? "")
|
|
||||||
setAcmeTokenConfigured(s.tokenConfigured)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, [backendUrl, liveReady])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLive) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setCertificates([])
|
|
||||||
setServerList([])
|
|
||||||
setLoadState("idle")
|
|
||||||
setLoadError(null)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
queueMicrotask(() => {
|
|
||||||
void loadLive()
|
|
||||||
void loadAcmeSettings()
|
|
||||||
})
|
|
||||||
}, [isLive, loadLive, loadAcmeSettings])
|
|
||||||
|
|
||||||
const expiring = useMemo(
|
const expiring = useMemo(
|
||||||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||||
[scopedCerts],
|
[scopedCerts],
|
||||||
)
|
)
|
||||||
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||||
|
|
||||||
|
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
|
||||||
|
const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
|
||||||
|
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return scopedCerts.filter((c) => {
|
return scopedCerts.filter((c) => {
|
||||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
@@ -559,93 +450,19 @@ export default function CertificatesPage() {
|
|||||||
}, [scopedCerts, search, statusFilter])
|
}, [scopedCerts, search, statusFilter])
|
||||||
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
if (!liveReady) return
|
await loadLive()
|
||||||
try {
|
|
||||||
const res = await refreshCertificates(backendUrl)
|
|
||||||
setCertificates(res.certificates)
|
|
||||||
toast.success("Список сертификатов обновлён")
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка обновления")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollIssueJob(jobId: string) {
|
|
||||||
for (let i = 0; i < 120; i++) {
|
|
||||||
await new Promise((r) => setTimeout(r, 2000))
|
|
||||||
const job = await getCertificateIssueJob(backendUrl, jobId)
|
|
||||||
if (job.status === "done") {
|
|
||||||
toast.success("Сертификат выпущен и импортирован на роутер")
|
|
||||||
setIssueOpen(false)
|
|
||||||
await loadLive(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (job.status === "failed") {
|
|
||||||
throw new Error(job.error ?? "Выпуск не удался")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("Таймаут ожидания выпуска сертификата")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIssue() {
|
async function handleIssue() {
|
||||||
if (!liveReady) return
|
if (issue.checkpoint) { await issue.run(); return }
|
||||||
const domains = [
|
if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
|
||||||
issueCommonName.trim(),
|
const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
|
||||||
...issueSans.split(/[\n,;]+/).map((s) => s.trim()),
|
if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
|
||||||
].filter(Boolean)
|
await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
|
||||||
if (!issueServerId || !issueCertName.trim() || domains.length === 0) {
|
|
||||||
toast.error("Укажите сервер, имя сертификата и домены")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIssueBusy(true)
|
|
||||||
try {
|
|
||||||
const trustStore: Array<"www" | "api"> = []
|
|
||||||
if (issueTrustWww) trustStore.push("www")
|
|
||||||
if (issueTrustApi) trustStore.push("api")
|
|
||||||
const { jobId } = await createCertificateIssueJob(backendUrl, {
|
|
||||||
serverId: issueServerId,
|
|
||||||
certName: issueCertName.trim(),
|
|
||||||
domainNames: domains,
|
|
||||||
trustStore,
|
|
||||||
})
|
|
||||||
toast.message("Выпуск сертификата запущен…")
|
|
||||||
await pollIssueJob(jobId)
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка выпуска")
|
|
||||||
} finally {
|
|
||||||
setIssueBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
function startNewIssue() {
|
||||||
async function handleSaveAcmeSettings() {
|
if (!issue.reset()) return
|
||||||
if (!liveReady) return
|
setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
|
||||||
setAcmeSaveBusy(true)
|
|
||||||
try {
|
|
||||||
const saved = await putAcmeSettings(backendUrl, {
|
|
||||||
directoryUrl: acmeDirectoryUrl.trim(),
|
|
||||||
defaultZoneId: acmeZoneId.trim() || null,
|
|
||||||
cloudflareApiToken: acmeTokenDraft.trim() ? acmeTokenDraft.trim() : undefined,
|
|
||||||
})
|
|
||||||
setAcmeTokenConfigured(saved.tokenConfigured)
|
|
||||||
setAcmeTokenDraft("")
|
|
||||||
toast.success("Настройки ACME сохранены")
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось сохранить настройки")
|
|
||||||
} finally {
|
|
||||||
setAcmeSaveBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTestAcme() {
|
|
||||||
if (!liveReady) return
|
|
||||||
try {
|
|
||||||
const res = await testAcmeSettings(backendUrl, {
|
|
||||||
cloudflareApiToken: acmeTokenDraft.trim() || undefined,
|
|
||||||
})
|
|
||||||
if (res.ok) toast.success(res.message ?? "Cloudflare API доступен")
|
|
||||||
else toast.error(res.message ?? "Проверка не прошла")
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Проверка Cloudflare не удалась")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -656,7 +473,8 @@ export default function CertificatesPage() {
|
|||||||
onSelect={setSelectedServerId}
|
onSelect={setSelectedServerId}
|
||||||
showAll
|
showAll
|
||||||
allCount={displayServers.length}
|
allCount={displayServers.length}
|
||||||
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
preserveSelection
|
||||||
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
header={
|
header={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||||
@@ -666,47 +484,59 @@ export default function CertificatesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!liveReady || loadState === "loading"}
|
disabled={!isLive || loading || (!canRead && !canReadServers)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleRefresh()
|
void handleRefresh()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
<Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
|
||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
Импорт
|
Импорт
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!liveReady || issueBusy}
|
disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIssueStep(1)
|
setIssueStep(issue.checkpoint ? 4 : 1)
|
||||||
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||||
setIssueOpen(true)
|
setIssueOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Выпустить сертификат
|
{issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5 min-w-0">
|
||||||
|
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
|
||||||
|
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
|
||||||
|
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
|
||||||
{isLive && backendStatus === false && (
|
{isLive && backendStatus === false && (
|
||||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
<div className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
|
||||||
Backend недоступен — live-операции отключены.
|
Backend недоступен — live-операции отключены.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loadError && isLive && (
|
{loadError && isLive && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
<div role="alert" aria-label="Каталог сертификатов" className="[overflow-wrap:anywhere] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
{loadError}
|
{catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов" className="[overflow-wrap:anywhere]">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
|
||||||
|
{isLive && !canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Нет права mm:servers:read. Выбор устройства для выпуска недоступен.</p>}
|
||||||
|
{isLive && catalog.collectedAt && <p className="text-xs text-muted-foreground">Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}</p>}
|
||||||
|
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot => <div key={snapshot.latest.serverId}>
|
||||||
|
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId} className="[overflow-wrap:anywhere]">{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
{(expiring.length > 0 || expired.length > 0) && (
|
{(expiring.length > 0 || expired.length > 0) && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
|
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
|
||||||
@@ -714,51 +544,38 @@ export default function CertificatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
{!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
|
||||||
|
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} totalKnown={scopeKnown} />
|
||||||
|
|
||||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
|
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||||
|
|
||||||
{liveReady && (
|
<CertificateAcmeSettings backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||||
<CertPartAcmeSettings
|
|
||||||
acmeDirectoryUrl={acmeDirectoryUrl}
|
|
||||||
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
|
|
||||||
acmeZoneId={acmeZoneId}
|
|
||||||
setAcmeZoneId={setAcmeZoneId}
|
|
||||||
acmeTokenDraft={acmeTokenDraft}
|
|
||||||
setAcmeTokenDraft={setAcmeTokenDraft}
|
|
||||||
acmeTokenConfigured={acmeTokenConfigured}
|
|
||||||
acmeSaveBusy={acmeSaveBusy}
|
|
||||||
onTest={() => {
|
|
||||||
void handleTestAcme()
|
|
||||||
}}
|
|
||||||
onSave={() => {
|
|
||||||
void handleSaveAcmeSettings()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
||||||
segmented={{
|
filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
|
||||||
value: statusFilter,
|
value={statusFilter}
|
||||||
onChange: setStatusFilter,
|
onChange={setStatusFilter}
|
||||||
options: [
|
options={[
|
||||||
{ value: "all", label: "Все" },
|
{ value: "all", label: "Все" },
|
||||||
{ value: "valid", label: "Действующие" },
|
{ value: "valid", label: "Действующие" },
|
||||||
{ value: "expired", label: "Истёкшие" },
|
{ value: "expired", label: "Истёкшие" },
|
||||||
{ value: "revoked", label: "Отозванные" },
|
{ value: "revoked", label: "Отозванные" },
|
||||||
],
|
{ value: "unknown", label: "Неизвестные" },
|
||||||
}}
|
]}
|
||||||
countLabel={`${filtered.length} сертификатов`}
|
/></div>}
|
||||||
|
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
|
||||||
/>
|
/>
|
||||||
<CertificatesDataGrid
|
{urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
|
||||||
certificates={filtered}
|
certificates={filtered}
|
||||||
serverMap={serverById}
|
serverMap={serverById}
|
||||||
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
|
emptyTitle={search || statusFilter !== "all" ? "Нет совпадений" : "Нет сертификатов"}
|
||||||
/>
|
emptyDescription={search || statusFilter !== "all" ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
|
||||||
|
isLoading={isLive && loading && displayCerts.length === 0}
|
||||||
|
/>}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
<CertPartReference />
|
<CertPartReference />
|
||||||
@@ -766,7 +583,7 @@ export default function CertificatesPage() {
|
|||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Выпуск сертификата</SheetTitle>
|
<SheetTitle>Выпуск сертификата</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
@@ -774,6 +591,11 @@ export default function CertificatesPage() {
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="px-6 pt-3 text-sm min-w-0 [overflow-wrap:anywhere]">
|
||||||
|
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
|
||||||
|
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
|
||||||
|
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
|
||||||
|
</div>
|
||||||
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<StepperNav className="mb-5">
|
<StepperNav className="mb-5">
|
||||||
{[
|
{[
|
||||||
@@ -794,30 +616,33 @@ export default function CertificatesPage() {
|
|||||||
<StepperPanel className="flex-1 overflow-y-auto">
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
{[1, 2, 3, 4].map((s) => (
|
{[1, 2, 3, 4].map((s) => (
|
||||||
<StepperContent key={s} value={s}>
|
<StepperContent key={s} value={s}>
|
||||||
|
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
|
||||||
<CertPartIssueForm
|
<CertPartIssueForm
|
||||||
step={s as 1 | 2 | 3 | 4}
|
step={s as 1 | 2 | 3 | 4}
|
||||||
serverList={displayServers}
|
serverList={displayServers}
|
||||||
issueServerId={issueServerId}
|
issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
|
||||||
setIssueServerId={setIssueServerId}
|
setIssueServerId={setIssueServerId}
|
||||||
issueCertName={issueCertName}
|
issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
|
||||||
setIssueCertName={setIssueCertName}
|
setIssueCertName={setIssueCertName}
|
||||||
issueCommonName={issueCommonName}
|
issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
|
||||||
setIssueCommonName={setIssueCommonName}
|
setIssueCommonName={setIssueCommonName}
|
||||||
issueSans={issueSans}
|
issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
|
||||||
setIssueSans={setIssueSans}
|
setIssueSans={setIssueSans}
|
||||||
issueTrustWww={issueTrustWww}
|
issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
|
||||||
setIssueTrustWww={setIssueTrustWww}
|
setIssueTrustWww={setIssueTrustWww}
|
||||||
issueTrustApi={issueTrustApi}
|
issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
|
||||||
setIssueTrustApi={setIssueTrustApi}
|
setIssueTrustApi={setIssueTrustApi}
|
||||||
/>
|
/>
|
||||||
|
</fieldset>
|
||||||
</StepperContent>
|
</StepperContent>
|
||||||
))}
|
))}
|
||||||
</StepperPanel>
|
</StepperPanel>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
|
{issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
|
||||||
Отмена
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>
|
||||||
|
{issue.checkpoint ? "Закрыть" : "Отмена"}
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
{issueStep > 1 && (
|
{issueStep > 1 && (
|
||||||
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
||||||
@@ -827,7 +652,7 @@ export default function CertificatesPage() {
|
|||||||
{issueStep < 4 ? (
|
{issueStep < 4 ? (
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
|
disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||||
onClick={() => setIssueStep((s) => s + 1)}
|
onClick={() => setIssueStep((s) => s + 1)}
|
||||||
>
|
>
|
||||||
Далее
|
Далее
|
||||||
@@ -835,26 +660,16 @@ export default function CertificatesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!liveReady || issueBusy}
|
disabled={!issue.hydrated || !liveReady || !canRead || issueBusy || issueTerminal || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
|
||||||
onClick={() => { void handleIssue() }}
|
onClick={() => { void handleIssue() }}
|
||||||
>
|
>
|
||||||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
{issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<FileImportDialog
|
|
||||||
open={importOpen}
|
|
||||||
onOpenChange={setImportOpen}
|
|
||||||
title="Импорт сертификата"
|
|
||||||
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
|
|
||||||
accept=".pem,.crt,.cer,.p12,.pfx"
|
|
||||||
onImport={async (files) => {
|
|
||||||
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from "react"
|
import { useEffect, useRef, useState, useMemo } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import {
|
import {
|
||||||
@@ -8,21 +8,20 @@ import {
|
|||||||
type CommunityRow,
|
type CommunityRow,
|
||||||
TYPE_LABELS,
|
TYPE_LABELS,
|
||||||
ACTION_LABELS,
|
ACTION_LABELS,
|
||||||
ACTION_COLOR,
|
|
||||||
} from "@/components/data-grids/communities-data-grid"
|
} from "@/components/data-grids/communities-data-grid"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||||
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||||
LoaderCircleIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { filters } from "@/lib/data"
|
import { filters } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -98,24 +97,31 @@ const COMMUNITIES: Community[] = [
|
|||||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CommunitiesPage() {
|
export default function CommunitiesPage() {
|
||||||
const { mode } = useDataSource()
|
const { sourceKey } = useEvoBGP()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
return <CommunitiesContent key={sourceKey} />
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
}
|
||||||
|
function CommunitiesContent() {
|
||||||
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
|
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||||
const [copied, setCopied] = useState<string | null>(null)
|
const [copied, setCopied] = useState<string | null>(null)
|
||||||
const [selected, setSelected] = useState<Community | null>(null)
|
const [copying, setCopying] = useState(false)
|
||||||
|
const [copyError, setCopyError] = useState<string | null>(null)
|
||||||
|
const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType<typeof setTimeout> | null })
|
||||||
|
useEffect(() => {
|
||||||
|
const state = copyRequest.current; state.active = true
|
||||||
|
return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) }
|
||||||
|
}, [])
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
|
||||||
const listData = useMemo((): Community[] => {
|
const listData = useMemo((): Community[] => {
|
||||||
if (!useEvoCatalog) return COMMUNITIES
|
if (mode === "mock") return COMMUNITIES
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.communities ?? []
|
return snapshot?.communities ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
useEffect(() => {
|
const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
|
||||||
setSelected(null)
|
|
||||||
}, [useEvoCatalog])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
@@ -126,10 +132,24 @@ export default function CommunitiesPage() {
|
|||||||
})
|
})
|
||||||
}, [search, typeFilter, listData])
|
}, [search, typeFilter, listData])
|
||||||
|
|
||||||
const handleCopy = (value: string) => {
|
const handleCopy = async (value: string) => {
|
||||||
navigator.clipboard.writeText(value).catch(() => {})
|
const state = copyRequest.current
|
||||||
setCopied(value)
|
if (!state.active || state.pending) return
|
||||||
setTimeout(() => setCopied(null), 1500)
|
state.pending = true; const revision = ++state.revision
|
||||||
|
if (state.timer) clearTimeout(state.timer)
|
||||||
|
const current = () => state.active && state.revision === revision
|
||||||
|
setCopying(true); setCopied(null); setCopyError(null)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
if (!current()) return
|
||||||
|
setCopied(value)
|
||||||
|
state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500)
|
||||||
|
} catch {
|
||||||
|
if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.")
|
||||||
|
} finally {
|
||||||
|
state.pending = false
|
||||||
|
if (current()) setCopying(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
||||||
@@ -138,8 +158,7 @@ export default function CommunitiesPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
||||||
actions={
|
actions={mode === "live" ? null : <Button size="sm">
|
||||||
<Button size="sm">
|
|
||||||
<PlusIcon className="size-4" />Добавить
|
<PlusIcon className="size-4" />Добавить
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -147,19 +166,10 @@ export default function CommunitiesPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{useEvoCatalog && (
|
<EvoBgpCatalogStatus />
|
||||||
<p className={cn(
|
{copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
|
||||||
"text-xs flex items-center gap-1.5 -mt-1",
|
{copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
{copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
|
||||||
)}>
|
|
||||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
||||||
{error
|
|
||||||
? `EvoBGP: ${error}`
|
|
||||||
: snapshot?.fetchedAt
|
|
||||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
||||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка communities"
|
aria-label="Сводка communities"
|
||||||
@@ -167,35 +177,35 @@ export default function CommunitiesPage() {
|
|||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего communities",
|
label: "Всего communities",
|
||||||
value: String(listData.length),
|
value: known ? String(listData.length) : "—",
|
||||||
icon: <TagIcon className="size-4" />,
|
icon: <TagIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enabled",
|
id: "enabled",
|
||||||
label: "Активных",
|
label: "Активных",
|
||||||
value: String(listData.filter((c) => c.enabled).length),
|
value: known ? String(listData.filter((c) => c.enabled).length) : "—",
|
||||||
icon: <CheckIcon className="size-4" />,
|
icon: <CheckIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "standard",
|
id: "standard",
|
||||||
label: "Стандартных",
|
label: "Стандартных",
|
||||||
value: String(listData.filter((c) => c.type === "standard").length),
|
value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
|
||||||
icon: <TagIcon className="size-4" />,
|
icon: <TagIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "filters",
|
id: "filters",
|
||||||
label: "Использует фильтры",
|
label: "Использует фильтры",
|
||||||
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
|
value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
|
||||||
icon: <FilterIcon className="size-4" />,
|
icon: <FilterIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-[1fr_320px] gap-5">
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
|
||||||
{/* ── main table ── */}
|
{/* ── main table ── */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* toolbar */}
|
{/* toolbar */}
|
||||||
@@ -204,7 +214,7 @@ export default function CommunitiesPage() {
|
|||||||
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
||||||
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
||||||
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
||||||
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
||||||
@@ -216,13 +226,14 @@ export default function CommunitiesPage() {
|
|||||||
|
|
||||||
{/* list */}
|
{/* list */}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<CommunitiesDataGrid
|
{known ? <CommunitiesDataGrid
|
||||||
communities={filtered}
|
communities={filtered}
|
||||||
selectedId={selected?.id}
|
selectedId={selected?.id}
|
||||||
copiedValue={copied}
|
copiedValue={copied}
|
||||||
onSelect={setSelected}
|
copying={copying}
|
||||||
|
onSelect={row => setSelectedId(row.id)}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
/>
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,8 +245,8 @@ export default function CommunitiesPage() {
|
|||||||
description={selected.name}
|
description={selected.name}
|
||||||
headerRight={
|
headerRight={
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
<Button size="sm" variant="ghost" aria-label="Изменить community" disabled className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
<Button size="sm" variant="ghost" aria-label="Удалить community" disabled className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
||||||
@@ -256,7 +267,7 @@ export default function CommunitiesPage() {
|
|||||||
{[
|
{[
|
||||||
["Тип", TYPE_LABELS[selected.type]],
|
["Тип", TYPE_LABELS[selected.type]],
|
||||||
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
||||||
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")],
|
["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
|
||||||
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k} className="flex justify-between gap-2">
|
<div key={k} className="flex justify-between gap-2">
|
||||||
@@ -282,7 +293,7 @@ export default function CommunitiesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t flex flex-col gap-2">
|
<div className="pt-2 border-t flex flex-col gap-2">
|
||||||
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}>
|
<Button size="sm" variant="outline" className="w-full justify-start gap-2" disabled={copying} onClick={() => { void handleCopy(selected.value) }}>
|
||||||
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||||
Скопировать значение
|
Скопировать значение
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+369
-182
@@ -1,15 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { routerContainers, servers } from "@/lib/data"
|
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||||
import type { RouterContainer } from "@/lib/data"
|
import type { RouterContainer, Server } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
DropdownMenuItem, DropdownMenuSeparator,
|
DropdownMenuItem, DropdownMenuSeparator,
|
||||||
@@ -18,124 +23,110 @@ import {
|
|||||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||||
CodeXmlIcon, ActivityIcon, ServerIcon,
|
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||||
TerminalIcon, AlertCircleIcon,
|
TerminalIcon, AlertCircleIcon, RefreshCwIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
|
||||||
} from "@/components/ui/sheet"
|
|
||||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
|
import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
|
||||||
|
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||||
|
import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { userApiError } from "@/shared/api/users"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
type ContainerView = Omit<RouterContainer, "status" | "startOnBoot"> & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
|
||||||
|
type ContainerServer = Omit<Server, "status"> & {status: Server["status"] | null}
|
||||||
|
|
||||||
function serverFor(id: string) {
|
interface BackendServer {
|
||||||
return servers.find((s) => s.id === id)
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"] | null
|
||||||
|
latency?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusConfig(status: RouterContainer["status"]) {
|
function mapBackendServer(s: BackendServer): ContainerServer {
|
||||||
return {
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? null,
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusConfig(status: ContainerView["status"]) {
|
||||||
|
return {
|
||||||
|
unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
|
||||||
running: {
|
running: {
|
||||||
dot: "bg-emerald-500 animate-pulse",
|
dot: "bg-success",
|
||||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
badge: "bg-success/10 text-success border-success/20",
|
||||||
label: "Running",
|
label: "Работает",
|
||||||
},
|
},
|
||||||
stopped: {
|
stopped: {
|
||||||
dot: "bg-muted-foreground",
|
dot: "bg-muted-foreground",
|
||||||
badge: "bg-muted/50 text-muted-foreground border-border",
|
badge: "bg-muted/50 text-muted-foreground border-border",
|
||||||
label: "Stopped",
|
label: "Остановлен",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
dot: "bg-red-500 animate-pulse",
|
dot: "bg-destructive",
|
||||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
||||||
label: "Error",
|
label: "Ошибка",
|
||||||
},
|
},
|
||||||
}[status]
|
}[status]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
|
||||||
|
function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
|
||||||
function generateContainerRsc(c: RouterContainer): string {
|
const formats = useMemo(() => [{
|
||||||
const srv = serverFor(c.serverId)
|
id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
|
||||||
const lines: string[] = []
|
code: selection ? JSON.stringify(selection, null, 2) : "",
|
||||||
lines.push(`# RouterOS Container — ${c.name}`)
|
}], [selection])
|
||||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
|
||||||
lines.push(`# Образ: ${c.image}:${c.tag}`)
|
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
|
||||||
lines.push(`# RouterOS 7.4+ · /container`)
|
beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
|
||||||
lines.push(``)
|
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
|
||||||
|
formats={formats} />
|
||||||
// interface
|
|
||||||
for (const iface of c.interfaces) {
|
|
||||||
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
|
|
||||||
}
|
|
||||||
lines.push(``)
|
|
||||||
|
|
||||||
// envs
|
|
||||||
if (c.envs.length > 0) {
|
|
||||||
lines.push(`/container/envs/add name=${c.name}-envs \\`)
|
|
||||||
for (const { key, value } of c.envs) {
|
|
||||||
lines.push(` ${key}="${value}" \\`)
|
|
||||||
}
|
|
||||||
lines.push(``)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mounts
|
|
||||||
for (const m of c.mounts) {
|
|
||||||
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
|
|
||||||
if (m.src) lines.push(` src=${m.src} \\`)
|
|
||||||
lines.push(` dst=${m.dst}`)
|
|
||||||
lines.push(``)
|
|
||||||
}
|
|
||||||
|
|
||||||
// container
|
|
||||||
lines.push(`/container/add \\`)
|
|
||||||
lines.push(` remote-image=${c.image}:${c.tag} \\`)
|
|
||||||
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
|
|
||||||
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
|
|
||||||
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
|
|
||||||
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
|
|
||||||
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
|
|
||||||
if (c.comment) lines.push(` comment="${c.comment}" \\`)
|
|
||||||
lines.push(` logging=yes`)
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ExportSheet({ open, container, onClose }: {
|
|
||||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
|
||||||
}) {
|
|
||||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CodeExportSheet
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title="Экспорт Container"
|
|
||||||
description="RouterOS 7.4+ · /container · /interface/veth"
|
|
||||||
formats={[
|
|
||||||
{
|
|
||||||
id: "rsc",
|
|
||||||
label: "MikroTik .rsc",
|
|
||||||
filename: `${container?.name ?? "container"}.rsc`,
|
|
||||||
code,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Container card ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ContainerCard({
|
function ContainerCard({
|
||||||
container,
|
container,
|
||||||
|
server,
|
||||||
|
live,
|
||||||
|
busy,
|
||||||
onExport,
|
onExport,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onRestart,
|
||||||
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
container: RouterContainer
|
container: ContainerView
|
||||||
|
server?: ContainerServer
|
||||||
|
live: boolean
|
||||||
|
busy: boolean
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onStart: () => void
|
||||||
|
onStop: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const srv = serverFor(container.serverId)
|
|
||||||
const cfg = statusConfig(container.status)
|
const cfg = statusConfig(container.status)
|
||||||
|
const canMutate = live && Boolean(container.rosId) && !busy
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense className="w-full overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
@@ -150,51 +141,56 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={
|
<DropdownMenuTrigger render={
|
||||||
<Button variant="ghost" size="icon" className="size-7 shrink-0">
|
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
|
||||||
<MoreHorizontalIcon className="size-4" />
|
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
} />
|
||||||
<DropdownMenuContent side="bottom" align="end">
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
{container.status === "running" ? (
|
{container.status === "running" ? (
|
||||||
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
||||||
|
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
|
||||||
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
|
||||||
|
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
<DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
|
||||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
<DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
<DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
|
||||||
|
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
||||||
|
<Trash2Icon className="size-4" />Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-3 flex flex-col gap-3">
|
<div className="px-4 py-3 flex flex-col gap-3">
|
||||||
{/* image */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
<BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<span className="font-mono text-xs text-foreground/80">
|
<span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
|
||||||
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
|
{container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* server */}
|
{server && (
|
||||||
{srv && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<ServerIcon className="size-3.5 shrink-0" />
|
<ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
<Flag code={srv.country} size={12} />
|
<Flag code={server.country} size={12} />
|
||||||
<span className="font-mono">{srv.name}</span>
|
<span className="font-mono truncate" title={server.name}>{server.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* uptime + stats */}
|
{container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
|
||||||
{container.status === "running" && (
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||||||
{container.uptime && (
|
{container.uptime && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ActivityIcon className="size-3 text-emerald-500" />
|
<ActivityIcon aria-hidden="true" className="size-3 text-success" />
|
||||||
<span>{container.uptime}</span>
|
<span>{container.uptime}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -202,7 +198,7 @@ function ContainerCard({
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">CPU </span>
|
<span className="text-muted-foreground">CPU </span>
|
||||||
<span className={cn("font-mono font-medium",
|
<span className={cn("font-mono font-medium",
|
||||||
container.cpu > 50 ? "text-amber-500" : "text-foreground")}>
|
container.cpu > 50 ? "text-warning" : "text-foreground")}>
|
||||||
{container.cpu}%
|
{container.cpu}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,13 +206,12 @@ function ContainerCard({
|
|||||||
{container.memMb !== undefined && (
|
{container.memMb !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">RAM </span>
|
<span className="text-muted-foreground">RAM </span>
|
||||||
<span className="font-mono font-medium">{container.memMb} МБ</span>
|
<span className="font-mono font-medium">{container.memMb} МиБ</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* interfaces */}
|
|
||||||
{container.interfaces.length > 0 && (
|
{container.interfaces.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{container.interfaces.map((i) => (
|
{container.interfaces.map((i) => (
|
||||||
@@ -227,7 +222,6 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* mounts */}
|
|
||||||
{container.mounts.length > 0 && (
|
{container.mounts.length > 0 && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{container.mounts.map((m, idx) => (
|
{container.mounts.map((m, idx) => (
|
||||||
@@ -241,7 +235,7 @@ function ContainerCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{container.comment && (
|
{container.comment && (
|
||||||
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
|
<p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
@@ -249,42 +243,225 @@ function ContainerCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
|
||||||
export default function ContainersPage() {
|
function ContainersSource() {
|
||||||
const [search, setSearch] = useState("")
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
|
||||||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
|
||||||
|
}
|
||||||
|
function ContainersContent() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
const canRead = can("mm:dashboard:read")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
const canWrite = can("mm:servers:write")
|
||||||
|
const params = useSearchParams()
|
||||||
|
const context = readContainersUrl(params)
|
||||||
|
const search = context.search
|
||||||
|
const statusFilter = context.status
|
||||||
|
const selectedServerId = context.server
|
||||||
|
const urlError = context.error
|
||||||
|
function writeContext(changes: Record<string, string | null>, replace = false) {
|
||||||
|
const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||||
|
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
||||||
|
}
|
||||||
|
const setSearch = (value: string) => writeContext({ q: value }, true)
|
||||||
|
const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
||||||
|
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
||||||
|
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
|
||||||
|
const [state, setState] = useState<ContainersState | null>(null)
|
||||||
|
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
|
||||||
|
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
||||||
|
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
|
||||||
|
const deleteReturnLabel = useRef<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteTarget || busyId || !deleteReturnLabel.current) return
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
|
||||||
|
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [deleteTarget, busyId])
|
||||||
|
const [operationError, setOperationError] = useState<string | null>(null)
|
||||||
|
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||||
|
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
|
||||||
|
const mutationLock = useRef(false)
|
||||||
|
const mutationController = useRef<AbortController | null>(null)
|
||||||
|
const alive = useRef(true)
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
const [serversError, setServersError] = useState<string | null>(null)
|
||||||
|
const [readAt, setReadAt] = useState<number | null>(null)
|
||||||
|
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
||||||
|
const loadController = useRef<AbortController | null>(null)
|
||||||
|
const loadRevision = useRef(0)
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive) return false
|
||||||
|
loadController.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
loadController.current = controller
|
||||||
|
const revision = ++loadRevision.current
|
||||||
|
const current = () => !controller.signal.aborted && loadRevision.current === revision
|
||||||
|
setLoading(true)
|
||||||
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||||
|
let stateComplete = !canRead
|
||||||
|
const tasks = await Promise.allSettled([
|
||||||
|
canRead ? readContainersState(backendUrl, { signal }).then(result => {
|
||||||
|
stateComplete = result.servers.every(node => node.availability !== "error")
|
||||||
|
if (current()) {
|
||||||
|
setState(result); setReadAt(Date.now()); setLiveError(null)
|
||||||
|
if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
|
||||||
|
setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
|
||||||
|
}
|
||||||
|
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||||
|
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
||||||
|
const result = serverListSchema.parse(payload)
|
||||||
|
if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
|
||||||
|
if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
|
||||||
|
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||||
|
])
|
||||||
|
if (current()) setLoading(false)
|
||||||
|
return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
|
||||||
|
}, [isLive, backendUrl, canRead, canReadServers])
|
||||||
|
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
|
||||||
|
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
|
||||||
|
const effectiveServerId = selectedServerId
|
||||||
|
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
|
||||||
|
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
|
||||||
|
const stale = isLive && !!liveError
|
||||||
|
const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
|
||||||
|
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
|
||||||
|
const scoped = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||||
|
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
||||||
|
}, [displayContainers, effectiveServerId])
|
||||||
|
|
||||||
|
const serverById = useMemo(
|
||||||
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
|
[displayServers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status ?? "unknown",
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length),
|
||||||
|
}))
|
||||||
|
), [displayServers, displayContainers, state, isLive])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return routerContainers.filter((c) => {
|
return scoped.filter((c) => {
|
||||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return (
|
return (
|
||||||
c.name.toLowerCase().includes(q) ||
|
c.name.toLowerCase().includes(q) ||
|
||||||
c.image.toLowerCase().includes(q) ||
|
c.image.toLowerCase().includes(q) ||
|
||||||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
|
(serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [search, statusFilter])
|
}, [search, statusFilter, scoped, serverById])
|
||||||
|
|
||||||
const running = routerContainers.filter((c) => c.status === "running").length
|
const running = scoped.filter((c) => c.status === "running").length
|
||||||
const stopped = routerContainers.filter((c) => c.status === "stopped").length
|
const stopped = scoped.filter((c) => c.status === "stopped").length
|
||||||
const errors = routerContainers.filter((c) => c.status === "error").length
|
const errors = scoped.filter((c) => c.status === "error").length
|
||||||
|
const statusKnown = known && scoped.every(container => container.status !== "unknown")
|
||||||
|
const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined
|
||||||
|
const deleteStopped = currentDeleteTarget?.status === "stopped"
|
||||||
|
|
||||||
|
async function mutate(c: ContainerView, action: ContainerAction) {
|
||||||
|
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return
|
||||||
|
if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return
|
||||||
|
mutationLock.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
mutationController.current = controller
|
||||||
|
const current = () => alive.current && !controller.signal.aborted
|
||||||
|
setBusyId(c.id); setOperationError(null); setOperationNotice(null)
|
||||||
|
try {
|
||||||
|
await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||||
|
if (!current()) return
|
||||||
|
const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
|
||||||
|
const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
|
||||||
|
setOperationNotice(message)
|
||||||
|
if (action === "remove") {
|
||||||
|
setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
toast.success("Команда подтверждена API.")
|
||||||
|
const refreshed = await loadLive()
|
||||||
|
if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
|
||||||
|
} catch (error) {
|
||||||
|
if (!current()) return
|
||||||
|
const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
|
||||||
|
setCommandNeedsRead(uncertain)
|
||||||
|
setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
|
||||||
|
} finally {
|
||||||
|
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||||
|
if (alive.current) setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
items={railItems}
|
||||||
actions={
|
selectedId={effectiveServerId}
|
||||||
<Button size="sm">
|
onSelect={setSelectedServerId}
|
||||||
<BoxIcon className="size-4" />Новый контейнер
|
showAll
|
||||||
</Button>
|
preserveSelection
|
||||||
}
|
allCount={displayServers.length}
|
||||||
/>
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void loadLive() }}
|
||||||
|
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
|
||||||
|
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
{urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
|
||||||
<div className="flex flex-col gap-5">
|
{operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
|
||||||
|
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||||
|
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
|
||||||
|
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
|
||||||
|
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
|
||||||
|
{isLive && serversReadAt && <p role="status" className="text-xs text-muted-foreground">Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}</p>}
|
||||||
|
{isLive && liveError && <Alert variant="warning" role="alert" aria-label="Каталог контейнеров"><AlertDescription>{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}</AlertDescription></Alert>}
|
||||||
|
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
|
||||||
|
{isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? <Alert key={node.serverId} variant="warning" role="alert" aria-label={"Контейнеры сервера " + node.serverId}><AlertDescription>Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."}</AlertDescription></Alert> : node.availability === "unsupported" ? <p key={node.serverId} role="status">Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.</p> : null)}
|
||||||
|
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
|
||||||
|
{mode === "mock" && (
|
||||||
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
|
Моковые данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка контейнеров"
|
aria-label="Сводка контейнеров"
|
||||||
@@ -292,97 +469,100 @@ export default function ContainersPage() {
|
|||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего",
|
label: "Всего",
|
||||||
value: routerContainers.length,
|
value: known ? scoped.length : "—",
|
||||||
icon: <BoxIcon className="size-4" />,
|
icon: <BoxIcon aria-hidden="true" className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "running",
|
id: "running",
|
||||||
label: "Running",
|
label: "Работает",
|
||||||
value: running,
|
value: statusKnown ? running : "—",
|
||||||
icon: <PlayIcon className="size-4" />,
|
icon: <PlayIcon aria-hidden="true" className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "stopped",
|
id: "stopped",
|
||||||
label: "Stopped",
|
label: "Остановлен",
|
||||||
value: stopped,
|
value: statusKnown ? stopped : "—",
|
||||||
icon: <StopCircleIcon className="size-4" />,
|
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "errors",
|
id: "errors",
|
||||||
label: "Ошибок",
|
label: "Ошибок",
|
||||||
value: errors,
|
value: statusKnown ? errors : "—",
|
||||||
icon: <AlertCircleIcon className="size-4" />,
|
icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
|
||||||
iconClassName: "text-destructive",
|
iconClassName: "text-destructive",
|
||||||
variant: errors > 0 ? "destructive" : "default",
|
variant: errors > 0 ? "destructive" : "default",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Info banner */}
|
<div className="flex items-start gap-3 rounded-lg bg-info/5 border border-info/20 px-4 py-3 text-sm">
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
<BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
|
||||||
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p>
|
<p className="font-medium text-foreground">Контейнеры доступны с RouterOS 7.4+</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
||||||
Интерфейсы veth создаются автоматически.
|
Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-0 w-full sm:w-auto">
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
<SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<input
|
<input
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||||
placeholder="Поиск по имени, образу, серверу…"
|
aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
<div role="group" aria-label="Статус контейнеров" className="flex flex-wrap items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||||
{(["all", "running", "stopped", "error"] as const).map((s) => (
|
{(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
|
||||||
<button key={s}
|
<button key={s}
|
||||||
onClick={() => setStatusFilter(s)}
|
type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||||||
statusFilter === s
|
statusFilter === s
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}>
|
)}>
|
||||||
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
|
{s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
<span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||||
<BoxIcon className="size-10 mb-3 opacity-20" />
|
<BoxIcon aria-hidden="true" className="size-10 mb-3 opacity-20" />
|
||||||
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
||||||
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
|
<p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filtered.map((c) => (
|
{filtered.map((c) => (
|
||||||
<ContainerCard
|
<ContainerCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
container={c}
|
container={c}
|
||||||
onExport={() => setExportContainer(c)}
|
server={serverById[c.serverId]}
|
||||||
|
live={isLive && !!nodeFresh(c.serverId)}
|
||||||
|
busy={!!busyId}
|
||||||
|
onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
|
||||||
|
onStart={() => { void mutate(c, "start") }}
|
||||||
|
onStop={() => { void mutate(c, "stop") }}
|
||||||
|
onRestart={() => { void mutate(c, "restart") }}
|
||||||
|
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
|
||||||
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
@@ -433,7 +613,7 @@ export default function ContainersPage() {
|
|||||||
].map((b) => (
|
].map((b) => (
|
||||||
<div key={b.title}>
|
<div key={b.title}>
|
||||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
<pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||||
{b.lines.join("\n")}
|
{b.lines.join("\n")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,13 +622,20 @@ export default function ContainersPage() {
|
|||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<ExportSheet
|
{deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
|
||||||
open={!!exportContainer}
|
<AlertDialogContent>
|
||||||
container={exportContainer}
|
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
|
||||||
onClose={() => setExportContainer(null)}
|
{operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
|
||||||
/>
|
<AlertDialogFooter>
|
||||||
</div>
|
{(commandNeedsRead || !deleteStopped) && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
|
||||||
|
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !deleteStopped || !nodeFresh(deleteTarget.serverId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>}
|
||||||
|
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+206
-198
@@ -1,5 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
@@ -24,9 +26,9 @@ import { DashboardEventsTimeline } from "@/components/dashboard/dashboard-events
|
|||||||
import { InternetPathPanel } from "@/components/dashboard/internet-path-panel"
|
import { InternetPathPanel } from "@/components/dashboard/internet-path-panel"
|
||||||
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
import { DashboardDataStatus } from "@/components/dashboard/dashboard-data-status"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { fmtRate } from "@/lib/fmt-rate"
|
import { fmtRate } from "@/lib/fmt-rate"
|
||||||
import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live"
|
import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live"
|
||||||
@@ -36,31 +38,44 @@ function fmtIntRu(n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AttentionRows({ rows }: { rows: AttentionRow[] }) {
|
function AttentionRows({ rows }: { rows: AttentionRow[] }) {
|
||||||
|
const [expanded,setExpanded]=useState(false)
|
||||||
|
const [visible,setVisible]=useState(55)
|
||||||
|
function renderRow(row: AttentionRow) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={row.id}
|
||||||
|
href={row.href}
|
||||||
|
className="hover:bg-muted/40 focus-visible:ring-ring flex min-w-0 items-start justify-between gap-2 rounded-md px-1 py-1.5 focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 wrap-anywhere text-sm font-medium">{row.title}</span>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant={row.tone === "warning" ? "warning-light" : "destructive-light"}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{row.hint}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{rows.map((row) => (
|
{rows.slice(0, 5).map(renderRow)}
|
||||||
<Link
|
{rows.length > 5 && (
|
||||||
key={row.id}
|
<details onToggle={event=>{setExpanded(event.currentTarget.open);if(!event.currentTarget.open)setVisible(55)}}>
|
||||||
href={row.href}
|
<summary className="cursor-pointer rounded-md px-1 py-2 text-xs text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring">Ещё {rows.length - 5}</summary>
|
||||||
className="hover:bg-muted/40 flex min-w-0 items-center justify-between gap-2 rounded-md px-1 py-1.5"
|
{expanded && rows.slice(5,visible).map(renderRow)}
|
||||||
>
|
{expanded && rows.length>visible && <Button type="button" variant="ghost" size="sm" onClick={()=>setVisible(count=>Math.min(count+50,rows.length))}>Показать ещё {Math.min(50,rows.length-visible)}</Button>}
|
||||||
<span className="truncate text-sm font-medium">{row.title}</span>
|
</details>
|
||||||
<Badge
|
)}
|
||||||
size="sm"
|
|
||||||
variant={row.tone === "warning" ? "warning-light" : "destructive-light"}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{row.hint}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const dash = useDashboardLive()
|
const dash = useDashboardLive()
|
||||||
const offline = Math.max(0, dash.totalServers - dash.onlineCount)
|
const offline = dash.offlineCount
|
||||||
const health = dash.probeDown + dash.probeWarn
|
const health = dash.probeDown + dash.probeWarn
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,17 +84,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
<div className="flex flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
{dash.error && !dash.loading ? (
|
{dash.isLive && <DashboardDataStatus sources={dash.sources} refreshing={dash.refreshing} onRefresh={dash.retry} />}
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Не удалось обновить дашборд</AlertTitle>
|
|
||||||
<AlertDescription className="flex flex-wrap items-center gap-2">
|
|
||||||
<span>{dash.error}</span>
|
|
||||||
<Button variant="outline" size="sm" onClick={dash.retry}>
|
|
||||||
Повторить
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка дашборда"
|
aria-label="Сводка дашборда"
|
||||||
@@ -87,25 +92,25 @@ export default function DashboardPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "fleet",
|
id: "fleet",
|
||||||
label: "Флот",
|
label: "Серверы онлайн",
|
||||||
value: `${dash.onlineCount} / ${dash.totalServers}`,
|
value: dash.availability.servers ? `${dash.onlineCount} / ${dash.totalServers}` : "—",
|
||||||
hint: !dash.isLive
|
hint: !dash.availability.servers ? "нет данных" : !dash.isLive
|
||||||
? "демо"
|
? "демо"
|
||||||
: offline > 0
|
: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0
|
||||||
? `${offline} offline`
|
? [offline > 0 ? `${offline} не в сети` : null,dash.unknownServerCount > 0 ? `Статус неизвестен: ${dash.unknownServerCount}` : null,dash.degradedServerCount > 0 ? `Ухудшение: ${dash.degradedServerCount}` : null].filter(Boolean).join(" · ")
|
||||||
: dash.totalServers > 0
|
: dash.totalServers > 0
|
||||||
? "все online"
|
? "все в сети"
|
||||||
: "нет узлов",
|
: "нет серверов",
|
||||||
href: "/servers",
|
href: "/servers",
|
||||||
icon: <ServerIcon />,
|
icon: <ServerIcon />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
variant: offline > 0 ? "warning" : "default",
|
variant: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0 ? "warning" : "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "overlay",
|
id: "overlay",
|
||||||
label: "Оверлей",
|
label: "Туннели работают",
|
||||||
value: String(dash.overlay.up),
|
value: dash.availability.overlay ? String(dash.overlay.up) : "—",
|
||||||
hint: dash.overlay.total > 0 ? `${dash.overlay.down} down` : "нет туннелей",
|
hint: !dash.availability.overlay ? "не все источники доступны" : !dash.reliable.overlay ? "Последний успешный снимок; не все источники обновлены" : dash.overlay.total > 0 ? `Не работают: ${dash.overlay.down}` : "нет туннелей",
|
||||||
href: "/gre",
|
href: "/gre",
|
||||||
icon: <CableIcon />,
|
icon: <CableIcon />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
@@ -113,18 +118,18 @@ export default function DashboardPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bgp",
|
id: "bgp",
|
||||||
label: "BGP Rx",
|
label: "Префиксы BGP",
|
||||||
value: dash.bgp ? fmtIntRu(dash.bgp.prefixSum) : "—",
|
value: dash.bgp?.prefixSum != null ? fmtIntRu(dash.bgp.prefixSum) : "—",
|
||||||
hint: dash.bgp ? `${dash.bgp.establishedCount} Established` : "нет данных",
|
hint: dash.bgp ? `Сессий установлено: ${dash.bgp.establishedCount ?? "неизвестно"}${dash.bgp.prefixSum === null ? " · префиксы неизвестны" : ""}` : "нет данных",
|
||||||
href: "/bgp",
|
href: "/bgp",
|
||||||
icon: <GitMergeIcon />,
|
icon: <GitMergeIcon />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "health",
|
id: "health",
|
||||||
label: "Здоровье",
|
label: "Проверки с проблемами",
|
||||||
value: String(health),
|
value: dash.availability.probes && dash.unknownProbeCount===0 ? String(health) : "—",
|
||||||
hint: `${dash.probeDown} down · ${dash.probeWarn} warn`,
|
hint: dash.availability.probes ? `${dash.probeDown} ошибок · ${dash.probeWarn} предупреждений${dash.unknownProbeCount>0?` · неизвестны: ${dash.unknownProbeCount}`:""}` : "нет данных",
|
||||||
href: "/uptime",
|
href: "/uptime",
|
||||||
icon: <BellIcon />,
|
icon: <BellIcon />,
|
||||||
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
|
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
@@ -133,13 +138,165 @@ export default function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section aria-labelledby="dashboard-attention-title" className="flex min-w-0 flex-col gap-2">
|
||||||
|
<h2 id="dashboard-attention-title" className="text-sm font-semibold">Требует внимания</h2>
|
||||||
|
<AttentionQueue
|
||||||
|
className="lg:grid-cols-3"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
id: "nodes",
|
||||||
|
title: "Серверы",
|
||||||
|
icon: ServerIcon,
|
||||||
|
count: dash.attentionServers.length,
|
||||||
|
countVariant: "warning-light",
|
||||||
|
emptyTitle: dash.reliable.servers ? (dash.totalServers > 0 ? "Серверы без проблем" : "Нет серверов") : "Состояние не подтверждено",
|
||||||
|
emptyDescription: dash.reliable.servers ? "Нет включённых серверов с ошибками доступности." : "Проверьте состояние источников выше.",
|
||||||
|
children: <AttentionRows rows={dash.attentionServers} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "overlay",
|
||||||
|
title: "Туннели",
|
||||||
|
icon: CableIcon,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
count: dash.attentionOverlay.length,
|
||||||
|
countVariant: "destructive-light",
|
||||||
|
emptyTitle: dash.reliable.overlay ? (dash.overlay.total > 0 ? "Туннели работают" : "Нет туннелей") : "Состояние не подтверждено",
|
||||||
|
emptyDescription: dash.reliable.overlay ? "В GRE, WireGuard и VXLAN проблем не обнаружено." : "Проверьте состояние источников выше.",
|
||||||
|
children: <AttentionRows rows={dash.attentionOverlay} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "probes",
|
||||||
|
title: "Проверки",
|
||||||
|
icon: HeartPulseIcon,
|
||||||
|
iconClassName: "text-destructive",
|
||||||
|
count: dash.attentionProbes.length,
|
||||||
|
countVariant: "destructive-light",
|
||||||
|
emptyTitle: dash.reliable.probes ? (dash.probeCount > 0 ? "Проверки без ошибок" : "Нет активных проверок") : "Состояние не подтверждено",
|
||||||
|
emptyDescription: dash.reliable.probes ? "Нет ошибок и предупреждений активных проверок." : "Проверьте состояние источников выше.",
|
||||||
|
children: <AttentionRows rows={dash.attentionProbes} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<OpsPanel
|
||||||
|
title="Задержка до серверов"
|
||||||
|
description={
|
||||||
|
dash.latency.kind === "mock" || dash.latency.kind === "live"
|
||||||
|
? dash.latency.subtitle
|
||||||
|
: dash.latency.kind === "loading"
|
||||||
|
? "Загрузка…"
|
||||||
|
: "Нет серии RTT"
|
||||||
|
}
|
||||||
|
contentClassName="px-3 pb-3"
|
||||||
|
>
|
||||||
|
{dash.latency.kind === "loading" && (
|
||||||
|
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
||||||
|
)}
|
||||||
|
{dash.latency.kind === "empty" && (
|
||||||
|
<p className="text-muted-foreground px-4 py-10 text-center text-sm">{dash.latency.message}</p>
|
||||||
|
)}
|
||||||
|
{(dash.latency.kind === "mock" || dash.latency.kind === "live") && (
|
||||||
|
<LatencyChart series={dash.latency.series} labels={dash.latency.labels} />
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<OpsPanel
|
||||||
|
title="Последние события"
|
||||||
|
description="Система и BGP"
|
||||||
|
headerRight={
|
||||||
|
<Link href="/alerts" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 text-xs")}>
|
||||||
|
Все →
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DashboardEventsTimeline
|
||||||
|
events={dash.recentEvents}
|
||||||
|
loading={dash.eventsLoading}
|
||||||
|
error={dash.eventsError}
|
||||||
|
/>
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<OpsPanel
|
||||||
|
title="Пропускная способность"
|
||||||
|
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарная история RX / TX · 1 ч; значения — последний измеренный снимок"}
|
||||||
|
headerRight={<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>Трафик →</Link>}
|
||||||
|
contentClassName="px-3 pb-3"
|
||||||
|
>
|
||||||
|
{dash.loading && !dash.traffic ? (
|
||||||
|
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
||||||
|
) : !dash.traffic ? (
|
||||||
|
<p className="text-muted-foreground px-4 py-10 text-center text-sm">
|
||||||
|
Нет данных трафика за последний час.{" "}
|
||||||
|
<Link href="/traffic" className="underline underline-offset-2">
|
||||||
|
Открыть трафик
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 px-2 py-3 text-xs">
|
||||||
|
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : !dash.traffic.observationsKnown ? <Badge variant="outline" size="sm">Измерения не подтверждены API</Badge> : null}
|
||||||
|
<span className="tabular-nums">RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}</span>
|
||||||
|
<span className="tabular-nums">TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)}</span>
|
||||||
|
</div>
|
||||||
|
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InternetPathPanel
|
||||||
|
model={dash.internetPath}
|
||||||
|
loading={dash.internetPathLoading}
|
||||||
|
error={dash.isLive ? dash.internetPathError : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OpsPanel
|
||||||
|
title="Активные пробы"
|
||||||
|
description={
|
||||||
|
<span className={dash.error && dash.isLive ? "text-destructive" : undefined}>{dash.probesSubtitle}</span>
|
||||||
|
}
|
||||||
|
headerRight={
|
||||||
|
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
||||||
|
Мониторинг →
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
contentClassName="px-0 pb-0"
|
||||||
|
>
|
||||||
|
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
|
||||||
|
<EmptyState
|
||||||
|
className="py-10"
|
||||||
|
title={dash.availability.probes ? "Нет проб на дашборде" : "Данные проб недоступны"}
|
||||||
|
description={dash.availability.probes ? "Включите пробу и отметьте ★ на странице мониторинга." : "Повторите обновление данных."}
|
||||||
|
action={
|
||||||
|
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
Открыть мониторинг
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DashboardActiveProbesDataGrid
|
||||||
|
probes={dash.activeProbes}
|
||||||
|
catalog={dash.servers}
|
||||||
|
isLoading={dash.probesLoading}
|
||||||
|
emptyDescription="Нет проб с звездой на дашборде."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
<QuickActionGrid
|
<QuickActionGrid
|
||||||
description="Разделы операционного контура"
|
title="Быстрые переходы"
|
||||||
|
description="Другие разделы управления сетью"
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
id: "traffic",
|
id: "traffic",
|
||||||
title: "Трафик",
|
title: "Трафик",
|
||||||
description: "RX/TX флота и IPFIX",
|
description: "Приём, передача и потоки IPFIX",
|
||||||
href: "/traffic",
|
href: "/traffic",
|
||||||
icon: <ActivityIcon />,
|
icon: <ActivityIcon />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
@@ -187,155 +344,6 @@ export default function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
|
|
||||||
<OpsPanel
|
|
||||||
title="Задержка до серверов"
|
|
||||||
description={
|
|
||||||
dash.latency.kind === "mock" || dash.latency.kind === "live"
|
|
||||||
? dash.latency.subtitle
|
|
||||||
: dash.latency.kind === "loading"
|
|
||||||
? "Загрузка…"
|
|
||||||
: "Нет серии RTT"
|
|
||||||
}
|
|
||||||
contentClassName="px-3 pb-3"
|
|
||||||
>
|
|
||||||
{dash.latency.kind === "loading" && (
|
|
||||||
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
|
||||||
)}
|
|
||||||
{dash.latency.kind === "empty" && (
|
|
||||||
<p className="text-muted-foreground px-4 py-10 text-center text-sm">{dash.latency.message}</p>
|
|
||||||
)}
|
|
||||||
{(dash.latency.kind === "mock" || dash.latency.kind === "live") && (
|
|
||||||
<LatencyChart series={dash.latency.series} labels={dash.latency.labels} />
|
|
||||||
)}
|
|
||||||
</OpsPanel>
|
|
||||||
|
|
||||||
<OpsPanel
|
|
||||||
title="Последние события"
|
|
||||||
description="Система и BGP"
|
|
||||||
headerRight={
|
|
||||||
<Link href="/alerts" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 text-xs")}>
|
|
||||||
Все →
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardEventsTimeline
|
|
||||||
events={dash.recentEvents}
|
|
||||||
loading={dash.eventsLoading}
|
|
||||||
error={dash.eventsError}
|
|
||||||
/>
|
|
||||||
</OpsPanel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[3fr_2fr]">
|
|
||||||
<OpsPanel
|
|
||||||
title="Пропускная способность"
|
|
||||||
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарный RX / TX флота · 1 ч"}
|
|
||||||
headerRight={
|
|
||||||
dash.traffic ? (
|
|
||||||
<div className="flex items-center gap-3 text-xs">
|
|
||||||
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : null}
|
|
||||||
<span className="tabular-nums">RX {fmtRate(dash.traffic.rxNow)}</span>
|
|
||||||
<span className="tabular-nums">TX {fmtRate(dash.traffic.txNow)}</span>
|
|
||||||
<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
|
||||||
Трафик →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
contentClassName="px-3 pb-3"
|
|
||||||
>
|
|
||||||
{dash.loading && !dash.traffic ? (
|
|
||||||
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
|
||||||
) : !dash.traffic ? (
|
|
||||||
<p className="text-muted-foreground px-4 py-10 text-center text-sm">
|
|
||||||
Нет данных трафика за последний час.{" "}
|
|
||||||
<Link href="/traffic" className="underline underline-offset-2">
|
|
||||||
Открыть трафик
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
|
|
||||||
)}
|
|
||||||
</OpsPanel>
|
|
||||||
|
|
||||||
<AttentionQueue
|
|
||||||
className="@3xl:grid-cols-1"
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
id: "nodes",
|
|
||||||
title: "Узлы",
|
|
||||||
icon: ServerIcon,
|
|
||||||
count: dash.attentionServers.length,
|
|
||||||
countVariant: "warning-light",
|
|
||||||
emptyTitle: "Все online",
|
|
||||||
emptyDescription: "Нет офлайн или degraded узлов.",
|
|
||||||
children: <AttentionRows rows={dash.attentionServers} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "overlay",
|
|
||||||
title: "Оверлей",
|
|
||||||
icon: CableIcon,
|
|
||||||
iconClassName: "text-info",
|
|
||||||
count: dash.attentionOverlay.length,
|
|
||||||
countVariant: "destructive-light",
|
|
||||||
emptyTitle: "Туннели up",
|
|
||||||
emptyDescription: "GRE и WireGuard в норме.",
|
|
||||||
children: <AttentionRows rows={dash.attentionOverlay} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "probes",
|
|
||||||
title: "Пробы",
|
|
||||||
icon: HeartPulseIcon,
|
|
||||||
iconClassName: "text-destructive",
|
|
||||||
count: dash.attentionProbes.length,
|
|
||||||
countVariant: "destructive-light",
|
|
||||||
emptyTitle: "Пробы up",
|
|
||||||
emptyDescription: "Нет down или warn.",
|
|
||||||
children: <AttentionRows rows={dash.attentionProbes} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InternetPathPanel
|
|
||||||
model={dash.internetPath}
|
|
||||||
loading={dash.internetPathLoading}
|
|
||||||
error={dash.isLive ? dash.internetPathError : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OpsPanel
|
|
||||||
title="Активные пробы"
|
|
||||||
description={
|
|
||||||
<span className={dash.error && dash.isLive ? "text-destructive" : undefined}>{dash.probesSubtitle}</span>
|
|
||||||
}
|
|
||||||
headerRight={
|
|
||||||
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
|
||||||
Мониторинг →
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
contentClassName="px-0 pb-0"
|
|
||||||
>
|
|
||||||
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
|
|
||||||
<EmptyState
|
|
||||||
className="py-10"
|
|
||||||
title="Нет проб на дашборде"
|
|
||||||
description="Включите пробу и отметьте ★ на странице мониторинга."
|
|
||||||
action={
|
|
||||||
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
|
||||||
Открыть мониторинг
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DashboardActiveProbesDataGrid
|
|
||||||
probes={dash.activeProbes}
|
|
||||||
catalog={dash.servers}
|
|
||||||
isLoading={dash.probesLoading}
|
|
||||||
emptyDescription="Нет проб с звездой на дашборде."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</OpsPanel>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+307
-434
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
@@ -21,12 +20,15 @@ import {
|
|||||||
SCHEDULER_JOB_DESCRIPTIONS,
|
SCHEDULER_JOB_DESCRIPTIONS,
|
||||||
SCHEDULER_JOB_KEYS,
|
SCHEDULER_JOB_KEYS,
|
||||||
SCHEDULER_JOB_LABELS,
|
SCHEDULER_JOB_LABELS,
|
||||||
type CollectorSettingsDto,
|
|
||||||
type SchedulerJobStatusDto,
|
type SchedulerJobStatusDto,
|
||||||
|
type SchedulerStatusDto,
|
||||||
type SchedulerRunRowDto,
|
type SchedulerRunRowDto,
|
||||||
type UptimeSettingsDto,
|
|
||||||
} from "@/lib/scheduler-settings"
|
} from "@/lib/scheduler-settings"
|
||||||
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings, putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "@/shared/api/collector-settings"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { getCertificateRenewSettings, putCertificateRenewSettings } from "@/shared/api/certificates"
|
||||||
|
import { getSchedulerStatus, listSchedulerRuns, runSchedulerJobNow, refreshScheduler } from "@/shared/api/scheduler"
|
||||||
|
import { ApiClientError } from "@/shared/api/http-client"
|
||||||
import {
|
import {
|
||||||
parseSchedulerRunSnapshot,
|
parseSchedulerRunSnapshot,
|
||||||
type AlertEngineRunSnapshot,
|
type AlertEngineRunSnapshot,
|
||||||
@@ -56,6 +58,7 @@ import {
|
|||||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||||
|
import { LogsCollectorSettings } from "@/components/logs/logs-collector-settings"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
@@ -68,33 +71,13 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
|
||||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
return requestJson<T>(backendUrl, path, init)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractApiError(reason: unknown): string {
|
function extractApiError(reason: unknown): string {
|
||||||
if (reason instanceof ApiClientError) return reason.message
|
if (reason instanceof ApiClientError) return reason.message
|
||||||
|
if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
|
||||||
if (reason instanceof Error) return reason.message
|
if (reason instanceof Error) return reason.message
|
||||||
return String(reason)
|
return String(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
|
|
||||||
return result.status === "fulfilled" ? result.value : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
|
|
||||||
const errors: string[] = []
|
|
||||||
for (let i = 0; i < results.length; i += 1) {
|
|
||||||
const result = results[i]
|
|
||||||
if (result.status === "rejected") {
|
|
||||||
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtMs(ms: number): string {
|
function fmtMs(ms: number): string {
|
||||||
if (ms < 1000) return `${ms} мс`
|
if (ms < 1000) return `${ms} мс`
|
||||||
const s = ms / 1000
|
const s = ms / 1000
|
||||||
@@ -102,6 +85,7 @@ function fmtMs(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||||
|
if (snap.job === "router_logs") return <div className="flex flex-col gap-2 text-sm">{snap.skipped && <p>Сбор логов выключен или приостановлен.</p>}{snap.servers.map(server => <p key={server.serverId}>{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}</p>)}</div>
|
||||||
if (snap.job === "traffic") {
|
if (snap.job === "traffic") {
|
||||||
const t = snap as TrafficRunSnapshot
|
const t = snap as TrafficRunSnapshot
|
||||||
return (
|
return (
|
||||||
@@ -432,7 +416,11 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||||
const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey
|
const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey
|
||||||
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
||||||
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
const snapshot = useMemo(() => {
|
||||||
|
const parsed = parseSchedulerRunSnapshot(r.resultJson)
|
||||||
|
return parsed?.job === r.jobKey ? parsed : null
|
||||||
|
}, [r.resultJson, r.jobKey])
|
||||||
|
const hasSnapshot = r.resultJson != null && (typeof r.resultJson !== "string" || r.resultJson.trim() !== "")
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 text-sm">
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||||
@@ -501,6 +489,12 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
||||||
{snapshot ? (
|
{snapshot ? (
|
||||||
<SnapshotTables snap={snapshot} />
|
<SnapshotTables snap={snapshot} />
|
||||||
|
) : hasSnapshot ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>Снимок недоступен</AlertTitle>
|
||||||
|
<AlertDescription>Сохранённые результаты имеют неподдерживаемый формат или относятся к другой задаче.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Снимок отсутствует (запись до обновления бекенда или задача без детализации).
|
Снимок отсутствует (запись до обновления бекенда или задача без детализации).
|
||||||
@@ -511,47 +505,34 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SchedulerToggleOverrides = {
|
const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } as const
|
||||||
trafficEnabled?: boolean
|
const SETTINGS_PERMISSIONS = { traffic: "mm:traffic:write", rest: "mm:servers:write", uptime: "mm:uptime:write", internet: "mm:network:write", cert: "mm:certificates:write" } as const
|
||||||
serversApiEnabled?: boolean
|
type SettingsKey = keyof typeof SETTINGS_LABELS
|
||||||
resourcesEnabled?: boolean
|
type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null }
|
||||||
pingEnabled?: boolean
|
const INITIAL_SETTINGS: Record<SettingsKey, SettingsRead> = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record<SettingsKey, SettingsRead>
|
||||||
speedEnabled?: boolean
|
|
||||||
internetPathEnabled?: boolean
|
|
||||||
certRenewEnabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySchedulerJobsToDrafts(
|
|
||||||
jobs: SchedulerJobStatusDto[],
|
|
||||||
setters: {
|
|
||||||
setDraftTrafficEnabled: (value: boolean) => void
|
|
||||||
setDraftServersApiEnabled: (value: boolean) => void
|
|
||||||
setDraftResourcesEnabled: (value: boolean) => void
|
|
||||||
setDraftPingEnabled: (value: boolean) => void
|
|
||||||
setDraftSpeedEnabled: (value: boolean) => void
|
|
||||||
setDraftInternetPathEnabled: (value: boolean) => void
|
|
||||||
setDraftCertRenewEnabled: (value: boolean) => void
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
|
|
||||||
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
|
|
||||||
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
|
|
||||||
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
|
|
||||||
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
|
|
||||||
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
|
|
||||||
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
|
|
||||||
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DataCollectionPage() {
|
export default function DataCollectionPage() {
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = prefsHydrated && mode === "live"
|
if (!prefsHydrated) return <p role="status" className="p-6 text-sm text-muted-foreground">Загрузка настроек источника…</p>
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
return <DataCollectionContent key={JSON.stringify([mode, backendUrl])} />
|
||||||
|
}
|
||||||
|
|
||||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
function DataCollectionContent() {
|
||||||
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
|
const isLive = prefsHydrated && mode === "live"
|
||||||
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
|
|
||||||
|
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
|
||||||
|
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
|
||||||
|
const [uptimeCollector, setUptimeCollector] = useState<Awaited<ReturnType<typeof getUptimeCollectorSettings>> | null>(null)
|
||||||
|
const [internetPathCollector, setInternetPathCollector] = useState<Awaited<ReturnType<typeof getInternetPathSettings>> | null>(null)
|
||||||
|
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
|
||||||
|
const dirtySettings = useRef(new Set<SettingsKey>())
|
||||||
|
const dirtyFields = useRef<Record<SettingsKey, Set<string>>>({ traffic: new Set(), rest: new Set(), uptime: new Set(), internet: new Set(), cert: new Set() })
|
||||||
|
const [dirtyCount, setDirtyCount] = useState(0)
|
||||||
|
const [saveMessage, setSaveMessage] = useState<string | null>(null)
|
||||||
|
const mutationLock = useRef(false)
|
||||||
|
const writable = (key: SettingsKey) => isLive && can(SETTINGS_PERMISSIONS[key]) && settingsReads[key].known && !settingsReads[key].error
|
||||||
|
const markDirty = (key: SettingsKey, field: string) => { dirtySettings.current.add(key); dirtyFields.current[key].add(field); setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0)) }
|
||||||
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
||||||
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
||||||
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
|
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
|
||||||
@@ -570,6 +551,12 @@ export default function DataCollectionPage() {
|
|||||||
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
|
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
|
||||||
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
|
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
|
||||||
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
||||||
|
const [schedulerStatus, setSchedulerStatus] = useState<SchedulerStatusDto | null>(null)
|
||||||
|
const [schedulerStatusError, setSchedulerStatusError] = useState<string | null>(null)
|
||||||
|
const [schedulerStatusReceivedAt, setSchedulerStatusReceivedAt] = useState<string | null>(null)
|
||||||
|
const [runsKnown, setRunsKnown] = useState(false)
|
||||||
|
const [runsError, setRunsError] = useState<string | null>(null)
|
||||||
|
const [runsReceivedAt, setRunsReceivedAt] = useState<string | null>(null)
|
||||||
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
||||||
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
||||||
const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
|
const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
|
||||||
@@ -577,6 +564,13 @@ export default function DataCollectionPage() {
|
|||||||
const [collectorError, setCollectorError] = useState<string | null>(null)
|
const [collectorError, setCollectorError] = useState<string | null>(null)
|
||||||
const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => new Set())
|
const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 })
|
||||||
|
useEffect(() => {
|
||||||
|
reader.current.active = true
|
||||||
|
const state = reader.current
|
||||||
|
return () => { state.active = false; state.revision++; state.controller?.abort() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleRunOpen = useCallback((id: string, open: boolean) => {
|
const toggleRunOpen = useCallback((id: string, open: boolean) => {
|
||||||
setOpenRunIds((prev) => {
|
setOpenRunIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -584,265 +578,190 @@ export default function DataCollectionPage() {
|
|||||||
else next.delete(id)
|
else next.delete(id)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [setOpenRunIds])
|
||||||
|
|
||||||
const schedulerJobsByKey = useMemo(() => {
|
const schedulerJobsByKey = useMemo(() => {
|
||||||
const jobs = uptimeCollector?.scheduler?.jobs ?? []
|
const jobs = schedulerStatus?.jobs ?? []
|
||||||
return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record<string, SchedulerJobStatusDto>
|
return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record<string, SchedulerJobStatusDto>
|
||||||
}, [uptimeCollector?.scheduler?.jobs])
|
}, [schedulerStatus?.jobs])
|
||||||
|
|
||||||
const loadCollectors = useCallback(async () => {
|
const loadCollectors = useCallback(async () => {
|
||||||
if (!isLive) return
|
if (!isLive || !reader.current.active || mutationLock.current) return
|
||||||
setCollectorError(null)
|
const revision = ++reader.current.revision
|
||||||
|
reader.current.controller?.abort()
|
||||||
|
const controller = new AbortController(); reader.current.controller = controller
|
||||||
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||||
|
const current = () => reader.current.active && revision === reader.current.revision && !controller.signal.aborted
|
||||||
setPageBusy(true)
|
setPageBusy(true)
|
||||||
try {
|
try {
|
||||||
const runsQuery =
|
const runsFilter = runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) ? runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number] : undefined
|
||||||
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
const runsRequest = listSchedulerRuns(backendUrl, { limit: 80, jobKey: runsFilter }, { signal }).then(data => {
|
||||||
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
if (current()) { setSchedulerRuns(data.runs); setRunsKnown(true); setRunsError(null); setRunsReceivedAt(new Date().toISOString()) }
|
||||||
: "?limit=80"
|
return data
|
||||||
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
|
}, error => { if (current()) setRunsError(extractApiError(error)); throw error })
|
||||||
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
const statusRequest = getSchedulerStatus(backendUrl, { signal }).then(data => {
|
||||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
|
||||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
return data
|
||||||
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
|
}, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error })
|
||||||
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
|
function publish<T>(key: SettingsKey, request: Promise<T>, accept: (data: T, updateDraft: boolean) => void) {
|
||||||
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
return request.then(data => {
|
||||||
|
if (current()) {
|
||||||
|
accept(data, !dirtySettings.current.has(key))
|
||||||
|
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
|
||||||
|
}
|
||||||
|
}, error => {
|
||||||
|
if (current()) setSettingsReads(prev => ({ ...prev, [key]: { ...prev[key], error: extractApiError(error) } }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await Promise.allSettled([
|
||||||
|
publish("traffic", getTrafficCollectorSettings(backendUrl, { signal }), (data, draft) => {
|
||||||
|
setTrafficCollector(data)
|
||||||
|
if (draft) { setTrafficIntervalDraft(String(data.intervalSec)); setTrafficRetentionDraft(String(data.retentionDays)); setDraftTrafficEnabled(data.enabled) }
|
||||||
|
}),
|
||||||
|
publish("rest", getServersApiPingSettings(backendUrl, { signal }), (data, draft) => {
|
||||||
|
setServersApiCollector(data)
|
||||||
|
if (draft) { setServersApiIntervalDraft(String(data.intervalSec)); setDraftServersApiEnabled(data.enabled) }
|
||||||
|
}),
|
||||||
|
publish("uptime", getUptimeCollectorSettings(backendUrl, { signal }), (data, draft) => {
|
||||||
|
setUptimeCollector(data)
|
||||||
|
if (draft) {
|
||||||
|
setUptimeResourceIntervalDraft(String(data.intervalSec)); setUptimeIntervalDraft(String(data.probeIntervalSec)); setUptimeSpeedIntervalDraft(String(data.speedIntervalSec)); setUptimeRetentionDraft(String(data.retentionDays))
|
||||||
|
setDraftResourcesEnabled(data.resourcesEnabled); setDraftPingEnabled(data.pingEnabled); setDraftSpeedEnabled(data.speedEnabled)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
publish("internet", getInternetPathSettings(backendUrl, { signal }), (data, draft) => {
|
||||||
|
setInternetPathCollector(data)
|
||||||
|
if (draft) { setInternetPathIntervalDraft(String(data.intervalSec)); setDraftInternetPathEnabled(data.enabled) }
|
||||||
|
}),
|
||||||
|
publish("cert", getCertificateRenewSettings(backendUrl, { signal }), (data, draft) => {
|
||||||
|
if (draft) { setCertRenewIntervalDraft(String(data.intervalSec)); setRenewBeforeDaysDraft(String(data.renewBeforeDays)); setDraftCertRenewEnabled(data.enabled) }
|
||||||
|
}),
|
||||||
|
runsRequest, statusRequest,
|
||||||
])
|
])
|
||||||
|
|
||||||
const loadErrors = collectSettledErrors(
|
} catch (e) {
|
||||||
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
|
if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||||
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
|
} finally {
|
||||||
)
|
if (current()) setPageBusy(false)
|
||||||
if (loadErrors.length > 0) {
|
}
|
||||||
setCollectorError(loadErrors.join("; "))
|
}, [backendUrl, isLive, runFilterJobKey])
|
||||||
}
|
|
||||||
|
|
||||||
const traffic = readSettled(trafficRes)
|
const writeSettings = useCallback(async (key: SettingsKey, patch: Record<string, number | boolean>) => {
|
||||||
if (traffic) {
|
if (!isLive || !reader.current.active || !can(SETTINGS_PERMISSIONS[key]) || !settingsReads[key].known || settingsReads[key].error) throw new Error("Настройки не получены или недостаточно прав для записи")
|
||||||
setTrafficCollector(traffic)
|
const data = key === "traffic" ? await putTrafficCollectorSettings(backendUrl, patch)
|
||||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
: key === "rest" ? await putServersApiPingSettings(backendUrl, patch)
|
||||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
: key === "uptime" ? await putUptimeCollectorSettings(backendUrl, patch)
|
||||||
setDraftTrafficEnabled(!!traffic.enabled)
|
: key === "internet" ? await putInternetPathSettings(backendUrl, patch)
|
||||||
}
|
: await putCertificateRenewSettings(backendUrl, patch)
|
||||||
|
if (Object.entries(patch).some(([field, value]) => (data as Record<string, unknown>)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
|
||||||
|
if (!reader.current.active) return
|
||||||
|
if (key === "traffic") setTrafficCollector(data as Awaited<ReturnType<typeof getTrafficCollectorSettings>>)
|
||||||
|
if (key === "rest") setServersApiCollector(data as Awaited<ReturnType<typeof getServersApiPingSettings>>)
|
||||||
|
if (key === "uptime") setUptimeCollector(data as Awaited<ReturnType<typeof getUptimeCollectorSettings>>)
|
||||||
|
if (key === "internet") setInternetPathCollector(data as Awaited<ReturnType<typeof getInternetPathSettings>>)
|
||||||
|
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
|
||||||
|
}, [backendUrl, isLive, settingsReads])
|
||||||
|
|
||||||
const serversApi = readSettled(serversApiRes)
|
const persistSchedulerDrafts = async () => {
|
||||||
if (serversApi) {
|
if (mutationLock.current || !reader.current.active) return
|
||||||
setServersApiCollector(serversApi)
|
const drafts: Record<SettingsKey, Record<string, string>> = {
|
||||||
setDraftServersApiEnabled(!!serversApi.enabled)
|
traffic: { intervalSec: trafficIntervalDraft, retentionDays: trafficRetentionDraft }, rest: { intervalSec: serversApiIntervalDraft },
|
||||||
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
uptime: { intervalSec: uptimeResourceIntervalDraft, probeIntervalSec: uptimeIntervalDraft, speedIntervalSec: uptimeSpeedIntervalDraft, retentionDays: uptimeRetentionDraft },
|
||||||
}
|
internet: { intervalSec: internetPathIntervalDraft }, cert: { intervalSec: certRenewIntervalDraft, renewBeforeDays: renewBeforeDaysDraft },
|
||||||
|
}
|
||||||
const uptime = readSettled(uptimeRes)
|
const plan: { key: SettingsKey; patch: Record<string, number> }[] = []
|
||||||
if (uptime) {
|
try {
|
||||||
setUptimeCollector(uptime)
|
for (const key of Object.keys(drafts) as SettingsKey[]) {
|
||||||
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
if (!dirtyFields.current[key].size) continue
|
||||||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
if (!writable(key)) throw new Error("Настройки не получены или недостаточно прав: " + SETTINGS_LABELS[key])
|
||||||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
const patch: Record<string, number> = {}
|
||||||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
for (const field of dirtyFields.current[key]) {
|
||||||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
const raw = drafts[key][field]; const value = Number(raw)
|
||||||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
const min = field === "retentionDays" || field === "renewBeforeDays" ? 1 : key === "cert" ? 300 : key === "internet" ? 30 : key === "rest" || field === "speedIntervalSec" ? 10 : 5
|
||||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < min || (field === "renewBeforeDays" && value > 90)) throw new Error(SETTINGS_LABELS[key] + ": укажите целое значение от " + min + (field === "renewBeforeDays" ? " до 90" : ""))
|
||||||
if (uptime.scheduler?.jobs?.length) {
|
patch[field] = value
|
||||||
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
|
|
||||||
setDraftTrafficEnabled,
|
|
||||||
setDraftServersApiEnabled,
|
|
||||||
setDraftResourcesEnabled,
|
|
||||||
setDraftPingEnabled,
|
|
||||||
setDraftSpeedEnabled,
|
|
||||||
setDraftInternetPathEnabled,
|
|
||||||
setDraftCertRenewEnabled,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
plan.push({ key, patch })
|
||||||
}
|
}
|
||||||
|
if (!plan.length) return
|
||||||
const internetPath = readSettled(internetPathRes)
|
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
|
||||||
if (internetPath) {
|
const successes: string[] = [], errors: string[] = []
|
||||||
setInternetPathCollector(internetPath)
|
for (const { key, patch } of plan) {
|
||||||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
if (!reader.current.active) break
|
||||||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
try {
|
||||||
|
await writeSettings(key, patch)
|
||||||
|
if (!reader.current.active) return
|
||||||
|
for (const field of Object.keys(patch)) dirtyFields.current[key].delete(field)
|
||||||
|
if (!dirtyFields.current[key].size) dirtySettings.current.delete(key)
|
||||||
|
successes.push(SETTINGS_LABELS[key])
|
||||||
|
} catch (error) { if (reader.current.active) errors.push(SETTINGS_LABELS[key] + ": " + extractApiError(error)) }
|
||||||
}
|
}
|
||||||
|
if (!reader.current.active) return
|
||||||
const certRenew = readSettled(certRenewRes)
|
setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0))
|
||||||
if (certRenew) {
|
if (successes.length) setSaveMessage("Сохранение подтверждено: " + successes.join(", "))
|
||||||
setDraftCertRenewEnabled(!!certRenew.enabled)
|
if (errors.length) setCollectorError(errors.join("; "))
|
||||||
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
|
||||||
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
|
||||||
}
|
|
||||||
|
|
||||||
const runsPayload = readSettled(runsRes)
|
|
||||||
if (runsPayload) {
|
|
||||||
setSchedulerRuns(runsPayload.runs ?? [])
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
|
||||||
} finally {
|
|
||||||
setPageBusy(false)
|
|
||||||
}
|
|
||||||
}, [apiFetch, isLive, runFilterJobKey])
|
|
||||||
|
|
||||||
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
|
|
||||||
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
|
|
||||||
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
|
|
||||||
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
|
|
||||||
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled
|
|
||||||
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled
|
|
||||||
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled
|
|
||||||
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled
|
|
||||||
|
|
||||||
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
|
|
||||||
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
|
|
||||||
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
|
|
||||||
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
|
|
||||||
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
|
|
||||||
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
|
|
||||||
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
|
|
||||||
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
|
|
||||||
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
|
|
||||||
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
|
|
||||||
|
|
||||||
const saveResults = await Promise.allSettled([
|
|
||||||
apiFetch("/api/traffic/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: trafficEnabled,
|
|
||||||
intervalSec: tInt,
|
|
||||||
retentionDays: tRet,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
apiFetch("/api/servers-api-ping/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: serversApiEnabled,
|
|
||||||
intervalSec: sApiInt,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
apiFetch("/api/uptime/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
resourcesEnabled,
|
|
||||||
pingEnabled,
|
|
||||||
speedEnabled,
|
|
||||||
intervalSec: uRes,
|
|
||||||
probeIntervalSec: uPing,
|
|
||||||
speedIntervalSec: uSpd,
|
|
||||||
retentionDays: uRet,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
apiFetch("/api/internet-path/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: internetPathEnabled,
|
|
||||||
intervalSec: ipInt,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
apiFetch("/api/certificates/renew-settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: certRenewEnabled,
|
|
||||||
intervalSec: certRenewInt,
|
|
||||||
renewBeforeDays,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const saveErrors = collectSettledErrors(
|
|
||||||
saveResults,
|
|
||||||
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
|
|
||||||
)
|
|
||||||
if (saveErrors.length > 0) {
|
|
||||||
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
apiFetch,
|
|
||||||
certRenewIntervalDraft,
|
|
||||||
draftCertRenewEnabled,
|
|
||||||
draftInternetPathEnabled,
|
|
||||||
draftPingEnabled,
|
|
||||||
draftResourcesEnabled,
|
|
||||||
draftServersApiEnabled,
|
|
||||||
draftSpeedEnabled,
|
|
||||||
draftTrafficEnabled,
|
|
||||||
internetPathIntervalDraft,
|
|
||||||
renewBeforeDaysDraft,
|
|
||||||
serversApiIntervalDraft,
|
|
||||||
trafficIntervalDraft,
|
|
||||||
trafficRetentionDraft,
|
|
||||||
uptimeIntervalDraft,
|
|
||||||
uptimeResourceIntervalDraft,
|
|
||||||
uptimeRetentionDraft,
|
|
||||||
uptimeSpeedIntervalDraft,
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
|
|
||||||
const overrides: SchedulerToggleOverrides = {}
|
|
||||||
if (jobKey === "traffic") {
|
|
||||||
setDraftTrafficEnabled(nextEnabled)
|
|
||||||
overrides.trafficEnabled = nextEnabled
|
|
||||||
} else if (jobKey === "servers_rest_ping") {
|
|
||||||
setDraftServersApiEnabled(nextEnabled)
|
|
||||||
overrides.serversApiEnabled = nextEnabled
|
|
||||||
} else if (jobKey === "uptime_resources") {
|
|
||||||
setDraftResourcesEnabled(nextEnabled)
|
|
||||||
overrides.resourcesEnabled = nextEnabled
|
|
||||||
} else if (jobKey === "uptime_ping") {
|
|
||||||
setDraftPingEnabled(nextEnabled)
|
|
||||||
overrides.pingEnabled = nextEnabled
|
|
||||||
} else if (jobKey === "uptime_speed") {
|
|
||||||
setDraftSpeedEnabled(nextEnabled)
|
|
||||||
overrides.speedEnabled = nextEnabled
|
|
||||||
} else if (jobKey === "certificates_renew") {
|
|
||||||
setDraftCertRenewEnabled(nextEnabled)
|
|
||||||
overrides.certRenewEnabled = nextEnabled
|
|
||||||
} else if (jobKey === "internet_path") {
|
|
||||||
setDraftInternetPathEnabled(nextEnabled)
|
|
||||||
overrides.internetPathEnabled = nextEnabled
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCollectorError(null)
|
|
||||||
setSchedulerSaveBusy(true)
|
|
||||||
try {
|
|
||||||
await persistSchedulerDrafts(overrides)
|
|
||||||
await loadCollectors()
|
|
||||||
} catch (e) {
|
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
|
||||||
} finally {
|
|
||||||
setSchedulerSaveBusy(false)
|
|
||||||
}
|
|
||||||
}, [loadCollectors, persistSchedulerDrafts])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLive) {
|
|
||||||
setTrafficCollector(null)
|
|
||||||
setServersApiCollector(null)
|
|
||||||
setUptimeCollector(null)
|
|
||||||
setInternetPathCollector(null)
|
|
||||||
setSchedulerRuns([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void loadCollectors()
|
|
||||||
}, [isLive, loadCollectors])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOpenRunIds(new Set())
|
|
||||||
}, [runFilterJobKey])
|
|
||||||
|
|
||||||
const handleRefreshScheduler = async () => {
|
|
||||||
if (!isLive) return
|
|
||||||
setCollectorError(null)
|
|
||||||
try {
|
|
||||||
await apiFetch("/api/scheduler/refresh", { method: "POST" })
|
|
||||||
await loadCollectors()
|
|
||||||
} catch (e) {
|
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось обновить планировщик")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => {
|
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
|
||||||
|
const targets = {
|
||||||
|
traffic: ["traffic", "enabled", setDraftTrafficEnabled], servers_rest_ping: ["rest", "enabled", setDraftServersApiEnabled],
|
||||||
|
uptime_resources: ["uptime", "resourcesEnabled", setDraftResourcesEnabled], uptime_ping: ["uptime", "pingEnabled", setDraftPingEnabled], uptime_speed: ["uptime", "speedEnabled", setDraftSpeedEnabled],
|
||||||
|
internet_path: ["internet", "enabled", setDraftInternetPathEnabled], certificates_renew: ["cert", "enabled", setDraftCertRenewEnabled],
|
||||||
|
} as const
|
||||||
|
if (!(jobKey in targets) || mutationLock.current || !reader.current.active) return
|
||||||
|
const [key, field, accept] = targets[jobKey as keyof typeof targets]
|
||||||
|
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
|
||||||
|
try {
|
||||||
|
await writeSettings(key, { [field]: nextEnabled })
|
||||||
|
if (!reader.current.active) return
|
||||||
|
accept(nextEnabled); setSaveMessage("Сохранение подтверждено: " + SETTINGS_LABELS[key])
|
||||||
|
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
|
||||||
|
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
|
||||||
|
}, [writeSettings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) return
|
||||||
|
queueMicrotask(() => { void loadCollectors() })
|
||||||
|
}, [isLive, loadCollectors])
|
||||||
|
|
||||||
|
const handleRefreshScheduler = async () => {
|
||||||
|
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin")) return
|
||||||
|
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
|
||||||
|
try {
|
||||||
|
const data = await refreshScheduler(backendUrl)
|
||||||
|
if (!reader.current.active) return
|
||||||
|
setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString())
|
||||||
|
setSaveMessage("Перечитывание таймеров подтверждено сервером")
|
||||||
|
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
|
||||||
|
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRunNow = async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number]) => {
|
||||||
|
const job = schedulerJobsByKey[jobKey]
|
||||||
|
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin") || !job || job.running) return
|
||||||
|
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setRunNowJobKey(jobKey); setCollectorError(null); setSaveMessage(null)
|
||||||
|
try {
|
||||||
|
const data = await runSchedulerJobNow(backendUrl, jobKey)
|
||||||
|
if (!reader.current.active) return
|
||||||
|
if (data.status) { setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
|
||||||
|
else setSchedulerStatusError(data.statusError ?? "Статус после запуска недоступен")
|
||||||
|
const skipped = "skipped" in data.result.result && data.result.result.skipped
|
||||||
|
const notDue = data.result.result.job === "backups" && !data.result.result.due
|
||||||
|
setSaveMessage((!data.ok ? "Запуск завершился с ошибками: " : skipped || notDue ? "Прогон пропущен: " : "Запуск подтверждён: ") + SCHEDULER_JOB_LABELS[jobKey])
|
||||||
|
const warnings = [data.result.error, data.result.eventError ? "Журнал событий: " + data.result.eventError : null, data.statusError ? "Статус: " + data.statusError : null].filter(Boolean)
|
||||||
|
if (warnings.length) setCollectorError(warnings.join("; "))
|
||||||
|
} catch (error) {
|
||||||
|
if (reader.current.active) setCollectorError(extractApiError(error) + ". Результат операции неизвестен; перед повторным запуском обновите журнал.")
|
||||||
|
} finally { mutationLock.current = false; if (reader.current.active) { setSchedulerSaveBusy(false); setRunNowJobKey(null) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedulerGridRows: SchedulerJobGridRow[] = (() => {
|
||||||
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||||
const j = schedulerJobsByKey[jobKey]
|
const j = schedulerJobsByKey[jobKey]
|
||||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
|
||||||
|
const fixedSchedule = jobKey === "router_logs" || jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
|
||||||
const enabled = fixedSchedule
|
const enabled = fixedSchedule
|
||||||
? Boolean(j?.enabled ?? true)
|
? Boolean(j?.enabled ?? true)
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
@@ -904,16 +823,18 @@ export default function DataCollectionPage() {
|
|||||||
? 21600
|
? 21600
|
||||||
: 300
|
: 300
|
||||||
|
|
||||||
|
const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known
|
||||||
return {
|
return {
|
||||||
id: jobKey,
|
id: jobKey,
|
||||||
|
settingsKnown,
|
||||||
jobKey,
|
jobKey,
|
||||||
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
|
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
|
||||||
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
|
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
|
||||||
fixedSchedule,
|
fixedSchedule,
|
||||||
enabled,
|
enabled,
|
||||||
intervalValue,
|
intervalValue: settingsKnown ? intervalValue : "",
|
||||||
intervalReadOnly: fixedSchedule,
|
intervalReadOnly: fixedSchedule,
|
||||||
intervalDisabled: !enabled && !fixedSchedule,
|
intervalDisabled: !settingsKnown || schedulerSaveBusy || (!fixedSchedule && !writable(settingsKey)),
|
||||||
defaultInterval,
|
defaultInterval,
|
||||||
job: j,
|
job: j,
|
||||||
onEnabledChange: fixedSchedule
|
onEnabledChange: fixedSchedule
|
||||||
@@ -921,94 +842,32 @@ export default function DataCollectionPage() {
|
|||||||
: (nextEnabled) => {
|
: (nextEnabled) => {
|
||||||
void handleJobEnabledChange(jobKey, nextEnabled)
|
void handleJobEnabledChange(jobKey, nextEnabled)
|
||||||
},
|
},
|
||||||
onIntervalChange,
|
onIntervalChange: (value: string) => { markDirty(settingsKey, jobKey === "uptime_ping" ? "probeIntervalSec" : jobKey === "uptime_speed" ? "speedIntervalSec" : "intervalSec"); onIntervalChange(value) },
|
||||||
onRunNow: async () => {
|
onRunNow: async () => { await handleRunNow(jobKey) },
|
||||||
setRunNowJobKey(jobKey)
|
runNowDisabled: schedulerSaveBusy || !can("mm:settings:admin") || !j || j.running,
|
||||||
setCollectorError(null)
|
|
||||||
try {
|
|
||||||
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
await loadCollectors()
|
|
||||||
} catch (e) {
|
|
||||||
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
|
||||||
} finally {
|
|
||||||
setRunNowJobKey(null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
runNowLoading: runNowJobKey === jobKey,
|
runNowLoading: runNowJobKey === jobKey,
|
||||||
saveBusy: schedulerSaveBusy,
|
saveBusy: schedulerSaveBusy || !writable(settingsKey),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [
|
})()
|
||||||
apiFetch,
|
|
||||||
certRenewIntervalDraft,
|
|
||||||
draftCertRenewEnabled,
|
|
||||||
draftInternetPathEnabled,
|
|
||||||
draftPingEnabled,
|
|
||||||
draftResourcesEnabled,
|
|
||||||
draftServersApiEnabled,
|
|
||||||
draftSpeedEnabled,
|
|
||||||
draftTrafficEnabled,
|
|
||||||
handleJobEnabledChange,
|
|
||||||
internetPathIntervalDraft,
|
|
||||||
loadCollectors,
|
|
||||||
runNowJobKey,
|
|
||||||
schedulerJobsByKey,
|
|
||||||
schedulerSaveBusy,
|
|
||||||
serversApiIntervalDraft,
|
|
||||||
trafficIntervalDraft,
|
|
||||||
uptimeIntervalDraft,
|
|
||||||
uptimeResourceIntervalDraft,
|
|
||||||
uptimeSpeedIntervalDraft,
|
|
||||||
])
|
|
||||||
|
|
||||||
const enabledJobsCount = useMemo(() => {
|
const enabledJobsCount = schedulerStatus?.jobs.filter(job => job.enabled).length ?? 0
|
||||||
const jobs = uptimeCollector?.scheduler?.jobs
|
const schedulerJobCount = schedulerStatus?.jobs.length ?? 0
|
||||||
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
const runningJobsCount = schedulerStatus?.jobs.filter(job => job.running).length ?? 0
|
||||||
|
|
||||||
let n = draftTrafficEnabled ? 1 : 0
|
|
||||||
if (draftServersApiEnabled) n += 1
|
|
||||||
if (draftResourcesEnabled) n += 1
|
|
||||||
if (draftPingEnabled) n += 1
|
|
||||||
if (draftSpeedEnabled) n += 1
|
|
||||||
if (draftInternetPathEnabled) n += 1
|
|
||||||
if (draftCertRenewEnabled) n += 1
|
|
||||||
return n
|
|
||||||
}, [
|
|
||||||
draftCertRenewEnabled,
|
|
||||||
draftInternetPathEnabled,
|
|
||||||
draftPingEnabled,
|
|
||||||
draftResourcesEnabled,
|
|
||||||
draftServersApiEnabled,
|
|
||||||
draftSpeedEnabled,
|
|
||||||
draftTrafficEnabled,
|
|
||||||
uptimeCollector?.scheduler?.jobs,
|
|
||||||
])
|
|
||||||
|
|
||||||
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
|
||||||
|
|
||||||
const runningJobsCount = useMemo(
|
|
||||||
() => (uptimeCollector?.scheduler?.jobs ?? []).filter((j) => j.running).length,
|
|
||||||
[uptimeCollector?.scheduler?.jobs],
|
|
||||||
)
|
|
||||||
|
|
||||||
const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns])
|
const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns])
|
||||||
|
|
||||||
const stats = useMemo(
|
const stats = [
|
||||||
() => [
|
|
||||||
{
|
{
|
||||||
label: "Включено задач",
|
label: "Включено задач",
|
||||||
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
value: schedulerStatus ? `${enabledJobsCount} / ${schedulerJobCount}` : "—",
|
||||||
sub: uptimeCollector?.scheduler?.jobs?.length
|
sub: schedulerStatusError ? (schedulerStatus ? "Последний подтверждённый статус" : "Статус не получен") : schedulerStatus ? "По сохранённым задачам планировщика" : "Ожидание статуса",
|
||||||
? "По сохранённым задачам планировщика"
|
|
||||||
: "По переключателям на этой странице",
|
|
||||||
icon: <CalendarClockIcon className="size-4" />,
|
icon: <CalendarClockIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Сейчас выполняется",
|
label: "Сейчас выполняется",
|
||||||
value: String(runningJobsCount),
|
value: schedulerStatus ? String(runningJobsCount) : "—",
|
||||||
sub: "Фоновые прогоны планировщика",
|
sub: schedulerStatusError ? "Статус требует обновления" : "Фоновые прогоны планировщика",
|
||||||
icon: <LoaderCircleIcon className="size-4" />,
|
icon: <LoaderCircleIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1016,31 +875,20 @@ export default function DataCollectionPage() {
|
|||||||
value: trafficCollector?.lastCollectedAt
|
value: trafficCollector?.lastCollectedAt
|
||||||
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
|
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
|
||||||
: "—",
|
: "—",
|
||||||
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
sub: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||||
icon: trafficCollector?.lastError ? (
|
icon: trafficCollector?.lastError ? (
|
||||||
<XCircleIcon className="size-4" />
|
<XCircleIcon className="size-4" />
|
||||||
) : (
|
) : trafficCollector?.lastCollectedAt ? (
|
||||||
<CheckCircleIcon className="size-4" />
|
<CheckCircleIcon className="size-4" />
|
||||||
),
|
) : (<CalendarClockIcon className="size-4" />),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Журнал (в списке)",
|
label: "Журнал (в списке)",
|
||||||
value: String(schedulerRuns.length),
|
value: runsKnown ? String(schedulerRuns.length) : "—",
|
||||||
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
sub: runsError ? (runsKnown ? "Показана последняя подтверждённая история" : "История не получена") : runsKnown ? (errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет") : "Ожидание истории",
|
||||||
icon: <DatabaseIcon className="size-4" />,
|
icon: <DatabaseIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
[
|
|
||||||
enabledJobsCount,
|
|
||||||
schedulerJobCount,
|
|
||||||
runningJobsCount,
|
|
||||||
trafficCollector?.lastCollectedAt,
|
|
||||||
trafficCollector?.lastDurationMs,
|
|
||||||
trafficCollector?.lastError,
|
|
||||||
schedulerRuns.length,
|
|
||||||
errorRunsInView,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -1054,13 +902,13 @@ export default function DataCollectionPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!isLive || pageBusy}
|
disabled={!isLive || pageBusy || schedulerSaveBusy}
|
||||||
onClick={() => { void loadCollectors() }}
|
onClick={() => { void loadCollectors() }}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" disabled={!isLive || pageBusy} onClick={() => { void handleRefreshScheduler() }}>
|
<Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy || !can("mm:settings:admin")} onClick={() => { void handleRefreshScheduler() }}>
|
||||||
Перечитать таймеры
|
Перечитать таймеры
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1090,23 +938,39 @@ export default function DataCollectionPage() {
|
|||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLive && schedulerStatusError ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertCircleIcon aria-hidden="true" />
|
||||||
|
<AlertTitle>{schedulerStatus ? "Статус планировщика не обновлён" : "Статус планировщика не получен"}</AlertTitle>
|
||||||
|
<AlertDescription>{schedulerStatusError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{isLive && schedulerStatusReceivedAt ? <p className="text-xs text-muted-foreground">Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}</p> : null}
|
||||||
|
{isLive && Object.entries(settingsReads).map(([key, value]) => (
|
||||||
|
<div key={key} className="text-xs text-muted-foreground" data-testid={"settings-" + key}>
|
||||||
|
<p role="status">{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " · Получено: " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}</p>
|
||||||
|
{value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isLive && saveMessage ? <p role="status" className="text-sm text-muted-foreground">{saveMessage}</p> : null}
|
||||||
{isLive && collectorError && (
|
{isLive && collectorError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertTitle>Ошибка загрузки статуса коллекторов</AlertTitle>
|
<AlertTitle>Операция не завершена</AlertTitle>
|
||||||
<AlertDescription>{collectorError}</AlertDescription>
|
<AlertDescription>{collectorError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLive && can("mm:settings:admin") && <LogsCollectorSettings key={backendUrl} base={backendUrl} onSaved={settings => setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />}
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<>
|
<>
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка сбора данных"
|
aria-label="Сводка сбора данных"
|
||||||
items={stats.map((s, i) => ({
|
items={stats.map((s, i) => ({
|
||||||
id: `dc-${i}`,
|
id: `dc-${i}`,
|
||||||
label: s.label,
|
label: <span className="whitespace-normal">{s.label}</span>,
|
||||||
value: s.value,
|
value: s.value,
|
||||||
hint: s.sub,
|
hint: <span className="text-xs text-muted-foreground whitespace-normal">{s.sub}</span>,
|
||||||
icon: s.icon,
|
icon: s.icon,
|
||||||
iconClassName:
|
iconClassName:
|
||||||
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||||
@@ -1115,7 +979,7 @@ export default function DataCollectionPage() {
|
|||||||
? "text-warning"
|
? "text-warning"
|
||||||
: s.label === "Журнал (в списке)" && errorRunsInView
|
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: s.label === "Трафик — последний сбор"
|
: s.label === "Трафик — последний сбор" && trafficCollector?.lastCollectedAt
|
||||||
? "text-success"
|
? "text-success"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
variant:
|
variant:
|
||||||
@@ -1131,7 +995,7 @@ export default function DataCollectionPage() {
|
|||||||
<div className="border-b border-border px-5 py-4">
|
<div className="border-b border-border px-5 py-4">
|
||||||
<p className="text-base font-medium">Планировщик сбора данных</p>
|
<p className="text-base font-medium">Планировщик сбора данных</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
Интервалы и вкл/выкл по задачам. Переключатель сохраняет только свой флаг; кнопка ниже — изменённые интервалы и срок хранения. Ручной запуск использует сохранённые настройки, без отправки черновика.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||||
@@ -1141,8 +1005,10 @@ export default function DataCollectionPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||||
<Input
|
<Input
|
||||||
value={trafficRetentionDraft}
|
value={settingsReads.traffic.known ? trafficRetentionDraft : ""}
|
||||||
onChange={(e) => setTrafficRetentionDraft(e.target.value)}
|
aria-label="Хранение трафика"
|
||||||
|
disabled={!writable("traffic") || schedulerSaveBusy}
|
||||||
|
onChange={(e) => { markDirty("traffic", "retentionDays"); setTrafficRetentionDraft(e.target.value) }}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
@@ -1150,8 +1016,10 @@ export default function DataCollectionPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
|
||||||
<Input
|
<Input
|
||||||
value={uptimeRetentionDraft}
|
value={settingsReads.uptime.known ? uptimeRetentionDraft : ""}
|
||||||
onChange={(e) => setUptimeRetentionDraft(e.target.value)}
|
aria-label="Хранение uptime"
|
||||||
|
disabled={!writable("uptime") || schedulerSaveBusy}
|
||||||
|
onChange={(e) => { markDirty("uptime", "retentionDays"); setUptimeRetentionDraft(e.target.value) }}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
@@ -1159,11 +1027,13 @@ export default function DataCollectionPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
|
||||||
<Input
|
<Input
|
||||||
value={renewBeforeDaysDraft}
|
value={settingsReads.cert.known ? renewBeforeDaysDraft : ""}
|
||||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
aria-label="Обновлять сертификаты за дней"
|
||||||
|
|
||||||
|
onChange={(e) => { markDirty("cert", "renewBeforeDays"); setRenewBeforeDaysDraft(e.target.value) }}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
disabled={!draftCertRenewEnabled}
|
disabled={!writable("cert") || schedulerSaveBusy}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
||||||
@@ -1175,22 +1045,12 @@ export default function DataCollectionPage() {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={schedulerSaveBusy}
|
aria-label="Сохранить настройки планировщика"
|
||||||
onClick={async () => {
|
disabled={schedulerSaveBusy || dirtyCount === 0}
|
||||||
setSchedulerSaveBusy(true)
|
onClick={() => { void persistSchedulerDrafts() }}
|
||||||
setCollectorError(null)
|
|
||||||
try {
|
|
||||||
await persistSchedulerDrafts()
|
|
||||||
await loadCollectors()
|
|
||||||
} catch (e) {
|
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
|
||||||
} finally {
|
|
||||||
setSchedulerSaveBusy(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
||||||
Сохранить настройки планировщика
|
Сохранить изменения
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
|
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
|
||||||
<p>
|
<p>
|
||||||
@@ -1206,7 +1066,7 @@ export default function DataCollectionPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : "Трафик: ошибок нет"}
|
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
REST API (серверы):{" "}
|
REST API (серверы):{" "}
|
||||||
@@ -1223,7 +1083,7 @@ export default function DataCollectionPage() {
|
|||||||
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{serversApiCollector?.lastError
|
{serversApiCollector?.lastError
|
||||||
? `REST API (серверы): ${serversApiCollector.lastError}`
|
? `REST API (серверы): ${serversApiCollector.lastError}`
|
||||||
: "REST API (серверы): ошибок нет"}
|
: (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Uptime (агрегат в настройках):{" "}
|
Uptime (агрегат в настройках):{" "}
|
||||||
@@ -1233,7 +1093,7 @@ export default function DataCollectionPage() {
|
|||||||
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
|
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}
|
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Internet Path snapshot:{" "}
|
Internet Path snapshot:{" "}
|
||||||
@@ -1245,7 +1105,7 @@ export default function DataCollectionPage() {
|
|||||||
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{internetPathCollector?.lastError
|
{internetPathCollector?.lastError
|
||||||
? `Internet Path: ${internetPathCollector.lastError}`
|
? `Internet Path: ${internetPathCollector.lastError}`
|
||||||
: "Internet Path: ошибок нет"}
|
: (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1255,7 +1115,7 @@ export default function DataCollectionPage() {
|
|||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Журнал прогонов"
|
title="Журнал прогонов"
|
||||||
description="Таблица `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
description="Последние 80 запусков. Раскройте запись для результатов и объяснения ошибок."
|
||||||
headerRight={
|
headerRight={
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
||||||
@@ -1265,7 +1125,8 @@ export default function DataCollectionPage() {
|
|||||||
id="run-filter"
|
id="run-filter"
|
||||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
|
||||||
value={runFilterJobKey}
|
value={runFilterJobKey}
|
||||||
onChange={(e) => setRunFilterJobKey(e.target.value)}
|
disabled={schedulerSaveBusy}
|
||||||
|
onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
|
||||||
>
|
>
|
||||||
<option value="">Все</option>
|
<option value="">Все</option>
|
||||||
{SCHEDULER_JOB_KEYS.map((k) => (
|
{SCHEDULER_JOB_KEYS.map((k) => (
|
||||||
@@ -1278,7 +1139,19 @@ export default function DataCollectionPage() {
|
|||||||
}
|
}
|
||||||
contentClassName="px-0 pb-0"
|
contentClassName="px-0 pb-0"
|
||||||
>
|
>
|
||||||
{schedulerRuns.length === 0 ? (
|
{runsError ? (
|
||||||
|
<div className="mx-4 mb-3">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertCircleIcon aria-hidden="true" />
|
||||||
|
<AlertTitle>{runsKnown ? "История не обновлена" : "История не получена"}</AlertTitle>
|
||||||
|
<AlertDescription>{runsError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{runsReceivedAt ? <p className="px-4 pb-3 text-xs text-muted-foreground">История получена: {new Date(runsReceivedAt).toLocaleString("ru-RU")}</p> : null}
|
||||||
|
{!runsKnown ? (
|
||||||
|
!runsError ? <p role="status" className="px-4 py-10 text-center text-sm text-muted-foreground">Загрузка журнала…</p> : null
|
||||||
|
) : schedulerRuns.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
|
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
|
|||||||
+13
-28
@@ -8,25 +8,22 @@ import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
|
|||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { domains as mockDomains } from "@/lib/data"
|
import { domains as mockDomains } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live"
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!useEvoCatalog) return mockDomains
|
if (mode === "mock") return mockDomains
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.domains ?? []
|
return snapshot?.domains ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return rows
|
if (!search) return rows
|
||||||
@@ -43,8 +40,7 @@ export default function DomainsPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||||
actions={
|
actions={mode === "live" ? null : <>
|
||||||
<>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<UploadIcon className="size-4" />Импорт
|
<UploadIcon className="size-4" />Импорт
|
||||||
</Button>
|
</Button>
|
||||||
@@ -58,34 +54,23 @@ export default function DomainsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
|
Домены из каталога для BGP-фильтрации
|
||||||
</p>
|
</p>
|
||||||
{useEvoCatalog && (
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
<p className={cn(
|
|
||||||
"text-xs mt-2 flex items-center gap-1.5",
|
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
||||||
{error
|
|
||||||
? `EvoBGP: ${error}`
|
|
||||||
: snapshot?.fetchedAt
|
|
||||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
||||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по домену…"
|
searchPlaceholder="Поиск по домену…"
|
||||||
countLabel={`${filtered.length} доменов`}
|
countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
|
||||||
/>
|
/>
|
||||||
<DomainsDataGrid
|
{known || loading ? <DomainsDataGrid
|
||||||
domains={filtered}
|
domains={filtered}
|
||||||
isLoading={useEvoCatalog && loading && !snapshot}
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
pagination={useEvoCatalog}
|
pagination={useEvoCatalog}
|
||||||
/>
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+559
-289
File diff suppressed because it is too large.
Load diff
+468
-205
File diff suppressed because it is too large.
Load diff
+541
-397
File diff suppressed because it is too large.
Load diff
@@ -8,25 +8,22 @@ import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
|
|||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function IpRangesPage() {
|
export default function IpRangesPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live"
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!useEvoCatalog) return mockIpRanges
|
if (mode === "mock") return mockIpRanges
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.ipRanges ?? []
|
return snapshot?.ipRanges ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return rows
|
if (!search) return rows
|
||||||
@@ -44,8 +41,7 @@ export default function IpRangesPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||||
actions={
|
actions={mode === "live" ? null : <>
|
||||||
<>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<UploadIcon className="size-4" />Импорт
|
<UploadIcon className="size-4" />Импорт
|
||||||
</Button>
|
</Button>
|
||||||
@@ -59,34 +55,23 @@ export default function IpRangesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
|
CIDR-блоки из каталога для сетевых фильтров
|
||||||
</p>
|
</p>
|
||||||
{useEvoCatalog && (
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
<p className={cn(
|
|
||||||
"text-xs mt-2 flex items-center gap-1.5",
|
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
||||||
{error
|
|
||||||
? `EvoBGP: ${error}`
|
|
||||||
: snapshot?.fetchedAt
|
|
||||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
||||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||||
countLabel={`${filtered.length} диапазонов`}
|
countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
|
||||||
/>
|
/>
|
||||||
<IpRangesDataGrid
|
{known || loading ? <IpRangesDataGrid
|
||||||
ipRanges={filtered}
|
ipRanges={filtered}
|
||||||
isLoading={useEvoCatalog && loading && !snapshot}
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
pagination={useEvoCatalog}
|
pagination={useEvoCatalog}
|
||||||
/>
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -22,7 +22,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
|
|||||||
К содержимому
|
К содержимому
|
||||||
</a>
|
</a>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { LogsWorkspace } from "@/components/logs/logs-workspace"
|
||||||
|
export default function LogsPage() { return <LogsWorkspace /> }
|
||||||
+1023
-567
File diff suppressed because it is too large.
Load diff
+415
-383
File diff suppressed because it is too large.
Load diff
+269
-327
@@ -1,6 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
import { toast } from "sonner"
|
||||||
|
import { Suspense, useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { readProbeSource } from "@/lib/probe-source"
|
||||||
|
import { readProbeUrl, patchProbeUrl } from "@/lib/probe-url"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
@@ -11,21 +15,21 @@ import {
|
|||||||
type SchedRule,
|
type SchedRule,
|
||||||
type SchedType,
|
type SchedType,
|
||||||
} from "@/components/data-grids/probes-schedule-data-grid"
|
} from "@/components/data-grids/probes-schedule-data-grid"
|
||||||
import {
|
import {ProbesSpeedSchedule} from "@/components/probes-speed-schedule"
|
||||||
ProbesSpeedProbesDataGrid,
|
|
||||||
type SpeedProbeApiRow,
|
|
||||||
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||||
|
import { filtersGreResponseSchema } from "@/lib/filters-response"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import {
|
import {
|
||||||
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
||||||
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
||||||
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
|
ZapIcon, CheckIcon, TerminalIcon,
|
||||||
LoaderCircleIcon, AlertCircleIcon,
|
LoaderCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
@@ -33,7 +37,7 @@ import { requestJson } from "@/shared/api/http-client"
|
|||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
|
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
|
||||||
type RunStatus = "running" | "done" | "error"
|
type RunStatus = "running" | "done" | "error" | "cancelled"
|
||||||
type TraceProto = "icmp" | "udp" | "tcp"
|
type TraceProto = "icmp" | "udp" | "tcp"
|
||||||
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
|
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
|
||||||
|
|
||||||
@@ -66,12 +70,12 @@ const TOOL_META: Record<DiagTool, {
|
|||||||
color: string
|
color: string
|
||||||
description: string
|
description: string
|
||||||
}> = {
|
}> = {
|
||||||
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" },
|
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-info", description: "Проверка связи и RTT" },
|
||||||
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" },
|
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-info", description: "Трассировка маршрута" },
|
||||||
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" },
|
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-success", description: "Пропускная способность" },
|
||||||
dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" },
|
dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-warning", description: "DNS на хосте приложения" },
|
||||||
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" },
|
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-info", description: "Поиск активного маршрута" },
|
||||||
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" },
|
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-warning", description: "Определение MTU пути" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
@@ -80,31 +84,25 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyProbeText(text:string){
|
||||||
|
try{await navigator.clipboard.writeText(text);toast.success("Скопировано.")}
|
||||||
|
catch{toast.error("Не удалось скопировать. Разрешите доступ к буферу обмена или выделите текст вручную.")}
|
||||||
|
}
|
||||||
|
|
||||||
function inferProbeLineKind(line: string): OutputLine["kind"] {
|
function inferProbeLineKind(line: string): OutputLine["kind"] {
|
||||||
const l = line.toLowerCase()
|
const l = line.toLowerCase()
|
||||||
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
|
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
|
||||||
if (l.includes("sent=") || l.includes("received=") || l.includes("packet-loss") || l.includes("✓")) return "ok"
|
if (l.includes("✓")) return "ok"
|
||||||
if (l.startsWith(";;") || l.trim() === "") return "dim"
|
if (l.startsWith(";;") || l.trim() === "") return "dim"
|
||||||
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
|
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
|
||||||
return "normal"
|
return "normal"
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProbeOutput(text: string): OutputLine[] {
|
function parseProbeOutput(text: string): OutputLine[] {
|
||||||
return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
|
const lines = text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
|
||||||
|
return lines.length > 1000 ? [{ text: "Показаны последние 1000 строк; начало вывода сокращено.", kind: "dim" }, ...lines.slice(-1000)] : lines
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BackendServerRow {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
host: string
|
|
||||||
site: string
|
|
||||||
country: string
|
|
||||||
asn: string
|
|
||||||
type: Server["type"]
|
|
||||||
enabled: boolean
|
|
||||||
status?: Server["status"]
|
|
||||||
latency?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
|
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
|
||||||
|
|
||||||
@@ -321,7 +319,7 @@ function buildCommand(
|
|||||||
case "bandwidth":
|
case "bandwidth":
|
||||||
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
|
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
|
||||||
case "dns":
|
case "dns":
|
||||||
return `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1`
|
return "DNS приложения: " + target + " · " + (opts.dnsType ?? "A") + " · резолвер хоста backend"
|
||||||
case "route":
|
case "route":
|
||||||
return `/ip route lookup ip=${target}`
|
return `/ip route lookup ip=${target}`
|
||||||
case "mtu":
|
case "mtu":
|
||||||
@@ -331,11 +329,11 @@ function buildCommand(
|
|||||||
|
|
||||||
// ─── small UI components ──────────────────────────────────────────────────────
|
// ─── small UI components ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NativeSelect({ value, onChange, children, className }: {
|
function NativeSelect({ value, onChange, children, className, ariaLabel }: {
|
||||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; ariaLabel?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<select value={value} onChange={e => onChange(e.target.value)}
|
<select aria-label={ariaLabel} value={value} onChange={e => onChange(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
|
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
|
||||||
"transition-colors outline-none",
|
"transition-colors outline-none",
|
||||||
@@ -347,7 +345,8 @@ function NativeSelect({ value, onChange, children, className }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OptionLabel({ children }: { children: React.ReactNode }) {
|
function OptionLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
||||||
|
if (htmlFor) return <label htmlFor={htmlFor} className="block text-xs font-medium text-muted-foreground mb-1">{children}</label>
|
||||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,9 +354,9 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
|
|||||||
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
|
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button onClick={() => onClick(value)}
|
<button type="button" aria-pressed={value === current} onClick={() => onClick(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors",
|
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
|
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
@@ -368,107 +367,18 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
|
|||||||
// ─── terminal output ──────────────────────────────────────────────────────────
|
// ─── terminal output ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TerminalOutput({ test }: { test: DiagTest }) {
|
function TerminalOutput({ test }: { test: DiagTest }) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref=useRef<HTMLDivElement>(null),follow=useRef(true)
|
||||||
const visible = test.lines.slice(0, test.totalLines)
|
const visible=test.lines.slice(0,test.totalLines)
|
||||||
|
useEffect(()=>{if(ref.current&&follow.current)ref.current.scrollTop=ref.current.scrollHeight},[visible.length])
|
||||||
useEffect(() => {
|
return <div ref={ref} role="region" aria-label={"Вывод "+TOOL_META[test.tool].label+" · "+test.srcServerName} tabIndex={0}
|
||||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
|
onScroll={()=>{const el=ref.current;if(el)follow.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}}
|
||||||
}, [visible.length])
|
className="h-full min-h-0 overflow-auto rounded-lg bg-muted/30 border px-3 py-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<div className="mb-2 text-muted-foreground [overflow-wrap:anywhere]">{test.source==="demo"?"Демо":test.tool==="dns"?"DNS приложения":test.srcServerName} · {test.command}</div>
|
||||||
return (
|
{visible.map((line,i)=><div key={i} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="ok"&&"text-success",line.kind==="err"&&"text-destructive",line.kind==="dim"&&"text-muted-foreground",line.kind==="header"&&"text-foreground font-semibold",line.kind==="cmd"&&"text-warning",line.kind==="normal"&&"text-foreground")}>{line.text||" "}</div>)}
|
||||||
<div ref={ref}
|
</div>
|
||||||
className="h-72 overflow-y-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3 font-mono text-xs leading-relaxed">
|
|
||||||
{/* command line */}
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<span className="text-zinc-500">[{test.srcServerName}]</span>
|
|
||||||
<span className="text-emerald-400">$</span>
|
|
||||||
<span className="text-zinc-300">{test.command}</span>
|
|
||||||
{test.status === "running" && (
|
|
||||||
<span className="inline-block size-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* output */}
|
|
||||||
{visible.map((line, i) => (
|
|
||||||
<div key={i} className={cn(
|
|
||||||
"whitespace-pre leading-5",
|
|
||||||
line.kind === "ok" && "text-emerald-400",
|
|
||||||
line.kind === "err" && "text-red-400",
|
|
||||||
line.kind === "dim" && "text-zinc-500",
|
|
||||||
line.kind === "header" && "text-zinc-400 font-semibold",
|
|
||||||
line.kind === "cmd" && "text-amber-400",
|
|
||||||
line.kind === "normal" && "text-zinc-300",
|
|
||||||
)}>
|
|
||||||
{line.text || " "}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{test.status === "running" && visible.length < test.lines.length && (
|
|
||||||
<div className="text-zinc-600 animate-pulse">…</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── uptime speed probes (live) ───────────────────────────────────────────────
|
// ─── uptime speed probes (live) ───────────────────────────────────────────────
|
||||||
|
|
||||||
function ScheduleSpeedProbesLive({
|
|
||||||
apiFetch,
|
|
||||||
serversForName,
|
|
||||||
}: {
|
|
||||||
apiFetch: ReturnType<typeof makeApiFetch>
|
|
||||||
serversForName: Server[]
|
|
||||||
}) {
|
|
||||||
const [rows, setRows] = useState<SpeedProbeApiRow[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [err, setErr] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
setLoading(true)
|
|
||||||
setErr(null)
|
|
||||||
void apiFetch<{ probes: SpeedProbeApiRow[] }>("/api/uptime/speed-probes")
|
|
||||||
.then(d => {
|
|
||||||
if (!cancelled) setRows(d.probes ?? [])
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
if (!cancelled) setErr(e instanceof Error ? e.message : String(e))
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false)
|
|
||||||
})
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [apiFetch])
|
|
||||||
|
|
||||||
const name = (id: string) => serversForName.find(s => s.id === id)?.name ?? id
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
|
||||||
<LoaderCircleIcon className="size-5 animate-spin" />
|
|
||||||
Загрузка расписания из БД…
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (err) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
||||||
<AlertCircleIcon className="size-4 shrink-0" />
|
|
||||||
{err}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<DataPageCard>
|
|
||||||
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
|
|
||||||
</DataPageCard>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── schedule tab ─────────────────────────────────────────────────────────────
|
// ─── schedule tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const INIT_RULES: SchedRule[] = [
|
const INIT_RULES: SchedRule[] = [
|
||||||
@@ -492,21 +402,12 @@ function ScheduleTab({
|
|||||||
}) {
|
}) {
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
|
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
|
||||||
const [addTun, setAddTun] = useState("")
|
const [selectedTunnel, setAddTun] = useState("")
|
||||||
const [addType, setAddType] = useState<SchedType>("ping")
|
const [addType, setAddType] = useState<SchedType>("ping")
|
||||||
const [addMin, setAddMin] = useState(10)
|
const [addMin, setAddMin] = useState(10)
|
||||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||||
|
|
||||||
useEffect(() => {
|
const addTun = addTunnels.some(t=>t.id===selectedTunnel) ? selectedTunnel : addTunnels[0]?.id ?? ""
|
||||||
if (defaultSrc) setAddSrc(defaultSrc)
|
|
||||||
}, [defaultSrc])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const list = tunnelsForServer(addSrc)
|
|
||||||
if (list.length && !list.some(t => t.id === addTun)) {
|
|
||||||
setAddTun(list[0]?.id ?? "")
|
|
||||||
}
|
|
||||||
}, [addSrc, addTun, tunnelsForServer])
|
|
||||||
|
|
||||||
const tunnelName = (srcId: string, tunnelId: string) =>
|
const tunnelName = (srcId: string, tunnelId: string) =>
|
||||||
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
|
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
|
||||||
@@ -528,21 +429,21 @@ function ScheduleTab({
|
|||||||
<Frame dense className="w-full overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
<FramePanel className="p-0 overflow-hidden">
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
||||||
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
|
<PlusIcon aria-hidden="true" className="size-4 text-muted-foreground" />Новое правило
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
|
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
|
||||||
<div><OptionLabel>Сервер</OptionLabel>
|
<div><OptionLabel>Сервер</OptionLabel>
|
||||||
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
|
<NativeSelect ariaLabel="Устройство расписания" value={addSrc} onChange={value=>{setAddSrc(value);setAddTun("")}} className="min-w-[160px]">
|
||||||
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</div>
|
</div>
|
||||||
<div><OptionLabel>GRE-туннель</OptionLabel>
|
<div><OptionLabel>GRE-туннель</OptionLabel>
|
||||||
<NativeSelect value={addTun} onChange={setAddTun} className="min-w-[150px]">
|
<NativeSelect ariaLabel="Туннель расписания" value={addTun} onChange={setAddTun} className="min-w-[150px]">
|
||||||
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</div>
|
</div>
|
||||||
<div><OptionLabel>Тип</OptionLabel>
|
<div><OptionLabel>Тип</OptionLabel>
|
||||||
<NativeSelect value={addType} onChange={v => setAddType(v as SchedType)}>
|
<NativeSelect ariaLabel="Тип теста расписания" value={addType} onChange={v => setAddType(v as SchedType)}>
|
||||||
<option value="ping">Ping</option>
|
<option value="ping">Ping</option>
|
||||||
<option value="bandwidth">BW-тест</option>
|
<option value="bandwidth">BW-тест</option>
|
||||||
<option value="both">Ping + BW</option>
|
<option value="both">Ping + BW</option>
|
||||||
@@ -566,7 +467,7 @@ function ScheduleTab({
|
|||||||
</Frame>
|
</Frame>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
||||||
<PlusIcon className="size-4" />Добавить правило
|
<PlusIcon aria-hidden="true" className="size-4" />Добавить правило
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -576,16 +477,40 @@ function ScheduleTab({
|
|||||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ProbesPage() {
|
export default function ProbesPage() {
|
||||||
|
return <Suspense fallback={<p role="status">Загрузка диагностики…</p>}><ProbesRoute /></Suspense>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbesRoute() {
|
||||||
|
const params = useSearchParams()
|
||||||
|
const url = readProbeUrl(new URLSearchParams(params.toString()))
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
if (url.error) return <div role="alert" aria-label="Ссылка диагностики" className="p-4 space-y-2"><p>{url.error}</p><Button type="button" variant="outline" onClick={() => window.history.replaceState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), null) + window.location.hash)}>Сбросить контекст диагностики</Button></div>
|
||||||
|
if (!prefsHydrated) return <p role="status">Загрузка настроек диагностики…</p>
|
||||||
|
return <ProbesContent key={JSON.stringify([mode, backendUrl])} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbesContent() {
|
||||||
|
const params = useSearchParams()
|
||||||
|
const { server } = readProbeUrl(new URLSearchParams(params.toString()))
|
||||||
|
const srcId = server ?? ""
|
||||||
|
const selectServer = (id: string) => window.history.pushState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), id || null) + window.location.hash)
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const canRead = can("mm:servers:read")
|
||||||
|
const canRun = can("mm:servers:write")
|
||||||
|
const canReadGre = can("mm:filters:read")
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
|
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
|
||||||
|
const [catalogError, setCatalogError] = useState<string | null>(null)
|
||||||
|
const [catalogRevision, setCatalogRevision] = useState(0)
|
||||||
|
const [catalogReadAt, setCatalogReadAt] = useState<number | null>(null)
|
||||||
|
const [greState, setGreState] = useState<{ id: string; state: "loading" | "ready" | "error"; error?: string } | null>(null)
|
||||||
|
const [greRevision, setGreRevision] = useState(0)
|
||||||
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
|
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
|
||||||
|
|
||||||
const [tool, setTool] = useState<DiagTool>("ping")
|
const [tool, setTool] = useState<DiagTool>("ping")
|
||||||
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
|
|
||||||
const [target, setTarget] = useState("8.8.8.8")
|
const [target, setTarget] = useState("8.8.8.8")
|
||||||
const [pingCount, setPingCount] = useState(5)
|
const [pingCount, setPingCount] = useState(5)
|
||||||
const [pingSize, setPingSize] = useState(64)
|
const [pingSize, setPingSize] = useState(64)
|
||||||
@@ -594,44 +519,40 @@ export default function ProbesPage() {
|
|||||||
const [traceUseDns, setTraceUseDns] = useState(false)
|
const [traceUseDns, setTraceUseDns] = useState(false)
|
||||||
const [traceHops] = useState(30)
|
const [traceHops] = useState(30)
|
||||||
const [dnsType, setDnsType] = useState<DnsType>("A")
|
const [dnsType, setDnsType] = useState<DnsType>("A")
|
||||||
const [bwTunId, setBwTunId] = useState("")
|
const [bwChoice, setBwChoice] = useState<{ serverId: string; id: string } | null>(null)
|
||||||
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
|
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
|
||||||
const [bwDuration, setBwDuration] = useState(10)
|
const [bwDuration, setBwDuration] = useState(10)
|
||||||
const [runBusy, setRunBusy] = useState(false)
|
const [runBusy, setRunBusy] = useState(false)
|
||||||
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
|
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
|
||||||
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined)
|
const [source, setSource] = useState<{ key: string; ipv4?: string | null; error?: string; loading: boolean } | null>(null)
|
||||||
|
const [sourceRevision, setSourceRevision] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive || !canRead) return
|
||||||
queueMicrotask(() => setLiveLoad("idle"))
|
let cancelled = false
|
||||||
return
|
const ctrl = new AbortController()
|
||||||
}
|
queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
|
||||||
setLiveLoad("loading")
|
void apiFetch<unknown>("/api/servers", { signal: ctrl.signal })
|
||||||
void apiFetch<BackendServerRow[]>("/api/servers")
|
.then(raw => {
|
||||||
.then(rows => {
|
const result = probeServersSchema.safeParse(raw)
|
||||||
const mapped: Server[] = rows.map(s => ({
|
if (!result.success) throw new Error("Некорректный ответ API: каталог устройств не подтверждён.")
|
||||||
id: String(s.id),
|
if (cancelled) return
|
||||||
name: s.name || s.host,
|
setLiveServers(result.data.map(s => ({
|
||||||
host: s.host,
|
id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—",
|
||||||
model: "—",
|
site: s.site ?? "", country: s.country ?? "UN", asn: s.asn ?? "", type: s.type,
|
||||||
os: "—",
|
enabled: s.enabled, status: s.status ?? "unknown",
|
||||||
site: s.site,
|
latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0,
|
||||||
country: s.country || "UN",
|
})))
|
||||||
asn: s.asn,
|
setCatalogReadAt(Date.now())
|
||||||
type: s.type,
|
|
||||||
enabled: s.enabled,
|
|
||||||
status: (s.status ?? "offline") as Server["status"],
|
|
||||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
|
||||||
sessions: 0,
|
|
||||||
}))
|
|
||||||
setLiveServers(mapped)
|
|
||||||
setLiveLoad("idle")
|
setLiveLoad("idle")
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(error => {
|
||||||
setLiveServers([])
|
if (cancelled) return
|
||||||
|
setCatalogError(error instanceof Error ? error.message : "Не удалось прочитать устройства.")
|
||||||
setLiveLoad("error")
|
setLiveLoad("error")
|
||||||
})
|
})
|
||||||
}, [isLive, apiFetch])
|
return () => { cancelled = true; ctrl.abort() }
|
||||||
|
}, [isLive, canRead, apiFetch, catalogRevision])
|
||||||
|
|
||||||
const allServers = useMemo(() => {
|
const allServers = useMemo(() => {
|
||||||
if (!isLive) return servers
|
if (!isLive) return servers
|
||||||
@@ -652,72 +573,71 @@ export default function ProbesPage() {
|
|||||||
}))
|
}))
|
||||||
), [allServers])
|
), [allServers])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive || !canReadGre || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) return
|
||||||
setRosSrcV4(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const n = Number.parseInt(srcId, 10)
|
|
||||||
if (!Number.isFinite(n)) {
|
|
||||||
setRosSrcV4(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setRosSrcV4(undefined)
|
const ctrl = new AbortController()
|
||||||
void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`)
|
queueMicrotask(() => { if (!cancelled) setGreState({ id: srcId, state: "loading" }) })
|
||||||
.then((r) => {
|
void apiFetch<unknown>("/api/filters/gre-tunnels?serverId=" + encodeURIComponent(srcId), { signal: ctrl.signal })
|
||||||
if (!cancelled) setRosSrcV4(r.ipv4)
|
.then(raw => {
|
||||||
|
const result = filtersGreResponseSchema.safeParse(raw)
|
||||||
|
if (!result.success || result.data.tunnels.some(tunnel => tunnel.serverId !== srcId)) throw new Error("Некорректный ответ API: GRE выбранного устройства не подтверждены.")
|
||||||
|
if (cancelled) return
|
||||||
|
setGreByServer(prev => ({ ...prev, [srcId]: result.data.tunnels }))
|
||||||
|
setGreState({ id: srcId, state: "ready" })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(error => {
|
||||||
if (!cancelled) setRosSrcV4(null)
|
if (cancelled) return
|
||||||
|
setGreState({ id: srcId, state: "error", error: error instanceof Error ? error.message : "Не удалось прочитать GRE." })
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true; ctrl.abort() }
|
||||||
}, [isLive, srcId, apiFetch])
|
}, [isLive, canReadGre, liveLoad, allServers, srcId, apiFetch, greRevision])
|
||||||
|
|
||||||
const ensureGre = useCallback(async (serverId: string) => {
|
|
||||||
if (!isLive) return
|
|
||||||
try {
|
|
||||||
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
|
|
||||||
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
|
|
||||||
)
|
|
||||||
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
|
|
||||||
} catch {
|
|
||||||
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
|
|
||||||
}
|
|
||||||
}, [isLive, apiFetch])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLive || !srcId) return
|
|
||||||
void ensureGre(srcId)
|
|
||||||
}, [isLive, srcId, ensureGre])
|
|
||||||
|
|
||||||
const tunnelsForSrc = useMemo(() => {
|
const tunnelsForSrc = useMemo(() => {
|
||||||
if (isLive) return greByServer[srcId] ?? []
|
if (isLive) return greByServer[srcId] ?? []
|
||||||
return greTunnels.filter(t => t.serverId === srcId)
|
return greTunnels.filter(t => t.serverId === srcId)
|
||||||
}, [isLive, greByServer, srcId])
|
}, [isLive, greByServer, srcId])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const first = allServers[0]?.id
|
|
||||||
if (!first) return
|
|
||||||
if (!allServers.some(s => s.id === srcId)) setSrcId(first)
|
|
||||||
}, [allServers, srcId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const list = isLive ? (greByServer[srcId] ?? []) : greTunnels.filter(t => t.serverId === srcId)
|
|
||||||
if (list.length && !list.some(t => t.id === bwTunId)) setBwTunId(list[0]!.id)
|
|
||||||
}, [isLive, greByServer, srcId, bwTunId])
|
|
||||||
|
|
||||||
// ── run state ──
|
// ── run state ──
|
||||||
const [tests, setTests] = useState<DiagTest[]>([])
|
const [tests, setTests] = useState<DiagTest[]>([])
|
||||||
const [tab, setTab] = useState<"history" | "schedule">("history")
|
const [tab, setTab] = useState<"history" | "schedule">("history")
|
||||||
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
|
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
|
||||||
const nextId = useRef(1)
|
const nextId = useRef(1)
|
||||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const targetRef = useRef<HTMLInputElement>(null)
|
||||||
|
const runButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
/** Живой POST /probes/run — отмена через fetch AbortSignal */
|
/** Живой POST /probes/run — отмена через fetch AbortSignal */
|
||||||
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
|
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
|
||||||
|
|
||||||
|
const runLock = useRef(false)
|
||||||
|
const alive = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true
|
||||||
|
return () => { alive.current = false; liveProbeRunRef.current?.ctrl.abort() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const bwTunnels = tunnelsForSrc
|
const bwTunnels = tunnelsForSrc
|
||||||
|
const bwTunId = bwChoice?.serverId === srcId ? bwChoice.id : (bwTunnels[0]?.id ?? "")
|
||||||
|
const setBwTunId = (id: string) => setBwChoice({ serverId: srcId, id })
|
||||||
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
|
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
|
||||||
|
const sourceKey = JSON.stringify([srcId, srcServer?.host])
|
||||||
|
const sourceRequired = tool === "ping" || tool === "traceroute" || tool === "mtu"
|
||||||
|
const sourceCurrent = source?.key === sourceKey ? source : null
|
||||||
|
const rosSrcV4 = sourceCurrent?.ipv4
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive || !canRead || liveLoad !== "idle" || !srcServer?.enabled || !sourceRequired) return
|
||||||
|
const host = srcServer.host, id = srcServer.id
|
||||||
|
if (!/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id))) return
|
||||||
|
let cancelled = false
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
queueMicrotask(() => { if (!cancelled) setSource({ key: sourceKey, loading: true }) })
|
||||||
|
void apiFetch<unknown>("/api/servers/" + id + "/ros-src-address", { signal: ctrl.signal })
|
||||||
|
.then(raw => { const ipv4 = readProbeSource(raw, host); if (!cancelled) setSource({ key: sourceKey, loading: false, ipv4 }) })
|
||||||
|
.catch(error => { if (!cancelled) setSource({ key: sourceKey, loading: false, error: error instanceof Error ? error.message : "Источник preview неизвестен." }) })
|
||||||
|
return () => { cancelled = true; ctrl.abort() }
|
||||||
|
}, [isLive, canRead, liveLoad, srcServer, sourceRequired, sourceKey, apiFetch, sourceRevision])
|
||||||
|
const greReady = !isLive || (greState?.id === srcId && greState.state === "ready")
|
||||||
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
|
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
|
||||||
|
|
||||||
// current command preview
|
// current command preview
|
||||||
@@ -726,37 +646,21 @@ export default function ProbesPage() {
|
|||||||
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
|
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
|
||||||
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
|
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
|
||||||
|
|
||||||
// ── progressive reveal tick ──
|
|
||||||
useEffect(() => {
|
|
||||||
tickRef.current = setInterval(() => {
|
|
||||||
setTests(prev => {
|
|
||||||
const hasRunning = prev.some(t => t.status === "running")
|
|
||||||
if (!hasRunning) return prev
|
|
||||||
return prev.map(t => {
|
|
||||||
if (t.source === "live") return t
|
|
||||||
if (t.status !== "running") return t
|
|
||||||
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
|
|
||||||
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
|
|
||||||
const done = nextTotal >= t.lines.length
|
|
||||||
return { ...t, totalLines: nextTotal, status: done ? "done" : "running" }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, 400)
|
|
||||||
return () => { if (tickRef.current) clearInterval(tickRef.current) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ── run test ──
|
// ── run test ──
|
||||||
const runTest = useCallback(async () => {
|
const runTest = useCallback(async () => {
|
||||||
const srv = allServers.find(s => s.id === srcId)
|
const srv = allServers.find(s => s.id === srcId)
|
||||||
if (!srv) return
|
if (!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || !srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
|
||||||
const id = String(nextId.current++)
|
const id = String(nextId.current++)
|
||||||
const numericServerId = Number.parseInt(srcId, 10)
|
const numericServerId = Number.parseInt(srcId, 10)
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
if (!Number.isFinite(numericServerId)) return
|
if (!Number.isFinite(numericServerId)) return
|
||||||
|
runLock.current = true
|
||||||
setRunBusy(true)
|
setRunBusy(true)
|
||||||
const abortCtrl = new AbortController()
|
const abortCtrl = new AbortController()
|
||||||
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
|
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
|
||||||
|
let timedOut = false
|
||||||
|
const timeout = setTimeout(() => { timedOut = true; abortCtrl.abort() }, 60_000)
|
||||||
setTests(p => [{
|
setTests(p => [{
|
||||||
id,
|
id,
|
||||||
tool,
|
tool,
|
||||||
@@ -766,7 +670,7 @@ export default function ProbesPage() {
|
|||||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||||
command: cmdPreview,
|
command: cmdPreview,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
lines: [{ text: "Выполняется запрос к MikroTik через API…", kind: "dim" }],
|
lines: [{ text: tool === "dns" ? "Выполняется DNS-запрос приложения…" : "Выполняется запрос к MikroTik через API…", kind: "dim" }],
|
||||||
totalLines: 1,
|
totalLines: 1,
|
||||||
source: "live",
|
source: "live",
|
||||||
}, ...p.slice(0, 9)])
|
}, ...p.slice(0, 9)])
|
||||||
@@ -793,32 +697,34 @@ export default function ProbesPage() {
|
|||||||
bwProto,
|
bwProto,
|
||||||
bwDuration,
|
bwDuration,
|
||||||
}
|
}
|
||||||
const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, {
|
const res = await apiFetch<unknown>(`/api/servers/${numericServerId}/probes/run`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: abortCtrl.signal,
|
signal: abortCtrl.signal,
|
||||||
})
|
})
|
||||||
const lines = parseProbeOutput(res.output ?? "")
|
if (!alive.current || abortCtrl.signal.aborted) return
|
||||||
const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:")
|
const output = readProbeOutput(res)
|
||||||
|
const lines = parseProbeOutput(output || "Запрос выполнен: API вернул пустой вывод.")
|
||||||
setTests(p => p.map(t => (t.id === id
|
setTests(p => p.map(t => (t.id === id
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
lines,
|
lines,
|
||||||
totalLines: lines.length,
|
totalLines: lines.length,
|
||||||
status: looksErr ? "error" : "done",
|
status: "done",
|
||||||
}
|
}
|
||||||
: t)))
|
: t)))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const aborted =
|
if (!alive.current) return
|
||||||
|
const aborted = abortCtrl.signal.aborted ||
|
||||||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|
||||||
|| (e instanceof Error && e.name === "AbortError")
|
|| (e instanceof Error && e.name === "AbortError")
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
setTests(p => p.map(t => (t.id === id
|
setTests(p => p.map(t => (t.id === id
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }],
|
lines: [{ text: timedOut ? "Время ожидания истекло. Результат на устройстве не подтверждён; повтор выполняется вручную." : "Ожидание отменено. Остановка на устройстве не подтверждена.", kind: timedOut ? "err" : "dim" }],
|
||||||
totalLines: 1,
|
totalLines: 1,
|
||||||
status: "done",
|
status: timedOut ? "error" : "cancelled",
|
||||||
}
|
}
|
||||||
: t)))
|
: t)))
|
||||||
} else {
|
} else {
|
||||||
@@ -833,8 +739,13 @@ export default function ProbesPage() {
|
|||||||
: t)))
|
: t)))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
|
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
|
||||||
setRunBusy(false)
|
runLock.current = false
|
||||||
|
if (alive.current) {
|
||||||
|
setRunBusy(false)
|
||||||
|
requestAnimationFrame(() => { if (alive.current && (document.activeElement === runButtonRef.current || document.activeElement === document.body)) (targetRef.current ?? runButtonRef.current)?.focus() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -867,6 +778,10 @@ export default function ProbesPage() {
|
|||||||
setTab("history")
|
setTab("history")
|
||||||
}, [
|
}, [
|
||||||
isLive,
|
isLive,
|
||||||
|
liveLoad,
|
||||||
|
canRun,
|
||||||
|
canReadGre,
|
||||||
|
greReady,
|
||||||
allServers,
|
allServers,
|
||||||
srcId,
|
srcId,
|
||||||
tool,
|
tool,
|
||||||
@@ -879,7 +794,6 @@ export default function ProbesPage() {
|
|||||||
traceHops,
|
traceHops,
|
||||||
dnsType,
|
dnsType,
|
||||||
bwTun,
|
bwTun,
|
||||||
bwTunId,
|
|
||||||
bwProto,
|
bwProto,
|
||||||
bwDuration,
|
bwDuration,
|
||||||
cmdPreview,
|
cmdPreview,
|
||||||
@@ -887,21 +801,27 @@ export default function ProbesPage() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const stopTest = (id: string) => {
|
const stopTest = (id: string) => {
|
||||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
|
if (liveProbeRunRef.current?.testId === id) { liveProbeRunRef.current.ctrl.abort(); return }
|
||||||
setTests(p => p.map(t => (t.id === id && t.status === "running"
|
setTests(p => p.map(t => (t.id === id && t.status === "running"
|
||||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
? { ...t, status: "cancelled" as const, totalLines: t.lines.length }
|
||||||
: t)))
|
: t)))
|
||||||
}
|
}
|
||||||
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id))
|
const clearTest = (id: string) => {
|
||||||
|
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
|
||||||
|
setTests(p => p.filter(t => t.id !== id))
|
||||||
|
runButtonRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
const running = tests.filter(t => t.status === "running")
|
const running = tests.filter(t => t.status === "running")
|
||||||
|
|
||||||
if (isLive && liveLoad === "loading") {
|
if (!canRead) return <p role="status">Для просмотра устройств требуется mm:servers:read.</p>
|
||||||
|
|
||||||
|
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
|
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
<LoaderCircleIcon className="size-8 animate-spin opacity-50" />
|
<LoaderCircleIcon aria-hidden="true" className="size-8 motion-safe:animate-spin opacity-50" />
|
||||||
<p className="text-sm">Загрузка серверов из бекенда…</p>
|
<p className="text-sm">Загрузка серверов из бекенда…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -912,7 +832,8 @@ export default function ProbesPage() {
|
|||||||
<ServerRailLayout
|
<ServerRailLayout
|
||||||
items={probeRailItems}
|
items={probeRailItems}
|
||||||
selectedId={srcId}
|
selectedId={srcId}
|
||||||
onSelect={setSrcId}
|
onSelect={selectServer}
|
||||||
|
preserveSelection
|
||||||
showAll={false}
|
showAll={false}
|
||||||
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
||||||
header={
|
header={
|
||||||
@@ -921,14 +842,12 @@ export default function ProbesPage() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ServerRailMobileButton />
|
<ServerRailMobileButton />
|
||||||
|
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить устройства диагностики" disabled={liveLoad === "loading"} onClick={() => setCatalogRevision(n => n + 1)}>Обновить</Button>}
|
||||||
{running.length > 0
|
{running.length > 0
|
||||||
? <Button variant="outline" size="sm" onClick={() => {
|
? <Button variant="outline" size="sm" onClick={() => {
|
||||||
liveProbeRunRef.current?.ctrl.abort()
|
running.forEach(test => stopTest(test.id))
|
||||||
setTests(p => p.map(t => (t.status === "running"
|
|
||||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
|
||||||
: t)))
|
|
||||||
}}>
|
}}>
|
||||||
<SquareIcon className="size-4" />Остановить все
|
<SquareIcon aria-hidden="true" className="size-4" />Остановить все
|
||||||
</Button>
|
</Button>
|
||||||
: null}
|
: null}
|
||||||
</>
|
</>
|
||||||
@@ -938,35 +857,61 @@ export default function ProbesPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{!server && <p role="status">Выберите устройство для диагностики. Выбор сохраняется в URL.</p>}
|
||||||
|
{server && (!isLive || catalogReadAt !== null) && !srcServer && <div role="alert" aria-label="Устройство диагностики"><p>Указанное устройство отсутствует в текущем каталоге. Другое устройство автоматически не выбирается.</p><Button type="button" variant="outline" onClick={() => selectServer("")}>Сбросить выбор устройства</Button></div>}
|
||||||
|
{srcServer && !srcServer.enabled && <p role="status">Выбранное устройство выключено. Диагностика недоступна.</p>}
|
||||||
{isLive && liveLoad === "error" && (
|
{isLive && liveLoad === "error" && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-xs text-destructive flex items-center gap-2">
|
<div role="alert" aria-label="Каталог диагностики" className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
<p>{catalogReadAt === null ? "Устройства пока неизвестны." : "Не удалось обновить устройства. Последний список сохранён; запуск приостановлен."}</p>
|
||||||
Бекенд недоступен — переключитесь в режим «Демо» в настройках источника данных или проверьте URL API.
|
<p>{catalogError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLive && catalogReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(catalogReadAt).toLocaleTimeString("ru-RU")}{liveLoad === "loading" ? " · обновляется" : ""}</p>}
|
||||||
|
{isLive && liveLoad === "idle" && allServers.length === 0 && <p role="status">В каталоге нет устройств. Добавьте устройство в разделе «Серверы».</p>}
|
||||||
|
|
||||||
|
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: запросы не отправляются; результаты синтетические.</p>}
|
||||||
|
{isLive && (
|
||||||
|
<details className="text-xs text-muted-foreground"><summary className="cursor-pointer">Где выполняются тесты</summary><p className="pt-2">
|
||||||
|
Сетевые тесты выполняются на выбранном устройстве. DNS использует резолвер приложения. Traceroute ограничен примерно минутой; сокращение числа хопов указывается в ответе. Стоп отменяет ожидание, но не подтверждает остановку на устройстве.
|
||||||
|
</p></details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canRun && <p role="status" className="text-sm text-muted-foreground">Для запуска диагностики требуется mm:servers:write.</p>}
|
||||||
|
{isLive && tool === "bandwidth" && !canReadGre && <p role="status" className="text-sm text-muted-foreground">Для выбора GRE требуется mm:filters:read.</p>}
|
||||||
|
|
||||||
|
{isLive && tool === "bandwidth" && canReadGre && (
|
||||||
|
<div className="text-sm">
|
||||||
|
{greState?.id === srcId && greState.state === "error" ? <div role="alert" aria-label="GRE диагностики"><p className="text-destructive">GRE недоступны. Последний список сохранён; BW-тест приостановлен. {greState.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setGreRevision(n => n + 1)}>Повторить GRE</Button></div>
|
||||||
|
: !greReady ? <p role="status">GRE выбранного устройства загружаются…</p>
|
||||||
|
: !bwTunnels.length ? <p role="status">У выбранного устройства нет GRE-туннелей.</p> : !bwTun?.enabled || !bwTun.remoteAddress.trim() ? <p role="status">Выберите включённый GRE с адресом назначения.</p> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLive && (
|
{isLive && srcServer?.enabled && sourceRequired && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
|
{sourceCurrent?.error ? <div role="alert" aria-label="Источник preview"><p>{sourceCurrent.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setSourceRevision(n => n + 1)}>Повторить определение IPv4</Button></div>
|
||||||
</p>
|
: !sourceCurrent || sourceCurrent.loading ? <p role="status">Определяем IPv4 для preview…</p>
|
||||||
|
: <p role="status">{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}</p>}
|
||||||
|
<p>Адрес справочный: при запуске определяется заново.</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── tool selector + config ── */}
|
{/* ── tool selector + config ── */}
|
||||||
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
|
<OpsPanel contentClassName="pt-4 pb-4 px-3 md:px-4 flex min-w-0 flex-col gap-4">
|
||||||
|
|
||||||
{/* tool chips */}
|
{/* tool chips */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
|
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
|
||||||
const active = tool === t
|
const active = tool === t
|
||||||
return (
|
return (
|
||||||
<button key={t} onClick={() => setTool(t)}
|
<button key={t} type="button" aria-pressed={active} title={m.description} onClick={() => setTool(t)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors",
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
active
|
active
|
||||||
? "border-foreground bg-foreground text-background"
|
? "border-foreground bg-foreground text-background"
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||||
)}>
|
)}>
|
||||||
<m.Icon className={cn("size-3.5", active ? "" : m.color)} />
|
<m.Icon aria-hidden="true" className={cn("size-3.5", active ? "" : m.color)} />
|
||||||
{m.label}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -979,11 +924,12 @@ export default function ProbesPage() {
|
|||||||
{/* target — all tools except bandwidth */}
|
{/* target — all tools except bandwidth */}
|
||||||
{tool !== "bandwidth" && (
|
{tool !== "bandwidth" && (
|
||||||
<div className="flex-1 min-w-[140px]">
|
<div className="flex-1 min-w-[140px]">
|
||||||
<OptionLabel>
|
<OptionLabel htmlFor="probe-target">
|
||||||
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"}
|
{tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель (IP или домен)"}
|
||||||
</OptionLabel>
|
</OptionLabel>
|
||||||
<Input value={target} onChange={e => setTarget(e.target.value)}
|
<Input id="probe-target" ref={targetRef} maxLength={1024} value={target} onChange={e => setTarget(e.target.value)}
|
||||||
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
|
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
|
||||||
|
aria-label={tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель диагностики"}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
onKeyDown={e => e.key === "Enter" && runTest()}
|
onKeyDown={e => e.key === "Enter" && runTest()}
|
||||||
/>
|
/>
|
||||||
@@ -994,7 +940,8 @@ export default function ProbesPage() {
|
|||||||
{tool === "bandwidth" && (
|
{tool === "bandwidth" && (
|
||||||
<div>
|
<div>
|
||||||
<OptionLabel>GRE-туннель (цель)</OptionLabel>
|
<OptionLabel>GRE-туннель (цель)</OptionLabel>
|
||||||
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]">
|
<NativeSelect ariaLabel="GRE-туннель для BW-теста" value={bwTunnels.some(t => t.id === bwTunId) ? bwTunId : ""} onChange={setBwTunId} className="min-w-[180px]">
|
||||||
|
{!bwTunnels.some(t => t.id === bwTunId) && <option value="" disabled>Выберите GRE-туннель</option>}
|
||||||
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</div>
|
</div>
|
||||||
@@ -1004,14 +951,14 @@ export default function ProbesPage() {
|
|||||||
{tool === "ping" && (
|
{tool === "ping" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<OptionLabel>Кол-во</OptionLabel>
|
<OptionLabel>Количество пакетов</OptionLabel>
|
||||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
<div className="flex flex-wrap rounded-lg border border-input min-h-8">
|
||||||
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
|
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<OptionLabel>Размер (байт)</OptionLabel>
|
<OptionLabel>Размер (байт)</OptionLabel>
|
||||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
<div className="flex flex-wrap rounded-lg border border-input overflow-hidden min-h-8">
|
||||||
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
|
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1033,7 +980,7 @@ export default function ProbesPage() {
|
|||||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
|
<FormToggle ariaLabel="Разрешать имена хопов на устройстве" checked={traceUseDns} onChange={setTraceUseDns} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1042,7 +989,7 @@ export default function ProbesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<OptionLabel>Тип записи</OptionLabel>
|
<OptionLabel>Тип записи</OptionLabel>
|
||||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||||
{(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
|
{(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1064,25 +1011,25 @@ export default function ProbesPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button ref={runButtonRef} type="button" aria-busy={runBusy}
|
||||||
onClick={() => void runTest()}
|
onClick={() => void runTest()}
|
||||||
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)}
|
disabled={!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
|
||||||
className="h-8 shrink-0 gap-1.5 self-end"
|
className="h-8 shrink-0 gap-1.5 self-end"
|
||||||
>
|
>
|
||||||
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
|
{runBusy ? <LoaderCircleIcon aria-hidden="true" className="size-3.5 motion-safe:animate-spin" /> : <PlayIcon aria-hidden="true" className="size-3.5" />}
|
||||||
Запустить
|
Запустить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RouterOS command preview */}
|
{/* RouterOS command preview */}
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-zinc-950 dark:bg-zinc-900 px-3 py-2 border border-zinc-800">
|
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-muted/30 px-3 py-2 border">
|
||||||
<TerminalIcon className="size-3 text-zinc-500 shrink-0" />
|
<TerminalIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||||
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span>
|
<span className="hidden md:inline text-muted-foreground text-xs font-mono truncate max-w-24">[{tool === "dns" ? (isLive ? "backend" : "демо") : srcServer?.name}]</span>
|
||||||
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span>
|
<span className="hidden md:inline text-success text-xs font-mono shrink-0">$</span>
|
||||||
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span>
|
<span className="min-w-0 flex-1 text-foreground text-xs font-mono [overflow-wrap:anywhere]">{cmdPreview}</span>
|
||||||
<button onClick={() => navigator.clipboard.writeText(cmdPreview)}
|
<button type="button" aria-label="Скопировать параметры диагностики" onClick={() => void copyProbeText(cmdPreview)}
|
||||||
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto">
|
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors ml-auto">
|
||||||
<CopyIcon className="size-3" />
|
<CopyIcon aria-hidden="true" className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
@@ -1094,7 +1041,7 @@ export default function ProbesPage() {
|
|||||||
["history", "История тестов"],
|
["history", "История тестов"],
|
||||||
["schedule", "Расписание"],
|
["schedule", "Расписание"],
|
||||||
] as const).map(([t, label]) => (
|
] as const).map(([t, label]) => (
|
||||||
<button key={t} onClick={() => setTab(t)}
|
<button key={t} type="button" aria-pressed={tab === t} onClick={() => setTab(t)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
|
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
|
||||||
tab === t
|
tab === t
|
||||||
@@ -1116,7 +1063,7 @@ export default function ProbesPage() {
|
|||||||
{tab === "history" && (
|
{tab === "history" && (
|
||||||
tests.length === 0 ? (
|
tests.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
|
||||||
<TerminalIcon className="size-8 opacity-20" />
|
<TerminalIcon aria-hidden="true" className="size-8 opacity-20" />
|
||||||
<p className="text-sm">Запустите тест — результаты появятся здесь</p>
|
<p className="text-sm">Запустите тест — результаты появятся здесь</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1124,52 +1071,46 @@ export default function ProbesPage() {
|
|||||||
{tests.map(test => {
|
{tests.map(test => {
|
||||||
const { Icon, color, label } = TOOL_META[test.tool]
|
const { Icon, color, label } = TOOL_META[test.tool]
|
||||||
return (
|
return (
|
||||||
<Frame key={test.id} dense className="w-full overflow-hidden">
|
<Frame key={test.id} dense aria-busy={test.status === "running"} className="w-full h-[60dvh] min-h-0 flex flex-col overflow-hidden">
|
||||||
<FramePanel className="p-0 overflow-hidden">
|
<FramePanel className="p-0 flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
|
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 border-b bg-muted/20">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex flex-wrap items-center gap-2 min-w-0 w-full md:w-auto">
|
||||||
<Icon className={cn("size-4 shrink-0", color)} />
|
<Icon aria-hidden="true" className={cn("size-4 shrink-0", color)} />
|
||||||
<span className="text-sm font-medium">{label}</span>
|
<span className="text-sm font-medium">{label}</span>
|
||||||
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span>
|
<span className="min-w-0 max-w-[70%] text-xs font-mono text-muted-foreground truncate" title={test.target}>{test.target}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">← {test.srcServerName}</span>
|
<span className="w-full md:w-auto max-w-full text-xs text-muted-foreground truncate" title={test.tool === "dns" ? "DNS приложения" : test.srcServerName}>{test.tool === "dns" ? "DNS приложения" : "← " + test.srcServerName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||||
{test.status === "running" && (
|
{test.status === "running" && (
|
||||||
<>
|
<>
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
<span role="status" className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
<span className="size-1.5 rounded-full bg-info motion-safe:animate-pulse" />
|
||||||
запущен
|
ожидаем ответ
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" variant="outline" className="h-6 px-2 text-xs"
|
<Button size="sm" variant="outline" type="button" className="h-11 md:h-7 px-2 text-xs"
|
||||||
onClick={() => stopTest(test.id)}>
|
onClick={() => stopTest(test.id)}>
|
||||||
<SquareIcon className="size-3" />Стоп
|
<SquareIcon aria-hidden="true" className="size-3" />Стоп
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{test.status === "done" && test.source !== "live" && (
|
{test.status === "done" && <span role="status" className="text-xs text-muted-foreground">{test.source === "live" ? "ответ API" : "демо"}</span>}
|
||||||
<span className="text-[11px] text-muted-foreground">
|
{test.status === "cancelled" && <span role="status" className="text-[11px] text-muted-foreground">ожидание отменено</span>}
|
||||||
{((test.lines.length * 0.4)).toFixed(1)}с
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{test.status === "done" && test.source === "live" && (
|
|
||||||
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
|
|
||||||
)}
|
|
||||||
{test.status === "error" && (
|
{test.status === "error" && (
|
||||||
<span className="text-[11px] text-destructive">ошибка</span>
|
<span role="alert" className="text-xs text-destructive">ошибка</span>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))}
|
<button type="button" aria-label={"Скопировать показанный вывод · " + label + " · " + test.id} onClick={() => void copyProbeText(test.lines.map(l => l.text).join("\n"))}
|
||||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
|
className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-foreground hover:bg-muted transition-colors">
|
||||||
<CopyIcon className="size-3.5" />
|
<CopyIcon aria-hidden="true" className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => clearTest(test.id)}
|
<button type="button" aria-label={"Удалить результат · " + label + " · " + test.id} onClick={() => clearTest(test.id)}
|
||||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
|
className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-destructive hover:bg-destructive/10 transition-colors">
|
||||||
<Trash2Icon className="size-3.5" />
|
<Trash2Icon aria-hidden="true" className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* terminal output */}
|
{/* terminal output */}
|
||||||
<div className="p-3">
|
<div className="flex-1 min-h-0 p-3">
|
||||||
<TerminalOutput test={test} />
|
<TerminalOutput test={test} />
|
||||||
</div>
|
</div>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
@@ -1183,9 +1124,10 @@ export default function ProbesPage() {
|
|||||||
{/* schedule */}
|
{/* schedule */}
|
||||||
{tab === "schedule" && (
|
{tab === "schedule" && (
|
||||||
isLive
|
isLive
|
||||||
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} />
|
? <ProbesSpeedSchedule base={backendUrl} serversForName={allServers} />
|
||||||
: (
|
: (
|
||||||
<ScheduleTab
|
<ScheduleTab
|
||||||
|
key={srcId}
|
||||||
rules={rules}
|
rules={rules}
|
||||||
setRules={setRules}
|
setRules={setRules}
|
||||||
serverOptions={allServers}
|
serverOptions={allServers}
|
||||||
|
|||||||
@@ -1,48 +1,49 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useRef, useMemo, useState, Suspense } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { readRecursiveUrl, patchRecursiveUrl, type RecursiveUrlPatch, type RecursiveUrlState } from "@/lib/recursive-route-url-state"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
RecursiveRoutesDataGrid,
|
RecursiveRoutesDataGrid,
|
||||||
type RecursiveRouteGroup,
|
type RecursiveRouteGroup,
|
||||||
inferCountry,
|
|
||||||
} from "@/components/data-grids/recursive-routes-data-grid"
|
} from "@/components/data-grids/recursive-routes-data-grid"
|
||||||
|
import { RecursiveGatewayChoices, type GatewayOption } from "@/components/recursive-gateway-choices"
|
||||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { recursiveReadSchema, recursiveReceiptSchema, recursiveGatewaysSchema, recursiveServersSchema, type RecursiveRouteRow } from "@/lib/recursive-route-response"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { servers as mockServers, type Server } from "@/lib/data"
|
import { servers as mockServers, type Server } from "@/lib/data"
|
||||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
import { PlusIcon, TrashIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
|
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
host: string
|
host: string
|
||||||
type: "jump-host" | "exit-node" | "home-router"
|
type?: "jump-host" | "exit-node" | "home-router"
|
||||||
site: string
|
site: string
|
||||||
country: string
|
country: string
|
||||||
asn: string
|
asn: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status: "online" | "offline" | null
|
status: "online" | "offline" | "degraded" | null
|
||||||
latency: number | null
|
latency: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GatewayOption {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
ip: string
|
|
||||||
status: "up" | "down"
|
|
||||||
}
|
|
||||||
|
|
||||||
const COUNTRY_OPTIONS = [
|
const COUNTRY_OPTIONS = [
|
||||||
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||||
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||||
@@ -58,21 +59,7 @@ const COUNTRY_OPTIONS = [
|
|||||||
{ code: "NO", label: "Норвегия" },
|
{ code: "NO", label: "Норвегия" },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface RecursiveRouteRow {
|
type RouteGroup = RecursiveRouteGroup
|
||||||
id: string
|
|
||||||
dstAddress: string
|
|
||||||
gateway: string
|
|
||||||
distance: number
|
|
||||||
scope: number | null
|
|
||||||
targetScope: number | null
|
|
||||||
routingTable: string
|
|
||||||
checkGateway: string
|
|
||||||
comment: string
|
|
||||||
disabled: boolean
|
|
||||||
country: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RouteGroup extends RecursiveRouteGroup {}
|
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -80,7 +67,7 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBackendServer(s: BackendServer): Server {
|
function mapBackendServer(s: BackendServer): Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] } {
|
||||||
return {
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || s.host,
|
name: s.name || s.host,
|
||||||
@@ -92,7 +79,7 @@ function mapBackendServer(s: BackendServer): Server {
|
|||||||
asn: s.asn,
|
asn: s.asn,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
status: (s.status ?? "offline") as Server["status"],
|
status: s.status,
|
||||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||||
sessions: 0,
|
sessions: 0,
|
||||||
}
|
}
|
||||||
@@ -107,6 +94,7 @@ interface RouteForm {
|
|||||||
|
|
||||||
interface RouteEndpointForm {
|
interface RouteEndpointForm {
|
||||||
id: string
|
id: string
|
||||||
|
disabled: boolean
|
||||||
gateway: string
|
gateway: string
|
||||||
distance: number
|
distance: number
|
||||||
scope: number | null
|
scope: number | null
|
||||||
@@ -117,6 +105,7 @@ interface RouteEndpointForm {
|
|||||||
|
|
||||||
const newEndpoint = (): RouteEndpointForm => ({
|
const newEndpoint = (): RouteEndpointForm => ({
|
||||||
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
disabled: false,
|
||||||
gateway: "",
|
gateway: "",
|
||||||
distance: 1,
|
distance: 1,
|
||||||
scope: null,
|
scope: null,
|
||||||
@@ -144,7 +133,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
|||||||
<label className="text-sm font-medium">Страна</label>
|
<label className="text-sm font-medium">Страна</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input placeholder="Поиск или код (RU, DE…)" value={query}
|
<Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const v = e.target.value.toUpperCase().slice(0, 3)
|
const v = e.target.value.toUpperCase().slice(0, 3)
|
||||||
setQuery(v)
|
setQuery(v)
|
||||||
@@ -167,7 +156,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-5 gap-1.5 max-h-40 overflow-y-auto pr-0.5">
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-1.5">
|
||||||
{visible.map(c => (
|
{visible.map(c => (
|
||||||
<button key={c.code} type="button"
|
<button key={c.code} type="button"
|
||||||
onClick={() => { onChange(c.code); setQuery("") }}
|
onClick={() => { onChange(c.code); setQuery("") }}
|
||||||
@@ -188,133 +177,129 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RouteSheet({
|
function RouteSheet({
|
||||||
open, mode, initial, onSave, onClose, gateways,
|
open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
mode: "create" | "edit"
|
mode: "create" | "edit"
|
||||||
initial: RouteForm
|
initial: RouteForm
|
||||||
onSave: (v: RouteForm) => void
|
onSave: (v: RouteForm) => Promise<boolean>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
gateways: GatewayOption[]
|
gateways: GatewayOption[]
|
||||||
|
busy: boolean
|
||||||
|
saveError: string | null
|
||||||
|
returnFocus: () => HTMLElement | null
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<RouteForm>(initial)
|
const [form, setForm] = useState<RouteForm>(initial)
|
||||||
|
const [endpointPage, setEndpointPage] = useState(0)
|
||||||
|
const lastEndpointPage = Math.max(0, Math.ceil(form.endpoints.length / 10) - 1)
|
||||||
|
if (endpointPage > lastEndpointPage) setEndpointPage(lastEndpointPage)
|
||||||
|
const currentEndpointPage = Math.min(endpointPage, lastEndpointPage)
|
||||||
|
const endpointOffset = currentEndpointPage * 10
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { setForm(initial); setError(null) }, [initial, open])
|
useEffect(() => { setForm(initial); setError(null); setEndpointPage(0) }, [initial, open])
|
||||||
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
|
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
|
||||||
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
|
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
|
||||||
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
|
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
|
||||||
|
|
||||||
function handleSave() {
|
const saveLock = useRef(false)
|
||||||
|
async function handleSave() {
|
||||||
|
if (busy || saveLock.current) return
|
||||||
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
|
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
|
||||||
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
|
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
|
||||||
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
|
const emptyGateway = form.endpoints.findIndex(ep => !ep.gateway.trim())
|
||||||
|
if (emptyGateway >= 0) { setEndpointPage(Math.floor(emptyGateway / 10)); setError(`У каждой конечной точки должен быть Gateway (Endpoint ${emptyGateway + 1})`); return }
|
||||||
|
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
|
||||||
|
const invalidEndpoint = form.endpoints.findIndex(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))
|
||||||
|
if (invalidEndpoint >= 0) { setEndpointPage(Math.floor(invalidEndpoint / 10)); setError(`Distance, Scope и T.Scope должны быть целыми числами от 0 до 255 (Endpoint ${invalidEndpoint + 1})`); return }
|
||||||
setError(null)
|
setError(null)
|
||||||
onSave(form)
|
saveLock.current = true
|
||||||
|
try { await onSave(form) } finally { saveLock.current = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
<SheetContent side="right" finalFocus={returnFocus} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
||||||
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
||||||
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
|
<Button type="button" variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon aria-hidden="true" className="size-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
<fieldset disabled={busy} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
||||||
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
<Input aria-label="Адрес назначения" className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Конечные точки</SectionTitle>
|
<SectionTitle>Конечные точки</SectionTitle>
|
||||||
|
{form.endpoints.length > 10 && <div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input key={currentEndpointPage} type="number" min={1} max={form.endpoints.length} defaultValue={endpointOffset + 1} aria-label="Перейти к endpoint" aria-describedby="recursive-endpoint-jump-hint" className="w-24 min-h-11" onKeyDown={event => {
|
||||||
|
if (event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
const number = Number(event.currentTarget.value)
|
||||||
|
if (Number.isInteger(number) && number >= 1 && number <= form.endpoints.length) { setEndpointPage(Math.floor((number - 1) / 10)); setError(null) }
|
||||||
|
else setError("Укажите номер endpoint от 1 до " + form.endpoints.length)
|
||||||
|
}} />
|
||||||
|
<span id="recursive-endpoint-jump-hint" className="text-xs text-muted-foreground">Номер endpoint → Enter</span>
|
||||||
|
<span role="status" className="text-xs text-muted-foreground">Endpoints {endpointOffset + 1}–{Math.min(endpointOffset + 10, form.endpoints.length)} из {form.endpoints.length}</span>
|
||||||
|
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие endpoints формы" disabled={currentEndpointPage === 0} onClick={() => setEndpointPage(currentEndpointPage - 1)}>Назад</Button>
|
||||||
|
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие endpoints формы" disabled={currentEndpointPage === lastEndpointPage} onClick={() => setEndpointPage(currentEndpointPage + 1)}>Далее</Button>
|
||||||
|
</div>}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{form.endpoints.map((ep, idx) => (
|
{form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
|
||||||
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
<div key={ep.id} data-recursive-endpoint-form={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
Endpoint {idx + 1}
|
Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
||||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
aria-label={`Удалить конечную точку ${endpointOffset + idx + 1}`}
|
||||||
disabled={form.endpoints.length <= 1}
|
disabled={form.endpoints.length <= 1}
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-3.5" />
|
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
||||||
|
|
||||||
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
||||||
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
<Input aria-label="Шлюз" className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<FormField label="Distance (приоритет)">
|
<FormField label="Distance (приоритет)">
|
||||||
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
<Input aria-label="Distance (приоритет)" type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Check Gateway">
|
<FormField label="Check Gateway">
|
||||||
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
<Input aria-label="Check Gateway" className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<FormField label="Scope">
|
<FormField label="Scope">
|
||||||
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
<Input aria-label="Scope" type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="T.Scope">
|
<FormField label="T.Scope">
|
||||||
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
<Input aria-label="T.Scope" type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
<RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
|
||||||
{gateways.map((g) => {
|
|
||||||
const value = `${g.ip}%${g.name}`
|
|
||||||
const selected = ep.gateway === value
|
|
||||||
const country = inferCountry(g.name)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${ep.id}-${g.id}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEp(ep.id, "gateway", value)}
|
|
||||||
className={cn(
|
|
||||||
"text-left rounded-lg border px-3 py-2.5 transition-all",
|
|
||||||
selected
|
|
||||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
|
||||||
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} />
|
|
||||||
{country && <Flag code={country} className="shrink-0" />}
|
|
||||||
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
|
|
||||||
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
|
|
||||||
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
|
|
||||||
<span className="text-muted-foreground/30">→</span>
|
|
||||||
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => { setEndpointPage(Math.floor(form.endpoints.length / 10)); setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] })) }}>
|
||||||
<PlusIcon className="size-3.5" />Добавить endpoint
|
<PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
|
||||||
</Button>
|
</Button>
|
||||||
{gateways.length === 0 && (
|
{gateways.length === 0 && (
|
||||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||||
@@ -326,16 +311,18 @@ function RouteSheet({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Параметры</SectionTitle>
|
<SectionTitle>Параметры</SectionTitle>
|
||||||
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
<FormField label="Routing Table"><Input aria-label="Таблица маршрутизации" className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
||||||
<FormField label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
<Input aria-label="Комментарий" className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
|
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
|
||||||
</div>
|
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</p>}
|
||||||
|
{error && <div role="alert" className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}</div>}
|
||||||
|
</fieldset>
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
|
||||||
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
|
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</Button>
|
||||||
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="h-8 flex-none"><CheckIcon aria-hidden="true" className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -344,37 +331,81 @@ function RouteSheet({
|
|||||||
|
|
||||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<Badge size="xs" className="shrink-0 text-foreground"
|
||||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
variant={type === "home-router" ? "success-light" : type === "jump-host" ? "primary-light" : "info-light"}>
|
||||||
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
|
|
||||||
type === "jump-host"
|
|
||||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
|
||||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
|
||||||
)}>
|
|
||||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||||
</span>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecursiveRoutesPage() {
|
export default function RecursiveRoutesPage() {
|
||||||
|
return <Suspense fallback={<p role="status" className="p-6 text-sm text-muted-foreground">Загрузка маршрутов…</p>}><RecursiveRoutesRoute /></Suspense>
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecursiveRoutesRoute() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const params = useSearchParams(), context = readRecursiveUrl(new URLSearchParams(params.toString()))
|
||||||
|
const sourceKey = JSON.stringify([mode, backendUrl])
|
||||||
|
const pendingSources = useRef(new Set<string>())
|
||||||
|
const [pending, setPending] = useState<string[]>([])
|
||||||
|
const [readVersions, setReadVersions] = useState<Record<string, number>>({})
|
||||||
|
const beginWrite = useCallback(() => {
|
||||||
|
if (pendingSources.current.has(sourceKey)) return false
|
||||||
|
pendingSources.current.add(sourceKey); setPending([...pendingSources.current]); return true
|
||||||
|
}, [sourceKey])
|
||||||
|
const endWrite = useCallback(() => {
|
||||||
|
pendingSources.current.delete(sourceKey); setPending([...pendingSources.current])
|
||||||
|
setReadVersions(versions => ({ ...versions, [sourceKey]: (versions[sourceKey] ?? 0) + 1 }))
|
||||||
|
}, [sourceKey])
|
||||||
|
const patchUrl = useCallback((patch: RecursiveUrlPatch, replace = false) => {
|
||||||
|
const url = new URL(window.location.href); url.search = patchRecursiveUrl(url.searchParams, patch).toString()
|
||||||
|
const target = url.pathname + url.search + url.hash
|
||||||
|
if (replace) window.history.replaceState(null, "", target); else window.history.pushState(null, "", target)
|
||||||
|
}, [])
|
||||||
|
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl, context.serverId, context.error])} mode={mode} backendUrl={backendUrl} context={context} patchUrl={patchUrl} beginWrite={beginWrite} endWrite={endWrite} sharedBusy={pending.includes(sourceKey)} readVersion={readVersions[sourceKey] ?? 0} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrite, endWrite, sharedBusy, readVersion }: {
|
||||||
|
beginWrite: () => boolean; endWrite: () => void; sharedBusy: boolean; readVersion: number
|
||||||
|
mode: string; backendUrl: string; context: RecursiveUrlState; patchUrl: (patch: RecursiveUrlPatch, replace?: boolean) => void
|
||||||
|
}) {
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [servers, setServers] = useState<Server[]>([])
|
const [servers, setServers] = useState<Array<Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] }>>([])
|
||||||
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
|
||||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||||
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
||||||
const [search, setSearch] = useState("")
|
const search = context.q
|
||||||
|
const setSearch = (q: string) => patchUrl({ q }, true)
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||||
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
||||||
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
||||||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||||||
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
const expandedGroupKey = context.route && context.table ? JSON.stringify([context.route.toLowerCase(), context.table]) : null
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
|
const [liveStale, setLiveStale] = useState(true)
|
||||||
|
const [routeOwner, setRouteOwner] = useState("")
|
||||||
|
const [readAt, setReadAt] = useState<string | null>(null)
|
||||||
|
const [writeAt, setWriteAt] = useState<string | null>(null)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [gatewayError, setGatewayError] = useState<string | null>(null)
|
||||||
|
const historyEpoch = useRef(0)
|
||||||
|
const alive = useRef(false)
|
||||||
|
const readEpoch = useRef(0), gatewayEpoch = useRef(0), writeLock = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true
|
||||||
|
return () => { alive.current = false }
|
||||||
|
}, [])
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
||||||
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
||||||
|
const [inventoryTick, setInventoryTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
@@ -385,126 +416,175 @@ export default function RecursiveRoutesPage() {
|
|||||||
setRows([])
|
setRows([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setLiveServerListReady(false)
|
setLiveServerListReady(false)
|
||||||
apiFetch<BackendServer[]>("/api/servers")
|
let active = true
|
||||||
|
apiFetch<unknown>("/api/servers")
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const mapped = data.map(mapBackendServer)
|
if (!active) return
|
||||||
|
const parsed = recursiveServersSchema.safeParse(data)
|
||||||
|
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
|
||||||
|
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type, site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
|
||||||
setServers(mapped)
|
setServers(mapped)
|
||||||
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
|
setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "")
|
||||||
|
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(error => {
|
||||||
|
if (!active) return
|
||||||
|
setOpError(error instanceof Error ? error.message : "Не удалось загрузить устройства")
|
||||||
setServers([])
|
setServers([])
|
||||||
setSelectedServerId("")
|
setSelectedServerId("")
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setLiveServerListReady(true)
|
if (active) setLiveServerListReady(true)
|
||||||
})
|
})
|
||||||
}, [isLive, apiFetch])
|
return () => { active = false }
|
||||||
|
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
|
||||||
|
|
||||||
const loadRoutes = useCallback(async () => {
|
const loadRoutes = useCallback(async () => {
|
||||||
if (!isLive || !liveServerListReady || !selectedServerId) return
|
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId) || writeLock.current) return
|
||||||
setOpError(null)
|
const epoch = ++readEpoch.current
|
||||||
setBusy("load")
|
setOpError(null); setBusy("load"); setLiveStale(true)
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`)
|
const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||||
setRows(res.routes)
|
if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
|
||||||
} catch (e) {
|
const res = parsed.data
|
||||||
setRows([])
|
if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
|
if (!alive.current || epoch !== readEpoch.current) return
|
||||||
|
setRows(res.routes); setRouteOwner(selectedServerId); setLiveStale(res.stale)
|
||||||
|
if (res.live) setReadAt(new Date().toISOString())
|
||||||
|
else setOpError(res.error ?? "Роутер недоступен")
|
||||||
|
} catch (error) {
|
||||||
|
if (!alive.current || epoch !== readEpoch.current) return
|
||||||
|
setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось загрузить маршруты")
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
if (alive.current && epoch === readEpoch.current) setBusy(null)
|
||||||
}
|
}
|
||||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
||||||
|
|
||||||
const loadGateways = useCallback(async () => {
|
const loadGateways = useCallback(async () => {
|
||||||
if (!isLive || !liveServerListReady || !selectedServerId) return
|
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
|
||||||
|
const epoch = ++gatewayEpoch.current
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
|
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||||
setGatewayOptions(res.gateways)
|
if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
|
||||||
} catch {
|
const res = parsed.data
|
||||||
setGatewayOptions([])
|
if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству")
|
||||||
|
if (!alive.current || epoch !== gatewayEpoch.current) return
|
||||||
|
setGatewayOptions(res.gateways); setGatewayError(null)
|
||||||
|
} catch (error) {
|
||||||
|
if (!alive.current || epoch !== gatewayEpoch.current) return
|
||||||
|
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
|
||||||
}
|
}
|
||||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void loadRoutes()
|
void loadRoutes(); void loadGateways()
|
||||||
}, [loadRoutes])
|
const reads = readEpoch, gateways = gatewayEpoch
|
||||||
|
return () => { reads.current++; gateways.current++ }
|
||||||
|
}, [loadRoutes, loadGateways, readVersion])
|
||||||
|
|
||||||
useEffect(() => {
|
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || sharedBusy || writeLock.current || !can("mm:network:write")) return false
|
||||||
void loadGateways()
|
if (!beginWrite()) return false
|
||||||
}, [loadGateways])
|
writeLock.current = true; readEpoch.current++
|
||||||
|
setOpError(null); setSaveError(null); setBusy("apply")
|
||||||
const saveToDb = useCallback(async () => {
|
|
||||||
if (!isLive || !selectedServerId) return
|
|
||||||
setOpError(null)
|
|
||||||
setBusy("save")
|
|
||||||
try {
|
try {
|
||||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
|
const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
|
||||||
method: "PUT",
|
const parsed = recursiveReceiptSchema.safeParse(raw)
|
||||||
body: JSON.stringify({ serverId: selectedServerId, routes: rows }),
|
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId || parsed.data.pushedRoutes !== next.length) throw new Error("Сервер не подтвердил применение маршрутов")
|
||||||
})
|
if (!alive.current) return false
|
||||||
await loadRoutes()
|
setRows(parsed.data.routes); setLiveStale(false); setWriteAt(new Date().toISOString())
|
||||||
} catch (e) {
|
toast.success("Маршруты применены на роутер")
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (!alive.current) return false
|
||||||
|
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
|
||||||
|
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
writeLock.current = false
|
||||||
|
endWrite()
|
||||||
|
if (alive.current) setBusy(null)
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes])
|
}, [isLive, selectedServerId, routeOwner, liveStale, busy, sharedBusy, apiFetch, beginWrite, endWrite])
|
||||||
|
|
||||||
const syncFromRouter = useCallback(async () => {
|
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||||
if (!isLive || !selectedServerId) return
|
|
||||||
setOpError(null)
|
|
||||||
setBusy("from")
|
|
||||||
try {
|
|
||||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ serverId: selectedServerId }),
|
|
||||||
})
|
|
||||||
await loadRoutes()
|
|
||||||
} catch (e) {
|
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}, [isLive, selectedServerId, apiFetch, loadRoutes])
|
|
||||||
|
|
||||||
const syncToRouter = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !selectedServerId) return
|
||||||
setOpError(null)
|
const epoch = ++historyEpoch.current
|
||||||
setBusy("to")
|
setHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
try {
|
try {
|
||||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
|
const res = await apiFetch<unknown>(
|
||||||
method: "POST",
|
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||||
body: JSON.stringify({ serverId: selectedServerId }),
|
)
|
||||||
})
|
if (epoch !== historyEpoch.current) return
|
||||||
} catch (e) {
|
setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes"))
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
|
} catch (error) {
|
||||||
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, apiFetch])
|
}, [isLive, selectedServerId, apiFetch])
|
||||||
|
|
||||||
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
|
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || sharedBusy || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
|
||||||
|
if (!beginWrite()) throw new Error("Другая операция ещё выполняется")
|
||||||
|
writeLock.current = true; readEpoch.current++
|
||||||
|
setHistoryRestoring(true); setBusy("apply")
|
||||||
|
try {
|
||||||
|
const raw = await apiFetch<unknown>(`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: selectedServerId }) })
|
||||||
|
const parsed = recursiveReceiptSchema.safeParse(raw)
|
||||||
|
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId) throw new Error("Сервер не подтвердил восстановление")
|
||||||
|
if (!alive.current) return
|
||||||
|
setRows(parsed.data.routes); setRouteOwner(selectedServerId); setLiveStale(false); setWriteAt(new Date().toISOString())
|
||||||
|
toast.success("Версия применена на роутер"); await loadRevisions()
|
||||||
|
} catch (error) {
|
||||||
|
if (alive.current) { setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось восстановить маршруты") }
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
writeLock.current = false
|
||||||
|
endWrite()
|
||||||
|
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
|
||||||
|
}
|
||||||
|
}, [isLive, selectedServerId, servers, busy, sharedBusy, apiFetch, loadRevisions, beginWrite, endWrite])
|
||||||
|
|
||||||
function groupKeyOf(row: RecursiveRouteRow): string {
|
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||||
return row.dstAddress.trim().toLowerCase()
|
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
|
||||||
|
}
|
||||||
|
const sheetTrigger = useRef<HTMLElement | null>(null)
|
||||||
|
function returnSheetFocus() {
|
||||||
|
const target = sheetTrigger.current
|
||||||
|
if (target?.isConnected) return target
|
||||||
|
const label = target?.getAttribute("aria-label")
|
||||||
|
return label ? document.querySelector<HTMLElement>(`button[aria-label="${CSS.escape(label)}"]`) : null
|
||||||
}
|
}
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
|
if (mutationsLocked) return
|
||||||
|
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
|
setSaveError(null)
|
||||||
setSheetMode("create")
|
setSheetMode("create")
|
||||||
setEditingGroupKey(null)
|
setEditingGroupKey(null)
|
||||||
setSheetInitial(emptyForm())
|
setSheetInitial(emptyForm())
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
function openEdit(group: RouteGroup) {
|
function openEdit(group: RouteGroup) {
|
||||||
|
if (mutationsLocked) return
|
||||||
|
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
|
setSaveError(null)
|
||||||
|
const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
|
||||||
setSheetMode("edit")
|
setSheetMode("edit")
|
||||||
setEditingGroupKey(group.key)
|
setEditingGroupKey(group.key)
|
||||||
setSheetInitial({
|
setSheetInitial({
|
||||||
dstAddress: group.dstAddress,
|
dstAddress: group.dstAddress,
|
||||||
routingTable: group.routingTable,
|
routingTable: group.routingTable,
|
||||||
comment: group.comment,
|
comment: group.comment,
|
||||||
endpoints: group.endpoints.map(ep => ({
|
endpoints: endpoints.map(ep => ({
|
||||||
id: ep.id,
|
id: ep.id,
|
||||||
|
disabled: ep.disabled,
|
||||||
gateway: ep.gateway,
|
gateway: ep.gateway,
|
||||||
distance: ep.distance,
|
distance: ep.distance,
|
||||||
scope: ep.scope,
|
scope: ep.scope,
|
||||||
@@ -515,7 +595,7 @@ export default function RecursiveRoutesPage() {
|
|||||||
})
|
})
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
function handleSaveSheet(v: RouteForm) {
|
async function handleSaveSheet(v: RouteForm) {
|
||||||
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
|
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
|
||||||
id,
|
id,
|
||||||
dstAddress: v.dstAddress,
|
dstAddress: v.dstAddress,
|
||||||
@@ -526,25 +606,31 @@ export default function RecursiveRoutesPage() {
|
|||||||
routingTable: v.routingTable,
|
routingTable: v.routingTable,
|
||||||
checkGateway: ep.checkGateway,
|
checkGateway: ep.checkGateway,
|
||||||
comment: v.comment,
|
comment: v.comment,
|
||||||
disabled: false,
|
disabled: ep.disabled,
|
||||||
country: ep.country || inferCountry(ep.gateway) || "",
|
country: ep.country || "",
|
||||||
})
|
})
|
||||||
|
let next: RecursiveRouteRow[]
|
||||||
if (sheetMode === "create") {
|
if (sheetMode === "create") {
|
||||||
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||||
setRows(prev => [...prev, ...expanded])
|
next = [...rows, ...expanded]
|
||||||
} else if (editingGroupKey) {
|
} else if (editingGroupKey) {
|
||||||
setRows(prev => {
|
const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
|
||||||
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey)
|
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
next = [...kept, ...expanded]
|
||||||
return [...kept, ...expanded]
|
} else {
|
||||||
})
|
return false
|
||||||
}
|
}
|
||||||
setSheetOpen(false)
|
const saved = await applyRoutes(next)
|
||||||
|
if (saved && alive.current) setSheetOpen(false)
|
||||||
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contextError = context.error ?? (isLive && liveServerListReady && context.serverId && !servers.some(s => s.id === context.serverId) ? "Устройство из ссылки недоступно" : null)
|
||||||
|
const hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
|
||||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||||
|
const mutationsLocked = Boolean(contextError) || !isLive || busy !== null || sharedBusy || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
|
||||||
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
servers.map((s) => ({
|
servers.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -552,7 +638,7 @@ export default function RecursiveRoutesPage() {
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status,
|
status: s.status ?? undefined,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
}))
|
}))
|
||||||
@@ -592,7 +678,15 @@ export default function RecursiveRoutesPage() {
|
|||||||
<ServerRailLayout
|
<ServerRailLayout
|
||||||
items={rrRailItems}
|
items={rrRailItems}
|
||||||
selectedId={selectedServerId}
|
selectedId={selectedServerId}
|
||||||
onSelect={setSelectedServerId}
|
onSelect={id => {
|
||||||
|
if (writeLock.current || sharedBusy || id === selectedServerId) return
|
||||||
|
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
|
||||||
|
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
|
||||||
|
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
|
||||||
|
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(null)
|
||||||
|
patchUrl({ serverId: id, route: null, table: null })
|
||||||
|
}}
|
||||||
|
preserveSelection={true}
|
||||||
showAll={false}
|
showAll={false}
|
||||||
showCount={false}
|
showCount={false}
|
||||||
loading={isLive && !liveServerListReady}
|
loading={isLive && !liveServerListReady}
|
||||||
@@ -602,17 +696,34 @@ export default function RecursiveRoutesPage() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ServerRailMobileButton />
|
<ServerRailMobileButton />
|
||||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
{isLive && (
|
||||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
<>
|
||||||
</Button>
|
<Button
|
||||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
type="button" variant="outline"
|
||||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
size="sm"
|
||||||
</Button>
|
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
|
||||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
disabled={busy !== null}
|
||||||
<SaveIcon className="size-4" />Сохранить в БД
|
title="Прочитать маршруты с роутера"
|
||||||
</Button>
|
>
|
||||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
<RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
|
||||||
<PlusIcon className="size-4" />Добавить
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setHistoryOpen(true)
|
||||||
|
void loadRevisions()
|
||||||
|
}}
|
||||||
|
disabled={busy !== null}
|
||||||
|
>
|
||||||
|
<HistoryIcon aria-hidden="true" className="size-4" />
|
||||||
|
История
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button type="button" size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
||||||
|
<PlusIcon aria-hidden="true" className="size-4" />Добавить
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -621,26 +732,39 @@ export default function RecursiveRoutesPage() {
|
|||||||
banner={
|
banner={
|
||||||
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
<SearchIcon aria-hidden="true" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||||
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
<Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
{search && (
|
{search && (
|
||||||
<button onClick={() => setSearch("")}
|
<button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||||
<XIcon className="size-3.5" />
|
<XIcon aria-hidden="true" className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<span className="text-muted-foreground">Всего маршрутов</span>
|
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||||
<span className="font-semibold tabular-nums">{rows.length}</span>
|
<span className="font-semibold tabular-nums">{hasRouteSnapshot ? rows.length : "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground ml-auto">
|
<p className="text-xs text-muted-foreground ml-auto">
|
||||||
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||||
</p>
|
</p>
|
||||||
{opError && (
|
{busy === "load" && <p role="status" className="w-full text-xs text-muted-foreground">Обновление маршрутов…</p>}
|
||||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
{sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
|
||||||
{opError}
|
{contextError && <p role="alert" className="w-full text-sm text-warning">{contextError}. Выберите устройство или сбросьте ссылку. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов</Button></p>}
|
||||||
|
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) && <p role="status" className="w-full text-sm text-muted-foreground">Маршрут из ссылки отсутствует в текущем срезе. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ route: null, table: null })}>Закрыть маршрут</Button></p>}
|
||||||
|
{(opError || saveError) && (
|
||||||
|
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2" role="alert">
|
||||||
|
{opError || saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLive && readAt && <p role="status" className="w-full text-xs text-muted-foreground">Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.</p>}
|
||||||
|
{isLive && writeAt && <p role="status" className="w-full text-xs text-muted-foreground">Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.</p>}
|
||||||
|
{isLive && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
|
||||||
|
{isLive && liveStale && (
|
||||||
|
<div className="w-full text-xs text-warning bg-warning/10 border border-warning/20 rounded-md px-3 py-2 flex items-center gap-2">
|
||||||
|
<AlertTriangleIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
|
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -655,35 +779,43 @@ export default function RecursiveRoutesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
{currentServer && (
|
{currentServer && (
|
||||||
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
<div className="flex flex-wrap items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||||
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
|
{currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
|
||||||
<Flag code={currentServer.country} size={16} />
|
<Flag code={currentServer.country} size={16} />
|
||||||
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
|
<span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||||
<TypeChip type={currentServer.type} />
|
{currentServer.type && <TypeChip type={currentServer.type} />}
|
||||||
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
<code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
||||||
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
|
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
|
||||||
{currentServer.latency !== null && (
|
{currentServer.latency !== null && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs font-mono",
|
"text-xs font-mono",
|
||||||
currentServer.latency > 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
|
currentServer.latency > 60 ? "text-warning" : "text-muted-foreground",
|
||||||
)}>{currentServer.latency}мс</span>
|
)}>{currentServer.latency}мс</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span>{rows.length} маршрутов</span>
|
<span>{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RecursiveRoutesDataGrid
|
<RecursiveRoutesDataGrid
|
||||||
|
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
||||||
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||||
expandedKey={expandedGroupKey}
|
expandedKey={expandedGroupKey}
|
||||||
onExpandedChange={setExpandedGroupKey}
|
onExpandedChange={key => {
|
||||||
|
const group = groupedRoutes.find(group => group.key === key)
|
||||||
|
patchUrl({ route: group?.dstAddress ?? null, table: group?.routingTable ?? null })
|
||||||
|
}}
|
||||||
|
mutationsDisabled={mutationsLocked}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
|
onDelete={(g) => {
|
||||||
|
if (mutationsLocked) return
|
||||||
|
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button onClick={openCreate}
|
<button type="button" onClick={openCreate} disabled={mutationsLocked}
|
||||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon aria-hidden="true" className="size-3.5" />
|
||||||
Добавить маршрут
|
Добавить маршрут
|
||||||
</button>
|
</button>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
@@ -691,13 +823,30 @@ export default function RecursiveRoutesPage() {
|
|||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<RouteSheet
|
<RouteSheet
|
||||||
|
returnFocus={returnSheetFocus}
|
||||||
|
busy={busy !== null}
|
||||||
|
saveError={saveError}
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
mode={sheetMode}
|
mode={sheetMode}
|
||||||
initial={sheetInitial}
|
initial={sheetInitial}
|
||||||
onSave={handleSaveSheet}
|
onSave={handleSaveSheet}
|
||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
|
||||||
gateways={gatewayOptions}
|
gateways={gatewayOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||||
|
loadError={historyError}
|
||||||
|
onRetry={() => { void loadRevisions() }}
|
||||||
|
title="История маршрутов"
|
||||||
|
itemLabel="маршрутов"
|
||||||
|
revisions={revisions}
|
||||||
|
loading={historyLoading}
|
||||||
|
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || (busy !== null || sharedBusy) && !historyRestoring}
|
||||||
|
restoring={historyRestoring}
|
||||||
|
onRestore={restoreRevision}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@ import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-v
|
|||||||
import {
|
import {
|
||||||
formatCommitSubject,
|
formatCommitSubject,
|
||||||
getReleaseCommitPreview,
|
getReleaseCommitPreview,
|
||||||
RELEASE_COMMIT_PREVIEW_LIMIT,
|
|
||||||
} from "@/lib/release-commits"
|
} from "@/lib/release-commits"
|
||||||
import { readReleaseManifest } from "@/lib/release-manifest"
|
import { readReleaseManifest } from "@/lib/release-manifest"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -16,23 +15,23 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
||||||
if (value) {
|
if (value) {
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(new Date(value))
|
}).format(new Date(value))
|
||||||
}
|
}
|
||||||
return isProdBuild ? "Прод-сборка" : "Локальная сборка"
|
return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReleasesPage() {
|
export default function ReleasesPage() {
|
||||||
const manifest = readReleaseManifest()
|
const manifest = readReleaseManifest()
|
||||||
const version = getAppVersion()
|
const version = getAppVersion()
|
||||||
const isProdBuild = !version.endsWith("-dev")
|
const isProdBuild = !version.endsWith("-dev")
|
||||||
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
|
||||||
const commits = getReleaseCommitPreview(manifest.commits)
|
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex min-h-0 flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
||||||
actions={
|
actions={
|
||||||
@@ -40,53 +39,55 @@ export default function ReleasesPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={releaseUrl}
|
href={releaseUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" />
|
<ExternalLinkIcon className="size-4" aria-hidden />
|
||||||
Открыть в Gitea
|
Открыть релиз
|
||||||
</Link>
|
</Link>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
<div className="max-w-[960px] mx-auto space-y-6">
|
<div className="max-w-[960px] mx-auto space-y-6">
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Текущая версия"
|
title="Текущая версия"
|
||||||
description="Сборка MikrotikManager, опубликованная через CI/CD."
|
description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
|
||||||
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
<span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
|
||||||
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
|
<span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||||
</p>
|
</p>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
|
{!manifest ? <p role="alert" className="text-sm text-destructive">Метаданные релиза недоступны или повреждены. Дата, ссылка и список коммитов не подтверждены.</p> : manifest.version !== version && isProdBuild ? <p role="status" className="text-sm text-muted-foreground">Манифест относится к {formatAppVersionLabel(manifest.version)}; версия запущенной сборки отличается.</p> : null}
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Сводка коммитов"
|
title="Сводка коммитов"
|
||||||
description={
|
description={
|
||||||
commits.length > 0
|
commits.length > 0
|
||||||
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
|
||||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."
|
: manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
|
||||||
}
|
}
|
||||||
contentClassName="px-5 pb-5"
|
contentClassName="px-5 pb-5"
|
||||||
>
|
>
|
||||||
{commits.length === 0 ? (
|
{commits.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
|
<p className="text-sm text-muted-foreground">{manifest ? "Коммитов для отображения пока нет." : "Нет подтверждённых метаданных."}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{commits.map((commit) => (
|
{commits.map((commit) => (
|
||||||
<li key={commit.sha} className="rounded-md border px-3 py-2">
|
<li key={commit.sha} className="rounded-md border px-3 py-2 break-words">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="font-mono uppercase">{commit.type}</span>
|
<span className="font-mono uppercase">{commit.type}</span>
|
||||||
<span className="font-mono">{commit.shortSha}</span>
|
<span className="font-mono">{commit.shortSha}</span>
|
||||||
<span>{commit.author}</span>
|
<span>{commit.author}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p>
|
<p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+288
-316
File diff suppressed because it is too large.
Load diff
+262
-76
@@ -1,13 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { serverCreateSchema } from "@mmapp/contracts/servers"
|
||||||
|
import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations"
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
|
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
|
||||||
import type { Filter } from "@/components/reui/filters"
|
import { useServerUrlState } from "@/hooks/use-server-url-state"
|
||||||
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
import { ServerFilters } from "@/components/data-grids/server-filters"
|
||||||
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
|
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
|
||||||
|
import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields"
|
||||||
import { servers as initialServers } from "@/lib/data"
|
import { servers as initialServers } from "@/lib/data"
|
||||||
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
||||||
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
||||||
@@ -45,9 +53,9 @@ import {
|
|||||||
StepperSeparator,
|
StepperSeparator,
|
||||||
StepperTitle,
|
StepperTitle,
|
||||||
StepperTrigger,
|
StepperTrigger,
|
||||||
} from "@/components/reui/stepper"
|
} from "@/components/reui-kit/accessible-stepper"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
RefreshCwIcon, PlusIcon, TrashIcon,
|
||||||
EyeIcon, EyeOffIcon,
|
EyeIcon, EyeOffIcon,
|
||||||
ChevronRightIcon, ChevronDownIcon,
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
@@ -213,15 +221,18 @@ type TypeFilter = "all" | ServerType
|
|||||||
type SheetMode = "add" | "edit"
|
type SheetMode = "add" | "edit"
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
export default function ServersPage() {
|
function ServersPageContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const selectedServerId = searchParams.get("server")
|
||||||
|
const clearParams = new URLSearchParams(searchParams.toString())
|
||||||
|
clearParams.delete("server")
|
||||||
|
const allServersHref = "/servers" + (clearParams.size ? "?" + clearParams : "")
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [serverList, setServerList] = useState<Server[]>([])
|
const [serverList, setServerList] = useState<Server[]>([])
|
||||||
const [_backendOk, setBackendOk] = useState(false)
|
const [listState, setListState] = useState<"loading" | "ready" | "error">("loading")
|
||||||
const [search, setSearch] = useState("")
|
const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState()
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
|
||||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
@@ -231,35 +242,92 @@ export default function ServersPage() {
|
|||||||
const [testMsg, setTestMsg] = useState("")
|
const [testMsg, setTestMsg] = useState("")
|
||||||
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
|
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
|
||||||
const [pollAllBusy, setPollAllBusy] = useState(false)
|
const [pollAllBusy, setPollAllBusy] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [detailsLoading, setDetailsLoading] = useState(false)
|
||||||
|
const [detailsReady, setDetailsReady] = useState(true)
|
||||||
|
const [formError, setFormError] = useState("")
|
||||||
|
const [operationMessage, setOperationMessage] = useState("")
|
||||||
|
const [operationError, setOperationError] = useState("")
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Server | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState("")
|
||||||
|
const [pollResults, setPollResults] = useState<PollResult[]>([])
|
||||||
|
const [rowBusy, setRowBusy] = useState(false)
|
||||||
|
const gate = useRef(new OperationGate())
|
||||||
|
const formVersion = useRef(0)
|
||||||
|
const testVersion = useRef(0)
|
||||||
|
const contextVersion = useRef(0)
|
||||||
|
const canWrite = can("mm:servers:write")
|
||||||
|
const accessDenied = "Недостаточно прав: изменение и опрос серверов требуют разрешения на запись."
|
||||||
|
function requireWrite(report: (message: string) => void = setOperationError) {
|
||||||
|
if (can("mm:servers:write")) return true
|
||||||
|
report(accessDenied)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0
|
||||||
|
|
||||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => {
|
||||||
|
if (gate.current.has("save") || detailsLoading) return
|
||||||
|
if (["host", "port", "proto", "verifySsl", "apiPath", "username", "password"].includes(k)) {
|
||||||
|
testVersion.current++
|
||||||
|
setTestState("idle"); setTestMsg("")
|
||||||
|
}
|
||||||
setForm((f) => ({ ...f, [k]: v }))
|
setForm((f) => ({ ...f, [k]: v }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm(next: boolean) {
|
||||||
|
if (gate.current.has("save")) return
|
||||||
|
if (!next) { formVersion.current++; testVersion.current++ }
|
||||||
|
setOpen(next)
|
||||||
|
}
|
||||||
|
|
||||||
// ── load from backend when mode/url changes ───────────────────────────────
|
// ── load from backend when mode/url changes ───────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const version = ++contextVersion.current
|
||||||
|
let cancelled = false
|
||||||
|
const invalidate = () => {
|
||||||
|
cancelled = true
|
||||||
|
contextVersion.current++
|
||||||
|
formVersion.current++
|
||||||
|
testVersion.current++
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
formVersion.current++; testVersion.current++
|
||||||
|
setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([])
|
||||||
|
setListState("loading")
|
||||||
|
})
|
||||||
if (mode !== "live") {
|
if (mode !== "live") {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return
|
||||||
setServerList(initialServers)
|
setServerList(initialServers)
|
||||||
setBackendOk(false)
|
setListState("ready")
|
||||||
})
|
})
|
||||||
return
|
return invalidate
|
||||||
}
|
}
|
||||||
listServers(backendUrl)
|
listServers(backendUrl)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setBackendOk(true)
|
if (cancelled || version !== contextVersion.current) return
|
||||||
|
setListState("ready")
|
||||||
setServerList(data.map(toFrontendServer))
|
setServerList(data.map(toFrontendServer))
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
setBackendOk(false)
|
if (cancelled || version !== contextVersion.current) return
|
||||||
|
setOperationError(serverOperationError(error))
|
||||||
|
setListState("error")
|
||||||
setServerList([])
|
setServerList([])
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
return invalidate
|
||||||
}, [mode, backendUrl])
|
}, [mode, backendUrl])
|
||||||
|
|
||||||
// ── actions ──────────────────────────────────────────────────────────────
|
// ── actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
|
if (!requireWrite()) return
|
||||||
|
if (gate.current.has("save")) return
|
||||||
|
formVersion.current++; testVersion.current++
|
||||||
|
setFormError(""); setDetailsLoading(false); setDetailsReady(true)
|
||||||
setSheetMode("add"); setEditingId(null)
|
setSheetMode("add"); setEditingId(null)
|
||||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||||
setSheetStep(1)
|
setSheetStep(1)
|
||||||
@@ -267,11 +335,16 @@ export default function ServersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openEdit(s: Server) {
|
async function openEdit(s: Server) {
|
||||||
|
if (!requireWrite()) return
|
||||||
|
if (gate.current.has("save")) return
|
||||||
|
const version = ++formVersion.current
|
||||||
|
testVersion.current++
|
||||||
|
setFormError(""); setDetailsLoading(isLive); setDetailsReady(!isLive)
|
||||||
setSheetMode("edit"); setEditingId(s.id)
|
setSheetMode("edit"); setEditingId(s.id)
|
||||||
setForm({
|
setForm({
|
||||||
...defaultForm,
|
...defaultForm,
|
||||||
name: s.name, type: s.type, site: s.site, country: s.country ?? "RU",
|
name: s.name, type: s.type, site: s.site, country: s.country ?? "RU",
|
||||||
asn: s.asn ?? "", enabled: s.status !== "offline", host: s.host,
|
asn: s.asn ?? "", enabled: s.enabled, host: s.host,
|
||||||
comment: s.comment ?? "",
|
comment: s.comment ?? "",
|
||||||
lanSubnet: s.lanSubnet ?? "",
|
lanSubnet: s.lanSubnet ?? "",
|
||||||
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
||||||
@@ -282,9 +355,11 @@ export default function ServersPage() {
|
|||||||
if (isLive) {
|
if (isLive) {
|
||||||
try {
|
try {
|
||||||
const full = await getServer(backendUrl, s.id)
|
const full = await getServer(backendUrl, s.id)
|
||||||
|
if (version !== formVersion.current) return
|
||||||
setForm(prev => ({
|
setForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
username: full.username ?? "",
|
username: full.username ?? "",
|
||||||
|
enabled: full.enabled,
|
||||||
password: full.password ?? "",
|
password: full.password ?? "",
|
||||||
port: String(full.port ?? 443),
|
port: String(full.port ?? 443),
|
||||||
proto: full.useSsl ? "https" : "http",
|
proto: full.useSsl ? "https" : "http",
|
||||||
@@ -295,16 +370,24 @@ export default function ServersPage() {
|
|||||||
? JSON.parse(JSON.stringify(full.wanUplinks))
|
? JSON.parse(JSON.stringify(full.wanUplinks))
|
||||||
: prev.wanUplinks,
|
: prev.wanUplinks,
|
||||||
}))
|
}))
|
||||||
} catch {
|
setDetailsReady(true)
|
||||||
// ignore — user can fill in manually
|
} catch (error) {
|
||||||
|
if (version === formVersion.current) setFormError(serverOperationError(error))
|
||||||
|
} finally {
|
||||||
|
if (version === formVersion.current) setDetailsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
if (!requireWrite(setFormError)) return
|
||||||
|
if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return
|
||||||
|
const context = contextVersion.current
|
||||||
|
setSaving(true); setFormError("")
|
||||||
|
try {
|
||||||
const payload: ServerCreate = {
|
const payload: ServerCreate = {
|
||||||
host: form.host,
|
host: form.host,
|
||||||
port: Number(form.port) || (form.proto === "https" ? 443 : 80),
|
port: Number(form.port),
|
||||||
username: form.username,
|
username: form.username,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
useSsl: form.proto === "https",
|
useSsl: form.proto === "https",
|
||||||
@@ -320,18 +403,32 @@ export default function ServersPage() {
|
|||||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.host.trim() || !form.username.trim() || !Number.isInteger(payload.port) || payload.port! > 65535 || payload.port! < 1) {
|
||||||
|
setFormError("Укажите хост, имя пользователя и порт от 1 до 65535.")
|
||||||
|
setSheetStep(3)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const validation = serverCreateSchema.safeParse(payload)
|
||||||
|
if (!validation.success) {
|
||||||
|
setFormError("Проверьте параметры сервера: " + validation.error.issues.map(i => i.path.join(".")).join(", "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
try {
|
try {
|
||||||
if (sheetMode === "edit" && editingId) {
|
if (sheetMode === "edit" && editingId) {
|
||||||
const updatedPayload: ServerUpdate = payload
|
const updatedPayload: ServerUpdate = payload
|
||||||
const updated = await updateServer(backendUrl, editingId, updatedPayload)
|
const updated = await updateServer(backendUrl, editingId, updatedPayload)
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
|
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
|
||||||
} else {
|
} else {
|
||||||
const created = await createServer(backendUrl, payload)
|
const created = await createServer(backendUrl, payload)
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
setServerList(list => [...list, toFrontendServer(created)])
|
setServerList(list => [...list, toFrontendServer(created)])
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Ошибка сохранения:", e)
|
if (context === contextVersion.current) setFormError(serverOperationError(e))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// offline mode — update local state only
|
// offline mode — update local state only
|
||||||
@@ -349,7 +446,7 @@ export default function ServersPage() {
|
|||||||
comment: form.comment,
|
comment: form.comment,
|
||||||
lanSubnet: form.lanSubnet || undefined,
|
lanSubnet: form.lanSubnet || undefined,
|
||||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||||
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline",
|
enabled: form.enabled,
|
||||||
}
|
}
|
||||||
: s
|
: s
|
||||||
))
|
))
|
||||||
@@ -368,57 +465,85 @@ export default function ServersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
formVersion.current++; testVersion.current++
|
||||||
|
setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.")
|
||||||
|
} finally {
|
||||||
|
gate.current.release("save"); setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleStatus(id: string) {
|
async function handleToggleStatus(id: string) {
|
||||||
setServerList(list => list.map(s =>
|
if (!requireWrite()) return
|
||||||
s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s
|
const server = serverList.find(s => s.id === id)
|
||||||
))
|
if (!server || !gate.current.acquire("rows")) return
|
||||||
|
const context = contextVersion.current
|
||||||
|
setRowBusy(true); setOperationError(""); setOperationMessage("")
|
||||||
|
try {
|
||||||
|
let enabled = !server.enabled
|
||||||
|
if (isLive) enabled = (await updateServer(backendUrl, id, { enabled })).enabled
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
|
setServerList(list => list.map(s => s.id === id ? { ...s, enabled } : s))
|
||||||
|
setOperationMessage(`${server.name}: ${enabled ? "включён" : "отключён"}${isLive ? "." : " (демо)."}`)
|
||||||
|
} catch (error) {
|
||||||
|
if (context === contextVersion.current) setOperationError(serverOperationError(error))
|
||||||
|
} finally { gate.current.release("rows"); setRowBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (isLive) {
|
if (!requireWrite(setDeleteError)) return
|
||||||
try {
|
if (!gate.current.acquire("rows")) return
|
||||||
await deleteServer(backendUrl, id)
|
const context = contextVersion.current
|
||||||
} catch (e) {
|
setDeleting(true); setRowBusy(true); setDeleteError("")
|
||||||
console.error("Ошибка удаления:", e)
|
try {
|
||||||
}
|
if (isLive) await deleteServer(backendUrl, id)
|
||||||
}
|
if (context !== contextVersion.current) return
|
||||||
setServerList(list => list.filter(s => s.id !== id))
|
setServerList(list => list.filter(s => s.id !== id))
|
||||||
|
setDeleteTarget(null); setOperationError(""); setOperationMessage("Сервер удалён из списка.")
|
||||||
|
} catch (error) {
|
||||||
|
if (context === contextVersion.current) setDeleteError(serverOperationError(error))
|
||||||
|
} finally { gate.current.release("rows"); setDeleting(false); setRowBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePoll(id: string) {
|
async function handlePoll(id: string) {
|
||||||
if (!isLive) return
|
await runPoll([id])
|
||||||
setPollingIds(p => new Set(p).add(id))
|
|
||||||
try {
|
|
||||||
await pollServer(backendUrl, id)
|
|
||||||
// Reload full list to get updated status/version
|
|
||||||
const data = await listServers(backendUrl)
|
|
||||||
setServerList(data.map(toFrontendServer))
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Poll error:", e)
|
|
||||||
} finally {
|
|
||||||
setPollingIds(p => { const n = new Set(p); n.delete(id); return n })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePollAll() {
|
async function handlePollAll() {
|
||||||
if (!isLive) return
|
await runPoll(serverList.map(s => s.id))
|
||||||
setPollAllBusy(true)
|
}
|
||||||
|
|
||||||
|
async function runPoll(ids: string[]) {
|
||||||
|
if (!requireWrite()) return
|
||||||
|
if (!isLive || !gate.current.acquire("rows")) return
|
||||||
|
const context = contextVersion.current
|
||||||
|
setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([])
|
||||||
try {
|
try {
|
||||||
const ids = serverList.map(s => s.id)
|
const results = await pollServers(ids, async id => {
|
||||||
await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {})))
|
if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.")
|
||||||
const data = await listServers(backendUrl)
|
if (!can("mm:servers:write")) throw new Error(accessDenied)
|
||||||
setServerList(data.map(toFrontendServer))
|
await pollServer(backendUrl, id)
|
||||||
|
})
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
|
setPollResults(results)
|
||||||
|
try {
|
||||||
|
const data = await listServers(backendUrl)
|
||||||
|
if (context === contextVersion.current) setServerList(data.map(toFrontendServer))
|
||||||
|
} catch (error) {
|
||||||
|
if (context === contextVersion.current) setOperationError("Опрос завершён, но список не обновлён. " + serverOperationError(error))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setPollAllBusy(false)
|
gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
|
if (!requireWrite(setFormError)) return
|
||||||
|
if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return
|
||||||
|
const version = ++testVersion.current
|
||||||
if (!form.host || !form.username) {
|
if (!form.host || !form.username) {
|
||||||
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
|
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
|
||||||
}
|
}
|
||||||
|
gate.current.acquire("test")
|
||||||
setTestState("testing"); setTestMsg("")
|
setTestState("testing"); setTestMsg("")
|
||||||
try {
|
try {
|
||||||
const data = await testServerConnection(backendUrl, {
|
const data = await testServerConnection(backendUrl, {
|
||||||
@@ -430,28 +555,36 @@ export default function ServersPage() {
|
|||||||
username: form.username,
|
username: form.username,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
})
|
})
|
||||||
|
if (version !== testVersion.current) return
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setTestState("ok"); setTestMsg(data.message)
|
setTestState("ok"); setTestMsg(data.message)
|
||||||
} else {
|
} else {
|
||||||
setTestState("error"); setTestMsg(data.message)
|
setTestState("error"); setTestMsg(data.message)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (version !== testVersion.current) return
|
||||||
setTestState("error")
|
setTestState("error")
|
||||||
setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`)
|
setTestMsg(serverOperationError(error))
|
||||||
|
} finally {
|
||||||
|
gate.current.release("test")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── derived ──────────────────────────────────────────────────────────────
|
// ── derived ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedServer = serverList.find(server => server.id === selectedServerId)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
|
if (urlError) return []
|
||||||
const base = serverList.filter(sv => {
|
const base = serverList.filter(sv => {
|
||||||
|
if (selectedServerId !== null && sv.id !== selectedServerId) return false
|
||||||
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
||||||
})
|
})
|
||||||
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||||
}, [serverList, search, typeFilter, advancedFilters])
|
}, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError])
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
all: serverList.length,
|
all: serverList.length,
|
||||||
@@ -478,11 +611,10 @@ export default function ServersPage() {
|
|||||||
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || pollAllBusy}>
|
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
|
||||||
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />Проверить все
|
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||||
<Button size="sm" onClick={openAdd}><PlusIcon className="size-4" />Добавить сервер</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -490,6 +622,32 @@ export default function ServersPage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
|
||||||
|
{operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||||
|
{operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
|
||||||
|
{pollResults.length > 0 && (
|
||||||
|
<Alert variant={pollResults.some(r => r.error) ? "warning" : "success"} role="status">
|
||||||
|
<AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
|
||||||
|
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={!canWrite || controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedServerId !== null && (
|
||||||
|
<Alert variant={listState === "error" || (listState === "ready" && !selectedServer) ? "warning" : "info"} role="status" aria-label="Сервер по ссылке">
|
||||||
|
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href={allServersHref} className="underline underline-offset-2">Все серверы</Link>
|
||||||
|
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка серверов"
|
aria-label="Сводка серверов"
|
||||||
items={[
|
items={[
|
||||||
@@ -525,6 +683,8 @@ export default function ServersPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
|
{urlError && <Alert variant="warning" role="alert"><AlertTitle>Фильтры требуют внимания</AlertTitle><AlertDescription>{urlError}</AlertDescription></Alert>}
|
||||||
|
{hasFilters && <div><Button variant="outline" size="sm" onClick={resetFilters}>Сбросить фильтры списка</Button></div>}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
segmented={{
|
segmented={{
|
||||||
@@ -536,9 +696,7 @@ export default function ServersPage() {
|
|||||||
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
filters={advancedFilters}
|
filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
||||||
onFiltersChange={setAdvancedFilters}
|
|
||||||
filterFields={SERVER_FILTER_FIELDS}
|
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по имени, хосту…"
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
@@ -546,11 +704,14 @@ export default function ServersPage() {
|
|||||||
/>
|
/>
|
||||||
<ServersDataGrid
|
<ServersDataGrid
|
||||||
servers={filtered}
|
servers={filtered}
|
||||||
|
hasUnfilteredRows={serverList.length > 0}
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
pollingIds={pollingIds}
|
pollingIds={pollingIds}
|
||||||
|
busy={controlsBusy}
|
||||||
|
canWrite={canWrite}
|
||||||
onPoll={handlePoll}
|
onPoll={handlePoll}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
|
||||||
onToggleStatus={handleToggleStatus}
|
onToggleStatus={handleToggleStatus}
|
||||||
/>
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
@@ -558,13 +719,19 @@ export default function ServersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
|
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={closeForm}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
<SheetContent side="right" showCloseButton={!saving} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
|
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
|
||||||
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
|
{detailsLoading && <p className="px-6 py-2 text-sm" role="status">Загрузка параметров сервера…</p>}
|
||||||
|
{formError && <div className="px-6 py-2"><Alert variant="destructive" role="alert"><AlertDescription>{formError}</AlertDescription></Alert>
|
||||||
|
{!detailsReady && !detailsLoading && editingId && <Button type="button" variant="outline" onClick={() => { const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку</Button>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<fieldset disabled={!canWrite || saving || !detailsReady} className="flex min-h-0 min-w-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
|
||||||
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<StepperNav className="mb-5">
|
<StepperNav className="mb-5">
|
||||||
<StepperItem step={1}>
|
<StepperItem step={1}>
|
||||||
@@ -605,6 +772,7 @@ export default function ServersPage() {
|
|||||||
|
|
||||||
<FormField label="Тип узла" required>
|
<FormField label="Тип узла" required>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
|
className="max-w-full flex-wrap"
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onChange={v => set("type", v)}
|
onChange={v => set("type", v)}
|
||||||
options={[
|
options={[
|
||||||
@@ -725,7 +893,7 @@ export default function ServersPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
||||||
disabled={testState === "testing"} onClick={handleTest}>
|
disabled={!canWrite || testState === "testing"} onClick={handleTest}>
|
||||||
{testState === "testing"
|
{testState === "testing"
|
||||||
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
|
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
|
||||||
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
||||||
@@ -776,22 +944,40 @@ export default function ServersPage() {
|
|||||||
</StepperContent>
|
</StepperContent>
|
||||||
</StepperPanel>
|
</StepperPanel>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||||
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" disabled={saving} />}>Отмена</SheetClose>
|
||||||
{sheetStep > 1 && (
|
{sheetStep > 1 && (
|
||||||
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
<Button variant="outline" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
)}
|
)}
|
||||||
{sheetStep < 4 ? (
|
{sheetStep < 4 ? (
|
||||||
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
<Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="ml-auto" onClick={handleSave}>
|
<Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
|
||||||
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
{saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
<AlertDialog open={deleteTarget !== null} onOpenChange={next => { if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить сервер?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<Button type="button" variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>Отмена</Button>
|
||||||
|
<Button type="button" variant="destructive" disabled={!canWrite || deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ServersPage() {
|
||||||
|
return <Suspense fallback={<p role="status" className="p-6">Загрузка серверов…</p>}><ServersPageContent /></Suspense>
|
||||||
|
}
|
||||||
+49
-805
@@ -1,291 +1,44 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { useTheme } from "@/components/theme-provider"
|
||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { SystemDatabaseSection } from "@/components/settings/system-database-section"
|
||||||
|
import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
|
|
||||||
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
CheckIcon,
|
||||||
AlertDialogAction,
|
AlertCircleIcon,
|
||||||
AlertDialogCancel,
|
XIcon,
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogMedia,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import {
|
|
||||||
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
|
|
||||||
AlertCircleIcon, EyeIcon, EyeOffIcon,
|
|
||||||
RefreshCwIcon, LoaderCircleIcon,
|
|
||||||
DownloadIcon, UploadIcon, XIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
|
||||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
|
||||||
import { purgeTrafficFlowData } from "@/shared/api/traffic-flow"
|
|
||||||
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
|
|
||||||
|
|
||||||
|
|
||||||
const INIT_API_KEYS: ApiKey[] = [
|
|
||||||
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
|
|
||||||
{ id: "k2", name: "CI/CD deploy hook", prefix: "rl_live_vB3p8…", created: "1 мес назад", last: "вчера", scopes: ["filters:write", "domains:write"] },
|
|
||||||
{ id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] },
|
|
||||||
]
|
|
||||||
|
|
||||||
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
|
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
|
||||||
type NavSection = typeof SECTIONS_NAV[number]
|
type NavSection = typeof SECTIONS_NAV[number]
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
|
||||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
return requestJson<T>(backendUrl, path, init)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
<div className="flex flex-col items-start sm:flex-row sm:items-center sm:justify-between gap-3 py-3.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">{children}</div>
|
<div className="min-w-0 max-w-full">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatabaseRestoreConfirm({
|
|
||||||
open,
|
|
||||||
filename,
|
|
||||||
busy,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
filename: string
|
|
||||||
busy?: boolean
|
|
||||||
onConfirm: () => void
|
|
||||||
onCancel: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
|
||||||
<AlertDialogContent size="default">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
|
||||||
<AlertCircleIcon />
|
|
||||||
</AlertDialogMedia>
|
|
||||||
<AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Файл: {filename}. Текущие данные PostgreSQL на бекенде будут полностью заменены.
|
|
||||||
Рекомендуется сначала скачать актуальный бэкап.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
variant="destructive"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
|
||||||
{busy ? "Восстановление…" : "Восстановить"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [section, setSection] = useState<NavSection>("Общие")
|
const [section, setSection] = useState<NavSection>("Общие")
|
||||||
const [saved, setSaved] = useState(false)
|
const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource()
|
||||||
const [copied, setCopied] = useState<string | null>(null)
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
// data source
|
|
||||||
const { mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource()
|
|
||||||
const evo = useEvoBGP()
|
|
||||||
const [evoBaseDraft, setEvoBaseDraft] = useState("")
|
|
||||||
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
|
|
||||||
const [evoKeyDraft, setEvoKeyDraft] = useState("")
|
|
||||||
const [evoSaveBusy, setEvoSaveBusy] = useState(false)
|
|
||||||
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
|
|
||||||
const [urlDraft, setUrlDraft] = useState(backendUrl)
|
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUrlDraft(backendUrl)
|
|
||||||
}, [backendUrl])
|
|
||||||
|
|
||||||
const commitBackendUrl = useCallback(() => {
|
|
||||||
const normalized = urlDraft.trim().replace(/\/$/, "")
|
|
||||||
setUrlDraft(normalized)
|
|
||||||
setBackendUrl(normalized)
|
|
||||||
void checkBackend()
|
|
||||||
}, [urlDraft, setBackendUrl, checkBackend])
|
|
||||||
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
|
|
||||||
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
|
|
||||||
const [showEvoKey, setShowEvoKey] = useState(false)
|
|
||||||
|
|
||||||
// general
|
|
||||||
const [lang, setLang] = useState("ru")
|
|
||||||
const [theme, setTheme] = useState("system")
|
|
||||||
const [timezone, setTimezone] = useState("Europe/Moscow")
|
|
||||||
const [refreshSec, setRefreshSec] = useState("30")
|
|
||||||
const [probeTimeout, setProbeTimeout] = useState("5")
|
|
||||||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
|
||||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
|
||||||
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
|
||||||
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
|
||||||
const [dbPurgeOpen, setDbPurgeOpen] = useState(false)
|
|
||||||
const [dbPurgeBusy, setDbPurgeBusy] = useState(false)
|
|
||||||
|
|
||||||
// notifications
|
|
||||||
const [notifEmail, setNotifEmail] = useState(true)
|
|
||||||
const [notifSlack, setNotifSlack] = useState(false)
|
|
||||||
const [notifWh, setNotifWh] = useState(true)
|
|
||||||
const [notifDegr, setNotifDegr] = useState(true)
|
|
||||||
const [notifOffline, setNotifOffline] = useState(true)
|
|
||||||
const [notifBgp, setNotifBgp] = useState(true)
|
|
||||||
const [notifBackup, setNotifBackup] = useState(false)
|
|
||||||
|
|
||||||
// api keys
|
|
||||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>(INIT_API_KEYS)
|
|
||||||
|
|
||||||
// security
|
|
||||||
const [mfa, setMfa] = useState(true)
|
|
||||||
const [sessMin, setSessMin] = useState("480")
|
|
||||||
const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16")
|
|
||||||
const [auditLog, setAuditLog] = useState(true)
|
|
||||||
|
|
||||||
const handleCopy = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text).catch(() => {})
|
|
||||||
setCopied(text); setTimeout(() => setCopied(null), 1500)
|
|
||||||
toast.success("Скопировано в буфер обмена")
|
|
||||||
}
|
|
||||||
|
|
||||||
const markSaved = useCallback(() => {
|
|
||||||
setSaved(true)
|
|
||||||
toast.success("Настройки сохранены")
|
|
||||||
setTimeout(() => setSaved(false), 2000)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (section !== "EvoBGP") return
|
|
||||||
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
|
|
||||||
}, [section, mode, backendStatus, evo.loadSettings])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (section !== "EvoBGP" || !evo.settingsLoaded) return
|
|
||||||
setEvoBaseDraft(evo.baseUrl)
|
|
||||||
setEvoEnabledDraft(evo.enabled)
|
|
||||||
setEvoKeyDraft("")
|
|
||||||
setEvoSaveErr(null)
|
|
||||||
}, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled])
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (section === "EvoBGP") {
|
|
||||||
if (mode !== "live" || backendStatus !== true) {
|
|
||||||
markSaved()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEvoSaveBusy(true)
|
|
||||||
setEvoSaveErr(null)
|
|
||||||
try {
|
|
||||||
const patch: EvoBgpSavePayload = {
|
|
||||||
baseUrl: evoBaseDraft.trim(),
|
|
||||||
enabled: evoEnabledDraft,
|
|
||||||
}
|
|
||||||
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
|
|
||||||
await evo.saveSettings(patch)
|
|
||||||
setEvoKeyDraft("")
|
|
||||||
markSaved()
|
|
||||||
toast.success("Настройки EvoBGP сохранены")
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
|
|
||||||
setEvoSaveErr(msg)
|
|
||||||
toast.error(msg)
|
|
||||||
} finally {
|
|
||||||
setEvoSaveBusy(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
markSaved()
|
|
||||||
}, [
|
|
||||||
section,
|
|
||||||
mode,
|
|
||||||
backendStatus,
|
|
||||||
evoBaseDraft,
|
|
||||||
evoEnabledDraft,
|
|
||||||
evoKeyDraft,
|
|
||||||
markSaved,
|
|
||||||
evo.saveSettings,
|
|
||||||
])
|
|
||||||
|
|
||||||
const systemDbAvailable = mode === "live" && backendStatus === true
|
|
||||||
|
|
||||||
const handleSystemDatabaseBackup = useCallback(async () => {
|
|
||||||
if (!systemDbAvailable) return
|
|
||||||
setDbBackupBusy(true)
|
|
||||||
try {
|
|
||||||
const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl)
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement("a")
|
|
||||||
a.href = url
|
|
||||||
a.download = filename
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
toast.success("Бэкап базы приложения скачан")
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап")
|
|
||||||
} finally {
|
|
||||||
setDbBackupBusy(false)
|
|
||||||
}
|
|
||||||
}, [backendUrl, systemDbAvailable])
|
|
||||||
|
|
||||||
const handleSystemDatabaseRestoreConfirm = useCallback(async () => {
|
|
||||||
if (!dbRestoreFile || !systemDbAvailable) return
|
|
||||||
setDbRestoreBusy(true)
|
|
||||||
try {
|
|
||||||
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
|
||||||
toast.success("База приложения восстановлена")
|
|
||||||
setDbRestoreFile(null)
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
|
||||||
} finally {
|
|
||||||
setDbRestoreBusy(false)
|
|
||||||
}
|
|
||||||
}, [backendUrl, dbRestoreFile, systemDbAvailable])
|
|
||||||
|
|
||||||
const handleNetflowPurgeConfirm = useCallback(async () => {
|
|
||||||
if (!systemDbAvailable) return
|
|
||||||
setDbPurgeBusy(true)
|
|
||||||
try {
|
|
||||||
const result = await purgeTrafficFlowData(backendUrl)
|
|
||||||
toast.success(formatFlowPurgeResult(result))
|
|
||||||
setDbPurgeOpen(false)
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
|
|
||||||
} finally {
|
|
||||||
setDbPurgeBusy(false)
|
|
||||||
}
|
|
||||||
}, [backendUrl, systemDbAvailable])
|
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
|
||||||
|
|
||||||
// ── Общие ──
|
// ── Общие ──
|
||||||
if (section === "Общие") return (
|
if (section === "Общие") return (
|
||||||
@@ -296,18 +49,18 @@ export default function SettingsPage() {
|
|||||||
title="Источник данных"
|
title="Источник данных"
|
||||||
description={
|
description={
|
||||||
mockModeAvailable
|
mockModeAvailable
|
||||||
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
|
? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
|
||||||
: "В продакшен-образе доступны только живые данные от бекенда"
|
: "Источник живых данных задан конфигурацией приложения."
|
||||||
}
|
}
|
||||||
contentClassName="divide-y px-5"
|
contentClassName="divide-y px-5"
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* mode toggle */}
|
{/* mode toggle */}
|
||||||
{mockModeAvailable ? (
|
{mockModeAvailable ? (
|
||||||
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд">
|
<SettingRow label="Режим данных" description="Демонстрационный режим использует тестовые данные. Живой режим обращается к серверу.">
|
||||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||||
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
|
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
|
||||||
<button key={v} onClick={() => setMode(v)}
|
<button key={v} type="button" aria-pressed={mode===v} onClick={() => setMode(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 text-xs transition-colors border-input",
|
"px-3 text-xs transition-colors border-input",
|
||||||
i === 0 && "border-r",
|
i === 0 && "border-r",
|
||||||
@@ -333,31 +86,11 @@ export default function SettingsPage() {
|
|||||||
label="URL бекенда"
|
label="URL бекенда"
|
||||||
description={
|
description={
|
||||||
backendUrlLocked
|
backendUrlLocked
|
||||||
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра."
|
? "Адрес подключения задан конфигурацией приложения."
|
||||||
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)"
|
: "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<BackendUrlField key={backendUrl} />
|
||||||
<Input
|
|
||||||
className="w-56 h-8 text-sm font-mono"
|
|
||||||
value={urlDraft}
|
|
||||||
onChange={e => setUrlDraft(e.target.value)}
|
|
||||||
onBlur={commitBackendUrl}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
commitBackendUrl()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
readOnly={backendUrlLocked}
|
|
||||||
placeholder="http://localhost:8000"
|
|
||||||
/>
|
|
||||||
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
|
|
||||||
onClick={commitBackendUrl}
|
|
||||||
title="Проверить соединение">
|
|
||||||
<RefreshCwIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
{/* connection status */}
|
{/* connection status */}
|
||||||
@@ -366,11 +99,11 @@ export default function SettingsPage() {
|
|||||||
{backendStatus === undefined && (
|
{backendStatus === undefined && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
|
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
|
||||||
Не проверялось
|
{backendChecking ? "Проверка…" : "Не проверялось"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{backendStatus === true && (
|
{backendStatus === true && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
<span className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400">
|
||||||
<CheckIcon className="size-3.5" />Доступен
|
<CheckIcon className="size-3.5" />Доступен
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -388,516 +121,49 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* hint when live but backend unreachable */}
|
{/* hint when live but backend unreachable */}
|
||||||
{mode === "live" && backendStatus === false && (
|
{mode === "live" && backendStatus === false && (
|
||||||
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
<div className="flex items-start gap-2 py-3 text-xs text-amber-700 dark:text-amber-400">
|
||||||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||||||
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
|
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
{/* ── Основные настройки ── */}
|
<OpsPanel title="Интерфейс" description="Тема применяется сразу в этом браузере." contentClassName="divide-y px-5">
|
||||||
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5">
|
<SettingRow label="Язык интерфейса"><span className="text-sm">Русский</span></SettingRow>
|
||||||
<SettingRow label="Язык интерфейса">
|
<SettingRow label="Тема оформления"><div className="flex flex-wrap gap-2">
|
||||||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
{([["light","Светлая"],["dark","Тёмная"],["system","Авто"]] as const).map(([value,label]) => <Button key={value} size="sm" variant={theme===value?"default":"outline"} aria-pressed={theme===value} disabled={!theme} onClick={()=>setTheme(value)}>{label}</Button>)}
|
||||||
value={lang} onChange={e => setLang(e.target.value)}>
|
</div></SettingRow>
|
||||||
<option value="ru">Русский</option>
|
<p className="py-3 text-xs text-muted-foreground">Периоды сбора данных и параметры диагностики настраиваются в соответствующих разделах.</p>
|
||||||
<option value="en">English</option>
|
<div className="flex flex-wrap gap-2 pb-4"><Link href="/data-collection" className={buttonVariants({variant:"outline",size:"sm"})}>Сбор данных</Link><Link href="/probes" className={buttonVariants({variant:"outline",size:"sm"})}>Диагностика</Link></div>
|
||||||
</select>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Тема оформления">
|
|
||||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
|
||||||
{[["light","Светлая"],["dark","Тёмная"],["system","Авто"]].map(([v, l]) => (
|
|
||||||
<button key={v} onClick={() => setTheme(v)}
|
|
||||||
className={cn("px-3 text-xs transition-colors border-r last:border-r-0 border-input",
|
|
||||||
theme === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
|
|
||||||
{l}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Часовой пояс">
|
|
||||||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
value={timezone} onChange={e => setTimezone(e.target.value)}>
|
|
||||||
{["Europe/Moscow","Europe/Berlin","Europe/Amsterdam","Asia/Singapore","UTC"].map(z => (
|
|
||||||
<option key={z} value={z}>{z}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Интервал автообновления (сек)"
|
|
||||||
description="Как часто страницы обновляют данные">
|
|
||||||
<Input className="w-20 h-8 text-sm" value={refreshSec} onChange={e => setRefreshSec(e.target.value)} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Таймаут диагностики (сек)"
|
|
||||||
description="Максимальное время ожидания при probe-тестах">
|
|
||||||
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
|
|
||||||
</SettingRow>
|
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
<OpsPanel
|
<SystemDatabaseSection />
|
||||||
title="База данных приложения"
|
|
||||||
description="Резервная копия PostgreSQL (pg_dump custom) и сброс таблиц NetFlow. На время операции планировщик и коллектор IPFIX приостанавливаются. Preview: https://reui.io/preview/base/settings-16"
|
|
||||||
contentClassName="divide-y px-5"
|
|
||||||
>
|
|
||||||
{!systemDbAvailable && (
|
|
||||||
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
|
||||||
<span>Доступно только в live-режиме при доступном бекенде.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<SettingRow
|
|
||||||
label="Скачать бэкап"
|
|
||||||
description="Консистентный дамп PostgreSQL (pg_dump -Fc)"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
|
||||||
onClick={() => { void handleSystemDatabaseBackup() }}
|
|
||||||
>
|
|
||||||
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
|
||||||
{dbBackupBusy ? "Подготовка…" : "Скачать"}
|
|
||||||
</Button>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
label="Восстановить из файла"
|
|
||||||
description="Полностью заменяет текущую базу PostgreSQL (pg_restore)"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
|
||||||
onClick={() => setDbRestoreDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<UploadIcon className="size-4" />
|
|
||||||
Выбрать файл
|
|
||||||
</Button>
|
|
||||||
{dbRestoreFile && (
|
|
||||||
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
label="Сбросить данные NetFlow"
|
|
||||||
description="Удалит сессии и агрегаты NetFlow в PostgreSQL. Ключи WG и пиры не трогает"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
|
||||||
onClick={() => setDbPurgeOpen(true)}
|
|
||||||
>
|
|
||||||
{dbPurgeBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <TrashIcon className="size-4" />}
|
|
||||||
{dbPurgeBusy ? "Сброс…" : "Сбросить"}
|
|
||||||
</Button>
|
|
||||||
</SettingRow>
|
|
||||||
</OpsPanel>
|
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
|
||||||
id="route-ai"
|
|
||||||
className="scroll-mt-4"
|
|
||||||
title="Route AI"
|
|
||||||
description="Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и веса настраиваются в инструменте, не через отдельный API."
|
|
||||||
contentClassName="flex flex-col gap-4 px-5 pb-5"
|
|
||||||
>
|
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
|
||||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
|
||||||
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
|
|
||||||
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
|
||||||
<dt className="text-muted-foreground">Гистерезис</dt>
|
|
||||||
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
|
||||||
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
|
|
||||||
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
|
||||||
<dt className="text-muted-foreground">Интервал зондирования</dt>
|
|
||||||
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
|
|
||||||
<dt className="text-muted-foreground">Автоприменение</dt>
|
|
||||||
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<Link
|
|
||||||
href="/route-optimizer"
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
|
||||||
>
|
|
||||||
Открыть оптимизатор маршрутов
|
|
||||||
</Link>
|
|
||||||
</OpsPanel>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── EvoBGP ──
|
if (section === "EvoBGP") return <EvoBgpSettingsSection />
|
||||||
if (section === "EvoBGP") return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{(mode !== "live" || backendStatus !== true) && (
|
|
||||||
<Frame dense className="w-full">
|
|
||||||
<FramePanel className="px-4 py-4">
|
|
||||||
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
|
|
||||||
</p>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<OpsPanel
|
if (section === "Уведомления") return <OpsPanel title="Оповещения" description="Каналы и правила оповещений настраиваются в отдельном разделе." contentClassName="px-5 py-4"><Link href="/alerts" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оповещения</Link></OpsPanel>
|
||||||
title="EvoBGP API"
|
if (section === "API-ключи") return <OpsPanel title="API-ключи" contentClassName="px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Создание и управление отдельными API-ключами в приложении пока недоступны.</p></OpsPanel>
|
||||||
description={
|
return <OpsPanel title="Безопасность" description="Параметры доступа задаются в сервисе авторизации и конфигурации сервера." contentClassName="flex flex-col gap-3 px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Состояние MFA, ограничений IP и настроек аудита в этом разделе не получено. Изменение этих параметров здесь недоступно.</p><Link href="/users" className={buttonVariants({variant:"outline",size:"sm"})}>Управление пользователями</Link></OpsPanel>
|
||||||
<>
|
|
||||||
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
|
|
||||||
<a
|
|
||||||
className="underline underline-offset-2"
|
|
||||||
href="https://git.shx.one/denozord/EvoBGP/src/branch/main/docs/access.md"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
access.md
|
|
||||||
</a>
|
|
||||||
. URL и ключ хранятся в PostgreSQL на сервере бекенда (
|
|
||||||
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
|
|
||||||
). Каталог —{" "}
|
|
||||||
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
contentClassName="divide-y px-5 flex flex-col gap-4 pb-5"
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
label="Базовый URL API"
|
|
||||||
description="Например http://control.example:8080 — без суффикса /v1"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
className="w-full max-w-md h-8 text-sm font-mono"
|
|
||||||
value={evoBaseDraft}
|
|
||||||
onChange={(e) => setEvoBaseDraft(e.target.value)}
|
|
||||||
placeholder="http://localhost:8080"
|
|
||||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
label="API-ключ"
|
|
||||||
description={
|
|
||||||
evo.secretConfigured
|
|
||||||
? "В БД уже есть ключ — введите новый только для замены"
|
|
||||||
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
className="w-full max-w-md h-8 text-sm font-mono"
|
|
||||||
type={showEvoKey ? "text" : "password"}
|
|
||||||
autoComplete="off"
|
|
||||||
value={evoKeyDraft}
|
|
||||||
onChange={(e) => setEvoKeyDraft(e.target.value)}
|
|
||||||
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
|
|
||||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 shrink-0"
|
|
||||||
onClick={() => setShowEvoKey((v) => !v)}
|
|
||||||
>
|
|
||||||
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
label="Подставлять данные EvoBGP"
|
|
||||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
|
||||||
>
|
|
||||||
<FormToggle
|
|
||||||
checked={evoEnabledDraft}
|
|
||||||
onChange={(v) => setEvoEnabledDraft(v)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
|
|
||||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
|
|
||||||
onClick={async () => {
|
|
||||||
setEvoSaveBusy(true)
|
|
||||||
setEvoSaveErr(null)
|
|
||||||
try {
|
|
||||||
await evo.saveSettings({ apiKey: null })
|
|
||||||
setEvoKeyDraft("")
|
|
||||||
markSaved()
|
|
||||||
} catch (e) {
|
|
||||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
|
||||||
} finally {
|
|
||||||
setEvoSaveBusy(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Удалить ключ из БД
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
disabled={
|
|
||||||
mode !== "live" ||
|
|
||||||
backendStatus !== true ||
|
|
||||||
evoBusy !== null ||
|
|
||||||
evoSaveBusy ||
|
|
||||||
!(
|
|
||||||
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
|
|
||||||
(evoKeyDraft.trim() || evo.secretConfigured)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
setEvoBusy("test")
|
|
||||||
setEvoTestResult(null)
|
|
||||||
const b = evoBaseDraft.trim()
|
|
||||||
const k = evoKeyDraft.trim()
|
|
||||||
let draft: EvoBgpTestDraft | undefined
|
|
||||||
if (b || k) {
|
|
||||||
draft = {}
|
|
||||||
if (b) draft.baseUrl = b
|
|
||||||
if (k) draft.apiKey = k
|
|
||||||
}
|
|
||||||
const r = await evo.testConnection(draft)
|
|
||||||
setEvoTestResult({ ok: r.ok, message: r.message })
|
|
||||||
setEvoBusy(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
|
||||||
Проверить ключ
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
|
|
||||||
onClick={async () => {
|
|
||||||
setEvoBusy("refresh")
|
|
||||||
await evo.refresh()
|
|
||||||
setEvoBusy(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
|
||||||
Обновить каталог
|
|
||||||
</Button>
|
|
||||||
{evo.snapshot?.fetchedAt && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{evoTestResult && (
|
|
||||||
<p className={cn(
|
|
||||||
"text-xs",
|
|
||||||
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
|
|
||||||
)}>
|
|
||||||
{evoTestResult.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{evo.error && (
|
|
||||||
<p className="text-xs text-destructive">{evo.error}</p>
|
|
||||||
)}
|
|
||||||
</OpsPanel>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Уведомления ──
|
|
||||||
if (section === "Уведомления") return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
|
|
||||||
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
|
|
||||||
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
|
||||||
</SettingRow>
|
|
||||||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></div>}
|
|
||||||
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
|
||||||
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
|
|
||||||
</SettingRow>
|
|
||||||
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
|
||||||
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
|
||||||
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
|
||||||
</SettingRow>
|
|
||||||
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
|
||||||
</OpsPanel>
|
|
||||||
<OpsPanel title="Триггеры" contentClassName="divide-y px-5">
|
|
||||||
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
|
||||||
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Узел ушёл offline">
|
|
||||||
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Падение BGP-сессии">
|
|
||||||
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
|
||||||
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
|
||||||
</SettingRow>
|
|
||||||
</OpsPanel>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── API-ключи ──
|
|
||||||
if (section === "API-ключи") return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{apiKeys.map(k => (
|
|
||||||
<Frame key={k.id} dense className="w-full">
|
|
||||||
<FramePanel className="pt-4 pb-3 px-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium">{k.name}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{k.prefix}</code>
|
|
||||||
<button onClick={() => handleCopy(k.prefix)} className="text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
|
||||||
{copied === k.prefix ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
{k.scopes.map(s => (
|
|
||||||
<span key={s} className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{s}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<p className="text-xs text-muted-foreground">Создан: {k.created}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Использован: {k.last}</p>
|
|
||||||
<div className="flex gap-1 justify-end mt-2">
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs">Переиздать</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => setApiKeys(p => p.filter(x => x.id !== k.id))}>
|
|
||||||
<TrashIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<OpsPanel
|
|
||||||
title="Документация API"
|
|
||||||
description="Base URL: https://api.routerlists.io/v1"
|
|
||||||
contentClassName="px-4 pb-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
{[
|
|
||||||
["GET", "/servers", "Список серверов"],
|
|
||||||
["GET", "/traffic", "Данные трафика"],
|
|
||||||
["GET", "/filters", "Фильтры маршрутов"],
|
|
||||||
["POST", "/filters", "Создать фильтр"],
|
|
||||||
["GET", "/probes", "Диагностические пробы"],
|
|
||||||
["POST", "/probes/run", "Запустить тест"],
|
|
||||||
].map(([method, path, desc], i) => (
|
|
||||||
<div key={`${method}-${path}-${i}`} className="flex items-baseline gap-2">
|
|
||||||
<span className={cn("font-mono text-[10px] font-bold w-8 shrink-0",
|
|
||||||
method === "GET" ? "text-emerald-500" : "text-sky-500")}>{method}</span>
|
|
||||||
<code className="font-mono text-muted-foreground">{path}</code>
|
|
||||||
<span className="text-muted-foreground/60 text-[10px]">{desc}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</OpsPanel>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Безопасность ──
|
|
||||||
if (section === "Безопасность") return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
|
|
||||||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
|
||||||
description="TOTP / Authenticator app для всех администраторов">
|
|
||||||
<FormToggle checked={mfa} onChange={setMfa} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
|
||||||
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
|
||||||
</SettingRow>
|
|
||||||
</OpsPanel>
|
|
||||||
<OpsPanel
|
|
||||||
title="Allowlist IP-адресов"
|
|
||||||
description="Доступ разрешён только с этих сетей. Одна запись на строку."
|
|
||||||
contentClassName="px-4 pb-4"
|
|
||||||
>
|
|
||||||
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
|
|
||||||
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
|
|
||||||
</OpsPanel>
|
|
||||||
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
|
|
||||||
<SettingRow label="Расширенный журнал аудита"
|
|
||||||
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
|
||||||
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
|
||||||
</SettingRow>
|
|
||||||
</OpsPanel>
|
|
||||||
<OpsPanel title={<span className="text-destructive">Опасная зона</span>} contentClassName="px-4 pb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Сбросить все настройки</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Удалит пользовательские параметры и вернёт заводские значения</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</OpsPanel>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { void handleSave() }}
|
|
||||||
disabled={
|
|
||||||
section === "EvoBGP" &&
|
|
||||||
(evoSaveBusy || mode !== "live" || backendStatus !== true)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{section === "EvoBGP" && evoSaveBusy ? (
|
|
||||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
|
||||||
) : saved ? (
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<SaveIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
{section === "EvoBGP" && evoSaveBusy
|
|
||||||
? "Сохранение…"
|
|
||||||
: saved
|
|
||||||
? "Сохранено!"
|
|
||||||
: "Сохранить"}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-[960px] mx-auto grid grid-cols-[180px_1fr] gap-6">
|
<div className="max-w-[960px] mx-auto grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)] gap-6">
|
||||||
|
|
||||||
{/* nav */}
|
{/* nav */}
|
||||||
<nav className="flex flex-col gap-1 self-start sticky top-0">
|
<nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0">
|
||||||
{SECTIONS_NAV.map(s => (
|
{SECTIONS_NAV.map(s => (
|
||||||
<button key={s} onClick={() => setSection(s)}
|
<button key={s} aria-current={s===section?"page":undefined} onClick={() => setSection(s)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-left px-3 py-2 rounded-md text-sm transition-colors",
|
"text-left px-3 py-2 rounded-md text-sm transition-colors",
|
||||||
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
|
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||||
@@ -912,40 +178,18 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* delete confirm */}
|
|
||||||
{dbRestoreFile && (
|
|
||||||
<DatabaseRestoreConfirm
|
|
||||||
open={!!dbRestoreFile}
|
|
||||||
filename={dbRestoreFile.name}
|
|
||||||
busy={dbRestoreBusy}
|
|
||||||
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
|
|
||||||
onCancel={() => {
|
|
||||||
if (dbRestoreBusy) return
|
|
||||||
setDbRestoreFile(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NetflowPurgeConfirm
|
|
||||||
open={dbPurgeOpen}
|
|
||||||
busy={dbPurgeBusy}
|
|
||||||
onConfirm={() => { void handleNetflowPurgeConfirm() }}
|
|
||||||
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FileImportDialog
|
|
||||||
open={dbRestoreDialogOpen}
|
|
||||||
onOpenChange={setDbRestoreDialogOpen}
|
|
||||||
title="Восстановление базы данных"
|
|
||||||
description="Выберите файл pg_dump custom (.dump) — текущая база будет полностью заменена"
|
|
||||||
accept=".dump,.backup,application/octet-stream"
|
|
||||||
onImport={async (files) => {
|
|
||||||
const file = files[0]
|
|
||||||
if (!file) return
|
|
||||||
setDbRestoreFile(file)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function BackendUrlField(){
|
||||||
|
const {backendUrl,setBackendUrl,backendUrlLocked}=useDataSource()
|
||||||
|
const [draft,setDraft]=useState(backendUrl),[error,setError]=useState<string|null>(null)
|
||||||
|
function commit(){
|
||||||
|
if(backendUrlLocked||draft===backendUrl)return
|
||||||
|
try{const url=new URL(draft.trim());if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash)throw Error();setBackendUrl(url.toString().replace(/\/$/,""));setError(null)}
|
||||||
|
catch{setError("Укажите HTTP(S) адрес без учётных данных и параметров")}
|
||||||
|
}
|
||||||
|
return <div className="flex min-w-0 flex-col gap-2"><Input aria-label="URL бекенда" className="w-full sm:w-64 font-mono" value={draft} onChange={e=>{setDraft(e.target.value);setError(null)}} onBlur={commit} onKeyDown={e=>{if(e.key==="Enter"){e.preventDefault();commit()}}} readOnly={backendUrlLocked} aria-invalid={!!error} placeholder="http://localhost:8000"/>{error?<p role="alert" className="text-xs text-destructive">{error}</p>:null}</div>
|
||||||
|
}
|
||||||
+313
-209
@@ -1,52 +1,59 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
CableIcon,
|
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
GlobeIcon,
|
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { SegmentedControl } from "@/components/form-kit"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
|
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
|
||||||
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
|
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
|
||||||
|
import { DimensionSelect, PivotDimSelect } from "@/components/statistics/dimension-select"
|
||||||
|
import { SliceChips } from "@/components/statistics/slice-chips"
|
||||||
|
import { BreakdownDashboard } from "@/components/statistics/breakdown-dashboard"
|
||||||
|
import { StatisticsPivotGrid } from "@/components/statistics/statistics-pivot-grid"
|
||||||
import {
|
import {
|
||||||
StatisticsBreakdownDataGrid,
|
StatisticsBreakdownDataGrid,
|
||||||
type StatisticsSliceKind,
|
type StatisticsSliceKind,
|
||||||
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { StatisticsFilters } from "@/components/statistics/statistics-filters"
|
||||||
import type { Filter } from "@/components/reui/filters"
|
import {
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
isStatisticsPivotDim,
|
||||||
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
|
isStatisticsSliceKind,
|
||||||
|
STATISTICS_DIMS,
|
||||||
|
} from "@/lib/statistics-dims"
|
||||||
|
import { statisticsContextError, STATISTICS_CONTEXT_KEYS } from "@/lib/statistics-context"
|
||||||
|
import { readStatisticsRange } from "@/lib/statistics-range"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||||
import { getStatistics, type StatisticsDto, type StatisticsQuery } from "@/shared/api/statistics"
|
import {
|
||||||
|
getStatistics,
|
||||||
|
getStatisticsPivot,
|
||||||
|
STATISTICS_UNBOUND_USER_ID,
|
||||||
|
type StatisticsDto,
|
||||||
|
type StatisticsPivotDto,
|
||||||
|
type StatisticsQuery,
|
||||||
|
} from "@/shared/api/statistics"
|
||||||
|
import type { StatisticsBreakdownRow, StatisticsPivotDim } from "@mmapp/contracts/statistics"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отчётный куб трафика — KPI + период + график + табы-гриды.
|
* BI-куб трафика: критерий → остальные разрезы + pivot.
|
||||||
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||||
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/chart-23
|
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/solution-analytics-8
|
||||||
* · https://reui.io/preview/base/components/c-date-selector-2 · https://reui.io/preview/base/empty-state-12
|
* · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const TABS: { id: StatisticsSliceKind; label: string }[] = [
|
|
||||||
{ id: "users", label: "Пользователи" },
|
|
||||||
{ id: "servers", label: "Серверы" },
|
|
||||||
{ id: "interfaces", label: "Интерфейсы" },
|
|
||||||
{ id: "countries", label: "Страны" },
|
|
||||||
{ id: "services", label: "Сервисы" },
|
|
||||||
{ id: "asns", label: "ASN" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const EMPTY: StatisticsDto = {
|
const EMPTY: StatisticsDto = {
|
||||||
from: "",
|
from: "",
|
||||||
to: "",
|
to: "",
|
||||||
@@ -70,6 +77,15 @@ const EMPTY: StatisticsDto = {
|
|||||||
asns: [],
|
asns: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_PIVOT: StatisticsPivotDto = {
|
||||||
|
rowDim: "country",
|
||||||
|
colDim: "service",
|
||||||
|
metric: "bytes",
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
otherBytes: 0,
|
||||||
|
}
|
||||||
|
|
||||||
interface CubeSlices {
|
interface CubeSlices {
|
||||||
country?: string
|
country?: string
|
||||||
service?: string
|
service?: string
|
||||||
@@ -81,16 +97,22 @@ interface CubeSlices {
|
|||||||
|
|
||||||
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
||||||
|
|
||||||
function readRange(sp: URLSearchParams): DateRangeYmd {
|
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
||||||
const from = sp.get("from")
|
const t = sp.get("dim") ?? sp.get("tab")
|
||||||
const to = sp.get("to")
|
return t && isStatisticsSliceKind(t) ? t : "users"
|
||||||
if (from && to && from <= to) return { from, to }
|
|
||||||
return rangeForPreset("7d")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTab(sp: URLSearchParams): StatisticsSliceKind {
|
function readView(sp: URLSearchParams): "explore" | "pivot" {
|
||||||
const t = sp.get("tab")
|
return sp.get("view") === "pivot" ? "pivot" : "explore"
|
||||||
return TABS.some((x) => x.id === t) ? (t as StatisticsSliceKind) : "users"
|
}
|
||||||
|
|
||||||
|
function readPlanes(sp: URLSearchParams): "unique" | "all" {
|
||||||
|
return sp.get("planes") === "all" ? "all" : "unique"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
|
||||||
|
const v = sp.get(key)
|
||||||
|
return v && isStatisticsPivotDim(v) ? v : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSlices(sp: URLSearchParams): CubeSlices {
|
function readSlices(sp: URLSearchParams): CubeSlices {
|
||||||
@@ -102,30 +124,7 @@ function readSlices(sp: URLSearchParams): CubeSlices {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
function slicesToFilters(slices: CubeSlices): Filter[] {
|
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
|
||||||
return SLICE_KEYS.flatMap((key) => {
|
|
||||||
const val = slices[key]
|
|
||||||
if (!val) return []
|
|
||||||
return [{ id: key, field: key, operator: "is", values: [val] }]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function filtersToSlices(filters: Filter[]): CubeSlices {
|
|
||||||
const next: CubeSlices = {}
|
|
||||||
for (const f of filters) {
|
|
||||||
const raw = String(f.values[0] ?? "").trim()
|
|
||||||
if (!raw) continue
|
|
||||||
if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2)
|
|
||||||
else if (f.field === "service") next.service = raw
|
|
||||||
else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "")
|
|
||||||
else if (f.field === "serverId") next.serverId = raw
|
|
||||||
else if (f.field === "userId") next.userId = raw
|
|
||||||
else if (f.field === "iface") next.iface = raw
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
function toQuery(range: DateRangeYmd, slices: CubeSlices): StatisticsQuery {
|
|
||||||
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
||||||
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
||||||
return {
|
return {
|
||||||
@@ -137,69 +136,145 @@ function toQuery(range: DateRangeYmd, slices: CubeSlices): StatisticsQuery {
|
|||||||
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
|
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
|
||||||
service: slices.service,
|
service: slices.service,
|
||||||
asn: Number.isFinite(asn) ? asn : undefined,
|
asn: Number.isFinite(asn) ? asn : undefined,
|
||||||
|
planes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedIdForTab(tab: StatisticsSliceKind, slices: CubeSlices): string | undefined {
|
function selectedIdForKind(kind: StatisticsSliceKind, slices: CubeSlices): string | undefined {
|
||||||
if (tab === "users") return slices.userId
|
if (kind === "users") return slices.userId
|
||||||
if (tab === "servers") return slices.serverId
|
if (kind === "servers") return slices.serverId
|
||||||
if (tab === "countries") return slices.country
|
if (kind === "countries") return slices.country
|
||||||
if (tab === "services") return slices.service
|
if (kind === "services") return slices.service
|
||||||
if (tab === "asns") return slices.asn
|
if (kind === "asns") return slices.asn
|
||||||
if (tab === "interfaces" && slices.serverId && slices.iface) {
|
if (kind === "interfaces" && slices.serverId && slices.iface) {
|
||||||
return `${slices.serverId}:${slices.iface}`
|
return `${slices.serverId}:${slices.iface}`
|
||||||
}
|
}
|
||||||
if (tab === "interfaces") return slices.iface
|
if (kind === "interfaces") return slices.iface
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowsForTab(data: StatisticsDto, tab: StatisticsSliceKind) {
|
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind) {
|
||||||
if (tab === "users") return data.users
|
if (kind === "users") return data.users
|
||||||
if (tab === "servers") return data.servers
|
if (kind === "servers") return data.servers
|
||||||
if (tab === "interfaces") return data.interfaces
|
if (kind === "interfaces") return data.interfaces
|
||||||
if (tab === "countries") return data.countries
|
if (kind === "countries") return data.countries
|
||||||
if (tab === "services") return data.services
|
if (kind === "services") return data.services
|
||||||
return data.asns
|
return data.asns
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatisticsPage() {
|
function hasAnySlice(slices: CubeSlices): boolean {
|
||||||
const router = useRouter()
|
return SLICE_KEYS.some((k) => Boolean(slices[k]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenKinds(slices: CubeSlices): Set<StatisticsSliceKind> {
|
||||||
|
const hidden = new Set<StatisticsSliceKind>()
|
||||||
|
if (slices.userId) hidden.add("users")
|
||||||
|
if (slices.serverId) hidden.add("servers")
|
||||||
|
if (slices.iface) hidden.add("interfaces")
|
||||||
|
if (slices.country) hidden.add("countries")
|
||||||
|
if (slices.service) hidden.add("services")
|
||||||
|
if (slices.asn) hidden.add("asns")
|
||||||
|
return hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDimValue(slices: CubeSlices, kind: StatisticsSliceKind, rowId: string): CubeSlices {
|
||||||
|
const next: CubeSlices = { ...slices }
|
||||||
|
if (kind === "users") {
|
||||||
|
if (rowId === STATISTICS_UNBOUND_USER_ID) return next
|
||||||
|
if (next.userId === rowId) delete next.userId
|
||||||
|
else next.userId = rowId
|
||||||
|
} else if (kind === "servers") {
|
||||||
|
if (next.serverId === rowId) delete next.serverId
|
||||||
|
else next.serverId = rowId
|
||||||
|
} else if (kind === "countries") {
|
||||||
|
if (next.country === rowId) delete next.country
|
||||||
|
else next.country = rowId
|
||||||
|
} else if (kind === "services") {
|
||||||
|
if (next.service === rowId) delete next.service
|
||||||
|
else next.service = rowId
|
||||||
|
} else if (kind === "asns") {
|
||||||
|
if (next.asn === rowId) delete next.asn
|
||||||
|
else next.asn = rowId
|
||||||
|
} else {
|
||||||
|
const colon = rowId.indexOf(":")
|
||||||
|
const sid = colon >= 0 ? rowId.slice(0, colon) : undefined
|
||||||
|
const iface = colon >= 0 ? rowId.slice(colon + 1) : rowId
|
||||||
|
if (next.iface === iface && next.serverId === sid) {
|
||||||
|
delete next.iface
|
||||||
|
delete next.serverId
|
||||||
|
} else {
|
||||||
|
next.iface = iface
|
||||||
|
if (sid) next.serverId = sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPivotDim(slices: CubeSlices, dim: StatisticsPivotDim, id: string): CubeSlices {
|
||||||
|
const kind = STATISTICS_DIMS.find((d) => d.pivot === dim)?.id ?? "users"
|
||||||
|
return applyDimValue(slices, kind, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipList(slices: CubeSlices): { key: string; label: string }[] {
|
||||||
|
const chips: { key: string; label: string }[] = []
|
||||||
|
if (slices.country) chips.push({ key: "country", label: `страна ${slices.country}` })
|
||||||
|
if (slices.service) chips.push({ key: "service", label: `сервис ${slices.service}` })
|
||||||
|
if (slices.asn) chips.push({ key: "asn", label: `ASN ${slices.asn}` })
|
||||||
|
if (slices.serverId) chips.push({ key: "serverId", label: `сервер ${slices.serverId}` })
|
||||||
|
if (slices.userId) chips.push({ key: "userId", label: `пользователь ${slices.userId}` })
|
||||||
|
if (slices.iface) chips.push({ key: "iface", label: `iface ${slices.iface}` })
|
||||||
|
return chips
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatisticsPageInner() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const range = useMemo(() => readRange(searchParams), [searchParams])
|
const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams])
|
||||||
|
const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams])
|
||||||
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
||||||
const filters = useMemo(() => slicesToFilters(slices), [slices])
|
const dim = useMemo(() => readDim(searchParams), [searchParams])
|
||||||
const [tab, setTab] = useState<StatisticsSliceKind>(() => readTab(searchParams))
|
const view = useMemo(() => readView(searchParams), [searchParams])
|
||||||
|
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
|
||||||
|
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
|
||||||
|
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
|
||||||
|
|
||||||
const [data, setData] = useState<StatisticsDto>(EMPTY)
|
const [retry, setRetry] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError, paramsError })
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [result, setResult] = useState<{
|
||||||
|
key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null
|
||||||
|
} | null>(null)
|
||||||
|
const current = result?.key === requestKey ? result : null
|
||||||
|
const loading = isLive && !rangeError && !paramsError && current === null
|
||||||
|
const error = rangeError ?? paramsError ?? current?.error ?? null
|
||||||
|
const dataReady = isLive && current !== null && !error
|
||||||
|
const pivot = dataReady ? current.pivot : EMPTY_PIVOT
|
||||||
|
|
||||||
const replaceParams = useCallback(
|
const updateParams = useCallback(
|
||||||
(patch: Record<string, string | undefined>) => {
|
(patch: Record<string, string | undefined>) => {
|
||||||
const sp = new URLSearchParams(searchParams.toString())
|
const sp = new URLSearchParams(window.location.search)
|
||||||
for (const [k, v] of Object.entries(patch)) {
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
if (v) sp.set(k, v)
|
if (v) sp.set(k, v)
|
||||||
else sp.delete(k)
|
else sp.delete(k)
|
||||||
}
|
}
|
||||||
const qs = sp.toString()
|
const qs = sp.toString()
|
||||||
router.replace(qs ? `/statistics?${qs}` : "/statistics")
|
if (qs === searchParams.toString()) return
|
||||||
|
window.history.pushState(null, "", (qs ? `/statistics?${qs}` : "/statistics") + window.location.hash)
|
||||||
},
|
},
|
||||||
[router, searchParams],
|
[searchParams],
|
||||||
)
|
)
|
||||||
|
|
||||||
const setRange = useCallback(
|
const setRange = useCallback(
|
||||||
(next: DateRangeYmd) => {
|
(next: DateRangeYmd) => {
|
||||||
replaceParams({ from: next.from, to: next.to })
|
updateParams({ from: next.from, to: next.to })
|
||||||
},
|
},
|
||||||
[replaceParams],
|
[updateParams],
|
||||||
)
|
)
|
||||||
|
|
||||||
const setSlices = useCallback(
|
const setSlices = useCallback(
|
||||||
(next: CubeSlices) => {
|
(next: CubeSlices) => {
|
||||||
replaceParams({
|
updateParams({
|
||||||
country: next.country,
|
country: next.country,
|
||||||
service: next.service,
|
service: next.service,
|
||||||
asn: next.asn,
|
asn: next.asn,
|
||||||
@@ -208,76 +283,77 @@ export default function StatisticsPage() {
|
|||||||
iface: next.iface,
|
iface: next.iface,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[replaceParams],
|
[updateParams],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prefsHydrated || !isLive) return
|
if (!prefsHydrated || !isLive || rangeError || paramsError) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const dto = await getStatistics(backendUrl, toQuery(range, slices))
|
const query = toQuery(range, slices, planes)
|
||||||
if (!cancelled) setData(dto)
|
const dto = await getStatistics(backendUrl, query)
|
||||||
|
if (cancelled) return
|
||||||
|
let matrix = EMPTY_PIVOT
|
||||||
|
if (view === "pivot" && pivotRow !== pivotCol) {
|
||||||
|
matrix = await getStatisticsPivot(backendUrl, {
|
||||||
|
...query,
|
||||||
|
row: pivotRow,
|
||||||
|
col: pivotCol,
|
||||||
|
metric: "bytes",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null })
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setData(EMPTY)
|
setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
|
||||||
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false)
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [backendUrl, isLive, prefsHydrated, range, slices])
|
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError, paramsError])
|
||||||
|
|
||||||
const rows = rowsForTab(isLive ? data : EMPTY, tab)
|
const viewData = dataReady ? current.data : EMPTY
|
||||||
const selectedId = selectedIdForTab(tab, slices)
|
const sliced = hasAnySlice(slices)
|
||||||
const view = isLive ? data : EMPTY
|
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
||||||
|
|
||||||
function handleRowClick(kind: StatisticsSliceKind, row: { id: string }) {
|
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
|
||||||
const next: CubeSlices = { ...slices }
|
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
|
||||||
if (kind === "users") {
|
if (kind === "interfaces" && row.label.includes("· дубль")) return
|
||||||
if (next.userId === row.id) delete next.userId
|
setSlices(applyDimValue(slices, kind, row.id))
|
||||||
else next.userId = row.id
|
|
||||||
} else if (kind === "servers") {
|
|
||||||
if (next.serverId === row.id) delete next.serverId
|
|
||||||
else next.serverId = row.id
|
|
||||||
} else if (kind === "countries") {
|
|
||||||
if (next.country === row.id) delete next.country
|
|
||||||
else next.country = row.id
|
|
||||||
} else if (kind === "services") {
|
|
||||||
if (next.service === row.id) delete next.service
|
|
||||||
else next.service = row.id
|
|
||||||
} else if (kind === "asns") {
|
|
||||||
if (next.asn === row.id) delete next.asn
|
|
||||||
else next.asn = row.id
|
|
||||||
} else {
|
|
||||||
const colon = row.id.indexOf(":")
|
|
||||||
const sid = colon >= 0 ? row.id.slice(0, colon) : undefined
|
|
||||||
const iface = colon >= 0 ? row.id.slice(colon + 1) : row.id
|
|
||||||
if (next.iface === iface && next.serverId === sid) {
|
|
||||||
delete next.iface
|
|
||||||
delete next.serverId
|
|
||||||
} else {
|
|
||||||
next.iface = iface
|
|
||||||
if (sid) next.serverId = sid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSlices(next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const kpis = view.kpis
|
function handlePivotCell(rowId: string, colId: string) {
|
||||||
const emptyCube = !isLive || (!loading && kpis.bytes === 0)
|
if (rowId === "__other__" || colId === "__other__") return
|
||||||
|
let next = applyPivotDim(slices, pivotRow, rowId)
|
||||||
|
next = applyPivotDim(next, pivotCol, colId)
|
||||||
|
updateParams({
|
||||||
|
country: next.country,
|
||||||
|
service: next.service,
|
||||||
|
asn: next.asn,
|
||||||
|
serverId: next.serverId,
|
||||||
|
userId: next.userId,
|
||||||
|
iface: next.iface,
|
||||||
|
view: "explore",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpis = viewData.kpis
|
||||||
|
const chips = chipList(slices)
|
||||||
|
const countLabel =
|
||||||
|
view === "pivot"
|
||||||
|
? `${pivot.rows.length} × ${pivot.columns.length}`
|
||||||
|
: sliced
|
||||||
|
? `${STATISTICS_DIMS.filter((d) => !hiddenKinds(slices).has(d.id)).length} разрезов`
|
||||||
|
: `${rowsForKind(viewData, dim).length} строк`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
||||||
actions={<PeriodSelector range={range} onChange={setRange} />}
|
actions={rangeError ? <Button type="button" variant="outline" onClick={() => setRange(rangeForPreset("7d"))}>Выбрать последние 7 дней</Button> : <PeriodSelector range={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
@@ -291,13 +367,22 @@ export default function StatisticsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive" role="alert">
|
||||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
<AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}{!rangeError && !paramsError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}{paramsError && <Button type="button" variant="outline" size="sm" onClick={() => updateParams(Object.fromEntries(STATISTICS_CONTEXT_KEYS.map(key => [key, undefined])))}>Сбросить параметры отчёта</Button>}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<KpiStatGrid
|
{!slices.serverId && dataReady && !emptyCube ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Уникальный объём</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(dataReady || loading) && <KpiStatGrid
|
||||||
aria-label="Сводка трафика"
|
aria-label="Сводка трафика"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
skeletonCount={5}
|
skeletonCount={5}
|
||||||
@@ -306,7 +391,7 @@ export default function StatisticsPage() {
|
|||||||
id: "bytes",
|
id: "bytes",
|
||||||
label: "Объём",
|
label: "Объём",
|
||||||
value: formatBytes(kpis.bytes),
|
value: formatBytes(kpis.bytes),
|
||||||
hint: kpis.topCountry ? `топ: ${kpis.topCountry}` : undefined,
|
hint: "GRE/WG клиентов, без hops",
|
||||||
icon: <DatabaseIcon />,
|
icon: <DatabaseIcon />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
@@ -336,92 +421,111 @@ export default function StatisticsPage() {
|
|||||||
id: "servers",
|
id: "servers",
|
||||||
label: "Серверы",
|
label: "Серверы",
|
||||||
value: String(kpis.servers),
|
value: String(kpis.servers),
|
||||||
hint: kpis.ifaces ? `${kpis.ifaces} iface` : undefined,
|
hint: slices.serverId
|
||||||
|
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
|
||||||
|
: planes === "all"
|
||||||
|
? "WAN и дубли в списке"
|
||||||
|
: "без WAN и overlay",
|
||||||
icon: <ServerIcon />,
|
icon: <ServerIcon />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>}
|
||||||
|
|
||||||
<StatisticsVolumeChart series={view.series} grain={view.grain} />
|
{dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
|
||||||
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
filters={filters}
|
leading={
|
||||||
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
filterFields={STATISTICS_FILTER_FIELDS}
|
<SegmentedControl
|
||||||
countLabel={`${rows.length} строк`}
|
value={view}
|
||||||
|
onChange={(next) => updateParams({ view: next === "pivot" ? "pivot" : "explore" })}
|
||||||
|
options={[
|
||||||
|
{ value: "explore", label: "Разрез" },
|
||||||
|
{ value: "pivot", label: "Сводка" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
value={planes}
|
||||||
|
onChange={(next) => updateParams({ planes: next === "all" ? "all" : undefined })}
|
||||||
|
options={[
|
||||||
|
{ value: "unique", label: "Уникальный" },
|
||||||
|
{ value: "all", label: "Все плоскости" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{view === "explore" && !sliced ? (
|
||||||
|
<DimensionSelect
|
||||||
|
label="Критерий"
|
||||||
|
value={dim}
|
||||||
|
onChange={(next) => updateParams({ dim: next, tab: undefined })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{view === "pivot" ? (
|
||||||
|
<>
|
||||||
|
<PivotDimSelect
|
||||||
|
label="Строки"
|
||||||
|
value={pivotRow}
|
||||||
|
exclude={pivotCol}
|
||||||
|
onChange={(next) => updateParams({ pivotRow: next })}
|
||||||
|
/>
|
||||||
|
<PivotDimSelect
|
||||||
|
label="Колонки"
|
||||||
|
value={pivotCol}
|
||||||
|
exclude={pivotRow}
|
||||||
|
onChange={(next) => updateParams({ pivotCol: next })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
|
||||||
|
countLabel={dataReady ? countLabel : undefined}
|
||||||
/>
|
/>
|
||||||
{SLICE_KEYS.some((k) => slices[k]) ? (
|
<SliceChips
|
||||||
<div className="flex flex-wrap items-center gap-1.5 border-b px-5 py-2">
|
chips={chips}
|
||||||
{slices.country ? (
|
onRemove={(key) => {
|
||||||
<Badge variant="outline" size="sm">страна {slices.country}</Badge>
|
const next = { ...slices }
|
||||||
) : null}
|
delete next[key as keyof CubeSlices]
|
||||||
{slices.service ? (
|
setSlices(next)
|
||||||
<Badge variant="outline" size="sm">сервис {slices.service}</Badge>
|
}}
|
||||||
) : null}
|
/>
|
||||||
{slices.asn ? (
|
{isLive && !dataReady ? (
|
||||||
<Badge variant="outline" size="sm">ASN {slices.asn}</Badge>
|
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
|
||||||
) : null}
|
) : emptyCube ? (
|
||||||
{slices.serverId ? (
|
<EmptyState
|
||||||
<Badge variant="outline" size="sm">сервер {slices.serverId}</Badge>
|
title="Нет данных куба"
|
||||||
) : null}
|
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
||||||
{slices.userId ? (
|
/>
|
||||||
<Badge variant="outline" size="sm">пользователь {slices.userId}</Badge>
|
) : view === "pivot" ? (
|
||||||
) : null}
|
<StatisticsPivotGrid data={isLive ? pivot : EMPTY_PIVOT} onCellClick={handlePivotCell} isLoading={loading} />
|
||||||
{slices.iface ? (
|
) : sliced ? (
|
||||||
<Badge variant="outline" size="sm">iface {slices.iface}</Badge>
|
<BreakdownDashboard
|
||||||
) : null}
|
data={viewData}
|
||||||
</div>
|
hidden={hiddenKinds(slices)}
|
||||||
) : null}
|
selectedIdFor={(kind) => selectedIdForKind(kind, slices)}
|
||||||
<Tabs
|
onRowClick={handleRowClick}
|
||||||
value={tab}
|
isLoading={loading}
|
||||||
onValueChange={(v) => {
|
/>
|
||||||
const next = String(v) as StatisticsSliceKind
|
) : (
|
||||||
setTab(next)
|
<StatisticsBreakdownDataGrid
|
||||||
replaceParams({ tab: next })
|
rows={rowsForKind(viewData, dim)}
|
||||||
}}
|
kind={dim}
|
||||||
className="gap-0"
|
selectedId={selectedIdForKind(dim, slices)}
|
||||||
>
|
onRowClick={(row) => handleRowClick(dim, row)}
|
||||||
<div className="px-5 pt-2">
|
isLoading={loading}
|
||||||
<TabsList variant="line" className="w-fit">
|
/>
|
||||||
<TabsTrigger value="users">
|
)}
|
||||||
<UsersIcon /> Пользователи
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="servers">
|
|
||||||
<ServerIcon /> Серверы
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="interfaces">
|
|
||||||
<CableIcon /> Интерфейсы
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="countries">
|
|
||||||
<GlobeIcon /> Страны
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="services">Сервисы</TabsTrigger>
|
|
||||||
<TabsTrigger value="asns">ASN</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
{TABS.map((t) => (
|
|
||||||
<TabsContent key={t.id} value={t.id}>
|
|
||||||
{emptyCube ? (
|
|
||||||
<EmptyState
|
|
||||||
title="Нет данных куба"
|
|
||||||
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<StatisticsBreakdownDataGrid
|
|
||||||
rows={rowsForTab(view, t.id)}
|
|
||||||
kind={t.id}
|
|
||||||
selectedId={t.id === tab ? selectedId : undefined}
|
|
||||||
onRowClick={(row) => handleRowClick(t.id, row)}
|
|
||||||
isLoading={loading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function StatisticsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<StatisticsPageInner />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
+111
-421
@@ -1,27 +1,30 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
import { useState, useRef, useEffect, Suspense } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { servers as mockServers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import type { ServerStatus } from "@/lib/data"
|
import {useSearchParams} from "next/navigation"
|
||||||
|
import {can} from "@/lib/auth"
|
||||||
|
import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "@/lib/terminal-response"
|
||||||
|
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
||||||
|
import {Input} from "@/components/ui/input"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
FrameFooter,
|
FrameFooter,
|
||||||
FrameHeader,
|
FrameHeader,
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from "@/components/reui/frame"
|
} from "@/components/reui/frame"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ServerTileItem,
|
TrashIcon, RefreshCwIcon,
|
||||||
} from "@/components/server-tile-rail"
|
|
||||||
import {
|
|
||||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -171,231 +174,72 @@ function mockResolve(raw: string, serverName: string): string {
|
|||||||
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MOTD builders ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function mockMotd(server: TermServer): string {
|
function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) {
|
||||||
return [
|
const [lines,setLines]=useState<TermLine[]>([{id:0,kind:"info",text:isLive?"REST: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}])
|
||||||
"",
|
const [input,setInput]=useState(""),[history,setHistory]=useState<string[]>([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState<string|null>(null),[completed,setCompleted]=useState<string|null>(null)
|
||||||
" MMM MMM KKK TTTTTTTTTTT KKK",
|
const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
|
||||||
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK",
|
const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(null),followOutput=useRef(true),pendingFocus=useRef(false)
|
||||||
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK",
|
useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.controller?.abort()}},[])
|
||||||
"",
|
useEffect(()=>{if(followOutput.current)scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:"auto"})},[lines])
|
||||||
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
// Focus only after React has committed the re-enabled input.
|
||||||
"",
|
useEffect(()=>{
|
||||||
"[?] Gives the list of available commands",
|
if(executing||!pendingFocus.current)return
|
||||||
"[Tab] Completes the command/word",
|
pendingFocus.current=false
|
||||||
"[/] Move up to base level",
|
if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
|
||||||
"[..] Move up one level",
|
},[executing,lines])
|
||||||
"",
|
function append(kind:TermLine["kind"],text:string) {
|
||||||
].join("\n")
|
const added=text.split("\n").map(text=>({id:nextId.current++,kind,text}))
|
||||||
}
|
setLines(previous=>{const merged=[...previous.filter(line=>line.id!==-1),...added];return merged.length>1000||previous.some(line=>line.id===-1)?[{id:-1,kind:"info",text:"Показаны последние 1000 строк; ранний вывод удалён."},...merged.slice(-1000)]:merged})
|
||||||
|
}
|
||||||
function liveMotd(server: TermServer): string {
|
async function submit(){
|
||||||
return [
|
const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return
|
||||||
"",
|
if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return}
|
||||||
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1)
|
||||||
"",
|
append("prompt",server.name+" > "+command)
|
||||||
` Connected to ${server.name} (${server.host})`,
|
setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100))
|
||||||
"",
|
const controller=new AbortController();reader.current.controller=controller
|
||||||
"[?] Gives the list of available commands — type help or ?",
|
let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000)
|
||||||
"[Tab] Completes the command/word",
|
try {
|
||||||
"[/] Move up to base level",
|
const output=isLive?readTerminalOutput(await requestJson<unknown>(backendUrl,"/api/servers/"+server.backendId+"/exec",{method:"POST",body:JSON.stringify({command}),signal:controller.signal})):mockResolve(command,server.name)
|
||||||
"",
|
if(!reader.current.alive)return
|
||||||
].join("\n")
|
if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.")
|
||||||
}
|
setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true
|
||||||
|
} catch(err) {
|
||||||
// ─── terminal component ───────────────────────────────────────────────────────
|
if(!reader.current.alive)return
|
||||||
|
const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды."
|
||||||
function Terminal({
|
append("error",message);setError(message)
|
||||||
server,
|
} finally {
|
||||||
isLive,
|
window.clearTimeout(timer);lock.current=false
|
||||||
backendUrl,
|
if(reader.current.alive){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
|
||||||
}: {
|
|
||||||
server: TermServer
|
|
||||||
isLive: boolean
|
|
||||||
backendUrl: string
|
|
||||||
}) {
|
|
||||||
const [lines, setLines] = useState<TermLine[]>([])
|
|
||||||
const [input, setInput] = useState("")
|
|
||||||
const [history, setHistory] = useState<string[]>([])
|
|
||||||
const [_histIdx, setHistIdx] = useState(-1)
|
|
||||||
const [_idCtr, setIdCtr] = useState(0)
|
|
||||||
const [executing, setExecuting] = useState(false)
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const nextId = useCallback((): number => {
|
|
||||||
let id = 0
|
|
||||||
setIdCtr(prev => { id = prev + 1; return prev + 1 })
|
|
||||||
return id
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Initialize MOTD on mount / server change
|
|
||||||
useEffect(() => {
|
|
||||||
const motd = isLive ? liveMotd(server) : mockMotd(server)
|
|
||||||
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
|
|
||||||
setLines(init)
|
|
||||||
setIdCtr(init.length)
|
|
||||||
setInput("")
|
|
||||||
setHistory([])
|
|
||||||
setHistIdx(-1)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [server.uid, isLive])
|
|
||||||
|
|
||||||
// Auto-scroll
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
|
|
||||||
}, [lines])
|
|
||||||
|
|
||||||
const prompt = `[admin@${server.identityName ?? server.name}] > `
|
|
||||||
|
|
||||||
const submit = useCallback(async () => {
|
|
||||||
if (executing) return
|
|
||||||
const cmd = input.trim()
|
|
||||||
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
|
|
||||||
setInput("")
|
|
||||||
setHistIdx(-1)
|
|
||||||
|
|
||||||
if (!cmd) return
|
|
||||||
|
|
||||||
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
|
|
||||||
|
|
||||||
// local always-available commands
|
|
||||||
const lower = cmd.toLowerCase()
|
|
||||||
if (lower === "clear" || lower === "/clear") { setLines([]); return }
|
|
||||||
if (lower === "quit" || lower === "exit") {
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLive && server.backendId !== null) {
|
|
||||||
setExecuting(true)
|
|
||||||
try {
|
|
||||||
const data = await requestJson<{ output?: string; error?: string }>(
|
|
||||||
backendUrl,
|
|
||||||
`/api/servers/${server.backendId}/exec`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ command: cmd }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const text = data.output ?? data.error ?? "(empty response)"
|
|
||||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
|
||||||
text.split("\n").forEach(line =>
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
|
|
||||||
} finally {
|
|
||||||
setExecuting(false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// mock mode
|
|
||||||
const result = mockResolve(cmd, server.name)
|
|
||||||
if (result === "__CLEAR__") { setLines([]); return }
|
|
||||||
if (result) {
|
|
||||||
result.split("\n").forEach(line =>
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
|
|
||||||
|
|
||||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault(); void submit()
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault()
|
|
||||||
setHistIdx(i => {
|
|
||||||
const next = Math.min(i + 1, history.length - 1)
|
|
||||||
if (history[next] !== undefined) setInput(history[next])
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
} else if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault()
|
|
||||||
setHistIdx(i => {
|
|
||||||
const next = i - 1
|
|
||||||
if (next < 0) { setInput(""); return -1 }
|
|
||||||
if (history[next] !== undefined) setInput(history[next])
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
} else if (e.key === "l" && e.ctrlKey) {
|
|
||||||
e.preventDefault(); setLines([])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const lineColor = (kind: TermLine["kind"]) => {
|
function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
|
||||||
if (kind === "error") return "text-red-400"
|
if(e.key==="Enter"){e.preventDefault();void submit()}
|
||||||
if (kind === "prompt") return "text-emerald-400"
|
else if(e.key==="ArrowUp"){e.preventDefault();const index=Math.min(histIdx+1,history.length-1);setHistIdx(index);if(history[index]!==undefined)setInput(history[index])}
|
||||||
if (kind === "info") return "text-sky-400"
|
else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")}
|
||||||
return "text-[#c9d1d9]"
|
else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])}
|
||||||
}
|
}
|
||||||
|
const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null)
|
||||||
return (
|
return <Frame spacing="sm" className="h-full min-h-0 min-w-0">
|
||||||
<div
|
<FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
|
||||||
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
|
<FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
|
||||||
onClick={() => inputRef.current?.focus()}
|
<div className="min-w-0"><FrameTitle className="[overflow-wrap:anywhere]">{server.name}</FrameTitle><FrameDescription className="[overflow-wrap:anywhere]">{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}</FrameDescription></div>
|
||||||
>
|
<Button type="button" variant="ghost" size="icon-sm" aria-label="Очистить вывод терминала" onClick={()=>setLines([])}><TrashIcon aria-hidden="true" className="size-4"/></Button>
|
||||||
{/* title bar */}
|
</FrameHeader>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
|
<div ref={scrollRef} role="region" aria-label="Вывод терминала" tabIndex={0} onScroll={e=>{const el=e.currentTarget;followOutput.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}} className="flex-1 min-h-0 min-w-0 overflow-auto p-3 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring">
|
||||||
<CircleIcon className="size-3 text-red-500 fill-red-500" />
|
{lines.length?lines.map(line=><div key={line.id} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="error"?"text-destructive":line.kind==="prompt"?"text-primary":line.kind==="info"?"text-muted-foreground":"text-foreground")}>{line.text}</div>):<p className="text-muted-foreground">Вывод очищен. Следующая команда добавит новый результат.</p>}
|
||||||
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
|
</div>
|
||||||
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
|
<FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
|
||||||
<span className="mx-auto text-[#8b949e] text-[11px]">
|
<p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
|
||||||
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
|
{error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
|
||||||
</span>
|
{blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
|
||||||
{isLive && (
|
<label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
|
||||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400">
|
<div className="flex gap-2 min-w-0"><Input ref={inputRef} id="terminal-command" value={input} maxLength={1024} onChange={e=>setInput(e.target.value)} onKeyDown={handleKey} disabled={executing||!!blocked} className="flex-1 min-w-0 font-mono text-xs" autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}/><Button type="button" size="sm" disabled={executing||!!blocked||!input.trim()} onClick={()=>void submit()}>{executing?"Ожидание…":"Выполнить"}</Button></div>
|
||||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
<div className="flex flex-col sm:flex-row gap-2 text-xs text-muted-foreground"><select aria-label="Быстрая команда" value="" disabled={executing||!!blocked} className="min-w-0 rounded-md border bg-background px-2 py-1" onChange={e=>{setInput(e.target.value);setHistIdx(-1);inputRef.current?.focus()}}><option value="">Подставить команду…</option>{QUICK_CMDS.map(item=><option key={item.cmd} value={item.cmd}>{item.label}</option>)}</select><p>↑↓ — история · Ctrl+L — очистить · до 1024 символов</p></div>
|
||||||
</span>
|
</FrameFooter>
|
||||||
)}
|
</FramePanel>
|
||||||
<button
|
</Frame>
|
||||||
onClick={e => { e.stopPropagation(); setLines([]) }}
|
|
||||||
className="text-[#8b949e] hover:text-white transition-colors ml-1"
|
|
||||||
title="Очистить"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* output */}
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
|
|
||||||
{lines.map((line, i) => (
|
|
||||||
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
|
|
||||||
{line.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* executing indicator */}
|
|
||||||
{executing && (
|
|
||||||
<div className="flex items-center gap-2 text-amber-400 opacity-80">
|
|
||||||
<Loader2Icon className="size-3 animate-spin" />
|
|
||||||
<span>executing…</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* input line */}
|
|
||||||
{!executing && (
|
|
||||||
<div className="flex items-center gap-0">
|
|
||||||
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
value={input}
|
|
||||||
onChange={e => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKey}
|
|
||||||
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── quick commands ───────────────────────────────────────────────────────────
|
// ─── quick commands ───────────────────────────────────────────────────────────
|
||||||
@@ -423,201 +267,47 @@ function mockServersToTermServers(): TermServer[] {
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
host: s.host,
|
host: s.host,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status as TermServer["status"],
|
status: s.status==="unknown"?null:s.status,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
rosVersion: s.os ?? null,
|
rosVersion: s.os ?? null,
|
||||||
identityName: s.name,
|
identityName: s.name,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Backend ServerRead shape (only fields we need) */
|
|
||||||
interface BackendServer {
|
export default function TerminalPage(){
|
||||||
id: number
|
return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
|
||||||
name: string
|
|
||||||
host: string
|
|
||||||
country: string
|
|
||||||
status: "online" | "offline" | null
|
|
||||||
enabled: boolean
|
|
||||||
os: string | null
|
|
||||||
identityName: string | null
|
|
||||||
}
|
}
|
||||||
|
function TerminalRoute(){
|
||||||
export default function TerminalPage() {
|
const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)}
|
||||||
const isLive = mode === "live"
|
if(url.error)return <Alert variant="destructive"><AlertTitle>Некорректная ссылка терминала</AlertTitle><AlertDescription>{url.error}<Button type="button" variant="outline" onClick={reset}>Сбросить контекст терминала</Button></AlertDescription></Alert>
|
||||||
|
if(!prefsHydrated)return <p role="status" className="p-4">Загружаем источник…</p>
|
||||||
// Server list state
|
return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
|
||||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
}
|
||||||
const [serversLoading, setServersLoading] = useState(false)
|
function TerminalContent(){
|
||||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||||
|
const [liveServers,setLiveServers]=useState<TermServer[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState<string|null>(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState<string|null>(null),[refresh,setRefresh]=useState(0)
|
||||||
// Load servers from backend when in live mode
|
const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write")
|
||||||
useEffect(() => {
|
useEffect(()=>{
|
||||||
if (!isLive || !prefsHydrated) return
|
if(!isLive||!canRead)return
|
||||||
let cancelled = false
|
let cancelled=false;const controller=new AbortController()
|
||||||
queueMicrotask(() => {
|
queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson<unknown>(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{
|
||||||
if (cancelled) return
|
const rows=terminalServersSchema.parse(raw);if(cancelled)return
|
||||||
setServersLoading(true)
|
setLiveServers(rows.map(row=>({uid:String(row.id),backendId:row.id,name:row.name||row.host,host:row.host,country:row.country,status:row.status,enabled:row.enabled,rosVersion:row.os??null,identityName:row.identityName??null})));setLoaded(true);setReadAt(new Date().toLocaleTimeString("ru-RU"));setError(null)
|
||||||
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
}).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
|
||||||
.then((data) => {
|
return()=>{cancelled=true;controller.abort()}
|
||||||
if (cancelled) return
|
},[isLive,backendUrl,canRead,refresh])
|
||||||
setLiveServers(data.map(s => ({
|
const termServers=isLive?liveServers:mockServersToTermServers()
|
||||||
uid: String(s.id),
|
const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
|
||||||
backendId: s.id,
|
const selected=matches.length===1?matches[0]:null
|
||||||
name: s.name || s.host,
|
const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
|
||||||
host: s.host,
|
const items=termServers.map(server=>({id:server.uid,name:server.name,host:server.host,country:server.country||undefined,status:server.status??"unknown" as const,enabled:server.enabled,selectable:true,title:[server.name,server.host].join(" · ")}))
|
||||||
country: s.country || "",
|
function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
|
||||||
status: s.status,
|
const blocked=!!error||loading||isLive&&!loaded
|
||||||
enabled: s.enabled,
|
return <ServerRailLayout items={items} selectedId={selected?.uid??url.server??""} onSelect={select} preserveSelection showAll={false} showCount={false} showType={false} loading={loading} contentClassName="min-h-0 min-w-0 overflow-hidden p-2 md:p-3"
|
||||||
rosVersion: s.os,
|
header={<PageHeader crumbs={[{label:"Инструменты"},{label:"Терминал"}]} actions={<><ServerRailMobileButton/>{isLive&&<Button type="button" variant="outline" size="sm" disabled={loading||!canRead} onClick={()=>setRefresh(value=>value+1)}><RefreshCwIcon aria-hidden="true" className="size-4"/>Обновить устройства</Button>}</>}/>}
|
||||||
identityName: s.identityName,
|
banner={<div className="px-3 py-2 space-y-2"><p role="status" className="text-xs text-muted-foreground">{isLive?(readAt?"Каталог · ответ "+readAt+" (время браузера)":"Каталог ещё не подтверждён."):"Демо: команды не отправляются на устройство."} REST выполняет только поддерживаемые диагностические команды.</p>{!canRead&&isLive&&<Alert variant="destructive"><AlertTitle>Нет доступа к каталогу</AlertTitle><AlertDescription>Требуется mm:servers:read.</AlertDescription></Alert>}{error&&<Alert variant="destructive"><AlertTitle>{loaded?"Каталог не обновлён":"Устройства пока неизвестны"}</AlertTitle><AlertDescription>{error} {loaded?"Сохранён предыдущий каталог; выполнение приостановлено до успешного обновления.":"Повторите загрузку устройств."}</AlertDescription></Alert>}{unavailable&&<Alert variant="destructive"><AlertTitle>Устройство терминала недоступно</AlertTitle><AlertDescription>{matches.length>1?"Host неоднозначен: выберите точное устройство.":"Указанный server/host отсутствует в текущем каталоге."}<Button type="button" variant="outline" onClick={()=>select("")}>Сбросить выбор устройства</Button></AlertDescription></Alert>}</div>}>
|
||||||
})))
|
{selected?<Terminal key={JSON.stringify([selected.uid,selected.host])} server={selected} isLive={isLive} backendUrl={backendUrl} canExecute={canExecute} blockedReason={blocked?"Каталог не подтверждён; выполнение приостановлено до успешного обновления.":undefined}/>:<Frame spacing="sm" className="h-full min-h-0"><FramePanel className="h-full flex flex-col gap-2 items-center justify-center p-4 text-center"><FrameTitle>{loading?"Загружаем устройства…":loaded&&!termServers.length?"Устройств пока нет":"Выберите устройство для терминала"}</FrameTitle><FrameDescription>Выбор сохраняется в URL. Команда отправляется только после нажатия «Выполнить».</FrameDescription></FramePanel></Frame>}
|
||||||
setServersLoading(false)
|
</ServerRailLayout>
|
||||||
})
|
|
||||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
|
||||||
})
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [isLive, backendUrl, refreshKey, prefsHydrated])
|
|
||||||
|
|
||||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
|
||||||
|
|
||||||
// Select first available server by default
|
|
||||||
const [selectedUid, setSelectedUid] = useState<string>("")
|
|
||||||
|
|
||||||
const defaultUid = useMemo(() => {
|
|
||||||
const first = termServers.find(s => s.status !== "offline" && s.enabled)
|
|
||||||
?? termServers[0]
|
|
||||||
return first?.uid ?? ""
|
|
||||||
}, [termServers])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
|
|
||||||
})
|
|
||||||
}, [defaultUid, selectedUid])
|
|
||||||
|
|
||||||
// Reset selection when switching modes
|
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(() => setSelectedUid(""))
|
|
||||||
}, [isLive])
|
|
||||||
|
|
||||||
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
|
|
||||||
|
|
||||||
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
|
||||||
|
|
||||||
const railItems = useMemo<ServerTileItem[]>(() => {
|
|
||||||
return termServers.map((s) => ({
|
|
||||||
id: s.uid,
|
|
||||||
name: s.name,
|
|
||||||
host: s.host,
|
|
||||||
country: s.country || undefined,
|
|
||||||
status: (s.status ?? undefined) as ServerStatus | undefined,
|
|
||||||
enabled: s.enabled,
|
|
||||||
selectable: s.enabled && s.status !== "offline",
|
|
||||||
title: [s.name, s.host].filter(Boolean).join(" · "),
|
|
||||||
}))
|
|
||||||
}, [termServers])
|
|
||||||
|
|
||||||
const handleSelectServer = useCallback((id: string) => {
|
|
||||||
setSelectedUid(id)
|
|
||||||
setRefreshKey((k) => k + 1)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const railHeaderRight = isLive && !serversLoading
|
|
||||||
? <Badge variant="success-light" size="xs">LIVE</Badge>
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
function QuickCmds() {
|
|
||||||
return (
|
|
||||||
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
|
||||||
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
|
|
||||||
<FrameHeader className="border-b px-3 py-2">
|
|
||||||
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Быстрые команды
|
|
||||||
</FrameTitle>
|
|
||||||
</FrameHeader>
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
|
||||||
<div className="flex flex-col gap-0.5 p-1.5">
|
|
||||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
|
||||||
<button
|
|
||||||
key={cmd}
|
|
||||||
type="button"
|
|
||||||
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
||||||
onClick={() => injectCommand(cmd)}
|
|
||||||
title={cmd}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
|
|
||||||
<p>↑↓ — история команд</p>
|
|
||||||
<p>Ctrl+L — очистить экран</p>
|
|
||||||
{isLive
|
|
||||||
? <p className="text-info">Команды выполняются на роутере</p>
|
|
||||||
: <p>Режим: mock-данные</p>}
|
|
||||||
</FrameFooter>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function injectCommand(cmd: string) {
|
|
||||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
|
||||||
if (!el) return
|
|
||||||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set
|
|
||||||
nativeSetter?.call(el, cmd)
|
|
||||||
el.dispatchEvent(new Event("input", { bubbles: true }))
|
|
||||||
el.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ServerRailLayout
|
|
||||||
items={railItems}
|
|
||||||
selectedId={selected?.uid ?? selectedUid}
|
|
||||||
onSelect={handleSelectServer}
|
|
||||||
showAll={false}
|
|
||||||
showCount={false}
|
|
||||||
showType={false}
|
|
||||||
headerRight={railHeaderRight}
|
|
||||||
loading={serversLoading}
|
|
||||||
extra={<QuickCmds />}
|
|
||||||
contentClassName="overflow-hidden p-3 md:p-4"
|
|
||||||
header={
|
|
||||||
<PageHeader
|
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<ServerRailMobileButton />
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setRefreshKey((k) => k + 1)
|
|
||||||
if (isLive) setSelectedUid("")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className="size-4" />
|
|
||||||
Переподключить
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selected ? (
|
|
||||||
<Terminal
|
|
||||||
key={termKey}
|
|
||||||
server={selected}
|
|
||||||
isLive={isLive}
|
|
||||||
backendUrl={backendUrl}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
|
|
||||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ServerRailLayout>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
+399
-362
File diff suppressed because it is too large.
Load diff
+596
-502
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
|||||||
|
.container { container-type: inline-size; }
|
||||||
|
.header { display: none; }
|
||||||
|
.serverName { flex: 1 1 160px; }
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
grid-template-areas: "name name" "status toggle" "iface filter" "rtt loss" "bt graph" "star actions";
|
||||||
|
}
|
||||||
|
.row > * { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.name { grid-area: name; }
|
||||||
|
.status { grid-area: status; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; }
|
||||||
|
.toggle { grid-area: toggle; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; justify-content: flex-end; }
|
||||||
|
.iface { grid-area: iface; }
|
||||||
|
.filter { grid-area: filter; }
|
||||||
|
.rtt { grid-area: rtt; }
|
||||||
|
.loss { grid-area: loss; }
|
||||||
|
.bt { grid-area: bt; }
|
||||||
|
.graph { grid-area: graph; }
|
||||||
|
.star { grid-area: star; display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
|
||||||
|
.actions { grid-area: actions; }
|
||||||
|
.label { display: block; font-size: 0.625rem; color: var(--muted-foreground); }
|
||||||
|
.actions button, .star button { min-width: 2rem; min-height: 2rem; }
|
||||||
|
|
||||||
|
@container (min-width: 1100px) {
|
||||||
|
.header, .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 16px 130px 120px 140px 70px 44px minmax(132px, 1fr) 96px 36px 72px;
|
||||||
|
grid-template-areas: "toggle status iface name filter rtt loss bt graph star actions";
|
||||||
|
}
|
||||||
|
.label, .mobile { display: none; }
|
||||||
|
.toggle, .star { justify-content: center; }
|
||||||
|
.actions button, .star button { min-width: unset; min-height: unset; }
|
||||||
|
}
|
||||||
+204
-136
@@ -1,7 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useRef, useState, useMemo } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
@@ -23,22 +24,25 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
import {
|
import {
|
||||||
ALL_SECTIONS,
|
ALL_SECTIONS,
|
||||||
INIT_USERS,
|
INIT_USERS,
|
||||||
bindingDiffKey,
|
|
||||||
userInitials,
|
userInitials,
|
||||||
type AppUser,
|
type AppUser,
|
||||||
type AppUserForm,
|
type AppUserForm,
|
||||||
type UserServerOption,
|
type UserServerOption,
|
||||||
} from "@/lib/users"
|
} from "@/lib/users"
|
||||||
import { listServers } from "@/shared/api/servers"
|
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
import {
|
import {
|
||||||
|
userApiError,
|
||||||
createAppUser,
|
createAppUser,
|
||||||
|
getAppUser,
|
||||||
createUserBinding,
|
createUserBinding,
|
||||||
deleteAppUser,
|
deleteAppUser,
|
||||||
deleteUserBinding,
|
deleteUserBinding,
|
||||||
listAppUsers,
|
listAppUsers,
|
||||||
updateAppUser,
|
updateAppUser,
|
||||||
} from "@/shared/api/users"
|
} from "@/shared/api/users"
|
||||||
import { ApiClientError } from "@/shared/api/http-client"
|
import { saveUserSteps, userProfileRequest, type UserSaveCheckpoint } from "@/lib/user-save"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
CableIcon,
|
CableIcon,
|
||||||
@@ -55,168 +59,222 @@ function toServerOptionsFromMock(): UserServerOption[] {
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status,
|
status: s.status === "unknown" ? null : s.status,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
if (!prefsHydrated) return <p role="status">Загрузка настроек пользователей…</p>
|
||||||
|
return <UsersContent key={JSON.stringify([mode, backendUrl])} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersContent() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
const canRead = can("mm:users:read")
|
||||||
|
const canWrite = can("mm:users:write")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
|
||||||
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
|
const [users, setUsers] = useState<AppUser[]>(() => isLive ? [] : INIT_USERS)
|
||||||
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
|
const [userSearch, setUserSearch] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const visibleUsers = useMemo(() => {
|
||||||
|
const query = userSearch.trim().toLocaleLowerCase("ru-RU")
|
||||||
|
return query ? users.filter(user => [user.name, user.login, user.email].some(value => value.toLocaleLowerCase("ru-RU").includes(query))) : users
|
||||||
|
}, [users, userSearch])
|
||||||
|
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(() => isLive ? [] : toServerOptionsFromMock())
|
||||||
|
const [loading, setLoading] = useState(isLive && canRead)
|
||||||
|
const [usersReadAt, setUsersReadAt] = useState<number | null>(null)
|
||||||
|
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
||||||
|
const [usersError, setUsersError] = useState<string | null>(null)
|
||||||
|
const [serversError, setServersError] = useState<string | null>(null)
|
||||||
|
const loadController = useRef<AbortController | null>(null)
|
||||||
|
const loadRevision = useRef(0)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [saveBlocked, setSaveBlocked] = useState(false)
|
||||||
|
const [saveResume, setSaveResume] = useState(false)
|
||||||
|
const saveCheckpoint = useRef<UserSaveCheckpoint | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||||
|
const mutationLock = useRef(false)
|
||||||
|
const mutationController = useRef<AbortController | null>(null)
|
||||||
|
const alive = useRef(true)
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||||
|
const formReturnFocus = useRef<HTMLElement | null>(null)
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (sheetOpen || !formReturnFocus.current) return
|
||||||
|
const label = formReturnFocus.current.getAttribute("aria-label")
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === label)
|
||||||
|
;(target ?? document.querySelector<HTMLButtonElement>('button[aria-label="Добавить пользователя"]'))?.focus()
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [sheetOpen])
|
||||||
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
||||||
|
|
||||||
const loadLive = useCallback(async () => {
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive || !canRead) return false
|
||||||
|
loadController.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
loadController.current = controller
|
||||||
|
const revision = ++loadRevision.current
|
||||||
|
const current = () => !controller.signal.aborted && revision === loadRevision.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||||
const [list, srvs] = await Promise.all([
|
const [list, srvs] = await Promise.allSettled([
|
||||||
listAppUsers(backendUrl),
|
listAppUsers(backendUrl, { signal }).then(rows => {
|
||||||
listServers(backendUrl),
|
if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
|
||||||
])
|
return rows
|
||||||
setUsers(list)
|
}).catch(error => {
|
||||||
setServerOptions(srvs.map((s) => ({
|
if (current()) setUsersError(userApiError(error))
|
||||||
id: String(s.id),
|
throw error
|
||||||
name: s.name || s.host,
|
}),
|
||||||
host: s.host,
|
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
|
||||||
site: s.site,
|
const rows = serverListSchema.parse(raw)
|
||||||
country: s.country,
|
if (new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => row.id <= 0)) throw new Error("Некорректный каталог серверов.")
|
||||||
status: s.status,
|
if (current()) {
|
||||||
})))
|
setServerOptions(rows.map(s => ({
|
||||||
} catch (err) {
|
id: String(s.id), name: s.name || s.host, host: s.host, site: s.site, country: s.country, status: s.status,
|
||||||
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
|
})))
|
||||||
} finally {
|
setServersReadAt(Date.now()); setServersError(null)
|
||||||
setLoading(false)
|
}
|
||||||
}
|
return rows
|
||||||
}, [backendUrl])
|
}).catch(error => {
|
||||||
|
if (current()) setServersError(userApiError(error))
|
||||||
|
throw error
|
||||||
|
}) : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
if (!current()) return false
|
||||||
|
setLoading(false)
|
||||||
|
return list.status === "fulfilled" && srvs.status === "fulfilled"
|
||||||
|
}, [isLive, canRead, canReadServers, backendUrl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
let cancelled = false
|
||||||
setUsers(INIT_USERS)
|
if (isLive && canRead) queueMicrotask(() => { if (!cancelled) void loadLive() })
|
||||||
setServerOptions(toServerOptionsFromMock())
|
return () => { cancelled = true; loadController.current?.abort() }
|
||||||
return
|
}, [isLive, canRead, loadLive])
|
||||||
}
|
|
||||||
void loadLive()
|
|
||||||
}, [isLive, loadLive])
|
|
||||||
|
|
||||||
|
const usersKnown = !isLive || usersReadAt !== null
|
||||||
|
const serversKnown = !isLive || serversReadAt !== null
|
||||||
|
const writeReady = canWrite && (!isLive || (usersKnown && serversKnown && !loading && !usersError && !serversError))
|
||||||
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||||
const activeCount = users.filter((u) => u.active).length
|
const activeCount = users.filter((u) => u.active).length
|
||||||
|
|
||||||
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
|
||||||
const nextKeys = new Set(next.map(bindingDiffKey))
|
|
||||||
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
|
||||||
for (const b of prev) {
|
|
||||||
if (!nextKeys.has(bindingDiffKey(b))) {
|
|
||||||
await deleteUserBinding(backendUrl, userId, b.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const b of next) {
|
|
||||||
if (!prevKeys.has(bindingDiffKey(b))) {
|
|
||||||
await createUserBinding(backendUrl, userId, {
|
|
||||||
serverId: Number(b.serverId),
|
|
||||||
interfaceName: b.interfaceName,
|
|
||||||
interfaceType: b.interfaceType,
|
|
||||||
peerPublicKey: b.peerPublicKey,
|
|
||||||
peerName: b.peerName,
|
|
||||||
comment: b.comment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async (form: AppUserForm) => {
|
const handleSave = async (form: AppUserForm) => {
|
||||||
|
if (!writeReady || mutationLock.current || saveBlocked) return
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
if (!editUser) {
|
if (!editUser) {
|
||||||
const id = `u${Date.now()}`
|
const id = "u" + Date.now()
|
||||||
const created: AppUser = {
|
setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }])
|
||||||
id,
|
} else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||||
...form,
|
setSheetOpen(false); toast.success("Профиль сохранён в демо."); return
|
||||||
last: "только что",
|
|
||||||
avatar: userInitials(form.name),
|
|
||||||
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
|
|
||||||
}
|
|
||||||
setUsers((prev) => [...prev, created])
|
|
||||||
} else {
|
|
||||||
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
|
||||||
}
|
|
||||||
setSheetOpen(false)
|
|
||||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
mutationLock.current = true
|
||||||
setSaving(true)
|
const controller = new AbortController()
|
||||||
|
mutationController.current = controller
|
||||||
|
const current = () => alive.current && !controller.signal.aborted
|
||||||
|
const init = () => ({ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||||
|
const checkpoint = saveCheckpoint.current ?? { user: editUser }
|
||||||
|
saveCheckpoint.current = checkpoint
|
||||||
|
setSaving(true); setSaveError(null); setOperationNotice(null)
|
||||||
try {
|
try {
|
||||||
if (!editUser) {
|
const saved = await saveUserSteps(form, checkpoint, {
|
||||||
const created = await createAppUser(backendUrl, {
|
create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
|
||||||
name: form.name,
|
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
|
||||||
login: form.login,
|
read: id => getAppUser(backendUrl, id, init()),
|
||||||
email: form.email,
|
removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
|
||||||
role: form.role,
|
addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
|
||||||
active: form.active,
|
serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType,
|
||||||
avatar: userInitials(form.name),
|
peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment,
|
||||||
sections: form.sections,
|
}, init()),
|
||||||
servers: form.servers,
|
}, current, state => {
|
||||||
})
|
if (!current()) return
|
||||||
await applyBindingsDiff(created.id, form.bindings, [])
|
setSaveBlocked(!!state.createUncertain)
|
||||||
await loadLive()
|
if (state.user) {
|
||||||
} else {
|
if (!editUser) setSaveResume(true)
|
||||||
await updateAppUser(backendUrl, editUser.id, {
|
const confirmed = state.user
|
||||||
name: form.name,
|
setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed])
|
||||||
login: form.login,
|
}
|
||||||
email: form.email,
|
})
|
||||||
role: form.role,
|
if (!current() || !saved) return
|
||||||
active: form.active,
|
setSheetOpen(false); saveCheckpoint.current = null
|
||||||
avatar: userInitials(form.name),
|
setOperationNotice("Профиль и привязки подтверждены API.")
|
||||||
sections: form.sections,
|
toast.success("Пользователь сохранён.")
|
||||||
servers: form.servers,
|
const refreshed = await loadLive()
|
||||||
})
|
if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.")
|
||||||
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
|
} catch (error) {
|
||||||
await loadLive()
|
if (!current()) return
|
||||||
}
|
const detail = userApiError(error)
|
||||||
setSheetOpen(false)
|
setSaveError(checkpoint.createUncertain
|
||||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail
|
||||||
} catch (err) {
|
: checkpoint.bindingsNeedRead
|
||||||
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
|
? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail
|
||||||
toast.error(msg)
|
: detail)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||||
|
if (alive.current) setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget || !writeReady || mutationLock.current) return
|
||||||
|
const target = deleteTarget
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
|
setUsers(prev => prev.filter(u => u.id !== target.id)); setDeleteTarget(null); setDeleteError(null)
|
||||||
setDeleteTarget(null)
|
toast.success("Пользователь удалён в демо.")
|
||||||
toast.success("Пользователь удалён")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mutationLock.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
mutationController.current = controller
|
||||||
|
setDeleting(true); setDeleteError(null); setOperationNotice(null)
|
||||||
|
const current = () => alive.current && !controller.signal.aborted
|
||||||
try {
|
try {
|
||||||
await deleteAppUser(backendUrl, deleteTarget.id)
|
await deleteAppUser(backendUrl, target.id, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||||
|
if (!current()) return
|
||||||
|
setUsers(prev => prev.filter(u => u.id !== target.id))
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
await loadLive()
|
setOperationNotice("Удаление пользователя подтверждено API.")
|
||||||
toast.success("Пользователь удалён")
|
toast.success("Пользователь удалён.")
|
||||||
} catch (err) {
|
const refreshed = await loadLive()
|
||||||
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
|
if (current() && !refreshed) setOperationNotice("Удаление пользователя подтверждено API. Обновление каталогов не удалось; повторите чтение.")
|
||||||
|
} catch (error) {
|
||||||
|
if (current()) setDeleteError(userApiError(error))
|
||||||
|
} finally {
|
||||||
|
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||||
|
if (alive.current) setDeleting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full min-w-0">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
<>
|
||||||
<PlusIcon className="size-4" />Пригласить
|
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить пользователей" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить</Button>}
|
||||||
|
<Button type="button" size="sm" aria-label="Добавить пользователя" disabled={!writeReady || saving || deleting} onClick={() => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
|
||||||
|
<PlusIcon aria-hidden="true" className="size-4" />Добавить <span className="hidden sm:inline">пользователя</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 min-w-0 flex flex-col gap-5">
|
||||||
|
{operationNotice && <p role="status" className="text-sm text-muted-foreground">{operationNotice}</p>}
|
||||||
|
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: изменения сохраняются только в текущем экране.</p>}
|
||||||
|
{!canRead && <p role="status">Для чтения пользователей требуется mm:users:read.</p>}
|
||||||
|
{!canWrite && <p role="status">Для изменения пользователей требуется mm:users:write.</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Каталог серверов недоступен: требуется mm:servers:read. Изменения приостановлены.</p>}
|
||||||
|
{usersError && <div role="alert" aria-label="Пользователи" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{usersKnown ? "Не удалось обновить пользователей. Последний список сохранён." : "Пользователи пока неизвестны."}</p><p>{usersError}</p></div>}
|
||||||
|
{serversError && <div role="alert" aria-label="Серверы пользователей" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{serversKnown ? "Не удалось обновить серверы. Последний каталог сохранён." : "Каталог серверов пока неизвестен."} Изменения приостановлены.</p><p>{serversError}</p></div>}
|
||||||
|
{isLive && usersReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Пользователи получены: {new Date(usersReadAt).toLocaleTimeString("ru-RU")}{loading ? " · обновляются" : ""}</p>}
|
||||||
|
{isLive && loading && !usersKnown && <p role="status">Загрузка пользователей…</p>}
|
||||||
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка пользователей"
|
aria-label="Сводка пользователей"
|
||||||
@@ -224,21 +282,21 @@ export default function UsersPage() {
|
|||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Пользователи",
|
label: "Пользователи",
|
||||||
value: users.length,
|
value: usersKnown ? users.length : "—",
|
||||||
icon: <UsersIcon className="size-4" />,
|
icon: <UsersIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "active",
|
id: "active",
|
||||||
label: "Активные",
|
label: "Активные",
|
||||||
value: activeCount,
|
value: usersKnown ? activeCount : "—",
|
||||||
icon: <UserCheckIcon className="size-4" />,
|
icon: <UserCheckIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ifaces",
|
id: "ifaces",
|
||||||
label: "Привязанные ifaces",
|
label: "Привязанные интерфейсы",
|
||||||
value: boundCount,
|
value: usersKnown ? boundCount : "—",
|
||||||
icon: <CableIcon className="size-4" />,
|
icon: <CableIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
@@ -246,19 +304,24 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
||||||
<DataPageCard>
|
{canRead && (usersKnown || loading) && <DataPageCard>
|
||||||
|
<DataPageToolbar search={userSearch} onSearchChange={setUserSearch} searchLabel="Поиск по имени, логину или email" searchPlaceholder="Поиск по имени, логину или email…" countLabel={usersKnown ? `${visibleUsers.length} из ${users.length}` : "Данные пока неизвестны"} />
|
||||||
<UsersDataGrid
|
<UsersDataGrid
|
||||||
users={users}
|
users={visibleUsers}
|
||||||
serversCount={serverOptions.length}
|
emptyTitle={userSearch.trim() ? "Нет совпадений" : "Нет пользователей"}
|
||||||
|
emptyDescription={userSearch.trim() ? "Измените поиск" : "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"}
|
||||||
|
serversCount={serversKnown ? serverOptions.length : null}
|
||||||
allSectionsCount={ALL_SECTIONS.length}
|
allSectionsCount={ALL_SECTIONS.length}
|
||||||
isLoading={loading}
|
isLoading={loading && !usersKnown}
|
||||||
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
|
actionsDisabled={!writeReady || saving || deleting}
|
||||||
onDelete={setDeleteTarget}
|
onEdit={(u) => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
|
||||||
|
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
|
||||||
/>
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserSheet
|
<UserSheet
|
||||||
|
returnFocus={formReturnFocus}
|
||||||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
user={editUser}
|
user={editUser}
|
||||||
@@ -267,12 +330,16 @@ export default function UsersPage() {
|
|||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
backendUrl={backendUrl}
|
backendUrl={backendUrl}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
saveError={saveError}
|
||||||
|
saveBlocked={saveBlocked}
|
||||||
|
saveResume={saveResume}
|
||||||
|
onCheckCatalogs={() => void loadLive()}
|
||||||
onSave={(f) => { void handleSave(f) }}
|
onSave={(f) => { void handleSave(f) }}
|
||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
|
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
@@ -284,9 +351,10 @@ export default function UsersPage() {
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
|
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||||
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
|
<AlertDialogCancel disabled={deleting} onClick={() => { setDeleteTarget(null); setDeleteError(null) }}>Отмена</AlertDialogCancel>
|
||||||
Удалить
|
<AlertDialogAction type="button" disabled={deleting || !writeReady} aria-busy={deleting} variant="destructive" onClick={() => { void handleDelete() }}>
|
||||||
|
{deleting ? "Удаление…" : "Удалить"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
+292
-139
@@ -1,77 +1,94 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { vxlanTunnels, servers } from "@/lib/data"
|
import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
|
||||||
import type { VxlanTunnel } from "@/lib/data"
|
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import {
|
import {
|
||||||
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { generateVxlanRsc } from "@/lib/vxlan-export"
|
||||||
|
import { vxlanResponseSchema, vxlanServersSchema } from "@/lib/vxlan-response"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
function serverFor(id: string) {
|
name: string
|
||||||
return servers.find((s) => s.id === id)
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"] | null
|
||||||
|
latency?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RSC generator ───────────────────────────────────────────────────────────
|
interface VxlanApiResponse {
|
||||||
|
failures?: Array<{ serverId: string; serverName?: string; error: string }>
|
||||||
|
tunnels: VxlanTunnel[]
|
||||||
|
}
|
||||||
|
|
||||||
function generateVxlanRsc(t: VxlanTunnel): string {
|
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||||||
const srv = serverFor(t.serverId)
|
|
||||||
const lines: string[] = []
|
|
||||||
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
|
|
||||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
|
||||||
lines.push(`# RouterOS 7.x · /interface/vxlan`)
|
|
||||||
lines.push(``)
|
|
||||||
lines.push(`/interface/vxlan/add \\`)
|
|
||||||
lines.push(` name=${t.name} \\`)
|
|
||||||
lines.push(` vni=${t.vni} \\`)
|
|
||||||
lines.push(` port=${t.dstPort} \\`)
|
|
||||||
lines.push(` vtep-mac-address=auto \\`)
|
|
||||||
lines.push(` arp-proxy=${t.arpProxy ? "yes" : "no"} \\`)
|
|
||||||
lines.push(` mac-learning=${t.macLearning ? "yes" : "no"} \\`)
|
|
||||||
lines.push(` l2mtu=${t.l2mtu} \\`)
|
|
||||||
if (t.comment) lines.push(` comment="${t.comment}" \\`)
|
|
||||||
if (!t.enabled) lines.push(` disabled=yes \\`)
|
|
||||||
lines.push(``)
|
|
||||||
|
|
||||||
// FDB entries for remote VTEPs
|
function mapBackendServer(s: BackendServer): InventoryServer {
|
||||||
for (const vtep of t.remoteVteps) {
|
return {
|
||||||
lines.push(`/interface/vxlan/vteps/add \\`)
|
id: String(s.id),
|
||||||
lines.push(` interface=${t.name} \\`)
|
name: s.name || s.host,
|
||||||
lines.push(` remote-ip=${vtep}`)
|
host: s.host,
|
||||||
lines.push(``)
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? "offline",
|
||||||
|
monitoringUnknown: s.status == null,
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bridge
|
|
||||||
lines.push(`# Добавить в bridge:`)
|
|
||||||
lines.push(`/interface/bridge/port/add \\`)
|
|
||||||
lines.push(` bridge=bridge-overlay \\`)
|
|
||||||
lines.push(` interface=${t.name}`)
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
function ExportSheet({
|
||||||
|
open, tunnel, onClose, serverById, receivedAt, stale, mock,
|
||||||
function ExportSheet({ open, tunnel, onClose }: {
|
}: {
|
||||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
open: boolean
|
||||||
|
tunnel: VxlanTunnel | null
|
||||||
|
onClose: () => void
|
||||||
|
serverById: Record<string, Server>
|
||||||
|
receivedAt: string | null
|
||||||
|
stale: boolean
|
||||||
|
mock: boolean
|
||||||
}) {
|
}) {
|
||||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
const code = useMemo(
|
||||||
|
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
||||||
|
[tunnel, serverById],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeExportSheet
|
<CodeExportSheet
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
|
||||||
title="Экспорт VXLAN"
|
title="Экспорт VXLAN"
|
||||||
description="RouterOS 7.x · /interface/vxlan + vteps"
|
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
|
||||||
formats={[
|
formats={[
|
||||||
{
|
{
|
||||||
id: "rsc",
|
id: "rsc",
|
||||||
@@ -84,38 +101,200 @@ function ExportSheet({ open, tunnel, onClose }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: {
|
||||||
export default function VxlanPage() {
|
selectedServerId: string
|
||||||
const [search, setSearch] = useState("")
|
selectedTunnelId: string | null
|
||||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
allTunnelsHref: string
|
||||||
|
onSelectServer: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [exportSnapshot, setExportSnapshot] = useState<{ tunnel: VxlanTunnel; servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
|
||||||
|
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||||||
|
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
|
||||||
|
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [failures, setFailures] = useState<NonNullable<VxlanApiResponse["failures"]>>([])
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false)
|
||||||
|
const loadEpoch = useRef(0)
|
||||||
|
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive) return
|
||||||
|
const epoch = ++loadEpoch.current
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [tunnelsRes, serversRes] = await Promise.all([
|
||||||
|
requestJson<unknown>(backendUrl, "/api/vxlan"),
|
||||||
|
requestJson<unknown>(backendUrl, "/api/servers"),
|
||||||
|
])
|
||||||
|
if (epoch !== loadEpoch.current) return
|
||||||
|
const parsedTunnels = vxlanResponseSchema.safeParse(tunnelsRes)
|
||||||
|
const parsedServers = vxlanServersSchema.safeParse(serversRes)
|
||||||
|
if (!parsedTunnels.success || !parsedServers.success) throw new Error("Некорректный ответ загрузки VXLAN")
|
||||||
|
const nextTunnels = parsedTunnels.data.tunnels
|
||||||
|
const nextServers = parsedServers.data
|
||||||
|
const nextFailures = parsedTunnels.data.failures
|
||||||
|
const failedIds = new Set(nextFailures.map(f => f.serverId))
|
||||||
|
const enabledIds = new Set(nextServers.filter(s => s.enabled).map(s => String(s.id)))
|
||||||
|
setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
|
||||||
|
setFailures(nextFailures)
|
||||||
|
setLiveError(null)
|
||||||
|
setHasLoaded(true)
|
||||||
|
const at = new Date().toISOString()
|
||||||
|
setResponseReceipt(at)
|
||||||
|
setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1])))
|
||||||
|
setLiveServers(nextServers.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
|
} catch (e) {
|
||||||
|
if (epoch !== loadEpoch.current) return
|
||||||
|
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||||
|
} finally {
|
||||||
|
if (epoch === loadEpoch.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLiveTunnels([])
|
||||||
|
setLiveServers([])
|
||||||
|
setLiveError(null)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadLive()
|
||||||
|
})
|
||||||
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
|
const effectiveServerId = selectedServerId
|
||||||
|
|
||||||
|
const scopedTunnels = useMemo(() => {
|
||||||
|
if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === effectiveServerId && t.id === selectedTunnelId)
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
|
||||||
|
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
|
||||||
|
}, [displayTunnels, effectiveServerId, selectedTunnelId])
|
||||||
|
|
||||||
|
const selectedTunnel = selectedTunnelId === null ? undefined : scopedTunnels[0]
|
||||||
|
|
||||||
|
const serverById = useMemo(
|
||||||
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
|
[displayServers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`,
|
||||||
|
}))
|
||||||
|
), [displayServers, displayTunnels, isLive])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return vxlanTunnels
|
if (!search) return scopedTunnels
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return vxlanTunnels.filter((t) =>
|
return scopedTunnels.filter((t) =>
|
||||||
t.name.includes(q) ||
|
t.name.includes(q) ||
|
||||||
String(t.vni).includes(q) ||
|
String(t.vni).includes(q) ||
|
||||||
t.vtepIp.includes(q) ||
|
t.vtepIp.includes(q) ||
|
||||||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
|
(serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
|
||||||
)
|
)
|
||||||
}, [search])
|
}, [search, scopedTunnels, serverById])
|
||||||
|
|
||||||
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
|
const failedServerIds = useMemo(() => !isLive ? [] : liveError ? [...new Set([...liveTunnels.map(t => t.serverId), ...liveServers.map(s => s.id)])] : failures.map(f => f.serverId), [isLive, liveError, liveTunnels, liveServers, failures])
|
||||||
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
|
const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId)
|
||||||
|
const upCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||||
|
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
items={railItems}
|
||||||
actions={
|
selectedId={effectiveServerId}
|
||||||
<Button size="sm">
|
preserveSelection
|
||||||
<PlusIcon className="size-4" />Новый VXLAN
|
onSelect={onSelectServer}
|
||||||
</Button>
|
showAll
|
||||||
}
|
allCount={displayServers.length}
|
||||||
/>
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
header={
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void loadLive() }}
|
||||||
|
disabled={!isLive || loading}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Badge variant="outline">Только просмотр</Badge>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
{isLive && scopedFailures.length > 0 && <ContextAlert variant="warning" role="alert">
|
||||||
|
<ContextTitle>Не все устройства ответили</ContextTitle>
|
||||||
|
<ContextDescription>
|
||||||
|
<p>Не удалось опросить: {scopedFailures.map(f => f.serverName ?? f.serverId).join(", ")}. Для этих устройств сохранены последние данные; текущее состояние туннелей неизвестно.</p>
|
||||||
|
<Button variant="outline" size="sm" disabled={loading} onClick={() => void loadLive()}>Повторить опрос</Button>
|
||||||
|
</ContextDescription>
|
||||||
|
</ContextAlert>}
|
||||||
|
{selectedTunnelId !== null && (
|
||||||
|
<ContextAlert variant={liveError || ((!isLive || !loading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="VXLAN по ссылке">
|
||||||
|
<ContextTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран VXLAN: " + selectedTunnel.name : "VXLAN по ссылке"}</ContextTitle>
|
||||||
|
<ContextDescription>
|
||||||
|
<p className="wrap-anywhere">{isLive && loading ? "Загрузка туннеля…" : isLive && liveError ? "Не удалось обновить VXLAN. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · VNI " + selectedTunnel.vni + ". Список и сводка ограничены выбранным туннелем." : "Туннель не получен для указанного сервера. Проверьте ссылку и доступ к устройству: туннель может отсутствовать или устройство не ответило на опрос."}</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href={allTunnelsHref} className="underline underline-offset-2">Все туннели сервера</Link>
|
||||||
|
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||||||
|
</div>
|
||||||
|
</ContextDescription>
|
||||||
|
</ContextAlert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{isLive && liveError && selectedTunnelId === null && (
|
||||||
|
<Alert variant="warning" className="py-2">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{isLive && <div role="group" aria-label="Свежесть VXLAN" className="text-xs text-muted-foreground">
|
||||||
|
{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ получен в приложении: " : "Ответ устройства получен в приложении: "}
|
||||||
|
{(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]) ? <time dateTime={(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!} title="Клиентское время ответа, не время измерения RouterOS">{new Date((effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!).toLocaleString("ru-RU")}</time> : "неизвестно"}
|
||||||
|
</div>}
|
||||||
|
{isLive && loading && <p role="status" className="text-sm text-muted-foreground">{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}</p>}
|
||||||
|
{isLive && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
|
||||||
|
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
На опрошенных серверах нет VXLAN-интерфейсов
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "mock" && (
|
||||||
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
|
Моковые данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка VXLAN"
|
aria-label="Сводка VXLAN"
|
||||||
@@ -123,122 +302,96 @@ export default function VxlanPage() {
|
|||||||
{
|
{
|
||||||
id: "tunnels",
|
id: "tunnels",
|
||||||
label: "Туннелей",
|
label: "Туннелей",
|
||||||
value: vxlanTunnels.length,
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
|
||||||
icon: <NetworkIcon className="size-4" />,
|
icon: <NetworkIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "up",
|
id: "up",
|
||||||
label: "Активных",
|
label: "Активных",
|
||||||
value: upCount,
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
|
||||||
icon: <LayersIcon className="size-4" />,
|
icon: <LayersIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vni",
|
id: "vni",
|
||||||
label: "Уникальных VNI",
|
label: "VNI",
|
||||||
value: vnis,
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
|
||||||
icon: <LayersIcon className="size-4" />,
|
icon: <LayersIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "servers",
|
id: "servers",
|
||||||
label: "Серверов",
|
label: "Серверов",
|
||||||
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
|
||||||
icon: <NetworkIcon className="size-4" />,
|
icon: <NetworkIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Info banner */}
|
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||||
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
|
<p className="font-medium text-foreground">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215).
|
VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.
|
||||||
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||||||
countLabel={`${filtered.length} туннелей`}
|
countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`}
|
||||||
/>
|
/>
|
||||||
<VxlanDataGrid
|
{(!isLive || hasLoaded) && <VxlanDataGrid
|
||||||
tunnels={filtered}
|
tunnels={filtered}
|
||||||
servers={servers}
|
failedServerIds={failedServerIds}
|
||||||
onExport={setExportTunnel}
|
servers={displayServers}
|
||||||
/>
|
onExport={tunnel => setExportSnapshot({ tunnel, servers: serverById, at: isLive ? readReceipt[tunnel.serverId] ?? null : null, stale: isLive && (!!liveError || failedServerIds.includes(tunnel.serverId)), mock: !isLive })}
|
||||||
|
/>}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
{/* Reference */}
|
<OpsPanel title="Справка VXLAN" contentClassName="px-5 py-4">
|
||||||
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
|
<p className="text-xs text-muted-foreground">Экспорт содержит интерфейс, известный MTU и статические VTEP выбранного туннеля. IP-адреса, bridge и FDB в него не входят. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">Параметры VXLAN в документации MikroTik</a></p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
title: "Создать VXLAN",
|
|
||||||
lines: [
|
|
||||||
"/interface/vxlan/add \\",
|
|
||||||
" name=vxlan-10 \\",
|
|
||||||
" vni=10010 \\",
|
|
||||||
" port=8472 \\",
|
|
||||||
" arp-proxy=yes \\",
|
|
||||||
" mac-learning=yes",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Добавить VTEP",
|
|
||||||
lines: [
|
|
||||||
"/interface/vxlan/vteps/add \\",
|
|
||||||
" interface=vxlan-10 \\",
|
|
||||||
" remote-ip=10.0.1.1",
|
|
||||||
"",
|
|
||||||
"/interface/vxlan/vteps/add \\",
|
|
||||||
" interface=vxlan-10 \\",
|
|
||||||
" remote-ip=10.0.2.1",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Bridge + IP",
|
|
||||||
lines: [
|
|
||||||
"/interface/bridge/add \\",
|
|
||||||
" name=br-overlay",
|
|
||||||
"",
|
|
||||||
"/interface/bridge/port/add \\",
|
|
||||||
" bridge=br-overlay \\",
|
|
||||||
" interface=vxlan-10",
|
|
||||||
"",
|
|
||||||
"/ip/address/add \\",
|
|
||||||
" address=10.100.0.1/24 \\",
|
|
||||||
" interface=br-overlay",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
].map((b) => (
|
|
||||||
<div key={b.title}>
|
|
||||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
|
||||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
|
||||||
{b.lines.join("\n")}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<ExportSheet
|
<ExportSheet
|
||||||
open={!!exportTunnel}
|
open={!!exportSnapshot}
|
||||||
tunnel={exportTunnel}
|
tunnel={exportSnapshot?.tunnel ?? null}
|
||||||
onClose={() => setExportTunnel(null)}
|
onClose={() => setExportSnapshot(null)}
|
||||||
|
serverById={exportSnapshot?.servers ?? {}}
|
||||||
|
receivedAt={exportSnapshot?.at ?? null}
|
||||||
|
stale={exportSnapshot?.stale ?? false}
|
||||||
|
mock={exportSnapshot?.mock ?? !isLive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VxlanRoute() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const params = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const serverId = params.get("server") ?? ALL_SERVERS_ID
|
||||||
|
const tunnelId = params.get("tunnel")
|
||||||
|
const rest = new URLSearchParams(params.toString())
|
||||||
|
rest.delete("tunnel")
|
||||||
|
const allTunnelsHref = "/vxlan" + (rest.size ? "?" + rest.toString() : "")
|
||||||
|
return <VxlanPageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
|
||||||
|
const next = new URLSearchParams(rest)
|
||||||
|
if (id === ALL_SERVERS_ID) next.delete("server")
|
||||||
|
else next.set("server", id)
|
||||||
|
router.push("/vxlan" + (next.size ? "?" + next.toString() : ""))
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VxlanPage() {
|
||||||
|
return <Suspense fallback={<div role="status">Загрузка VXLAN…</div>}><VxlanRoute /></Suspense>
|
||||||
|
}
|
||||||
+330
-94
@@ -1,6 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { can } from "@/lib/auth"
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { wireGuardServersSchema } from "@/lib/wireguard-response"
|
||||||
|
import { useWireGuardUrlState } from "@/hooks/use-wireguard-url-state"
|
||||||
|
import type { WireGuardTab as WgWorkspaceTab } from "@/lib/wireguard-url-state"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { servers as mockServers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import type { Server } from "@/lib/data"
|
import type { Server } from "@/lib/data"
|
||||||
@@ -44,6 +51,7 @@ import {
|
|||||||
exportWireGuard,
|
exportWireGuard,
|
||||||
importWireGuard,
|
importWireGuard,
|
||||||
listWireGuard,
|
listWireGuard,
|
||||||
|
listWireGuardRevisions,
|
||||||
patchWireGuardInterface,
|
patchWireGuardInterface,
|
||||||
} from "@/shared/api/wireguard"
|
} from "@/shared/api/wireguard"
|
||||||
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
|
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
|
||||||
@@ -57,14 +65,15 @@ import {
|
|||||||
type ServerTileItem,
|
type ServerTileItem,
|
||||||
} from "@/components/server-tile-rail"
|
} from "@/components/server-tile-rail"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { selectWgExportPeer } from "@/lib/wg-export-peer"
|
||||||
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
|
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
import {
|
import {
|
||||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||||
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
||||||
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
|
|
||||||
type WgStatusFilter = "all" | "up" | "down"
|
|
||||||
type WgFailure = { serverId: string; serverName?: string; error: string }
|
type WgFailure = { serverId: string; serverName?: string; error: string }
|
||||||
type PendingDelete =
|
type PendingDelete =
|
||||||
| { kind: "iface"; iface: WgIfaceWithServer }
|
| { kind: "iface"; iface: WgIfaceWithServer }
|
||||||
@@ -129,7 +138,7 @@ interface BackendServer {
|
|||||||
country: string
|
country: string
|
||||||
type?: Server["type"]
|
type?: Server["type"]
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status?: Server["status"]
|
status?: Server["status"] | null
|
||||||
latency?: number | null
|
latency?: number | null
|
||||||
asn?: string
|
asn?: string
|
||||||
}
|
}
|
||||||
@@ -146,7 +155,7 @@ function mapBackendServer(s: BackendServer): Server {
|
|||||||
asn: s.asn ?? "",
|
asn: s.asn ?? "",
|
||||||
type: s.type ?? "exit-node",
|
type: s.type ?? "exit-node",
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
status: s.status ?? "online",
|
status: s.status ?? "unknown",
|
||||||
latency: s.latency ?? null,
|
latency: s.latency ?? null,
|
||||||
sessions: 0,
|
sessions: 0,
|
||||||
}
|
}
|
||||||
@@ -167,56 +176,96 @@ function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][nu
|
|||||||
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
|
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WireGuardPage() {
|
function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterfacesHref, onSelectServer }: {
|
||||||
|
selectedServerId: string
|
||||||
|
selectedInterfaceId: string | null
|
||||||
|
allInterfacesHref: string
|
||||||
|
onSelectServer: (id: string) => void
|
||||||
|
}) {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
const canWrite = !isLive || can("mm:network:write")
|
||||||
|
|
||||||
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
|
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
const [failures, setFailures] = useState<WgFailure[]>([])
|
const [failures, setFailures] = useState<WgFailure[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
|
const [readError, setReadError] = useState<string | null>(null)
|
||||||
|
const [receivedAt, setReceivedAt] = useState<number | null>(null)
|
||||||
|
const catalogKnown = !isLive || receivedAt !== null
|
||||||
|
const active = useRef(true)
|
||||||
|
const readRevision = useRef(0)
|
||||||
|
useEffect(() => { active.current = true; return () => { active.current = false } }, [])
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const toggleLock = useRef(false)
|
||||||
|
const [togglePending, setTogglePending] = useState<string | null>(null)
|
||||||
|
const [toggleError, setToggleError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
const { tab: workspaceTab, setTab: setWorkspaceTab, status: statusFilter, setStatus: setStatusFilter, search, setSearch, error: urlError, resetFilters } = useWireGuardUrlState()
|
||||||
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
|
|
||||||
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
|
|
||||||
const [search, setSearch] = useState("")
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const formLock = useRef(false)
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
|
const [peerError, setPeerError] = useState<string | null>(null)
|
||||||
|
const [importError, setImportError] = useState<string | null>(null)
|
||||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||||||
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
|
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
|
||||||
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
|
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
|
||||||
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
|
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
|
||||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||||
|
const pendingPeer = pendingDelete?.kind === "peer" ? pendingDelete.iface.peers.find((peer, index) => peerRowId(pendingDelete.iface, peer, index) === pendingDelete.peerId) : undefined
|
||||||
|
const deleteLock = useRef(false)
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
const historyEpoch = useRef(0)
|
||||||
|
useEffect(() => () => { historyEpoch.current += 1 }, [])
|
||||||
|
const restoreLock = useRef(false)
|
||||||
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
const [liveExport, setLiveExport] = useState<{
|
const [liveExport, setLiveExport] = useState<{
|
||||||
rsc?: string
|
rsc?: string
|
||||||
conf?: string
|
conf?: string
|
||||||
peerConf?: string
|
peerConf?: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [exportBusy, setExportBusy] = useState(false)
|
const [exportBusy, setExportBusy] = useState(false)
|
||||||
|
const exportEpoch = useRef(0)
|
||||||
|
const exportPending = useRef<number | null>(null)
|
||||||
|
useEffect(() => () => { exportEpoch.current += 1 }, [])
|
||||||
|
|
||||||
const loadLive = useCallback(async () => {
|
const loadLive = useCallback(async () => {
|
||||||
if (!isLive) return
|
if (!isLive || !active.current) return
|
||||||
|
const revision = ++readRevision.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [wg, servers] = await Promise.all([
|
const [wg, servers] = await Promise.all([
|
||||||
listWireGuard(backendUrl),
|
listWireGuard(backendUrl),
|
||||||
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
requestJson<unknown>(backendUrl, "/api/servers"),
|
||||||
])
|
])
|
||||||
setLiveIfaces(wg.interfaces.map(dtoToRow))
|
if (!active.current || revision !== readRevision.current) return
|
||||||
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
const parsedServers = wireGuardServersSchema.safeParse(servers)
|
||||||
|
if (!parsedServers.success) throw new Error("Ответ сервера содержит некорректный каталог устройств WireGuard")
|
||||||
|
const nextIfaces = wg.interfaces.map(dtoToRow), nextServers = parsedServers.data.filter(s => s.enabled).map(mapBackendServer)
|
||||||
|
setLiveIfaces(nextIfaces)
|
||||||
|
setLiveServers(nextServers)
|
||||||
|
setReceivedAt(Date.now())
|
||||||
|
setReadError(null)
|
||||||
setFailures(wg.failures ?? [])
|
setFailures(wg.failures ?? [])
|
||||||
|
setLoadError(false)
|
||||||
if (wg.failures?.length) {
|
if (wg.failures?.length) {
|
||||||
toast.warning(
|
if (active.current) toast.warning(
|
||||||
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
if (!active.current || revision !== readRevision.current) return
|
||||||
setLiveIfaces([])
|
setReadError(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
||||||
setFailures([])
|
setLoadError(true)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (active.current && revision === readRevision.current) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, backendUrl])
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
@@ -237,15 +286,61 @@ export default function WireGuardPage() {
|
|||||||
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
||||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
const effectiveServerId =
|
const effectiveServerId = selectedServerId
|
||||||
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
const selectedSourceError = !!urlError || isLive && (!catalogKnown || loadError || failures.some(f => f.serverId === selectedServerId))
|
||||||
? selectedServerId
|
|
||||||
: ALL_SERVERS_ID
|
const completeScope = catalogKnown && !urlError && (!isLive || !failures.some(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId))
|
||||||
|
|
||||||
|
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
||||||
|
|
||||||
|
const loadRevisions = useCallback(async () => {
|
||||||
|
if (!isLive || !historyServerId || !active.current) return
|
||||||
|
const epoch = ++historyEpoch.current
|
||||||
|
setHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
|
try {
|
||||||
|
const revisions = await listWireGuardRevisions(backendUrl, historyServerId)
|
||||||
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setRevisions(revisions)
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||||
|
} finally {
|
||||||
|
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, historyServerId, backendUrl])
|
||||||
|
|
||||||
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
|
if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
|
||||||
|
if (!isLive || !historyServerId || restoreLock.current) return
|
||||||
|
restoreLock.current = true
|
||||||
|
setHistoryRestoring(true)
|
||||||
|
try {
|
||||||
|
const result = await requestJson<{ ok: boolean }>(
|
||||||
|
backendUrl,
|
||||||
|
`/api/wireguard/revisions/${encodeURIComponent(id)}/restore`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
|
||||||
|
)
|
||||||
|
if (result?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
|
||||||
|
if (active.current) toast.success("Версия применена на роутер")
|
||||||
|
await loadLive()
|
||||||
|
await loadRevisions()
|
||||||
|
} catch (err) {
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
restoreLock.current = false
|
||||||
|
setHistoryRestoring(false)
|
||||||
|
}
|
||||||
|
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
|
||||||
|
|
||||||
const scopedIfaces = useMemo(() => {
|
const scopedIfaces = useMemo(() => {
|
||||||
|
if (urlError) return []
|
||||||
|
if (selectedInterfaceId !== null) return displayIfaces.filter(i => i.serverId === effectiveServerId && i.id === selectedInterfaceId)
|
||||||
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
|
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
|
||||||
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
|
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
|
||||||
}, [displayIfaces, effectiveServerId])
|
}, [displayIfaces, effectiveServerId, selectedInterfaceId, urlError])
|
||||||
|
|
||||||
|
const selectedInterface = selectedInterfaceId === null ? undefined : scopedIfaces[0]
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
let up = 0
|
let up = 0
|
||||||
@@ -309,8 +404,8 @@ export default function WireGuardPage() {
|
|||||||
}, [scopedIfaces, search])
|
}, [scopedIfaces, search])
|
||||||
|
|
||||||
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
|
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||||
const onlinePeers = scopedIfaces.reduce(
|
const peersWithHandshakeData = scopedIfaces.reduce(
|
||||||
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
|
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake?.trim()).length,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
|
const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
|
||||||
@@ -336,19 +431,23 @@ export default function WireGuardPage() {
|
|||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
count: counts.get(s.id) ?? 0,
|
count: !catalogKnown || failures.some(f => f.serverId === s.id) ? undefined : counts.get(s.id) ?? 0,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||||
}))
|
}))
|
||||||
}, [displayServers, displayIfaces])
|
}, [displayServers, displayIfaces, catalogKnown, failures])
|
||||||
|
|
||||||
async function handleCreate(form: WgCreateFormState) {
|
async function handleCreate(form: WgCreateFormState) {
|
||||||
|
if (urlError || !catalogKnown || loadError || formLock.current) return
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
toast.info("Создание на роутер доступно только в live-режиме")
|
if (active.current) toast.info("Создание на роутер доступно только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
formLock.current = true
|
||||||
|
setCreateError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
|
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||||
const ep = parseEndpoint(form.peerEndpoint)
|
const ep = parseEndpoint(form.peerEndpoint)
|
||||||
await createWireGuardInterface(backendUrl, {
|
await createWireGuardInterface(backendUrl, {
|
||||||
serverId: form.serverId,
|
serverId: form.serverId,
|
||||||
@@ -372,12 +471,13 @@ export default function WireGuardPage() {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
toast.success(`Интерфейс ${form.name} создан`)
|
if (active.current) toast.success(`Интерфейс ${form.name} создан`)
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка создания")
|
setCreateError(e instanceof Error ? e.message : "Ошибка создания")
|
||||||
} finally {
|
} finally {
|
||||||
|
formLock.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,19 +488,27 @@ export default function WireGuardPage() {
|
|||||||
format: "auto" | "rsc" | "conf"
|
format: "auto" | "rsc" | "conf"
|
||||||
dryRun: boolean
|
dryRun: boolean
|
||||||
}) {
|
}) {
|
||||||
|
if (formLock.current) return
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
toast.info("Импорт на роутер доступен только в live-режиме")
|
if (active.current) toast.info("Импорт на роутер доступен только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
formLock.current = true
|
||||||
|
setImportError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
|
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||||
const res = await importWireGuard(backendUrl, {
|
const res = await importWireGuard(backendUrl, {
|
||||||
serverId: args.serverId,
|
serverId: args.serverId,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
format: args.format,
|
format: args.format,
|
||||||
dryRun: args.dryRun,
|
dryRun: args.dryRun,
|
||||||
})
|
})
|
||||||
toast.success(
|
if (res.dryRun) {
|
||||||
|
if (active.current) toast.info("Конфигурация проверена без применения на роутер")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (active.current) toast.success(
|
||||||
res.applied
|
res.applied
|
||||||
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
|
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
|
||||||
: "Импорт выполнен",
|
: "Импорт выполнен",
|
||||||
@@ -408,61 +516,95 @@ export default function WireGuardPage() {
|
|||||||
setImportOpen(false)
|
setImportOpen(false)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка импорта")
|
setImportError(e instanceof Error ? e.message : "Ошибка импорта")
|
||||||
} finally {
|
} finally {
|
||||||
|
formLock.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceUnavailable(serverId: string | undefined) {
|
||||||
|
const unavailable = !!urlError || isLive && (loadError || failures.some(f => f.serverId === serverId))
|
||||||
|
if (unavailable && active.current) toast.error("Обновите данные сервера перед изменением объекта")
|
||||||
|
return unavailable
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToggle(iface: WgIfaceWithServer) {
|
async function handleToggle(iface: WgIfaceWithServer) {
|
||||||
|
if (toggleLock.current) return
|
||||||
|
if (sourceUnavailable(iface.serverId)) return
|
||||||
if (!isLive || !iface.rosId) {
|
if (!isLive || !iface.rosId) {
|
||||||
toast.info("Доступно только в live-режиме")
|
if (active.current) toast.info("Доступно только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
toggleLock.current = true
|
||||||
|
const context = `${iface.name} · ${iface.serverName}`
|
||||||
|
setTogglePending(context)
|
||||||
|
setToggleError(null)
|
||||||
try {
|
try {
|
||||||
await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
|
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||||
|
const result = await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
|
||||||
disabled: iface.enabled,
|
disabled: iface.enabled,
|
||||||
})
|
})
|
||||||
toast.success(iface.enabled ? "Отключено" : "Включено")
|
if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение интерфейса")
|
||||||
|
if (active.current) toast.success(iface.enabled ? "Отключено" : "Включено")
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
setToggleError(`${context}: ${e instanceof Error ? e.message : "Ошибка переключения"}`)
|
||||||
|
} finally {
|
||||||
|
toggleLock.current = false
|
||||||
|
setTogglePending(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!pendingDelete) return
|
if (!pendingDelete || deleteLock.current) return
|
||||||
|
if (sourceUnavailable(pendingDelete.iface.serverId)) return
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
toast.info("Доступно только в live-режиме")
|
if (active.current) toast.info("Доступно только в live-режиме")
|
||||||
setPendingDelete(null)
|
setPendingDelete(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
deleteLock.current = true
|
||||||
|
setDeleteBusy(true)
|
||||||
|
setDeleteError(null)
|
||||||
try {
|
try {
|
||||||
|
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||||
if (pendingDelete.kind === "iface") {
|
if (pendingDelete.kind === "iface") {
|
||||||
if (!pendingDelete.iface.rosId) {
|
if (!pendingDelete.iface.rosId) {
|
||||||
toast.info("Доступно только в live-режиме")
|
if (active.current) toast.info("Доступно только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
|
const result = await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
|
||||||
toast.success("Удалено")
|
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
|
||||||
|
if (active.current) toast.success("Удалено")
|
||||||
} else {
|
} else {
|
||||||
await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId)
|
const peer = pendingDelete.iface.peers.find(p => p.id === pendingDelete.peerId || p.rosId === pendingDelete.peerId)
|
||||||
toast.success("Пир удалён")
|
const result = await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, peer?.rosId ?? pendingDelete.peerId)
|
||||||
|
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
|
||||||
|
if (active.current) toast.success("Пир удалён")
|
||||||
}
|
}
|
||||||
setPendingDelete(null)
|
setPendingDelete(null)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
|
setDeleteError(e instanceof Error ? e.message : "Ошибка удаления")
|
||||||
|
} finally {
|
||||||
|
deleteLock.current = false
|
||||||
|
setDeleteBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddPeer(form: WgPeerFormState) {
|
async function handleAddPeer(form: WgPeerFormState) {
|
||||||
|
if (formLock.current) return
|
||||||
|
if (sourceUnavailable(peerIface?.serverId)) return
|
||||||
if (!isLive || !peerIface) {
|
if (!isLive || !peerIface) {
|
||||||
toast.info("Доступно только в live-режиме")
|
if (active.current) toast.info("Доступно только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
formLock.current = true
|
||||||
|
setPeerError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
|
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||||
const ep = parseEndpoint(form.endpoint)
|
const ep = parseEndpoint(form.endpoint)
|
||||||
await createWireGuardPeer(backendUrl, {
|
await createWireGuardPeer(backendUrl, {
|
||||||
serverId: peerIface.serverId,
|
serverId: peerIface.serverId,
|
||||||
@@ -477,27 +619,34 @@ export default function WireGuardPage() {
|
|||||||
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
|
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
|
||||||
comment: form.comment || undefined,
|
comment: form.comment || undefined,
|
||||||
})
|
})
|
||||||
toast.success("Пир добавлен")
|
if (active.current) toast.success("Пир добавлен")
|
||||||
setPeerIface(null)
|
setPeerIface(null)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
setPeerError(e instanceof Error ? e.message : "Ошибка")
|
||||||
} finally {
|
} finally {
|
||||||
|
formLock.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
||||||
if (!exportIface || !isLive) return
|
if (!exportIface || !isLive || exportPending.current !== null) return
|
||||||
|
const epoch = exportEpoch.current
|
||||||
|
exportPending.current = epoch
|
||||||
setExportBusy(true)
|
setExportBusy(true)
|
||||||
try {
|
try {
|
||||||
|
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||||
|
const peer = format === "peer-conf" ? selectWgExportPeer(exportIface.peers, exportPeerId) : undefined
|
||||||
|
if (format === "peer-conf" && !peer) throw new Error("Выбранный пир не найден. Обновите список и выберите пира заново.")
|
||||||
const res = await exportWireGuard(backendUrl, {
|
const res = await exportWireGuard(backendUrl, {
|
||||||
serverId: exportIface.serverId,
|
serverId: exportIface.serverId,
|
||||||
interfaceName: exportIface.name,
|
interfaceName: exportIface.name,
|
||||||
format,
|
format,
|
||||||
includePrivateKey: format !== "peer-conf",
|
includePrivateKey: format !== "peer-conf",
|
||||||
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
|
peerId: peer?.rosId ?? peer?.id,
|
||||||
})
|
})
|
||||||
|
if (epoch !== exportEpoch.current) return
|
||||||
setLiveExport((prev) => ({
|
setLiveExport((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
...(format === "rsc"
|
...(format === "rsc"
|
||||||
@@ -506,15 +655,22 @@ export default function WireGuardPage() {
|
|||||||
? { conf: res.content }
|
? { conf: res.content }
|
||||||
: { peerConf: res.content }),
|
: { peerConf: res.content }),
|
||||||
}))
|
}))
|
||||||
toast.success("Конфиг загружен с роутера")
|
if (active.current) toast.success("Конфиг загружен с роутера")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
|
if (epoch !== exportEpoch.current) return
|
||||||
|
if (active.current) toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
|
||||||
} finally {
|
} finally {
|
||||||
setExportBusy(false)
|
if (epoch === exportEpoch.current) {
|
||||||
|
exportPending.current = null
|
||||||
|
setExportBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
|
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
|
||||||
|
exportEpoch.current += 1
|
||||||
|
exportPending.current = null
|
||||||
|
setExportBusy(false)
|
||||||
setLiveExport(null)
|
setLiveExport(null)
|
||||||
setExportInitialTab(tab)
|
setExportInitialTab(tab)
|
||||||
setExportPeerId(peerId ?? null)
|
setExportPeerId(peerId ?? null)
|
||||||
@@ -522,7 +678,7 @@ export default function WireGuardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emptyCreateAction = (
|
const emptyCreateAction = (
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый интерфейс
|
Новый интерфейс
|
||||||
</Button>
|
</Button>
|
||||||
@@ -533,10 +689,12 @@ export default function WireGuardPage() {
|
|||||||
<ServerRailLayout
|
<ServerRailLayout
|
||||||
items={railItems}
|
items={railItems}
|
||||||
selectedId={effectiveServerId}
|
selectedId={effectiveServerId}
|
||||||
onSelect={setSelectedServerId}
|
preserveSelection
|
||||||
|
onSelect={onSelectServer}
|
||||||
showAll
|
showAll
|
||||||
allCount={displayIfaces.length}
|
allCount={completeScope ? displayIfaces.length : undefined}
|
||||||
loading={isLive && loading && displayServers.length === 0}
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
banner={!canWrite ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации и экспорт ключей недоступны.</p> : undefined}
|
||||||
header={
|
header={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||||
@@ -544,6 +702,7 @@ export default function WireGuardPage() {
|
|||||||
<>
|
<>
|
||||||
<ServerRailMobileButton />
|
<ServerRailMobileButton />
|
||||||
{isLive && (
|
{isLive && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -553,12 +712,26 @@ export default function WireGuardPage() {
|
|||||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading || !historyServerId}
|
||||||
|
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
||||||
|
onClick={() => {
|
||||||
|
setHistoryOpen(true)
|
||||||
|
void loadRevisions()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HistoryIcon className="size-4" />
|
||||||
|
История
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
<Button size="sm" variant="outline" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setImportError(null); setImportOpen(true) }}>
|
||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
Импорт
|
Импорт
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый интерфейс
|
Новый интерфейс
|
||||||
</Button>
|
</Button>
|
||||||
@@ -568,34 +741,51 @@ export default function WireGuardPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{urlError && <Alert variant="warning" role="alert" aria-label="Фильтры WireGuard"><AlertTitle>Проверьте ссылку</AlertTitle><AlertDescription><p>{urlError}</p><Button type="button" variant="outline" onClick={resetFilters}>Сбросить фильтры</Button></AlertDescription></Alert>}
|
||||||
|
{isLive && <Alert variant={loadError ? "warning" : "info"} role={loadError ? "alert" : "status"} aria-label="Чтение WireGuard"><AlertTitle>{loadError ? "Не удалось обновить WireGuard" : loading ? "Обновление WireGuard" : "Данные WireGuard"}</AlertTitle><AlertDescription>{loadError && readError && <p className="wrap-anywhere">{readError}</p>}{receivedAt !== null ? <p>Получены {new Date(receivedAt).toLocaleTimeString("ru-RU")}.{loadError ? " Показаны последние полученные данные. Повторите обновление перед изменением объектов." : loading ? " Выполняется обновление." : ""}</p> : <p>{loadError ? "Каталог пока неизвестен. Повторите обновление." : "Ожидание ответа сервера."}</p>}</AlertDescription></Alert>}
|
||||||
|
{selectedInterfaceId !== null && (
|
||||||
|
<Alert variant={selectedSourceError || ((!isLive || !loading) && !selectedInterface) ? "warning" : "info"} role="status" aria-label="WireGuard по ссылке">
|
||||||
|
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedInterface ? "Выбран интерфейс: " + selectedInterface.name : "WireGuard по ссылке"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="wrap-anywhere">{isLive && loading ? "Загрузка интерфейса…" : selectedSourceError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedInterface ? selectedInterface.serverName + " · " + (selectedInterface.address ?? selectedInterface.id) + ". Список, пиры и сводка ограничены выбранным интерфейсом." : "Интерфейс не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href={allInterfacesHref} className="underline underline-offset-2">Все интерфейсы сервера</Link>
|
||||||
|
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{togglePending && <p role="status" className="text-sm text-muted-foreground">Изменение интерфейса {togglePending}…</p>}
|
||||||
|
{toggleError && <p role="alert" className="text-sm text-destructive">{toggleError}</p>}
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка WireGuard"
|
aria-label="Сводка WireGuard"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "ifaces",
|
id: "ifaces",
|
||||||
label: "Интерфейсов",
|
label: "Интерфейсов",
|
||||||
value: scopedIfaces.length,
|
value: completeScope ? scopedIfaces.length : "—",
|
||||||
icon: <ShieldCheckIcon className="size-4" />,
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "up",
|
id: "up",
|
||||||
label: "Активных (UP)",
|
label: "Активных (UP)",
|
||||||
value: upIfaces,
|
value: completeScope ? upIfaces : "—",
|
||||||
icon: <ActivityIcon className="size-4" />,
|
icon: <ActivityIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "peers",
|
id: "peers",
|
||||||
label: "Всего пиров",
|
label: "Всего пиров",
|
||||||
value: totalPeers,
|
value: completeScope ? totalPeers : "—",
|
||||||
icon: <UsersIcon className="size-4" />,
|
icon: <UsersIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "online",
|
id: "handshake-data",
|
||||||
label: "Пиров онлайн",
|
label: "Данные рукопожатия",
|
||||||
value: `${onlinePeers}/${totalPeers}`,
|
value: completeScope ? `${peersWithHandshakeData}/${totalPeers}` : "—",
|
||||||
|
hint: <span className="block text-xs text-muted-foreground">Доступность пиров не проверена</span>,
|
||||||
icon: <KeyRoundIcon className="size-4" />,
|
icon: <KeyRoundIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
@@ -648,7 +838,7 @@ export default function WireGuardPage() {
|
|||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||||
countLabel={`${filteredIfaces.length} интерфейсов`}
|
countLabel={catalogKnown && !urlError ? `${filteredIfaces.length} интерфейсов` : "Количество неизвестно"}
|
||||||
segmented={{
|
segmented={{
|
||||||
value: statusFilter,
|
value: statusFilter,
|
||||||
onChange: setStatusFilter,
|
onChange: setStatusFilter,
|
||||||
@@ -659,17 +849,19 @@ export default function WireGuardPage() {
|
|||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<WireguardDataGrid
|
{catalogKnown && !urlError && (completeScope || filteredIfaces.length > 0) ? <WireguardDataGrid
|
||||||
interfaces={filteredIfaces}
|
interfaces={filteredIfaces}
|
||||||
|
canWrite={canWrite}
|
||||||
compactServer={compactServer}
|
compactServer={compactServer}
|
||||||
emptyAction={emptyCreateAction}
|
emptyAction={emptyCreateAction}
|
||||||
onExport={(iface) => openExport(iface, "rsc")}
|
onExport={(iface) => openExport(iface, "rsc")}
|
||||||
onAddPeer={setPeerIface}
|
onAddPeer={!canWrite || selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }}
|
||||||
onToggle={handleToggle}
|
onToggle={!canWrite || selectedSourceError ? undefined : handleToggle}
|
||||||
onDelete={(iface) => setPendingDelete({ kind: "iface", iface })}
|
toggleBusy={togglePending !== null}
|
||||||
onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
|
onDelete={!canWrite || selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
|
||||||
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
|
onDeletePeer={!canWrite || selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
|
||||||
/>
|
onExportPeer={canWrite ? (iface, peerId) => openExport(iface, "peer", peerId) : undefined}
|
||||||
|
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог интерфейсов пока неизвестен</p>}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -679,30 +871,30 @@ export default function WireGuardPage() {
|
|||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по ключу, IP, endpoint…"
|
searchPlaceholder="Поиск по ключу, IP, endpoint…"
|
||||||
countLabel={`${peerRows.length} пиров`}
|
countLabel={catalogKnown && !urlError ? `${peerRows.length} пиров` : "Количество неизвестно"}
|
||||||
/>
|
/>
|
||||||
<WireguardPeersGrid
|
{catalogKnown && !urlError && (completeScope || peerRows.length > 0) ? <WireguardPeersGrid
|
||||||
peers={peerRows}
|
peers={peerRows}
|
||||||
compactServer={compactServer}
|
compactServer={compactServer}
|
||||||
emptyAction={
|
emptyAction={
|
||||||
scopedIfaces.length === 1 ? (
|
scopedIfaces.length === 1 ? (
|
||||||
<Button size="sm" onClick={() => setPeerIface(scopedIfaces[0])}>
|
<Button size="sm" disabled={!canWrite || selectedSourceError} onClick={() => { setPeerError(null); setPeerIface(scopedIfaces[0]) }}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Добавить пира
|
Добавить пира
|
||||||
</Button>
|
</Button>
|
||||||
) : emptyCreateAction
|
) : emptyCreateAction
|
||||||
}
|
}
|
||||||
onDeletePeer={(row) => {
|
onDeletePeer={!canWrite || selectedSourceError ? undefined : (row) => {
|
||||||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
|
const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
|
||||||
if (!iface) return
|
if (!iface) return
|
||||||
setPendingDelete({ kind: "peer", iface, peerId: row.id })
|
setPendingDelete({ kind: "peer", iface, peerId: row.id })
|
||||||
}}
|
}}
|
||||||
onExportPeer={(row) => {
|
onExportPeer={canWrite ? (row) => {
|
||||||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
|
const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
|
||||||
if (!iface) return
|
if (!iface) return
|
||||||
openExport(iface, "peer", row.id)
|
openExport(iface, "peer", row.id)
|
||||||
}}
|
} : undefined}
|
||||||
/>
|
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог пиров пока неизвестен</p>}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -760,7 +952,8 @@ export default function WireGuardPage() {
|
|||||||
|
|
||||||
<WgCreateSheet
|
<WgCreateSheet
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={v => { if (!formLock.current) setCreateOpen(v) }}
|
||||||
|
error={createError}
|
||||||
servers={serverOptions}
|
servers={serverOptions}
|
||||||
defaultServerId={sheetServerId}
|
defaultServerId={sheetServerId}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -768,7 +961,8 @@ export default function WireGuardPage() {
|
|||||||
/>
|
/>
|
||||||
<WgImportSheet
|
<WgImportSheet
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
onOpenChange={setImportOpen}
|
onOpenChange={v => { if (!formLock.current) setImportOpen(v) }}
|
||||||
|
error={importError}
|
||||||
servers={serverOptions}
|
servers={serverOptions}
|
||||||
defaultServerId={sheetServerId}
|
defaultServerId={sheetServerId}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -778,15 +972,20 @@ export default function WireGuardPage() {
|
|||||||
open={!!peerIface}
|
open={!!peerIface}
|
||||||
iface={peerIface}
|
iface={peerIface}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
|
onOpenChange={(v) => { if (!v && !formLock.current) setPeerIface(null) }}
|
||||||
|
error={peerError}
|
||||||
onSubmit={handleAddPeer}
|
onSubmit={handleAddPeer}
|
||||||
/>
|
/>
|
||||||
<WgExportSheet
|
<WgExportSheet
|
||||||
open={!!exportIface}
|
open={!!exportIface}
|
||||||
iface={exportIface}
|
iface={exportIface}
|
||||||
|
serverHost={displayServers.find(server => server.id === exportIface?.serverId)?.host}
|
||||||
initialTab={exportInitialTab}
|
initialTab={exportInitialTab}
|
||||||
peerId={exportPeerId}
|
peerId={exportPeerId}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
exportEpoch.current += 1
|
||||||
|
exportPending.current = null
|
||||||
|
setExportBusy(false)
|
||||||
setExportIface(null)
|
setExportIface(null)
|
||||||
setExportPeerId(null)
|
setExportPeerId(null)
|
||||||
setExportInitialTab("rsc")
|
setExportInitialTab("rsc")
|
||||||
@@ -797,7 +996,7 @@ export default function WireGuardPage() {
|
|||||||
onRequestLiveExport={isLive ? handleLiveExport : undefined}
|
onRequestLiveExport={isLive ? handleLiveExport : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
|
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
@@ -806,22 +1005,59 @@ export default function WireGuardPage() {
|
|||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
|
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription className="break-all">
|
||||||
{pendingDelete?.kind === "iface"
|
{pendingDelete?.kind === "iface"
|
||||||
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.`
|
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры и IP-адреса на роутере.`
|
||||||
: pendingDelete
|
: pendingDelete
|
||||||
? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
|
? `Пир «${pendingPeer?.name || pendingPeer?.publicKey || pendingDelete.peerId}» (ID ${pendingDelete.peerId}) на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
|
||||||
: null}
|
: null}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
{deleteBusy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения удаления…</p>}
|
||||||
|
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
|
<AlertDialogCancel disabled={deleteBusy}>Отмена</AlertDialogCancel>
|
||||||
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}>
|
<AlertDialogAction type="button" variant="destructive" disabled={deleteBusy} onClick={() => void confirmDelete()}>
|
||||||
Удалить
|
{deleteBusy ? "Удаление…" : "Удалить"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||||
|
loadError={historyError}
|
||||||
|
onRetry={() => void loadRevisions()}
|
||||||
|
title="История WireGuard"
|
||||||
|
itemLabel="интерфейсов"
|
||||||
|
revisions={revisions}
|
||||||
|
loading={historyLoading}
|
||||||
|
restoring={historyRestoring}
|
||||||
|
restoreDisabled={!can("mm:network:write") || !isLive || !historyServerId || !displayServers.some(s => s.id === historyServerId && s.enabled) || loading || busy || deleteBusy || !!togglePending}
|
||||||
|
onRestore={restoreRevision}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WireGuardRoute() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const params = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const serverId = params.get("server") ?? ALL_SERVERS_ID
|
||||||
|
const interfaceId = params.get("interface")
|
||||||
|
const rest = new URLSearchParams(params.toString())
|
||||||
|
rest.delete("interface")
|
||||||
|
const allInterfacesHref = "/wireguard" + (rest.size ? "?" + rest.toString() : "")
|
||||||
|
return <WireGuardPageContent key={JSON.stringify([serverId, interfaceId, mode, backendUrl])} selectedServerId={serverId} selectedInterfaceId={interfaceId} allInterfacesHref={allInterfacesHref} onSelectServer={id => {
|
||||||
|
const next = new URLSearchParams(rest)
|
||||||
|
if (id === ALL_SERVERS_ID) next.delete("server")
|
||||||
|
else next.set("server", id)
|
||||||
|
router.push("/wireguard" + (next.size ? "?" + next.toString() : ""))
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WireGuardPage() {
|
||||||
|
return <Suspense fallback={<div role="status">Загрузка WireGuard…</div>}><WireGuardRoute /></Suspense>
|
||||||
|
}
|
||||||
@@ -5,15 +5,18 @@ import { useRouter } from "next/navigation"
|
|||||||
import {
|
import {
|
||||||
clearPortalHandoffFlag,
|
clearPortalHandoffFlag,
|
||||||
clearToken,
|
clearToken,
|
||||||
|
can,
|
||||||
ensureAuthConfig,
|
ensureAuthConfig,
|
||||||
firstAllowedPath,
|
firstAllowedPath,
|
||||||
getClaims,
|
getClaims,
|
||||||
getToken,
|
getToken,
|
||||||
parseHashToken,
|
parseHashToken,
|
||||||
|
permissionForPath,
|
||||||
redirectToPortalLogin,
|
redirectToPortalLogin,
|
||||||
redirectToPortalLoginInteractive,
|
redirectToPortalLoginInteractive,
|
||||||
setToken,
|
setToken,
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
|
import { permittedAuthReturnPath } from "@/lib/auth-return-path"
|
||||||
|
|
||||||
export default function AuthCallbackPage() {
|
export default function AuthCallbackPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -28,6 +31,10 @@ export default function AuthCallbackPage() {
|
|||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const error = params.get("error")
|
const error = params.get("error")
|
||||||
|
const returnPath = () => permittedAuthReturnPath(params.get("next"), pathname => {
|
||||||
|
const permission = permissionForPath(pathname)
|
||||||
|
return !permission || can(permission)
|
||||||
|
}, firstAllowedPath())
|
||||||
if (error === "sso_loop" || error === "jwt_rejected") {
|
if (error === "sso_loop" || error === "jwt_rejected") {
|
||||||
redirectToPortalLoginInteractive()
|
redirectToPortalLoginInteractive()
|
||||||
return
|
return
|
||||||
@@ -62,7 +69,7 @@ export default function AuthCallbackPage() {
|
|||||||
/* ignore network — proceed */
|
/* ignore network — proceed */
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = firstAllowedPath()
|
const next = returnPath()
|
||||||
if (next === "/access-denied") {
|
if (next === "/access-denied") {
|
||||||
router.replace("/access-denied")
|
router.replace("/access-denied")
|
||||||
return
|
return
|
||||||
@@ -77,11 +84,13 @@ export default function AuthCallbackPage() {
|
|||||||
router.replace("/access-denied")
|
router.replace("/access-denied")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.replace(firstAllowedPath())
|
router.replace(returnPath())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
const callback = new URL(`${window.location.origin}/auth/callback`)
|
||||||
|
if (params.get("next")) callback.searchParams.set("next", params.get("next")!)
|
||||||
|
const ok = redirectToPortalLogin(callback.toString())
|
||||||
if (!ok) redirectToPortalLoginInteractive()
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
|
||||||
|
.network-map-focusable {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.network-map-focus-ring {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.network-map-focusable:focus-visible > .network-map-focus-ring {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ AUTH_REQUIRED=false
|
|||||||
AUTH_JWT_SECRET=dev-secret-change-me
|
AUTH_JWT_SECRET=dev-secret-change-me
|
||||||
AUTH_ISSUER=https://auth.shnt.top
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
AUTH_PORTAL_URL=http://localhost:5175
|
AUTH_PORTAL_URL=http://localhost:5175
|
||||||
|
# Optional: enabled by the NetFlow agent Compose installer. Linux Unix socket only.
|
||||||
|
# FLOW_AGENT_SOCKET=/run/mm-netflow-host/agent.sock
|
||||||
+10
-8
@@ -37,6 +37,15 @@ RUN npm run build -w @mmapp/contracts \
|
|||||||
|
|
||||||
FROM node:22-bookworm-slim AS runner
|
FROM node:22-bookworm-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Keep system packages independent of application changes and version tags.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
|
||||||
|
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
|
||||||
|
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends postgresql-client-18 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& mkdir -p /app/data
|
||||||
ARG APP_VERSION=dev
|
ARG APP_VERSION=dev
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=8000
|
ENV PORT=8000
|
||||||
@@ -48,15 +57,8 @@ COPY --from=build /app/packages/contracts ./packages/contracts
|
|||||||
COPY --from=build /app/backend/dist ./backend/dist
|
COPY --from=build /app/backend/dist ./backend/dist
|
||||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||||
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
||||||
|
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
|
||||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
|
|
||||||
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
|
|
||||||
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends postgresql-client-18 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& mkdir -p /app/data
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["node", "backend/dist/index.js"]
|
CMD ["node", "backend/dist/index.js"]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
|
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
|
||||||
-- Compact types, fillfactor for HOT upserts, autovacuum tuned for ON CONFLICT.
|
-- Compact types. FILLFACTOR/autovacuum нельзя на partitioned parent (PG 42809) —
|
||||||
|
-- задаются на листовых партициях в ensurePartitionFor.
|
||||||
-- Retention: DROP partitions only (see PARTITION_SPECS).
|
-- Retention: DROP partitions only (see PARTITION_SPECS).
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flow_hour_facts (
|
CREATE TABLE IF NOT EXISTS flow_hour_facts (
|
||||||
@@ -14,12 +15,6 @@ CREATE TABLE IF NOT EXISTS flow_hour_facts (
|
|||||||
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
|
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
|
||||||
) PARTITION BY RANGE (bucket_at);
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
|
||||||
ALTER TABLE flow_hour_facts SET (
|
|
||||||
fillfactor = 70,
|
|
||||||
autovacuum_vacuum_scale_factor = 0.05,
|
|
||||||
autovacuum_vacuum_cost_limit = 2000
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flow_daily_facts (
|
CREATE TABLE IF NOT EXISTS flow_daily_facts (
|
||||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
day DATE NOT NULL,
|
day DATE NOT NULL,
|
||||||
@@ -31,9 +26,3 @@ CREATE TABLE IF NOT EXISTS flow_daily_facts (
|
|||||||
packets BIGINT NOT NULL DEFAULT 0,
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (server_id, day, iface, country, service, asn)
|
PRIMARY KEY (server_id, day, iface, country, service, asn)
|
||||||
) PARTITION BY RANGE (day);
|
) PARTITION BY RANGE (day);
|
||||||
|
|
||||||
ALTER TABLE flow_daily_facts SET (
|
|
||||||
fillfactor = 70,
|
|
||||||
autovacuum_vacuum_scale_factor = 0.05,
|
|
||||||
autovacuum_vacuum_cost_limit = 2000
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- История desired/actual снапшотов managed-секций (фильтры, рекурсивные маршруты).
|
||||||
|
-- Retention — prune в сервисе (последние 50 на пару server+section).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config_revisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
section TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_revisions_server_section_created
|
||||||
|
ON config_revisions (server_id, section, created_at DESC);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
|
||||||
|
-- Needed to rebuild facts with the same internet dest as the network map.
|
||||||
|
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Захват перед POST и удержание неизвестных исходов. Исторические строки не меняются.
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE alert_outbox DROP CONSTRAINT IF EXISTS alert_outbox_status_check;
|
||||||
|
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_status_check
|
||||||
|
CHECK (status IN ('pending', 'processing', 'unknown', 'sent', 'failed'));
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE alert_outbox ADD COLUMN IF NOT EXISTS resolution_version integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_resolution_version_check CHECK (resolution_version >= 0);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- A failed command is an attempt, not a measured packet-loss percentage.
|
||||||
|
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS measurement_known BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS observation_error TEXT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS traffic_collection_state (
|
||||||
|
server_id BIGINT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
server_updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_attempt_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_succeeded_at TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'ok', 'error')),
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE traffic_samples ADD COLUMN IF NOT EXISTS native_counter_identity TEXT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Retention runs after each scheduler receipt; avoid scanning the complete history.
|
||||||
|
-- On a busy existing installation, pre-create this same index CONCURRENTLY
|
||||||
|
-- outside a transaction before deploying (deploy/PRODUCTION-RETENTION-INDEX.md).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_finished_at ON scheduler_runs (finished_at);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS router_log_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 10 CHECK (interval_sec BETWEEN 5 AND 3600),
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14 CHECK (retention_days BETWEEN 1 AND 365)
|
||||||
|
);
|
||||||
|
INSERT INTO router_log_settings(id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||||
|
-- No foreign key: collected history survives device deletion.
|
||||||
|
CREATE TABLE IF NOT EXISTS router_log_collection_state (
|
||||||
|
server_id INTEGER PRIMARY KEY, server_name TEXT NOT NULL, host TEXT NOT NULL,
|
||||||
|
generation INTEGER NOT NULL DEFAULT 0, snapshot JSONB NOT NULL DEFAULT '[]',
|
||||||
|
last_collected_at TIMESTAMPTZ, last_error TEXT, gap_at TIMESTAMPTZ,
|
||||||
|
next_poll_at TIMESTAMPTZ, failures INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS router_log_entries (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, server_id INTEGER NOT NULL,
|
||||||
|
server_name TEXT NOT NULL, host TEXT NOT NULL, router_id TEXT NOT NULL, raw_time TEXT NOT NULL,
|
||||||
|
event_at TIMESTAMPTZ, received_at TIMESTAMPTZ NOT NULL,
|
||||||
|
effective_at TIMESTAMPTZ NOT NULL, topics TEXT[] NOT NULL, level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL, generation INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_server_time ON router_log_entries(server_id, effective_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_time ON router_log_entries(effective_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_level_time ON router_log_entries(level, effective_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_topics ON router_log_entries USING gin(topics);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_received ON router_log_entries(received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_server_cursor ON router_log_entries(server_id, id);
|
||||||
+32
-10
@@ -4,23 +4,45 @@
|
|||||||
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
|
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:logs": "tsx src/services/router-logs-model.test.ts && tsx src/plugins/auth.logs.test.ts && tsx src/services/mikrotik-logs.test.ts",
|
||||||
|
"test:logs:pg": "tsx src/services/router-logs-sql.test.ts",
|
||||||
|
"test:sidebar-counts": "tsx src/services/complete-count.test.ts && tsx src/routes/sidebar-counts.test.ts",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "npm run build -w @mmapp/contracts && tsc",
|
"build": "npm run build -w @mmapp/contracts && tsc",
|
||||||
"test:alert-engine": "tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
|
"test:alerts-telegram": "tsx src/services/alerts-outbox-retry.test.ts && tsx src/services/alerts-outbox-overview.test.ts && tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts",
|
||||||
|
"test:alert-engine": "tsx src/services/alert-engine/server-signal-identity.test.ts && tsx src/services/alert-engine/collector-source-signals.test.ts && tsx src/services/alert-engine/monitoring-history.test.ts && tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/gre-tunnels-live.test.ts && tsx src/services/bgp-peers-live.test.ts && tsx src/services/gre-bgp-snapshot-collector.test.ts && tsx src/services/gre-bgp-snapshot-history.test.ts && tsx src/services/gre-bgp-snapshot-sql.test.ts && tsx src/services/alert-engine/collector-source-sql.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
|
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
|
||||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
|
||||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
"test:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts && tsx src/services/ping-measurement.test.ts && tsx src/services/uptime-probe-observations.test.ts && tsx src/services/ping-measurement-sql.test.ts",
|
||||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
|
||||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/statistics-aggregate.test.ts",
|
"test:auth": "tsx src/plugins/auth-path.test.ts && tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
|
||||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
|
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
|
||||||
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts",
|
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
|
||||||
"test:backups": "tsx src/services/s3-backup-client.test.ts",
|
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
|
||||||
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups",
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
||||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
|
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
|
||||||
|
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
|
||||||
|
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
|
||||||
|
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
|
||||||
|
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
|
||||||
|
"test:backups": "tsx src/services/backup-local-file-state.test.ts && tsx src/services/router-config-export.test.ts && tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
|
||||||
|
"test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts",
|
||||||
|
"test:live-maps": "tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts && tsx src/routes/ospf-legacy.test.ts",
|
||||||
|
"test": "node scripts/test.mjs",
|
||||||
|
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
|
||||||
|
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
|
||||||
|
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts && tsx src/services/traffic-flow-agent-install.test.ts && tsx src/services/traffic-flow-agent-client.test.ts && tsx src/services/traffic-flow-operation-queue.test.ts && tsx src/routes/traffic-flow-host.test.ts",
|
||||||
|
"test:geoip": "tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
|
||||||
|
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
|
||||||
|
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
|
||||||
|
"test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
|
||||||
|
"test:system-database": "tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts",
|
||||||
|
"test:all": "npm run test:logs && npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy",
|
||||||
|
"test:server-privacy": "tsx src/routes/server-secrets-sql.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import fs from 'node:fs';import assert from 'node:assert/strict';import {chromium,expect} from '@playwright/test';
|
||||||
|
const privatePath=process.env.MMAPP_ACME_FIXTURE||'tmp/acme250-private.json',p=JSON.parse(fs.readFileSync(privatePath)),login=JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
|
||||||
|
assert(/^mmt250-[a-f0-9]{8}\.pngs\.top$/.test(p.fqdn));assert(!p.jobId,'Never submit an unknown or existing issue job again');
|
||||||
|
const report={date:'2026-10-14',block:'12.5.250',fqdn:p.fqdn,certName:p.certName,serverId:p.serverId,realPortalLogin:true,noMocks:true,issuePosts:0,pageErrors:[]};
|
||||||
|
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||||
|
try{
|
||||||
|
const page=await browser.newPage({viewport:{width:1440,height:1000}});page.on('pageerror',e=>report.pageErrors.push(e.name));page.on('request',r=>{if(new URL(r.url()).pathname==='/api/certificates/issue'&&r.method()==='POST'){const b=r.postDataJSON();assert.equal(String(b.serverId),p.serverId);assert.equal(b.certName,p.certName);assert.deepEqual(b.domainNames,[p.fqdn]);p.jobId=b.requestId;assert(p.jobId);report.issuePosts++;report.jobId=p.jobId;fs.writeFileSync(privatePath,JSON.stringify(p))}});
|
||||||
|
await page.goto('http://localhost:3000/certificates?server=2');await page.getByLabel('Email',{exact:true}).fill(login.email);await page.getByLabel('Пароль',{exact:true}).fill(login.password);await page.getByRole('button',{name:'Войти',exact:true}).click();await expect(page.getByRole('navigation',{name:'Путь страницы'})).toContainText('Сертификаты',{timeout:30000});
|
||||||
|
const settings=await page.evaluate(async({token,zoneId})=>{const headers={Authorization:'Bearer '+localStorage.getItem('mmapp_token'),'Content-Type':'application/json'};const before=await fetch('/api/certificates/acme-settings',{headers});if(!before.ok)throw Error('Settings read failed');const old=await before.json();if(old.tokenConfigured)throw Error('Existing CF token must not be overwritten');const test=await fetch('/api/certificates/acme-settings/test',{method:'POST',headers,body:JSON.stringify({cloudflareApiToken:token})});const tested=await test.json();if(!test.ok||tested.ok!==true)return{testStatus:test.status,testOk:false,message:tested.message};const put=await fetch('/api/certificates/acme-settings',{method:'PUT',headers,body:JSON.stringify({directoryUrl:'https://acme-v02.api.letsencrypt.org/directory',defaultZoneId:zoneId,cloudflareApiToken:token})});const configured=await put.json();if(!put.ok||!configured.tokenConfigured)throw Error('Settings save failed');return{testStatus:test.status,testOk:true,saveStatus:put.status,tokenConfigured:configured.tokenConfigured,directoryUrl:configured.directoryUrl}}, {token:p.token,zoneId:p.zoneId});
|
||||||
|
report.settings=settings;assert.equal(settings.testOk,true);
|
||||||
|
await page.getByRole('button',{name:'Выпустить сертификат',exact:true}).click();const dialog=page.getByRole('dialog',{name:'Выпуск сертификата'});await dialog.getByRole('combobox',{name:'Сервер выпуска'}).selectOption(p.serverId);await dialog.getByRole('textbox',{name:'Имя сертификата',exact:true}).fill(p.certName);await dialog.getByRole('button',{name:'Далее',exact:true}).click();await dialog.getByRole('textbox',{name:'Основной домен',exact:true}).fill(p.fqdn);await dialog.getByRole('button',{name:'Далее',exact:true}).click();await dialog.getByRole('button',{name:'Далее',exact:true}).click();await expect(dialog).toContainText(p.fqdn);
|
||||||
|
const response=page.waitForResponse(r=>new URL(r.url()).pathname==='/api/certificates/issue'&&r.request().method()==='POST');await dialog.getByRole('button',{name:'Выпустить',exact:true}).click();const accepted=await response;assert.equal(accepted.status(),200);assert.equal((await accepted.json()).jobId,p.jobId);report.accepted=true;fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.log('One ACME issue accepted for '+p.fqdn);
|
||||||
|
await expect.poll(()=>page.evaluate(async(jobId)=>{const r=await fetch('/api/certificates/issue/'+jobId,{headers:{Authorization:'Bearer '+localStorage.getItem('mmapp_token')}});if(!r.ok)return'unknown';return(await r.json()).status},p.jobId),{timeout:180000,intervals:[2000]}).toMatch(/^(done|failed)$/);
|
||||||
|
report.job=await page.evaluate(async(jobId)=>{const r=await fetch('/api/certificates/issue/'+jobId,{headers:{Authorization:'Bearer '+localStorage.getItem('mmapp_token')}});const b=await r.json();return{id:b.id,status:b.status,step:b.step,error:b.error}},p.jobId);assert.equal(report.issuePosts,1);assert.equal(report.job.status,'done');await expect(dialog.getByRole('status')).toContainText('Завершена',{timeout:15000});assert.deepEqual(report.pageErrors,[]);report.uiTerminalConfirmed=true;
|
||||||
|
fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.log('ACME UI job done; one issue POST, JS errors0');
|
||||||
|
}catch(e){report.failure={name:e.name};fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.error('ACME test incomplete: '+e.name);process.exitCode=1}finally{await browser.close()}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import tls from 'node:tls';
|
||||||
|
import { X509Certificate } from 'node:crypto';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const fingerprint = value => {
|
||||||
|
assert.equal(typeof value, 'string', 'Fingerprint is required');
|
||||||
|
const normalized = value.replaceAll(':', '').toLowerCase();
|
||||||
|
assert(/^[a-f0-9]{64}$/.test(normalized), 'Expected SHA-256 fingerprint');
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compareRenewal(before, after) {
|
||||||
|
for (const sample of [before, after]) {
|
||||||
|
assert(/^mmt253-[a-f0-9]{8}\.pngs\.top$/.test(sample.fqdn), 'Only the prepared exact test domain is permitted');
|
||||||
|
assert.equal(sample.remoteAddress, '104.165.244.7');
|
||||||
|
assert.equal(sample.authorized, true);
|
||||||
|
assert.equal(sample.hostnameMatched, true);
|
||||||
|
assert.equal(sample.nativePrivateKey, true);
|
||||||
|
assert.equal(sample.nativeName, sample.certName);
|
||||||
|
assert.equal(sample.certName, sample.fqdn.replace('.pngs.top', ''));
|
||||||
|
assert.equal(fingerprint(sample.nativeFingerprint256), fingerprint(sample.fingerprint256), 'Native leaf must match served TLS');
|
||||||
|
assert(Number.isFinite(Date.parse(sample.validUntil)), 'Expiry is required');
|
||||||
|
assert(Number.isFinite(Date.parse(sample.sampledAt)), 'Sample time is required');
|
||||||
|
assert(Date.parse(sample.validUntil) > Date.parse(sample.sampledAt), 'Certificate is expired');
|
||||||
|
}
|
||||||
|
assert.equal(after.fqdn, before.fqdn);
|
||||||
|
assert.equal(after.certName, before.certName);
|
||||||
|
assert.notEqual(fingerprint(after.fingerprint256), fingerprint(before.fingerprint256), 'Old certificate is still being served');
|
||||||
|
assert(Date.parse(after.sampledAt) >= Date.parse(before.sampledAt));
|
||||||
|
assert(Date.parse(after.validUntil) > Date.parse(after.sampledAt), 'New certificate is expired');
|
||||||
|
// A second issuance on the same day need not extend expiry by a full day.
|
||||||
|
return { fingerprintChanged: true, nativeAndTlsMatch: true, trustedHostnameMatch: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sample(fixture) {
|
||||||
|
assert(/^mmt253-[a-f0-9]{8}\.pngs\.top$/.test(fixture.fqdn));
|
||||||
|
assert.equal(fixture.certName, fixture.fqdn.replace('.pngs.top', ''));
|
||||||
|
assert.equal(fixture.nativePrivateKey, true);
|
||||||
|
fingerprint(fixture.nativeFingerprint256);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const socket = tls.connect({ host: '104.165.244.7', port: 443, servername: fixture.fqdn, rejectUnauthorized: true });
|
||||||
|
socket.setTimeout(15000, () => socket.destroy(new Error('TLS acceptance timeout')));
|
||||||
|
socket.once('error', reject);
|
||||||
|
socket.once('secureConnect', () => {
|
||||||
|
try {
|
||||||
|
assert.equal(socket.authorized, true);
|
||||||
|
const cert = new X509Certificate(socket.getPeerCertificate().raw);
|
||||||
|
assert(cert.checkHost(fixture.fqdn), 'Hostname mismatch');
|
||||||
|
assert(Date.parse(cert.validTo) > Date.now(), 'Certificate is expired');
|
||||||
|
assert.equal(fingerprint(cert.fingerprint256), fingerprint(fixture.nativeFingerprint256), 'Native leaf does not match TLS');
|
||||||
|
resolve({ sampledAt: new Date().toISOString(), fqdn: fixture.fqdn, certName: fixture.certName,
|
||||||
|
remoteAddress: socket.remoteAddress, authorized: socket.authorized, hostnameMatched: true,
|
||||||
|
protocol: socket.getProtocol(), fingerprint256: cert.fingerprint256,
|
||||||
|
validFrom: cert.validFrom, validUntil: cert.validTo,
|
||||||
|
nativeName: fixture.certName, nativePrivateKey: true, nativeFingerprint256: fixture.nativeFingerprint256 });
|
||||||
|
} catch (error) { reject(error); } finally { socket.end(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
const [mode, input, output] = process.argv.slice(2);
|
||||||
|
assert(input && output && ['sample', 'compare'].includes(mode), 'Usage: sample native-summary.json output.json | compare samples.json output.json');
|
||||||
|
assert(!fs.existsSync(output), 'Never overwrite acceptance evidence');
|
||||||
|
const data = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||||
|
const result = mode === 'sample' ? await sample(data) : compareRenewal(data.before, data.after);
|
||||||
|
fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' });
|
||||||
|
console.log(mode === 'sample' ? 'Trusted TLS/native fingerprint sample saved' : 'Renewal fingerprint comparison passed');
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { compareRenewal } from './acme-renew-tls.mjs';
|
||||||
|
const before = { fqdn: 'mmt253-a1b2c3d4.pngs.top', certName: 'mmt253-a1b2c3d4', nativeName: 'mmt253-a1b2c3d4',
|
||||||
|
remoteAddress: '104.165.244.7', authorized: true, hostnameMatched: true, nativePrivateKey: true,
|
||||||
|
fingerprint256: 'a'.repeat(64), nativeFingerprint256: 'a'.repeat(64), sampledAt: '2026-10-14T12:00:00Z', validUntil: '2027-01-12T12:00:00Z' };
|
||||||
|
const after = { ...before, fingerprint256: 'b'.repeat(64), nativeFingerprint256: 'b'.repeat(64), sampledAt: '2026-10-14T12:01:00Z' };
|
||||||
|
assert.equal(compareRenewal(before, after).fingerprintChanged, true);
|
||||||
|
for (const patch of [{ fingerprint256: before.fingerprint256, nativeFingerprint256: before.nativeFingerprint256 },
|
||||||
|
{ nativeFingerprint256: before.nativeFingerprint256 }, { authorized: false }, { hostnameMatched: false },
|
||||||
|
{ nativePrivateKey: false }, { nativeName: 'another' }, { fqdn: 'other.pngs.top' },
|
||||||
|
{ remoteAddress: '194.226.121.150' }, { validUntil: 'bad' }, { validUntil: '2026-10-13T12:00:00Z' },
|
||||||
|
{ sampledAt: '2026-10-14T11:00:00Z' }]) assert.throws(() => compareRenewal(before, { ...after, ...patch }));
|
||||||
|
console.log('Renewal acceptance comparison: changed leaf passes; 11 false-success cases rejected. No network or CA calls.');
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import {createServer} from 'node:http'
|
||||||
|
import {randomBytes} from 'node:crypto'
|
||||||
|
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||||
|
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||||
|
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own211.invalid';process.env.AUTH_PORTAL_URL='https://own211.invalid'
|
||||||
|
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||||
|
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||||
|
const psk='own211-'+randomBytes(24).toString('base64url'),baseline=false,cases=[];let app,serverId,nativeWrites=0,nativeCalls=0
|
||||||
|
const native=createServer((req,res)=>{nativeCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url.endsWith('/interface/gre')?[{'.id':'*1',name:'own211-encrypted','local-address':'192.0.2.1','remote-address':'192.0.2.2','ipsec-secret':psk,mtu:'1476',keepalive:'10s,3',disabled:'false',running:'true','allow-fast-path':'false'},{'.id':'*2',name:'own211-plain','remote-address':'192.0.2.3','ipsec-secret':'',disabled:'false',running:'true'}]:[]))});await new Promise(r=>native.listen(0,'127.0.0.1',r))
|
||||||
|
const report={date:'2026-10-13',block:'12.5.211',database,baseline,cases,scope:'Production compiled canonical auth/RBAC using own restricted PostgreSQL, local native HTTP fixture and synthetic JWT/PSK',passed:false}
|
||||||
|
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own211-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready()
|
||||||
|
for(const path of ['/api/servers','/api/%73ervers','/%61pi/servers','/%61%70%69/servers','/api/gre/tunnels','/%61pi/gre/tunnels'])for(const method of ['GET','HEAD']){const before=nativeCalls;const r=await app.inject({method,url:path});assert.equal(r.statusCode,401);assert.equal(nativeCalls,before);cases.push({path,method,role:'anonymous',status:401})}
|
||||||
|
for(const path of ['/api/gre/tunnels','/api/%67re/tunnels','/%61pi/gre/tunnels'])for(const role of ['dashboard','reader','writer']){const permissions=role==='dashboard'?['mm:dashboard:read']:role==='reader'?['mm:network:read']:['mm:network:write'],token=app.jwt.sign({sub:'own211-'+role,apps:['mm'],permissions,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});const r=await app.inject({method:'GET',url:path+'?serverId='+serverId,headers:{authorization:'Bearer '+token}});assert.equal(r.statusCode,role==='dashboard'?403:200);if(role!=='dashboard')assert.equal(r.json().tunnels.find(t=>t.name==='own211-encrypted').ipsec.secret,role==='writer'?psk:'');cases.push({path,method:'GET',role,status:r.statusCode,privateMaterialStored:false});if(role!=='writer'){const patch=await app.inject({method:'PATCH',url:path,headers:{authorization:'Bearer '+token},payload:{serverId:String(serverId),rosId:'*1',comment:'not applied'}});assert.equal(patch.statusCode,403);cases.push({path,method:'PATCH',role,status:403})}}
|
||||||
|
for(const path of ['/api/auth/config','/%61pi/auth/config']){assert.equal((await app.inject({url:path})).statusCode,200);cases.push({path,role:'anonymous',status:200,publicConfig:true})}
|
||||||
|
assert.equal(nativeWrites,0);report.nativeWrites=0;report.passed=true
|
||||||
|
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre211-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/auth211-production-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||||
|
if(!report.passed)process.exitCode=1
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import assert from'node:assert/strict';import fs from'node:fs';import{createRequire}from'node:module';import{createServer}from'node:http';
|
||||||
|
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||||
|
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-availability237',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();fs.mkdirSync('storage/backups',{recursive:true});fs.writeFileSync('storage/backups/available237.rsc','# harmless own fixture\n');fs.mkdirSync('storage/backups/directory237.rsc');const fixtures=[['available237','available237.rsc','local',null],['missing237','missing237.rsc','local',null],['both237','both237.rsc','both','own-test/both237.rsc'],['remote237','remote237.rsc','s3','own-test/remote237.rsc'],['directory237','directory237.rsc','local',null]];for(const[id,filename,storage,key]of fixtures)await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES($1,$2,$3,$4,1,$5,$6,$7,now())',[id,server.id,'fixture',filename,'manual',storage,key]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.237',passed:false,checks:[],externalS3Contacts:0,chrContacts:0};try{let r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.statusCode,200);const statuses=Object.fromEntries(r.json().map(r=>[r.id,r.localFileStatus]));assert.deepEqual(statuses,{available237:'available',missing237:'missing',both237:'missing',remote237:'not-stored',directory237:'unknown'});report.checks.push({listStates:statuses});r=await app.inject({method:'GET',url:'/api/backups/available237/download'});assert.equal(r.statusCode,200);assert.equal(r.body,'# harmless own fixture\n');report.checks.push({availableDownload:200});r=await app.inject({method:'GET',url:'/api/backups/missing237/download'});assert.equal(r.statusCode,404);report.checks.push({missingDownload:404});r=await app.inject({method:'POST',url:'/api/backups/missing237/restore'});assert.equal(r.statusCode,400);assert.equal(r.json().outcome,'not-applied');assert.match(r.json().error,/Локальный файл/);assert.equal(nativeRequests,0);report.checks.push({missingRestore:400,outcome:'not-applied',nativeRequests:0});r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.json().length,5);report.checks.push({failurePreservesCatalog:true});fs.writeFileSync('storage/backups/missing237.rsc','# recreated fixture\n');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,409);assert(fs.existsSync('storage/backups/missing237.rsc'));assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='missing237'")).rows[0].n,1);report.checks.push({recreatedFilePreserved:409});fs.unlinkSync('storage/backups/missing237.rsc');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,204);assert.equal(r.headers['x-backup-delete-mode'],'record-only');report.checks.push({missingRecordDeletion:204,mode:'record-only'});report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/backup-availability237.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import assert from 'node:assert/strict';import fs from 'node:fs';import{randomUUID}from'node:crypto';import{createServer}from'node:http';import{createRequire}from'node:module';
|
||||||
|
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||||
|
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-legacy234',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();const filename='legacy234-'+randomUUID()+'.rsc',file='storage/backups/'+filename;fs.mkdirSync('storage/backups',{recursive:true});await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,created_at) VALUES('owned-legacy234',$1,'fixture',$2,1,'manual','local',now())",[server.id,filename]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.234',checks:[],passed:false,chrContacts:0,externalS3Contacts:0};try{for(const prefix of['','\uFEFF\r\n ']){const content=prefix+'# synthetic export generated by MikrotikManager\n/system identity\nset name=legacy-fixture\n';fs.writeFileSync(file,content);const download=await app.inject({method:'GET',url:'/api/backups/owned-legacy234/download'});assert.equal(download.statusCode,200);assert.equal(download.body,content);const restore=await app.inject({method:'POST',url:'/api/backups/owned-legacy234/restore'});assert.equal(restore.statusCode,400);assert.equal(restore.json().outcome,'not-applied');assert.match(restore.json().error,/частичным экспортом/);assert.equal(fs.readFileSync(file,'utf8'),content);assert.equal(nativeRequests,0);assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='owned-legacy234'")).rows[0].n,1);report.checks.push({bomAndWhitespace:Boolean(prefix),downloadStatus:200,restoreStatus:400,outcome:'not-applied',fileAndCatalogPreserved:true,nativeRequests:0})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();const deleted=await app.inject({method:'DELETE',url:'/api/backups/owned-legacy234'});assert.equal(deleted.statusCode,204);assert(!fs.existsSync(file));assert.equal((await pool.query('SELECT count(*)::int AS n FROM backup_entries')).rows[0].n,0);report.cleanup={catalogRows:0,localFiles:0};await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/legacy-restore234.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import assert from 'node:assert/strict';import fs from 'node:fs';import{createRequire}from'node:module';
|
||||||
|
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||||
|
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');const baseline=process.env.MMAPP_ORIGIN_BASELINE==='true',report={date:'2026-10-14',block:'12.5.233',baseline,checks:[],externalS3Contacts:0,chrContacts:0,passed:false};const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const config={provider:'s3',s3Endpoint:'https://s3.invalid',s3Region:'test-region',s3Bucket:'origin-bucket',s3Prefix:'owned-test',s3AccessKeyId:'fixture',s3SecretAccessKey:'fixture',s3ForcePathStyle:false,keepLocalCopy:true};async function api(method,path,payload,status=200){const r=await app.inject({method,url:'/api'+path,...(payload?{payload}:{})});assert.equal(r.statusCode,status,method+' '+path);return r}
|
||||||
|
async function insert(){await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES('owned-origin',NULL,'fixture','fixture.rsc',1,'manual','s3','owned-test/fixture.rsc',now())")}
|
||||||
|
try{await api('PUT','/backups/storage',config);await insert();for(const patch of[{s3Bucket:'other-bucket'},{s3Endpoint:'https://other.invalid'},{s3Region:'other'},{s3ForcePathStyle:true},{provider:'local'}]){const before=(await api('GET','/backups/storage')).json();await api('PUT','/backups/storage',patch,baseline?200:409);const after=(await api('GET','/backups/storage')).json();if(!baseline)assert.deepEqual(after,before);else await api('PUT','/backups/storage',config);report.checks.push({change:Object.keys(patch)[0],status:baseline?200:409,settingsUnchanged:!baseline})}for(const patch of[{s3Prefix:'new-prefix'},{s3SecretAccessKey:'rotated-fixture'},{keepLocalCopy:false}]){await api('PUT','/backups/storage',patch);report.checks.push({allowed:Object.keys(patch)[0],status:200})}await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await api('PUT','/backups/storage',{s3Bucket:'empty-catalog-bucket'});report.checks.push({emptyCatalogChangeAllowed:true});if(!baseline){const{withBackupStorageOperation}=await import('/app/backend/dist/services/backup-storage-operation.js');let release,started;const active=new Promise(r=>started=r),wait=new Promise(r=>release=r);const creator=withBackupStorageOperation(async()=>{started();await wait;await insert()});await active;let settled=false;const update=api('PUT','/backups/storage',{s3Bucket:'racing-bucket'},409).then(()=>settled=true);await new Promise(r=>setTimeout(r,20));await update;assert.equal(settled,true);release();await creator;assert.equal((await api('GET','/backups/storage')).json().s3Bucket,'empty-catalog-bucket');report.checks.push({inFlightUpdateRejectedBeforeCatalogPublication:true});const{readBackupContent,getBackupById}=await import('/app/backend/dist/services/backup-service.js');const stale=await getBackupById('owned-origin');let releaseDelete;const deletion=withBackupStorageOperation(async()=>{await new Promise(r=>releaseDelete=r);await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'")});const download=readBackupContent(stale);const rejected=assert.rejects(download,/Бэкап не найден/);await Promise.resolve();releaseDelete();await deletion;await rejected;report.checks.push({staleDownloadRejectsBeforeS3AfterDeletion:true})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await databaseBackgroundWork.drain();await app.close();await closePool();fs.writeFileSync('/checks/storage-origin233.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import {createServer} from 'node:http'
|
||||||
|
import {randomBytes} from 'node:crypto'
|
||||||
|
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||||
|
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||||
|
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own216.invalid';process.env.AUTH_PORTAL_URL='https://own216.invalid'
|
||||||
|
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js'),{fetchBgpSessionsForAlerts}=await import('/app/backend/dist/services/bgp-peers-live.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js')
|
||||||
|
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||||
|
let phase='populated',app,nativeCalls=0,nativeWrites=0,disabledCalls=0;const cases=[]
|
||||||
|
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');if(phase==='failed'||(phase==='partial'&&label==='second')){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own216 fixture denied'}));return}const rows=phase==='populated'?[{'.id':'*1',name:'own216-peer',state:'established'}]:[];res.end(JSON.stringify(rows))})
|
||||||
|
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||||
|
const baseline=process.env.MMAPP_BASELINE==='true';const report={date:'2026-10-14',block:'12.5.216',baseline,database,cases,scope:'Compiled production routes/auth, isolated PostgreSQL and RouterOS HTTP fixture; real CHR unchanged',passed:false}
|
||||||
|
try{
|
||||||
|
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own216-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
|
||||||
|
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own216-reader',apps:['mm'],permissions:['mm:bgp:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token}
|
||||||
|
const wrongToken=app.jwt.sign({sub:'own216-other-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const [name,h,status] of [['anonymous',{},401],['wrong permission',{authorization:'Bearer '+wrongToken},403]]){const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/bgp/sessions',headers:h});assert.equal(r.statusCode,status);assert.equal(nativeCalls,before);cases.push({phase:name,endpoint:'bgp/sessions',status,nativeCalls:0})}
|
||||||
|
for(phase of ['populated','empty','partial','failed','disabled']){
|
||||||
|
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false')
|
||||||
|
const hasFailure=['partial','failed'].includes(phase),expected=phase==='partial'?[ids[1]]:phase==='failed'?ids.slice(0,2):[]
|
||||||
|
for(const endpoint of ['bgp/sessions','bgp/catalog']){
|
||||||
|
const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/'+endpoint,headers}),body=r.json(),isCatalog=endpoint==='bgp/catalog'
|
||||||
|
assert.equal(r.statusCode,!baseline&&hasFailure&&!isCatalog?503:200)
|
||||||
|
if(isCatalog||(!baseline&&hasFailure)){assert.deepEqual(body.failures.map(f=>f.serverId),expected);if(isCatalog)assert.equal(body.sessions.length,phase==='populated'?2:0)}else{assert(Array.isArray(body));assert.equal(body.length,phase==='populated'?2:0)}
|
||||||
|
if(phase==='disabled')assert.equal(nativeCalls,before)
|
||||||
|
cases.push({phase,endpoint,status:r.statusCode,failures:body.failures?.length??0,nativeCalls:nativeCalls-before})
|
||||||
|
}
|
||||||
|
const snapshot=await collectGreBgpSnapshotOnce({fetchGre:async()=>[{targetLabel:'own216-GRE-control',status:'up'}],fetchBgp:fetchBgpSessionsForAlerts})
|
||||||
|
assert.equal(snapshot.greWritten,1);assert.equal(snapshot.bgpWritten,phase==='populated'?2:0);assert.equal(snapshot.errors.length,!baseline&&hasFailure?1:0)
|
||||||
|
if(snapshot.errors.length)assert.match(snapshot.errors[0],/BGP: Не опрошены устройства BGP/)
|
||||||
|
cases.push({phase,endpoint:'compiled snapshot collector',bgpWritten:snapshot.bgpWritten,greWritten:1,sourceErrors:snapshot.errors.length})
|
||||||
|
}
|
||||||
|
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||||
|
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/bgp216-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/bgp216-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';import{pathToFileURL}from'node:url';import{createHash}from'node:crypto';
|
||||||
|
const vantage=process.env.MMAPP_TEST_VANTAGE;assert(['workstation','debian'].includes(vantage));const output=process.env.MMAPP_TEST_REPORT;assert(output);const hosts=['194.226.121.150','104.165.244.7'],root=path.resolve('backend/dist'),{MikrotikClient}=await import(pathToFileURL(root+'/services/mikrotik.js'));let rows;
|
||||||
|
if(vantage==='debian'){const{pool}=await import(pathToFileURL(root+'/db/index.js'));try{rows=(await pool.query("SELECT host,port,use_ssl,enabled,username,password FROM servers WHERE host IN ('194.226.121.150','104.165.244.7') ORDER BY host")).rows}finally{await pool.end()}}else{assert(process.env.MMAPP_TEST_CHR_USER&&process.env.MMAPP_TEST_CHR_PASSWORD);rows=hosts.map(host=>({host,port:80,use_ssl:false,enabled:true,username:process.env.MMAPP_TEST_CHR_USER,password:process.env.MMAPP_TEST_CHR_PASSWORD}))}assert.equal(rows.length,2);assert.deepEqual(rows.map(r=>r.host).sort(),[...hosts].sort());assert(rows.every(r=>r.enabled&&r.port===80&&!r.use_ssl));
|
||||||
|
const cycles=Number(process.env.MMAPP_REST_CYCLES??8),periodMs=Number(process.env.MMAPP_REST_PERIOD_MS??12000),maxMs=Number(process.env.MMAPP_REST_MAX_MS??960000);assert(Number.isInteger(cycles)&&cycles>=1&&cycles<=32);assert(Number.isInteger(periodMs)&&periodMs>=1000&&periodMs<=60000);assert(Number.isInteger(maxMs)&&maxMs>=10000&&maxMs<=960000);const deadline=Date.now()+maxMs;const report={date:'2026-10-14',block:process.env.MMAPP_REST_BLOCK??'12.5.236',vantage,cycles,periodMs,maxMs,credentialSource:vantage==='debian'?'stored-enabled-server-rows':'explicit-owned-test-env',startedAt:new Date().toISOString(),requests:[],diagnostics:[],configurationWrites:0,passwordChanges:0,rootCauseKnown:false};const sleep=ms=>new Promise(r=>setTimeout(r,ms));const clients=new Map(rows.map(r=>[r.host,new MikrotikClient({host:r.host,port:80,useSsl:false,verifySsl:false,username:r.username,password:r.password})]));
|
||||||
|
function errorMetadata(e,body){let obj;try{obj=JSON.parse(body)}catch{}const detail=String(obj?.detail??'');return{errorName:e?.name??null,errorBodySha256:body?createHash('sha256').update(body).digest('hex'):null,errorKeys:obj&&typeof obj==='object'?Object.keys(obj).sort():[],detailCategory:/not enough permissions/i.test(detail)?'insufficient-policy':/not allowed/i.test(detail)?'not-allowed':/session closed/i.test(detail)?'session-closed':detail?'unclassified':'absent'}}
|
||||||
|
async function read(row,endpoint,mode,cycle,kind='sample'){const begin=Date.now(),record={host:row.host,path:endpoint,mode,cycle,kind,startedAt:new Date().toISOString()};let data;try{if(mode==='compiled-client'){data=await clients.get(row.host).get(endpoint,6000);record.status=200}else{const authorization='Basic '+Buffer.from(row.username+':'+row.password).toString('base64');const r=await fetch('http://'+row.host+'/rest'+endpoint,{headers:{authorization,Connection:'close'},signal:AbortSignal.timeout(6000)}),body=await r.text();record.status=r.status;record.basicChallenge=/Basic/i.test(r.headers.get('www-authenticate')??'');if(r.ok)data=JSON.parse(body);else Object.assign(record,errorMetadata(null,body))}}catch(e){record.status=e.statusCode??null;Object.assign(record,errorMetadata(e,e.body??''))}record.durationMs=Date.now()-begin;report.requests.push(record);await sleep(125);return data}
|
||||||
|
async function diagnostic(row,phase){const userRows=await read(row,'/user','compiled-client',0,'diagnostic'),users=Array.isArray(userRows)?userRows.filter(u=>u.name===row.username):[];const groups=await read(row,'/user/group','compiled-client',0,'diagnostic'),group=Array.isArray(groups)?groups.find(g=>g.name===users[0]?.group):null;const active=await read(row,'/user/active','compiled-client',0,'diagnostic');const apiSessions=Array.isArray(active)?active.filter(s=>s.name===row.username&&s.via==='api').map(s=>createHash('sha256').update(String(s['.id'])+'|'+String(s.when)).digest('hex')).sort():null;const services=await read(row,'/ip/service','compiled-client',0,'diagnostic'),resource=await read(row,'/system/resource','compiled-client',0,'diagnostic'),logs=await read(row,'/log','compiled-client',0,'diagnostic');const auth=Array.isArray(logs)?logs.filter(l=>/login failure/i.test(String(l.message))):[];const events=auth.map(l=>{const message=String(l.message);return{time:l.time??null,via:message.match(/via (\S+)/)?.[1]??null,fromTestDebian:/from 161\.104\.46\.222\b/.test(message),forConfiguredUser:message.includes('for user '+row.username+' ')}});const www=Array.isArray(services)?services.filter(s=>s.name==='www'):[];report.diagnostics.push({host:row.host,phase,apiSessionFingerprints:apiSessions,retainedRestSessions:Array.isArray(active)?active.filter(s=>s.via==='rest-api').length:null,userPresent:users.length===1,userDisabled:users[0]?.disabled??null,userAddressRestriction:users[0]?.address??null,hasApiPolicy:group?String(group.policy).split(',').includes('api'):null,hasRestApiPolicy:group?String(group.policy).split(',').includes('rest-api'):null,wwwEnabled:www.some(s=>s.disabled==='false'),wwwMaxSessions:www.find(s=>s['max-sessions'])?.['max-sessions']??null,wwwDynamicRows:www.filter(s=>s.dynamic==='true').length,resource:resource?{version:resource.version,uptime:resource.uptime,cpuLoad:resource['cpu-load'],freeMemory:resource['free-memory']}:null,retainedAuthFailureCount:auth.length,recentFailures:events.slice(-15),retainedLogIsNotCurrentRate:true})}
|
||||||
|
function save(){const samples=report.requests.filter(r=>r.kind==='sample');report.completedAt=new Date().toISOString();report.summary={sampleRequests:samples.length,failedSamples:samples.filter(r=>r.status!==200).length,allRequests:report.requests.length,failures:report.requests.filter(r=>r.status!==200).map(r=>({host:r.host,path:r.path,mode:r.mode,status:r.status,kind:r.kind,startedAt:r.startedAt}))};fs.writeFileSync(output,JSON.stringify(report,null,2)+'\n')}
|
||||||
|
try{for(const row of rows)await diagnostic(row,'before');const started=Date.now(),paths=['/system/resource','/interface/gre','/routing/bgp/session','/routing/ospf/neighbor','/interface/wireguard/peers','/interface/vxlan'];for(let cycle=1;cycle<=cycles&&Date.now()<deadline;cycle++){for(const row of rows)for(const endpoint of paths){const modes=cycle%2?['compiled-client','fetch-close']:['fetch-close','compiled-client'];for(const mode of modes){const previous=report.requests.length;await read(row,endpoint,mode,cycle);if(report.requests[previous].status!==200){await read(row,'/system/resource','fetch-close',cycle,'failure-control');await diagnostic(row,'failure-'+cycle)}}}save();console.log(JSON.stringify({vantage,cycle,samples:report.summary.sampleRequests,failed:report.summary.failedSamples}));if(cycle<cycles)await sleep(Math.max(0,Math.min(deadline,started+cycle*periodMs)-Date.now()))}for(const row of rows)await diagnostic(row,'after')}finally{save();console.log(JSON.stringify({vantage,summary:report.summary,rootCauseKnown:false}))}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
|
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? '').split(',')
|
||||||
|
assert.deepEqual(hosts, ['194.226.121.150', '104.165.244.7'], 'Explicit owned lab hosts required')
|
||||||
|
const { MMAPP_TEST_CHR_USER: username, MMAPP_TEST_CHR_PASSWORD: password } = process.env
|
||||||
|
assert(username && password, 'Explicit test credentials required')
|
||||||
|
const vantage = process.env.MMAPP_TEST_VANTAGE
|
||||||
|
assert(['workstation', 'debian'].includes(vantage), 'Explicit vantage required')
|
||||||
|
const output = process.env.MMAPP_TEST_REPORT
|
||||||
|
assert(output, 'Explicit report path required')
|
||||||
|
const authorization = 'Basic ' + Buffer.from(username + ':' + password).toString('base64')
|
||||||
|
const report = { block: '12.5.225', date: '2026-10-14', vantage, startedAt: new Date().toISOString(), requests: [], diagnostics: [], configurationWrites: 0, passwordChanges: 0, rootCauseKnown: false }
|
||||||
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
async function read(host, path) {
|
||||||
|
const startedAt = new Date().toISOString(), start = Date.now()
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://${host}/rest${path}`, { headers: { authorization, Connection: 'close' }, signal: AbortSignal.timeout(8000) })
|
||||||
|
const body = await response.text()
|
||||||
|
report.requests.push({ host, path, startedAt, durationMs: Date.now() - start, status: response.status, headers: Object.fromEntries(['date', 'server', 'www-authenticate', 'connection'].map(key => [key, response.headers.get(key)])) })
|
||||||
|
if (!response.ok) return null
|
||||||
|
try { return JSON.parse(body) } catch { return null }
|
||||||
|
} catch (error) {
|
||||||
|
report.requests.push({ host, path, startedAt, durationMs: Date.now() - start, status: null, errorName: error.name })
|
||||||
|
return null
|
||||||
|
} finally { await sleep(300) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let cycle = 1; cycle <= 3; cycle++) {
|
||||||
|
for (const host of hosts) for (const path of ['/system/resource', '/interface/gre']) await read(host, path)
|
||||||
|
if (cycle < 3) await sleep(5000)
|
||||||
|
}
|
||||||
|
for (const host of hosts) {
|
||||||
|
const clock = await read(host, '/system/clock')
|
||||||
|
const services = await read(host, '/ip/service')
|
||||||
|
const logs = await read(host, '/log')
|
||||||
|
const failures = Array.isArray(logs) ? logs.filter(row => /login failure/i.test(String(row.message))) : null
|
||||||
|
const sources = {}
|
||||||
|
const events = failures?.map(row => {
|
||||||
|
const message = String(row.message), source = message.match(/from ([\d.]+)/)?.[1] ?? 'unparsed'
|
||||||
|
const origin = source === '161.104.46.222' ? 'test-debian' : 'other-or-unidentified'
|
||||||
|
sources[origin] = (sources[origin] ?? 0) + 1
|
||||||
|
return { time: row.time ?? null, origin, via: message.match(/via (\w+)/)?.[1] ?? null }
|
||||||
|
})
|
||||||
|
report.diagnostics.push({ host, clock: clock ? { date: clock.date, time: clock.time, gmtOffset: clock['gmt-offset'] } : null, wwwServices: Array.isArray(services) ? services.filter(row => row.name === 'www').map(row => ({ disabled: row.disabled, maxSessions: row['max-sessions'] ?? null, dynamic: row.dynamic ?? null })) : null, retainedAuthFailures: failures?.length ?? null, sources, recentFailures: events?.slice(-20) ?? null, retainedLogIsNotCurrentRate: true })
|
||||||
|
}
|
||||||
|
report.completedAt = new Date().toISOString()
|
||||||
|
report.summary = hosts.map(host => { const rows = report.requests.filter(row => row.host === host); return { host, requests: rows.length, ok: rows.filter(row => row.status === 200).length, unauthorized: rows.filter(row => row.status === 401).length, serverErrors: rows.filter(row => row.status >= 500).length, transportErrors: rows.filter(row => row.status === null).length } })
|
||||||
|
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n')
|
||||||
|
console.log(JSON.stringify({ vantage, summary: report.summary, configurationWrites: 0 }))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
const hosts=(process.env.MMAPP_TEST_CHR_HOSTS??'').split(',');assert.deepEqual(hosts,['194.226.121.150','104.165.244.7'],'Explicit owned lab hosts required')
|
||||||
|
const username=process.env.MMAPP_TEST_CHR_USER,password=process.env.MMAPP_TEST_CHR_PASSWORD;assert(username&&password,'Explicit test credentials required')
|
||||||
|
const authorization='Basic '+Buffer.from(username+':'+password).toString('base64'),paths=['/system/resource','/interface/gre','/routing/bgp/session','/routing/ospf/neighbor'],cases=[],start=Date.now(),deadline=start+360000,samples=20,periodMs=10000
|
||||||
|
const sleep=ms=>new Promise(r=>setTimeout(r,ms)),output='docs/ux-ui/acceptance/chr224-rest-observation-2026-10-14.json'
|
||||||
|
function save(completed){const failed=cases.filter(c=>c.status!==200);fs.writeFileSync(output,JSON.stringify({date:'2026-10-14',block:'12.5.224',startedAt:new Date(start).toISOString(),completedAt:new Date().toISOString(),plannedSamples:samples,completedSamples:completed,periodMs,requests:cases.length,failedRequests:failed.length,cases,configurationWrites:0,passwordChanges:0,scope:'Low-rate direct native REST with Connection close; short window, no burst/load/physical WAN/SLA/root-cause proof'},null,2)+'\n')}
|
||||||
|
for(let sample=1;sample<=samples;sample++){
|
||||||
|
if(Date.now()>deadline)break
|
||||||
|
for(const host of hosts)for(const path of paths){const began=Date.now();try{const r=await fetch('http://'+host+'/rest'+path,{headers:{authorization,Connection:'close'},signal:AbortSignal.timeout(8000)});await r.arrayBuffer();cases.push({sample,host,path,status:r.status,durationMs:Date.now()-began})}catch(e){cases.push({sample,host,path,status:null,errorName:e.name,durationMs:Date.now()-began})}await sleep(150)}
|
||||||
|
save(sample);console.log(JSON.stringify({completedSamples:sample,requests:cases.length,failedRequests:cases.filter(c=>c.status!==200).length}));if(sample<samples)await sleep(Math.max(0,start+sample*periodMs-Date.now()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Real Portal login; credentials stay in the caller's ignored private fixture.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { chromium, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const phase = process.argv[2];
|
||||||
|
assert(['guard', 'remove', 'repeat'].includes(phase));
|
||||||
|
const login = JSON.parse(fs.readFileSync(process.env.MMAPP_PORTAL_FIXTURE || 'tmp/portal195-private.json'));
|
||||||
|
const own = JSON.parse(fs.readFileSync(process.env.MMAPP_CONTAINER_FIXTURE || 'tmp/state248-public.json'));
|
||||||
|
const reportPath = 'docs/ux-ui/acceptance/containers248-live-ui.json';
|
||||||
|
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath)) : { date: '2026-10-14', block: '12.5.248', realPortalLogin: true, noMocks: true, steps: [], pageErrors: [] };
|
||||||
|
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
page.on('pageerror', error => report.pageErrors.push(error.message));
|
||||||
|
await page.goto((process.env.MMAPP_TEST_URL || 'http://localhost:3000') + '/containers');
|
||||||
|
await page.getByLabel('Email', { exact: true }).fill(login.email);
|
||||||
|
await page.getByLabel('Пароль', { exact: true }).fill(login.password);
|
||||||
|
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toContainText('Контейнеры', { timeout: 30000 });
|
||||||
|
const endpoint = `/api/servers/${own.serverId}/containers/remove`;
|
||||||
|
const direct = () => page.evaluate(async ({ endpoint, rosId }) => {
|
||||||
|
const r = await fetch(endpoint, { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('mmapp_token'), 'Content-Type': 'application/json' }, body: JSON.stringify({ rosId }) });
|
||||||
|
const body = await r.json();
|
||||||
|
return { status: r.status, ok: body.ok === true, error: body.error };
|
||||||
|
}, { endpoint, rosId: own.rosId });
|
||||||
|
if (phase === 'repeat') {
|
||||||
|
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false); assert.match(result.error, /уже отсутствует/);
|
||||||
|
report.steps.push({ phase, status: result.status, absentConflict: true });
|
||||||
|
} else {
|
||||||
|
await page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Удалить', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('alertdialog');
|
||||||
|
await expect(dialog).toContainText(own.name);
|
||||||
|
if (phase === 'guard') {
|
||||||
|
report.layout = [];
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
await expect(dialog).toContainText('после подтверждённой остановки');
|
||||||
|
await expect(dialog.getByRole('button', { name: 'Удалить', exact: true })).toBeDisabled();
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth > innerWidth), { timeout: 5000 }).toBe(false);
|
||||||
|
const layout = await page.evaluate(() => ({ viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
|
||||||
|
(report.layout ??= []).push(layout);
|
||||||
|
if (layout.scrollWidth > layout.viewport) {
|
||||||
|
report.overflow = await page.evaluate(() => Array.from(document.querySelectorAll('*')).map(el => ({tag:el.tagName,slot:el.getAttribute('data-slot'),left:el.getBoundingClientRect().left,right:el.getBoundingClientRect().right,width:el.getBoundingClientRect().width,position:getComputedStyle(el).position})).filter(el=>el.right>innerWidth+1).slice(0,20));
|
||||||
|
await page.screenshot({ path: `tmp/containers248-guard-${width}.png`, fullPage: true });
|
||||||
|
}
|
||||||
|
assert.equal(layout.scrollWidth > layout.viewport, false);
|
||||||
|
}
|
||||||
|
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false);
|
||||||
|
report.steps.push({ phase, viewports: [1440, 390], uiDeleteDisabled: true, backendConflict: result.status });
|
||||||
|
} else {
|
||||||
|
await expect(dialog).toContainText('остановленный контейнер');
|
||||||
|
const response = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST');
|
||||||
|
await dialog.getByRole('button', { name: 'Удалить', exact: true }).click();
|
||||||
|
const r = await response; assert.equal(r.request().postDataJSON().rosId, own.rosId); assert.equal(r.status(), 200); assert.equal((await r.json()).ok, true);
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true })).toHaveCount(0);
|
||||||
|
report.steps.push({ phase, status: 200, receiptOk: true, targetMatched: true, rowAbsent: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(report.pageErrors, []);
|
||||||
|
delete report.overflow;
|
||||||
|
if (report.failure?.phase === phase) { (report.harnessFailures ??= []).push(report.failure); delete report.failure; }
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
|
||||||
|
console.log('Live container removal: ' + phase + ' passed');
|
||||||
|
} catch (error) {
|
||||||
|
report.failure = { phase, name: error.name, ...(error.name === 'AssertionError' ? { actual: error.actual, expected: error.expected } : {}) }; fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); throw error;
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
})().catch(error => { console.error('Live container removal failed: ' + error.name); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
|
const { pool } = await import('/app/backend/dist/db/index.js');
|
||||||
|
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
|
||||||
|
const rows = (await pool.query("SELECT * FROM servers WHERE host='104.165.244.7' AND enabled=true")).rows; assert.equal(rows.length, 1);
|
||||||
|
const server = rows[0], client = new MikrotikClient({ host: server.host, port: 80, useSsl: false, verifySsl: false, username: server.username, password: server.password });
|
||||||
|
const controlRows = (await pool.query("SELECT * FROM servers WHERE host='194.226.121.150' AND enabled=true")).rows; assert.equal(controlRows.length, 1);
|
||||||
|
const control = new MikrotikClient({ host: controlRows[0].host, port: 80, useSsl: false, verifySsl: false, username: controlRows[0].username, password: controlRows[0].password });
|
||||||
|
const statePath = '/checks/state247-private.json', phase = process.argv[2];
|
||||||
|
const save = state => fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
|
||||||
|
const sha = value => createHash('sha256').update(value).digest('hex');
|
||||||
|
const normalizedExport = value => value.split(/\r?\n/).filter(line => !line.startsWith('#') && line.trim()).join('\n');
|
||||||
|
// RouterOS may list available, uninstalled extra packages after reboot; they have no installed version.
|
||||||
|
const packages = rows => rows.filter(p => typeof p.version === 'string' && p.version.trim()).map(p => ({ name: p.name, version: p.version, disabled: p.disabled ?? 'false' })).sort((a,b) => a.name.localeCompare(b.name));
|
||||||
|
const ownContainers = async state => (await client.get('/container', 5000)).filter(c => c.interface === state.veth && c.comment === state.comment);
|
||||||
|
const nativeStatus = row => row.status ?? (row.running === 'true' && row.stopped !== 'true' ? 'running' : row.stopped === 'true' && row.running !== 'true' ? 'stopped' : 'unknown');
|
||||||
|
async function poll(read, accept, maxMs = 150000) {
|
||||||
|
const until = Date.now() + maxMs; let last;
|
||||||
|
while (Date.now() < until) { try { last = await read(); if (accept(last)) return last; } catch {} await new Promise(r => setTimeout(r, 1500)); }
|
||||||
|
throw Error('Native state not confirmed within polling budget');
|
||||||
|
}
|
||||||
|
async function reboot(state, key) {
|
||||||
|
assert(!state[key], 'Never repeat a possibly applied reboot');
|
||||||
|
state[key] = { requested: true, acknowledgement: 'unknown' }; save(state);
|
||||||
|
try { await client.post('/system/reboot', {}, 5000); state[key].acknowledgement = 'confirmed'; }
|
||||||
|
catch (e) { state[key].errorName = e.name; state[key].status = e.statusCode ?? null; }
|
||||||
|
save(state); console.log(JSON.stringify({ phase, rebootRequestedOnce: true, acknowledgement: state[key].acknowledgement }));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (phase === 'init') {
|
||||||
|
assert(!fs.existsSync(statePath));
|
||||||
|
const uuid = randomUUID().slice(0,8), state = { serverId: Number(server.id), targetHost: server.host, veth: 'mmc247-' + uuid, root: 'mmc247-' + uuid + '-root', comment: 'mmc247-' + uuid, imageFile: 'mmc247-busybox.tar', packageFile: 'container-7.24.5.npk', packageSize: 1163409, imageSize: 2226176, beforePackages: packages(await client.get('/system/package')), beforeFiles: await client.get('/file'), beforeExport: normalizedExport(await client.exportConfigScript()), stages: [] };
|
||||||
|
assert(!state.beforePackages.some(p => p.name === 'container')); assert(state.beforePackages.every(p => p.version === '7.24.5'));
|
||||||
|
assert(!state.beforeFiles.some(f => f.name === state.imageFile || f.name === state.packageFile || f.name.startsWith(state.root)));
|
||||||
|
assert(!(await client.get('/interface')).some(i => i.name === state.veth)); await control.get('/system/resource', 5000);
|
||||||
|
state.beforeExportSha256 = sha(state.beforeExport); save(state); console.log('Private native baseline captured; control CHR reachable');
|
||||||
|
} else {
|
||||||
|
const state = JSON.parse(fs.readFileSync(statePath)); assert(/^mmc247-[a-f0-9]{8}$/.test(state.veth)); assert.equal(state.root, state.veth + '-root');
|
||||||
|
if (phase === 'reboot-install') {
|
||||||
|
const files = await client.get('/file');
|
||||||
|
assert(files.some(f => f.name === state.packageFile && Number(f.size) === state.packageSize));
|
||||||
|
assert(files.some(f => f.name === state.imageFile && Number(f.size) === state.imageSize));
|
||||||
|
await control.get('/system/resource', 5000); await reboot(state, 'installReboot');
|
||||||
|
} else if (phase === 'installed') {
|
||||||
|
const p = await poll(() => client.get('/system/package', 4000), p => p.some(p => p.name === 'container' && p.version === '7.24.5' && p.disabled === 'false'));
|
||||||
|
assert.equal((await client.get('/container')).length, 0); await control.get('/system/resource', 5000);
|
||||||
|
state.afterInstallPackages = packages(p); state.stages.push({ phase, packageInstalled: true, version: '7.24.5', controlReachable: true }); save(state); console.log('Container package installed on7.24.5; native endpoint200/control reachable');
|
||||||
|
} else if (phase === 'create') {
|
||||||
|
assert(state.afterInstallPackages); assert.equal((await ownContainers(state)).length, 0);
|
||||||
|
await client.put('/interface/veth', { name: state.veth, address: '198.18.247.2/30', gateway: '198.18.247.1', comment: state.comment });
|
||||||
|
await client.put('/container', { file: state.imageFile, interface: state.veth, 'root-dir': state.root, cmd: '/bin/sleep 600', 'start-on-boot': 'false', comment: state.comment });
|
||||||
|
const containers = await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === 'stopped'); assert.equal(containers.length, 1);
|
||||||
|
state.containerId = containers[0]['.id']; state.stages.push({ phase, initialStatus: 'stopped' }); save(state);
|
||||||
|
fs.writeFileSync('/checks/state247-public.json', JSON.stringify({ serverId: state.serverId, rosId: state.containerId, interface: state.veth, comment: state.comment, root: state.root })); console.log('Own container extracted/stopped; public identity saved');
|
||||||
|
} else if (phase === 'adopt-created') {
|
||||||
|
const containers = await ownContainers(state); assert.equal(containers.length, 1); assert.equal(nativeStatus(containers[0]), 'stopped');
|
||||||
|
state.containerId = containers[0]['.id']; state.stages.push({ phase: 'create', initialStatus: 'stopped', observedNativeStoppedFlag: containers[0].stopped }); save(state);
|
||||||
|
fs.writeFileSync('/checks/state247-public.json', JSON.stringify({ serverId: state.serverId, rosId: state.containerId, name: containers[0].name, interface: state.veth, comment: state.comment, root: state.root })); console.log('Existing own extracted container adopted read-only');
|
||||||
|
} else if (phase === 'running' || phase === 'stopped') {
|
||||||
|
const containers = await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === phase, 30000);
|
||||||
|
assert.equal(containers[0]['.id'], state.containerId); state.stages.push({ phase, nativeConfirmed: true }); save(state); console.log('Native status confirmed: ' + phase);
|
||||||
|
} else if (phase === 'cleanup-network') {
|
||||||
|
const containers = await ownContainers(state); assert(containers.length <= 1);
|
||||||
|
if (containers.length) { if (nativeStatus(containers[0]) !== 'stopped') { await client.post('/container/stop', { '.id': containers[0]['.id'] }); await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === 'stopped', 30000); } await client.delete('/container/' + encodeURIComponent(containers[0]['.id'])); }
|
||||||
|
const veth = (await client.get('/interface/veth')).filter(i => i.name === state.veth); assert(veth.length <= 1);
|
||||||
|
for (const i of veth) { assert.equal(i.comment, state.comment); await client.delete('/interface/veth/' + encodeURIComponent(i['.id'])); }
|
||||||
|
const files = await client.get('/file');
|
||||||
|
const ownFile = f => { const name = String(f.name).replace(/^\/+/, ''); return name === state.imageFile || name === state.packageFile || name === state.root || name.startsWith(state.root + '/'); };
|
||||||
|
assert(!state.beforeFiles.some(ownFile), 'Never remove an original file');
|
||||||
|
const own = files.filter(ownFile).sort((a,b) => b.name.length - a.name.length);
|
||||||
|
assert(own.every(f => !f.name.split('/').includes('..')));
|
||||||
|
for (const f of own) { const current = (await client.get('/file')).find(x => x.name === f.name); if (current) await client.removeFile(current['.id']); }
|
||||||
|
assert.equal((await ownContainers(state)).length, 0); assert(!(await client.get('/interface/veth')).some(i => i.name === state.veth));
|
||||||
|
assert(!(await client.get('/file')).some(ownFile));
|
||||||
|
state.stages.push({ phase, ownObjectsAbsent: true }); save(state); console.log('Own container/veth/image/root removed');
|
||||||
|
} else if (phase === 'uninstall') {
|
||||||
|
assert(state.stages.some(s => s.phase === 'cleanup-network'));
|
||||||
|
const p = (await client.get('/system/package')).filter(p => p.name === 'container'); assert.equal(p.length, 1); assert.equal(p[0].version, '7.24.5');
|
||||||
|
await client.post('/system/package/uninstall', { '.id': p[0]['.id'] });
|
||||||
|
const scheduled = (await client.get('/system/package')).find(p => p.name === 'container'); assert(['uninstall','scheduled for uninstall'].includes(scheduled.scheduled));
|
||||||
|
await reboot(state, 'uninstallReboot');
|
||||||
|
} else if (phase === 'reboot-uninstall') {
|
||||||
|
const scheduled = (await client.get('/system/package')).find(p => p.name === 'container'); assert(scheduled && ['uninstall','scheduled for uninstall'].includes(scheduled.scheduled));
|
||||||
|
await reboot(state, 'uninstallReboot');
|
||||||
|
} else if (phase === 'final') {
|
||||||
|
const p = await poll(() => client.get('/system/package', 4000), p => !packages(p).some(p => p.name === 'container'));
|
||||||
|
assert.deepEqual(packages(p), state.beforePackages);
|
||||||
|
// Automatic timezone discovery can settle after package removal/reboot.
|
||||||
|
// Wait for equality without excluding settings or logging the private export.
|
||||||
|
await poll(async () => normalizedExport(await client.exportConfigScript()), value => value === state.beforeExport);
|
||||||
|
await control.get('/system/resource', 5000);
|
||||||
|
const report = { date: '2026-10-14', block: '12.5.247', targetHost: server.host, controlHost: '194.226.121.150', stages: state.stages, installReboot: state.installReboot, uninstallReboot: state.uninstallReboot, originalPackagesMatched: true, originalNormalizedExportSha256: state.beforeExportSha256, originalNormalizedExportMatched: true, controlReachable: true, passwordChanges: 0, deviceModeChanges: 0, firmwareVersionChanged: false, passed: true };
|
||||||
|
fs.writeFileSync('/checks/containers247-native.json', JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report));
|
||||||
|
} else throw Error('Unsupported phase');
|
||||||
|
}
|
||||||
|
} finally { await pool.end(); }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
const { pool } = await import('/app/backend/dist/db/index.js');
|
||||||
|
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
|
||||||
|
const { getAcmeSettingsPublic } = await import('/app/backend/dist/services/certificates-service.js');
|
||||||
|
const rows = (await pool.query("SELECT * FROM servers WHERE host IN ('194.226.121.150','104.165.244.7') ORDER BY host")).rows;
|
||||||
|
assert.equal(rows.length, 2); assert(rows.every(s => s.enabled && s.port === 80 && !s.use_ssl));
|
||||||
|
const report = { date: '2026-10-14', block: '12.5.243', scope: 'Read-only availability, not container lifecycle or ACME issuance', hosts: [], requests: [], routerosWrites: 0, databaseWrites: 0, dnsWrites: 0, passwordChanges: 0 };
|
||||||
|
async function read(client, host, endpoint) {
|
||||||
|
try { const result = await client.get(endpoint, 8000); report.requests.push({ host, path: endpoint, status: 200 }); return result; }
|
||||||
|
catch (e) { report.requests.push({ host, path: endpoint, status: e.statusCode ?? null, errorName: e.name, errorBodySha256: e.body ? createHash('sha256').update(e.body).digest('hex') : null }); return undefined; }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const server of rows) {
|
||||||
|
const client = new MikrotikClient({ host: server.host, port: 80, useSsl: false, verifySsl: false, username: server.username, password: server.password });
|
||||||
|
const packages = await read(client, server.host, '/system/package');
|
||||||
|
const resource = await read(client, server.host, '/system/resource');
|
||||||
|
const mode = await read(client, server.host, '/system/device-mode');
|
||||||
|
const disks = await read(client, server.host, '/disk');
|
||||||
|
const containers = await read(client, server.host, '/container');
|
||||||
|
const m = Array.isArray(mode) ? mode[0] : mode;
|
||||||
|
report.hosts.push({ host: server.host, packages: Array.isArray(packages) ? packages.map(p => ({ name: p.name, version: p.version, disabled: p.disabled })) : null, resource: resource ? { version: resource.version, architecture: resource['architecture-name'], boardName: resource['board-name'], freeHddSpace: resource['free-hdd-space'], totalHddSpace: resource['total-hdd-space'], freeMemory: resource['free-memory'] } : null, deviceMode: m ? { mode: m.mode ?? null, container: m.container ?? null, flagged: m.flagged ?? null } : null, disks: Array.isArray(disks) ? disks.length : null, containers: Array.isArray(containers) ? { count: containers.length, statuses: containers.map(c => c.status ?? null) } : null });
|
||||||
|
}
|
||||||
|
const acme = await getAcmeSettingsPublic();
|
||||||
|
report.acme = { tokenConfigured: acme.tokenConfigured, defaultZoneConfigured: Boolean(acme.defaultZoneId), directoryHost: null, directoryStatus: null };
|
||||||
|
const directory = new URL(acme.directoryUrl);
|
||||||
|
assert(!directory.username && !directory.password);
|
||||||
|
report.acme.directoryHost = directory.hostname;
|
||||||
|
if (directory.protocol === 'https:' && ['acme-v02.api.letsencrypt.org','acme-staging-v02.api.letsencrypt.org'].includes(directory.hostname) && directory.pathname === '/directory') {
|
||||||
|
const r = await fetch(directory, { signal: AbortSignal.timeout(8000) }); report.acme.directoryStatus = r.status;
|
||||||
|
if (r.ok) { const data = await r.json(); report.acme.directoryKeys = Object.keys(data).sort(); }
|
||||||
|
}
|
||||||
|
} finally { await pool.end(); fs.writeFileSync('/checks/external-jobs243-availability.json', JSON.stringify(report, null, 2) + '\n'); console.log(JSON.stringify(report)); }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {execFileSync} from 'node:child_process'
|
||||||
|
import pg from 'pg'
|
||||||
|
const database=process.env.MMAPP_OWN_SQL_DB,root='/app/backend/acceptance'
|
||||||
|
assert(/^mm_accept_166_\d+$/.test(database))
|
||||||
|
const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||||
|
const client=new pg.Client({connectionString:url.toString()});await client.connect()
|
||||||
|
assert.equal((await client.query('SELECT current_database() AS name')).rows[0].name,database)
|
||||||
|
const role=(await client.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]
|
||||||
|
assert(Object.values(role).every(value=>value===false));await client.end()
|
||||||
|
const pkg=JSON.parse(fs.readFileSync('/app/backend/package.json'))
|
||||||
|
const profiles=pkg.scripts['test:all'].split(/\s*&&\s*/).map(c=>{const m=/^npm run (test:[\w-]+)$/.exec(c);assert(m);return m[1]})
|
||||||
|
const report={date:'2026-10-14',block:'12.5.222',scope:'full backend profile list on owned isolated PostgreSQL; no production DSN',database,profiles:[],passed:false}
|
||||||
|
for(const profile of profiles){
|
||||||
|
const started=Date.now();let output='',passed=true,errorName
|
||||||
|
try{output=execFileSync('npm',['--prefix','backend','run',profile],{cwd:'/app',timeout:240_000,maxBuffer:16*1024**2,encoding:'utf8',env:{...process.env,NODE_ENV:'test',GEOIP_DIR:'/tmp/'+database+'/geoip',DATABASE_PATH:'/tmp/'+database+'/legacy.db'},stdio:['ignore','pipe','pipe']})}
|
||||||
|
catch(e){passed=false;errorName=e.name;output=String(e.stdout??'')+'\n'+String(e.stderr??'')}
|
||||||
|
fs.writeFileSync(root+'/'+profile.replace(':','-')+'-private.log',output,{mode:0o600})
|
||||||
|
const skippedLines=output.split('\n').filter(s=>/\bskip(?:ped)?\b/i.test(s));const unavailableSql=skippedLines.filter(s=>/PostgreSQL|owned|own SQL|explicit owned|database|MMAPP_OWN/i.test(s)&&!s.includes('skip/release/recovery passed'));
|
||||||
|
report.profiles.push({profile,passed,durationMs:Date.now()-started,errorName,skippedLineCount:skippedLines.length,unavailableSqlBranches:unavailableSql.map(line=>line.trim())})
|
||||||
|
fs.writeFileSync(root+'/owned222-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report.profiles.at(-1)))
|
||||||
|
}
|
||||||
|
report.passed=report.profiles.every(p=>p.passed);fs.writeFileSync(root+'/owned222-result.json',JSON.stringify(report,null,2))
|
||||||
|
if(!report.passed)process.exitCode=1
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
const database = process.env.MMAPP_OWN_SQL_DB;
|
||||||
|
assert(/^mm_accept_166_\d+$/.test(database));
|
||||||
|
const url = new URL(process.env.DATABASE_URL); assert.equal(url.pathname, '/' + database); assert.equal(url.username, database);
|
||||||
|
assert.equal(process.env.GEOIP_DIR, '/tmp/' + database + '/geoip');
|
||||||
|
fs.mkdirSync('/tmp/' + database, { recursive: true }); process.chdir('/tmp/' + database);
|
||||||
|
const { pool, closePool } = await import('/app/backend/dist/db/index.js');
|
||||||
|
const { initDatabase } = await import('/app/backend/dist/db/bootstrap.js');
|
||||||
|
const role = (await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];
|
||||||
|
assert(Object.values(role).every(v => v === false)); await initDatabase();
|
||||||
|
const { collectGeoipUpdateOnce, setGeoipFetchForTests, resetGeoipUpdateForTests } = await import('/app/backend/dist/services/geoip-update-collector.js');
|
||||||
|
const { getGeoipSettings, updateGeoipSettings } = await import('/app/backend/dist/services/geoip-settings.js');
|
||||||
|
const { geoipReadersStatus, lookupGeoip, GEOIP_COUNTRY_FILE, GEOIP_ASN_FILE } = await import('/app/backend/dist/services/traffic-flow-geoip.js');
|
||||||
|
const { databaseBackgroundWork } = await import('/app/backend/dist/services/database-background-work.js');
|
||||||
|
const report = { date: '2026-10-14', block: '12.5.242', passed: false, scope: 'Compiled GeoIP collector + owned PostgreSQL/files + actual public mirror; controlled negative bodies separately marked', mirrorRequests: [], checks: [], chrContacts: 0, mainDatabaseWrites: 0 };
|
||||||
|
const file = name => path.join(process.env.GEOIP_DIR, name);
|
||||||
|
const sha = name => createHash('sha256').update(fs.readFileSync(file(name))).digest('hex');
|
||||||
|
const save = () => fs.writeFileSync('/checks/geoip-mirror242.json', JSON.stringify(report, null, 2) + '\n');
|
||||||
|
const check = (name, detail = {}) => { report.checks.push({ name, ...detail }); save(); };
|
||||||
|
const realFetch = globalThis.fetch.bind(globalThis);
|
||||||
|
async function observedFetch(url, options) {
|
||||||
|
const started = Date.now(), response = await realFetch(url, options);
|
||||||
|
report.mirrorRequests.push({ file: new URL(String(url)).pathname.split('/').at(-1), conditional: Boolean(options?.headers?.['If-None-Match']), status: response.status, headersMs: Date.now() - started }); save(); return response;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateGeoipSettings({ enabled: false }); setGeoipFetchForTests(observedFetch);
|
||||||
|
let snapshot = await collectGeoipUpdateOnce({ force: true });
|
||||||
|
assert(!snapshot.fatalError); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 2);
|
||||||
|
assert(geoipReadersStatus().countryLoaded && geoipReadersStatus().asnLoaded);
|
||||||
|
const initial = { country: sha(GEOIP_COUNTRY_FILE), asn: sha(GEOIP_ASN_FILE) };
|
||||||
|
const lookup = lookupGeoip('8.8.8.8'); assert.equal(lookup?.country, 'US'); assert.equal(lookup?.asn, 15169);
|
||||||
|
check('actual-two-downloads-valid-mmdb-and-lookup', { bytes: snapshot.bytes, hashes: initial, settings: await getGeoipSettings(), lookup: { country: lookup.country, asn: lookup.asn } });
|
||||||
|
const first = collectGeoipUpdateOnce({ force: true });
|
||||||
|
await assert.rejects(collectGeoipUpdateOnce({ force: true }), e => e.statusCode === 409);
|
||||||
|
assert.equal((await collectGeoipUpdateOnce()).skipped, true);
|
||||||
|
snapshot = await first; assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.skippedUnchanged, 2); assert.equal(snapshot.downloaded, 0);
|
||||||
|
check('actual-conditional304-and-concurrent409', { skippedUnchanged: 2, hashesUnchanged: sha(GEOIP_COUNTRY_FILE) === initial.country && sha(GEOIP_ASN_FILE) === initial.asn });
|
||||||
|
fs.unlinkSync(file(GEOIP_COUNTRY_FILE));
|
||||||
|
const beforeMissing = report.mirrorRequests.length; snapshot = await collectGeoipUpdateOnce({ force: true });
|
||||||
|
assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1); assert.equal(snapshot.skippedUnchanged, 1);
|
||||||
|
assert(report.mirrorRequests.slice(beforeMissing).some(r => r.file === GEOIP_COUNTRY_FILE && !r.conditional && r.status === 200));
|
||||||
|
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country);
|
||||||
|
check('missing-country-refetched-without-conditional-header');
|
||||||
|
fs.writeFileSync(file(GEOIP_COUNTRY_FILE), 'Own corrupt GeoIP fixture');
|
||||||
|
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1); assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country);
|
||||||
|
check('corrupt-local-country-refetched-and-validated');
|
||||||
|
const lastSuccess = (await getGeoipSettings()).lastSuccessAt;
|
||||||
|
setGeoipFetchForTests(async (url, options) => String(url).endsWith(GEOIP_COUNTRY_FILE) ? new Response('Own invalid MMDB body', { status: 200 }) : observedFetch(url, options));
|
||||||
|
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.equal(snapshot.errors.length, 1); assert.equal(snapshot.downloaded, 0); assert.equal(snapshot.skippedUnchanged, 1);
|
||||||
|
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country); assert.equal((await getGeoipSettings()).lastSuccessAt, lastSuccess);
|
||||||
|
assert((await getGeoipSettings()).lastError); assert(!fs.existsSync(file(GEOIP_COUNTRY_FILE) + '.tmp'));
|
||||||
|
check('controlled-invalid-body-preserves-file-and-last-success', { negativeCountryResponse: 'controlled-200-invalid-MMDB', realAsnStatus: 304 });
|
||||||
|
fs.unlinkSync(file(GEOIP_COUNTRY_FILE));
|
||||||
|
setGeoipFetchForTests(async (url, options) => String(url).endsWith(GEOIP_COUNTRY_FILE) ? new Response(null, { status: 304 }) : observedFetch(url, options));
|
||||||
|
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.equal(snapshot.errors.length, 1); assert.equal(snapshot.skippedUnchanged, 1); assert(snapshot.errors[0].includes('304 без проверенной локальной базы'));
|
||||||
|
assert.equal((await getGeoipSettings()).lastSuccessAt, lastSuccess);
|
||||||
|
check('controlled-uncached304-is-error-with-last-success-retained');
|
||||||
|
setGeoipFetchForTests(observedFetch); snapshot = await collectGeoipUpdateOnce({ force: true }); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1);
|
||||||
|
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country); assert.equal(sha(GEOIP_ASN_FILE), initial.asn);
|
||||||
|
const beforeSkip = report.mirrorRequests.length; assert.equal((await collectGeoipUpdateOnce()).skipped, true); assert.equal(report.mirrorRequests.length, beforeSkip);
|
||||||
|
check('actual-recovery-and-disabled-background-no-fetch'); report.passed = true;
|
||||||
|
} catch (e) { report.failure = { name: e.name }; process.exitCode = 1; }
|
||||||
|
finally { resetGeoipUpdateForTests(); await databaseBackgroundWork.drain(); await closePool(); save(); console.log(JSON.stringify(report)); }
|
||||||
Loaded 100 of 2438 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user