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 | ||
|
|
3687bb8fa2 | ||
|
|
a80caf5676 | ||
|
|
d39e3454aa | ||
|
|
a8f2055c77 | ||
|
|
cdeb97d841 | ||
|
|
36c5305db7 | ||
|
|
5f2b4e2d40 | ||
|
|
cff26813b9 | ||
|
|
39c8ec4a02 | ||
|
|
c6c859a495 | ||
|
|
bf5cfff12c | ||
|
|
30a8ec4420 | ||
|
|
ec43591a99 | ||
|
|
0e5fb065e2 | ||
|
|
5188b2aff2 | ||
|
|
cd1fd2c9d3 | ||
|
|
77425cca32 | ||
|
|
29d245cde3 | ||
|
|
7a491a325d | ||
|
|
db64621122 | ||
|
|
6332d83a12 | ||
|
|
3834c40aa8 | ||
|
|
90c8c393e5 | ||
|
|
cb799da13a | ||
|
|
e0ddb17539 |
No files matched your search
@@ -1,17 +1,17 @@
|
||||
---
|
||||
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
|
||||
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 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
|
||||
- **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
|
||||
@@ -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/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/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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
{ "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 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
|
||||
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
**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.
|
||||
|
||||
- **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)`.
|
||||
- **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).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## 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)).
|
||||
|
||||
## 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:
|
||||
|
||||
- `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"`, ...).
|
||||
- `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
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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?ref=skill (upgrade). Free accounts still get all components + examples.
|
||||
- **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.
|
||||
|
||||
@@ -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).
|
||||
- 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) {
|
||||
const parts = parseSemver(version)
|
||||
if (bump === "major") {
|
||||
parts.major += 1
|
||||
parts.minor = 0
|
||||
parts.patch = 0
|
||||
return formatSemver(parts)
|
||||
}
|
||||
if (bump === "minor") {
|
||||
parts.minor += 1
|
||||
parts.patch = 0
|
||||
@@ -66,8 +72,8 @@ function bumpVersion(version, bump) {
|
||||
function parseCommit(subject, body = "") {
|
||||
const match = CONVENTIONAL_RE.exec(subject.trim())
|
||||
const breaking =
|
||||
/BREAKING CHANGE/i.test(body) ||
|
||||
(match?.[1] && subject.includes(`${match[1]}!:`))
|
||||
/BREAKING[ -]CHANGE:/i.test(body) ||
|
||||
(match !== null && /^[^:]+!:\s/.test(subject.trim()))
|
||||
|
||||
if (!match) {
|
||||
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
||||
@@ -78,7 +84,9 @@ function parseCommit(subject, body = "") {
|
||||
const description = match[3].trim()
|
||||
let bump = "patch"
|
||||
|
||||
if (breaking || MINOR_TYPES.has(type)) {
|
||||
if (breaking) {
|
||||
bump = "major"
|
||||
} else if (MINOR_TYPES.has(type)) {
|
||||
bump = "minor"
|
||||
} else if (PATCH_TYPES.has(type)) {
|
||||
bump = "patch"
|
||||
@@ -251,7 +259,9 @@ function main() {
|
||||
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 tag = `v${version}`
|
||||
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", "release@example.invalid")
|
||||
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
|
||||
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
|
||||
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 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
|
||||
- **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
|
||||
@@ -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/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/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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
{ "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 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
|
||||
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
**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.
|
||||
|
||||
- **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)`.
|
||||
- **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).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## 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)).
|
||||
|
||||
## 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:
|
||||
|
||||
- `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"`, ...).
|
||||
- `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
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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?ref=skill (upgrade). Free accounts still get all components + examples.
|
||||
- **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.
|
||||
|
||||
@@ -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).
|
||||
- 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`)
|
||||
|
||||
`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
|
||||
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
|
||||
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 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
|
||||
- **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
|
||||
@@ -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/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/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)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
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
|
||||
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 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
|
||||
- **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
|
||||
@@ -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/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/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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
{ "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 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
|
||||
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
**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.
|
||||
|
||||
- **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)`.
|
||||
- **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).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## 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)).
|
||||
|
||||
## 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:
|
||||
|
||||
- `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"`, ...).
|
||||
- `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
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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?ref=skill (upgrade). Free accounts still get all components + examples.
|
||||
- **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.
|
||||
|
||||
@@ -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).
|
||||
- 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
|
||||
+144
-4
@@ -25,6 +25,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Test release version calculation
|
||||
run: node --test .ci/scripts/compute-release.test.mjs
|
||||
|
||||
- name: Compute release metadata
|
||||
id: release
|
||||
shell: bash
|
||||
@@ -38,8 +41,85 @@ jobs:
|
||||
node .ci/scripts/compute-release.mjs
|
||||
test -f .ci/release/release-manifest.json
|
||||
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Start PostgreSQL 18
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
docker rm -f "$NAME" 2>/dev/null || true
|
||||
# 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 \
|
||||
-e POSTGRES_USER=mmapp \
|
||||
-e POSTGRES_PASSWORD=mmapp \
|
||||
-e POSTGRES_DB=mmapp \
|
||||
"${NETWORK_ARGS[@]}" \
|
||||
postgres:18-alpine
|
||||
echo "PG_CONTAINER=$NAME" >> "${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
|
||||
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL не поднялся"
|
||||
docker logs "$NAME" || true
|
||||
exit 1
|
||||
|
||||
- name: Install and test backend
|
||||
shell: bash
|
||||
env:
|
||||
REQUIRE_TEST_POSTGRES: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
- name: Stop PostgreSQL
|
||||
if: always()
|
||||
shell: bash
|
||||
run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
|
||||
|
||||
backend-image:
|
||||
needs: prepare-release
|
||||
needs:
|
||||
- prepare-release
|
||||
- backend-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -75,6 +155,8 @@ jobs:
|
||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
cp backend/package.json backend/tsconfig.json "$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
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
@@ -249,12 +331,69 @@ jobs:
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
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:
|
||||
needs:
|
||||
- prepare-release
|
||||
- backend-image
|
||||
- frontend-image
|
||||
- updater-image
|
||||
- netflow-agent-image
|
||||
if: needs.prepare-release.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -336,17 +475,18 @@ jobs:
|
||||
fi
|
||||
|
||||
notify-webhook:
|
||||
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }}
|
||||
needs:
|
||||
- prepare-release
|
||||
- backend-image
|
||||
- frontend-image
|
||||
- netflow-agent-image
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Notify deploy webhook
|
||||
if: env.WEBHOOK_URL != ''
|
||||
shell: bash
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --silent --show-error --request POST \
|
||||
|
||||
+15
@@ -31,6 +31,10 @@ pnpm-debug.log*
|
||||
|
||||
# coverage
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
@@ -55,3 +59,14 @@ temp/
|
||||
.cursor/mcp.json
|
||||
.mcp.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
|
||||
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
|
||||
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 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
|
||||
- **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
|
||||
@@ -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/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/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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
{ "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 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
|
||||
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
**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.
|
||||
|
||||
- **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)`.
|
||||
- **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).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## 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)).
|
||||
|
||||
## 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:
|
||||
|
||||
- `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"`, ...).
|
||||
- `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
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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?ref=skill (upgrade). Free accounts still get all components + examples.
|
||||
- **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.
|
||||
|
||||
@@ -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).
|
||||
- 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
|
||||
|
||||
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).
|
||||
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||
@@ -19,7 +19,7 @@
|
||||
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|
||||
|-----------|------|------|----------------|--------------|
|
||||
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
|
||||
| Backend | `backend/` | Fastify 5, TypeScript ESM, SQLite, Drizzle | 8000 | `…-backend` |
|
||||
| Backend | `backend/` | Fastify 5, TypeScript ESM, PostgreSQL 18, Drizzle | 8000 | `…-backend` |
|
||||
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
|
||||
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
|
||||
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
|
||||
@@ -43,11 +43,11 @@ flowchart TB
|
||||
Browser["Браузер"]
|
||||
NextDev["Next.js :3000"]
|
||||
FastifyDev["Fastify :8000"]
|
||||
SqliteDev["SQLite файл"]
|
||||
PostgresDev["PostgreSQL 18"]
|
||||
Contracts["@mmapp/contracts"]
|
||||
Browser --> NextDev
|
||||
Browser -->|"fetch /api, CORS"| FastifyDev
|
||||
FastifyDev --> SqliteDev
|
||||
FastifyDev --> PostgresDev
|
||||
Contracts --> NextDev
|
||||
Contracts --> FastifyDev
|
||||
end
|
||||
@@ -60,7 +60,8 @@ flowchart TB
|
||||
subgraph prod [Прод-сервер]
|
||||
FE["mmapp-frontend :3000"]
|
||||
BE["mmapp-backend :8000"]
|
||||
DBVol["volume backend-data /app/data"]
|
||||
PG["mmapp-postgres"]
|
||||
DBVol["volume sqlite ETL /app/data"]
|
||||
UPD["mmapp-updater"]
|
||||
Sock["/var/run/docker.sock"]
|
||||
Reg --> FE
|
||||
@@ -69,6 +70,7 @@ flowchart TB
|
||||
UPD --> Sock
|
||||
UPD --> FE
|
||||
UPD --> BE
|
||||
BE --> PG
|
||||
BE --> DBVol
|
||||
BrowserProd["Клиент"] --> FE
|
||||
BrowserProd -->|"live + backendUrl"| BE
|
||||
@@ -117,7 +119,7 @@ sequenceDiagram
|
||||
```
|
||||
|
||||
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
|
||||
- Параллельные jobs: `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
|
||||
- Параллельные jobs: `backend-test` (PostgreSQL 18), `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
|
||||
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
|
||||
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
|
||||
|
||||
@@ -125,7 +127,8 @@ sequenceDiagram
|
||||
|
||||
| Зависимость | Реализация |
|
||||
|-------------|------------|
|
||||
| SQLite | Не отдельный контейнер. Файл `mikrotik.db` в томе `backend-data` → `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db` в образе backend). |
|
||||
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
|
||||
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При старте, пока нет маркера `data_migration.sqlite_imported_at`, backend импортирует sqlite в PG (`ON CONFLICT` / upsert). После успешного импорта повтор не копирует заново. Том PG18: `mmapp-pgdata:/var/lib/postgresql` (не `.../data`). |
|
||||
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
||||
|
||||
## Локальная разработка
|
||||
@@ -134,7 +137,8 @@ sequenceDiagram
|
||||
|
||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся (нужен только для одноразового boot-ETL из `mikrotik.db`).
|
||||
- Локально нужен PostgreSQL 18 (`deploy/docker-compose.postgres.yml` или `DATABASE_URL`).
|
||||
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||
|
||||
### Запуск
|
||||
@@ -161,7 +165,8 @@ Backend — скопировать `backend/.env.example` в `backend/.env`:
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------|--------------|------------|
|
||||
| `DATABASE_PATH` | `./mikrotik.db` | путь к файлу SQLite |
|
||||
| `DATABASE_URL` | `postgres://mmapp:mmapp@127.0.0.1:5432/mmapp` | подключение к PostgreSQL |
|
||||
| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
|
||||
| `PORT` | `8000` | порт Fastify |
|
||||
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
|
||||
|
||||
@@ -179,14 +184,20 @@ Frontend — в репозитории нет корневого `.env.example`.
|
||||
npm run build -w @mmapp/contracts
|
||||
```
|
||||
|
||||
После изменения схем Drizzle:
|
||||
Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
|
||||
|
||||
```bash
|
||||
npm --prefix backend run db:generate
|
||||
npm --prefix backend run db:migrate
|
||||
npm --prefix backend run db:studio
|
||||
npm --prefix backend run db:migrate-from-sqlite
|
||||
```
|
||||
|
||||
### GeoIP-базы GeoLite2 (страны и ASN для NetFlow)
|
||||
|
||||
Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country + ASN) в `backend/storage/geoip/` и скачивает их с зеркала [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb) — без регистрации и ключей. Lookup страны/ASN потока при ingest становится мгновенным (включая IPv6) и не упирается в лимиты RIPEstat; пока базы не скачаны или lookup промахнулся, работает прежний RIPE-fallback.
|
||||
|
||||
Управление — секция «GeoIP-базы (GeoLite2)» в настройках NetFlow (страница «Сбор данных»): автообновление (по умолчанию проверка раз в 7 дней, upstream обновляется еженедельно), статус сборки баз и кнопка «Обновить сейчас». Джоба планировщика — `geoip_update`. Атрибуция: данные MaxMind GeoLite2, CC BY-SA 4.0.
|
||||
|
||||
Примечание для Docker: каталог `storage/geoip` внутри контейнера ephemeral — без смонтированного volume базы (~17 МБ) перекачаются после пересоздания контейнера. Каталог переопределяется переменной `GEOIP_DIR`.
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
|
||||
@@ -208,9 +219,9 @@ npm --prefix backend run db:studio
|
||||
|
||||
### Версионирование и релизы
|
||||
|
||||
- Базовая версия: **`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**.
|
||||
- **`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>`.
|
||||
- Первый релиз без тега `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>`.
|
||||
@@ -281,7 +292,8 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
|
||||
|
||||
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|
||||
|--------|------------------|----------------|----------------------|------|-----------|
|
||||
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data` → `/app/data` | `unless-stopped` |
|
||||
| postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
|
||||
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
|
||||
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
|
||||
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` |
|
||||
|
||||
@@ -297,11 +309,14 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
|
||||
|
||||
### Переменные окружения (прод)
|
||||
|
||||
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`, `DATABASE_PATH=/app/data/mikrotik.db`; в compose обычно переопределяют только CORS):
|
||||
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`; в compose задают `DATABASE_URL`, `DATABASE_PATH` для ETL и CORS):
|
||||
|
||||
| Переменная | Источник в compose | Назначение |
|
||||
|------------|-------------------|------------|
|
||||
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
|
||||
| `DATABASE_URL` | `postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp` | PostgreSQL |
|
||||
| `DATABASE_PATH` | `/app/data/mikrotik.db` | SQLite на томе для одноразового ETL |
|
||||
| `POSTGRES_PASSWORD` | обязателен | пароль пользователя `mmapp` |
|
||||
|
||||
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
|
||||
|
||||
@@ -312,7 +327,7 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
|
||||
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
|
||||
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
|
||||
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
|
||||
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health |
|
||||
| `HEALTH_TIMEOUT_SECONDS` | `600` | ожидание HTTP health (первый boot с ETL может занять минуты) |
|
||||
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
|
||||
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
|
||||
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
|
||||
@@ -516,18 +531,20 @@ docker compose up -d --force-recreate
|
||||
### Откат
|
||||
|
||||
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
|
||||
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы SQLite** updater не выполняет — нужен отдельный backup тома `backend-data` / файла `mikrotik.db`.
|
||||
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы PostgreSQL** updater не выполняет — нужен `pg_dump` / restore или том `mmapp-pgdata`.
|
||||
|
||||
### Резервное копирование SQLite
|
||||
### Резервное копирование PostgreSQL
|
||||
|
||||
Том `backend-data` (или `mmapp-backend-data` при явном `docker volume create`). Пример остановки backend для консистентной копии:
|
||||
Том `mmapp-pgdata` и (до cleanup) sqlite `mikrotik.db` на `/app/data`. Из UI: Настройки → «Скачать бэкап» (`pg_dump -Fc`). С хоста:
|
||||
|
||||
```bash
|
||||
docker stop mmapp-backend
|
||||
docker run --rm -v backend-data:/data -v $(pwd):/backup alpine tar czf /backup/mikrotik-db-backup.tar.gz -C /data .
|
||||
docker start mmapp-backend
|
||||
docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
||||
```
|
||||
|
||||
Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
|
||||
|
||||
Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
|
||||
|
||||
### Отладка
|
||||
|
||||
| Действие | Команда |
|
||||
@@ -551,3 +568,7 @@ docker start mmapp-backend
|
||||
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
||||
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
||||
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 { asns as mockAsns } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function AsnsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
const useEvoCatalog = mode === "live"
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockAsns
|
||||
if (mode === "mock") return mockAsns
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.asns ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
}, [mode, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
@@ -35,7 +32,7 @@ export default function AsnsPage() {
|
||||
(r) =>
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.org.toLowerCase().includes(q) ||
|
||||
String(r.prefixes).includes(q),
|
||||
(r.prefixes === null ? "" : String(r.prefixes)).includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
@@ -43,8 +40,7 @@ export default function AsnsPage() {
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||
actions={
|
||||
<>
|
||||
actions={mode === "live" ? null : <>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />Импорт
|
||||
</Button>
|
||||
@@ -60,32 +56,21 @@ export default function AsnsPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Автономные системы — источники маршрутов для BGP-фильтров
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<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 className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||
countLabel={`${filtered.length} ASN`}
|
||||
countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
|
||||
/>
|
||||
<AsnsDataGrid
|
||||
{known || loading ? <AsnsDataGrid
|
||||
asns={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+506
-699
File diff suppressed because it is too large.
Load diff
+231
-161
@@ -1,37 +1,41 @@
|
||||
"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 { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
||||
import type { Filter } from "@/components/reui/filters"
|
||||
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||
import { useBgpUrlState } from "@/hooks/use-bgp-url-state"
|
||||
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 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 { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||
RefreshCwIcon, DownloadIcon,
|
||||
ActivityIcon, BarChart3Icon,
|
||||
ArrowDownIcon, ServerIcon,
|
||||
AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type BgpSession = BgpSessionRow
|
||||
type BgpTab = "sessions" | "routers" | "analytics"
|
||||
type StateFilter = "all" | BgpState
|
||||
type TypeFilter = "all" | BgpType
|
||||
|
||||
const AS_NAMES = BGP_AS_NAMES
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
@@ -181,6 +185,7 @@ const SESSIONS: BgpSession[] = [
|
||||
// ─── visual helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
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" },
|
||||
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" },
|
||||
@@ -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" },
|
||||
}
|
||||
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" },
|
||||
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 }) {
|
||||
const s = TYPE_STYLE[type]
|
||||
return (
|
||||
<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")}>
|
||||
{type}
|
||||
{type === "Unknown" ? "Тип неизвестен" : type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function fmtNum(n: number) {
|
||||
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)
|
||||
}
|
||||
const fmtNum = fmtBgpNum
|
||||
|
||||
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
interface BackendBgpSession {
|
||||
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
|
||||
name: string; peerIp: string; remoteAs: number; localAs: number
|
||||
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP"
|
||||
uptime: string | null; holdTime: number; keepalive: number
|
||||
prefixesRx: number; prefixesTx: number
|
||||
inputMessages: number; outputMessages: number
|
||||
capabilities: string[]; lastError: string | null
|
||||
type BackendServer = ReturnType<typeof bgpServersSchema.parse>[number]
|
||||
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||||
function mapBackendServer(s: BackendServer): InventoryServer {
|
||||
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 ?? "offline", monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0 }
|
||||
}
|
||||
|
||||
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||
@@ -244,15 +233,15 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||
localAs: b.localAs,
|
||||
routerId: b.remoteId || b.localId,
|
||||
description: b.name,
|
||||
state: (b.state as BgpState) || "Idle",
|
||||
state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown",
|
||||
type: b.type,
|
||||
afi: "IPv4 Unicast",
|
||||
afi: b.afi ?? "Неизвестно",
|
||||
uptime: b.uptime,
|
||||
holdTime: b.holdTime,
|
||||
keepalive: b.keepalive,
|
||||
prefixesRx: b.prefixesRx,
|
||||
prefixesTx: b.prefixesTx,
|
||||
prefixesActive: b.prefixesRx,
|
||||
prefixesActive: null,
|
||||
inputMessages: b.inputMessages,
|
||||
outputMessages: b.outputMessages,
|
||||
capabilities: b.capabilities,
|
||||
@@ -268,14 +257,12 @@ const BGP_FILTER_ACCESSORS = {
|
||||
afi: (s: BgpSession) => s.afi,
|
||||
}
|
||||
|
||||
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||
|
||||
function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
|
||||
const { search, setSearch, stateFilter, setStateFilter, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset } = useBgpUrlState()
|
||||
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 q = search.toLowerCase()
|
||||
const filtered = useMemo(() => {
|
||||
if (urlError) return []
|
||||
const base = sessions.filter((s) => {
|
||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||
@@ -283,46 +270,48 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
q &&
|
||||
!s.peerIp.includes(q) &&
|
||||
!s.description.toLowerCase().includes(q) &&
|
||||
!s.serverLabel.includes(q) &&
|
||||
!s.serverLabel.toLowerCase().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 true
|
||||
})
|
||||
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS)
|
||||
}, [sessions, q, stateFilter, typeFilter, advancedFilters])
|
||||
return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS)
|
||||
}, [sessions, q, stateFilter, typeFilter, advancedFilters, urlError])
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={{
|
||||
value: stateFilter,
|
||||
onChange: setStateFilter,
|
||||
options: [
|
||||
{ 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: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").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 },
|
||||
],
|
||||
}}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={setAdvancedFilters}
|
||||
filterFields={BGP_FILTER_FIELDS}
|
||||
filterControl={<QueryFilters fields={fields} query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="IP, AS, описание…"
|
||||
countLabel={`${filtered.length} из ${sessions.length}`}
|
||||
actions={
|
||||
<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
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(t)}
|
||||
aria-pressed={typeFilter === t}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
||||
typeFilter === t
|
||||
@@ -330,13 +319,15 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t === "all" ? "Все типы" : t}
|
||||
{t === "all" ? "Все типы" : t === "Unknown" ? "Неизвестно" : t}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
@@ -358,8 +349,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{byRouter.map(router => {
|
||||
const estCnt = router.sessions.filter(s => s.state === "Established").length
|
||||
const downCnt = router.sessions.length - estCnt
|
||||
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||
const downCnt = router.sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
|
||||
const totalRx = sumBgpCounters(router.sessions.map(s => s.prefixesRx))
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 className="flex items-center gap-2 shrink-0">
|
||||
{estCnt > 0 && (
|
||||
@@ -400,19 +391,19 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
<TypeBadge type={s.type} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span>
|
||||
{AS_NAMES[s.remoteAs] && (
|
||||
<span className="text-[10px] text-muted-foreground">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
|
||||
{s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
|
||||
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
↓ {fmtNum(s.prefixesRx)}
|
||||
</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>
|
||||
)
|
||||
@@ -420,7 +411,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
</div>
|
||||
|
||||
{/* footer summary */}
|
||||
{totalRx > 0 && (
|
||||
{totalRx !== null && totalRx > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
@@ -441,9 +432,9 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
// top peers by prefixes received (eBGP only)
|
||||
const topPeers = useMemo(() =>
|
||||
[...sessions]
|
||||
.filter(s => s.prefixesRx > 0)
|
||||
.sort((a, b) => b.prefixesRx - a.prefixesRx)
|
||||
(sessions.some(s => s.prefixesRx === null) ? [] : [...sessions])
|
||||
.filter(s => s.prefixesRx !== null && s.prefixesRx > 0)
|
||||
.sort((a, b) => (b.prefixesRx ?? 0) - (a.prefixesRx ?? 0))
|
||||
.slice(0, 8),
|
||||
[sessions]
|
||||
)
|
||||
@@ -457,8 +448,8 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
}, [sessions])
|
||||
|
||||
// total prefix stats
|
||||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0)
|
||||
const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
|
||||
const totalActive = sumBgpCounters(sessions.map(s => s.prefixesActive))
|
||||
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
|
||||
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
|
||||
|
||||
@@ -469,29 +460,30 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
items={[
|
||||
{
|
||||
id: "rx",
|
||||
label: "Всего префиксов",
|
||||
label: "Префиксы",
|
||||
value: fmtNum(totalRx),
|
||||
icon: <DownloadIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активных маршрутов",
|
||||
label: "Маршруты",
|
||||
hint: totalActive === null ? "нет данных API" : undefined,
|
||||
value: fmtNum(totalActive),
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "ebgp",
|
||||
label: "eBGP сессий",
|
||||
value: ebgpSessions,
|
||||
label: "eBGP",
|
||||
value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "ibgp",
|
||||
label: "iBGP сессий",
|
||||
value: ibgpSessions,
|
||||
label: "iBGP",
|
||||
value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
@@ -503,7 +495,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
{topPeers.map((s, i) => {
|
||||
const pct = (s.prefixesRx / maxRx) * 100
|
||||
const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
|
||||
const ss = STATE_STYLE[s.state]
|
||||
return (
|
||||
<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 gap-2 min-w-0">
|
||||
<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} />
|
||||
</div>
|
||||
<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 && (
|
||||
<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>
|
||||
</OpsPanel>
|
||||
@@ -552,7 +544,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
return (
|
||||
<div key={state} className="flex items-center gap-2">
|
||||
<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={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||||
</div>
|
||||
@@ -566,7 +558,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
|
||||
{/* eBGP vs iBGP */}
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
@@ -593,7 +585,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.entries(
|
||||
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]) => (
|
||||
<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() {
|
||||
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 isLive = mode === "live"
|
||||
|
||||
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 [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)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveSessions([])
|
||||
setLiveServers([])
|
||||
setLiveError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
|
||||
.then(data => {
|
||||
void Promise.all([
|
||||
requestJson<unknown>(backendUrl, "/api/bgp/catalog"),
|
||||
requestJson<unknown>(backendUrl, "/api/servers"),
|
||||
])
|
||||
.then(([data, servers]) => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
setFetchedAt(new Date())
|
||||
const catalog = bgpCatalogSchema.parse(data)
|
||||
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)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -656,52 +697,89 @@ export default function BgpPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, fetchTick])
|
||||
|
||||
// Use live or mock data for all tabs and KPI
|
||||
const sessions = isLive ? liveSessions : SESSIONS
|
||||
const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
|
||||
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 notEstab = sessions.length - established
|
||||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||
const serverCount = useMemo(
|
||||
() => new Set(liveSessions.map(s => s.serverId)).size,
|
||||
[liveSessions],
|
||||
)
|
||||
const notEstab = sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
|
||||
const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* tab bar */}
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{TABS.map(t => (
|
||||
<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"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||
)}>
|
||||
{t.icon}{t.label}
|
||||
</button>
|
||||
))}
|
||||
<ServerRailLayout
|
||||
preserveSelection
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={onSelectServer}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex min-w-0 items-center overflow-x-auto px-4 sm:px-6">
|
||||
{TABS.map(t => (
|
||||
<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 className="flex-1 overflow-y-auto p-6">
|
||||
}
|
||||
>
|
||||
<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 */}
|
||||
{isLive && loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
@@ -709,31 +787,23 @@ export default function BgpPage() {
|
||||
Загрузка BGP-сессий…
|
||||
</div>
|
||||
)}
|
||||
{isLive && !loading && fetchedAt && !liveError && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")}
|
||||
</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>
|
||||
{isLive && fetchedAt && (
|
||||
<div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
|
||||
Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
|
||||
Это время получения браузером, не время измерения RouterOS.
|
||||
{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>)}
|
||||
</div>
|
||||
)}
|
||||
{isLive && liveError && (
|
||||
<Alert variant="warning" className="py-2">
|
||||
{isLive && (liveError || failures.length > 0) && (
|
||||
<Alert variant="warning">
|
||||
<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>
|
||||
)}
|
||||
{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" && (
|
||||
<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={[
|
||||
{
|
||||
id: "sessions",
|
||||
label: "Сессий всего",
|
||||
value: sessions.length,
|
||||
label: "Сессий",
|
||||
value: incomplete ? "—" : sessions.length,
|
||||
hint: incomplete ? "неизвестно" : undefined,
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "established",
|
||||
label: "Established",
|
||||
value: established,
|
||||
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "not-estab",
|
||||
label: "Не установлено",
|
||||
value: notEstab,
|
||||
label: "Не поднято",
|
||||
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: notEstab > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "prefixes",
|
||||
label: "Получено префиксов",
|
||||
value: fmtNum(totalRx),
|
||||
label: "Префиксы",
|
||||
value: incomplete ? "—" : fmtNum(totalRx),
|
||||
icon: <DownloadIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
@@ -776,25 +847,24 @@ export default function BgpPage() {
|
||||
/>
|
||||
|
||||
{/* alert: not-established sessions */}
|
||||
{notEstab > 0 && (
|
||||
{!incomplete && notEstab > 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>
|
||||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
||||
</AlertTitle>
|
||||
<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>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* tab content */}
|
||||
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
|
||||
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
|
||||
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
||||
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
|
||||
{activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
|
||||
{activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
+160
-342
@@ -1,16 +1,19 @@
|
||||
"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 { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||
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 { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
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 { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
@@ -27,17 +30,11 @@ import {
|
||||
} from "@/components/ui/sheet"
|
||||
import { cn } from "@/lib/utils"
|
||||
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 {
|
||||
createCertificateIssueJob,
|
||||
getAcmeSettings,
|
||||
getCertificateIssueJob,
|
||||
listCertificates,
|
||||
putAcmeSettings,
|
||||
refreshCertificates,
|
||||
testAcmeSettings,
|
||||
} from "@/shared/api/certificates"
|
||||
import { useCertificateIssue } from "@/lib/use-certificate-issue"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
@@ -58,7 +55,7 @@ import {
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
} from "@/components/reui-kit/accessible-stepper"
|
||||
|
||||
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||
return {
|
||||
@@ -78,12 +75,12 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||
}
|
||||
}
|
||||
|
||||
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
||||
function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
|
||||
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">
|
||||
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<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-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-600 dark:text-red-400">
|
||||
<p className="font-medium text-destructive">
|
||||
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
@@ -94,12 +91,12 @@ function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) {
|
||||
function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
|
||||
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">
|
||||
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<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-warning shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||
<p className="font-medium text-warning">
|
||||
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
@@ -114,10 +111,14 @@ function CertPartKpi({
|
||||
displayCerts,
|
||||
expiring,
|
||||
expired,
|
||||
complete,
|
||||
totalKnown,
|
||||
}: {
|
||||
displayCerts: CertificateDto[]
|
||||
expiring: CertificateDto[]
|
||||
expired: CertificateDto[]
|
||||
displayCerts: CertificateView[]
|
||||
expiring: CertificateView[]
|
||||
expired: CertificateView[]
|
||||
complete: boolean
|
||||
totalKnown: boolean
|
||||
}) {
|
||||
return (
|
||||
<KpiStatGrid
|
||||
@@ -126,21 +127,21 @@ function CertPartKpi({
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: displayCerts.length,
|
||||
value: totalKnown ? displayCerts.length : "—",
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "valid",
|
||||
label: "Действующих",
|
||||
value: displayCerts.filter((c) => c.status === "valid").length,
|
||||
value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
|
||||
icon: <BadgeCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "expiring",
|
||||
label: "Истекают",
|
||||
value: expiring.length,
|
||||
value: complete ? expiring.length : "—",
|
||||
icon: <AlertTriangleIcon className="size-4" />,
|
||||
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: expiring.length > 0 ? "warning" : "default",
|
||||
@@ -148,7 +149,7 @@ function CertPartKpi({
|
||||
{
|
||||
id: "expired",
|
||||
label: "Истёкших",
|
||||
value: expired.length,
|
||||
value: complete ? expired.length : "—",
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
variant: expired.length > 0 ? "destructive" : "default",
|
||||
@@ -158,92 +159,11 @@ 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() {
|
||||
return (
|
||||
<OpsPanel
|
||||
title="RouterOS 7 · /certificate — справка CLI"
|
||||
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
|
||||
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
|
||||
contentClassName="px-5 py-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
@@ -279,7 +199,7 @@ function CertPartReference() {
|
||||
<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 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")}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -328,6 +248,7 @@ function CertPartIssueForm({
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||
<select
|
||||
aria-label="Сервер выпуска"
|
||||
value={issueServerId}
|
||||
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"
|
||||
@@ -345,6 +266,7 @@ function CertPartIssueForm({
|
||||
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Имя сертификата"
|
||||
value={issueCertName}
|
||||
onChange={(e) => setIssueCertName(e.target.value)}
|
||||
placeholder="router-le"
|
||||
@@ -359,6 +281,7 @@ function CertPartIssueForm({
|
||||
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Основной домен"
|
||||
value={issueCommonName}
|
||||
onChange={(e) => setIssueCommonName(e.target.value)}
|
||||
placeholder="vpn.example.com"
|
||||
@@ -367,6 +290,7 @@ function CertPartIssueForm({
|
||||
<FormField label="SAN" hint="По одному имени в строке">
|
||||
<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"
|
||||
aria-label="Дополнительные домены"
|
||||
value={issueSans}
|
||||
onChange={(e) => setIssueSans(e.target.value)}
|
||||
placeholder="www.example.com"
|
||||
@@ -387,15 +311,16 @@ function CertPartIssueForm({
|
||||
<p className="text-sm font-medium">Trust store · www</p>
|
||||
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
||||
</div>
|
||||
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
|
||||
<FormToggle ariaLabel="Trust store www" checked={issueTrustWww} onChange={setIssueTrustWww} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Trust store · api</p>
|
||||
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
||||
</div>
|
||||
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||||
<FormToggle ariaLabel="Trust store api" checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -407,6 +332,8 @@ function CertPartIssueForm({
|
||||
<li>Имя: {issueCertName || "—"}</li>
|
||||
<li>CN: {issueCommonName || "—"}</li>
|
||||
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
||||
<li>Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.</li>
|
||||
<li>DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -415,21 +342,42 @@ function CertPartIssueForm({
|
||||
}
|
||||
|
||||
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 isLive = prefsHydrated && mode === "live"
|
||||
const liveReady = isLive && backendStatus === true
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
||||
const [certificates, setCertificates] = useState<CertificateDto[]>([])
|
||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [serverList, setServerList] = useState<Server[]>([])
|
||||
const params = useSearchParams()
|
||||
const context = readCertificatesUrl(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 = 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 [issueStep, setIssueStep] = useState(1)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [issueBusy, setIssueBusy] = useState(false)
|
||||
const [issueServerId, setIssueServerId] = useState("")
|
||||
const [issueCertName, setIssueCertName] = useState("")
|
||||
const [issueCommonName, setIssueCommonName] = useState("")
|
||||
@@ -437,28 +385,20 @@ export default function CertificatesPage() {
|
||||
const [issueTrustWww, setIssueTrustWww] = 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(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (isLive) return certificates
|
||||
if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
|
||||
return routerCertificates.map(mockToDto)
|
||||
}, [prefsHydrated, isLive, certificates])
|
||||
}, [prefsHydrated, isLive, snapshots])
|
||||
|
||||
const displayServers = isLive ? serverList : mockServers
|
||||
|
||||
const scopedCerts = useMemo(() => {
|
||||
if (urlError) return []
|
||||
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
||||
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
||||
}, [displayCerts, selectedServerId])
|
||||
}, [displayCerts, selectedServerId, urlError])
|
||||
|
||||
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
displayServers.map((s) => ({
|
||||
@@ -467,12 +407,12 @@ export default function CertificatesPage() {
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
|
||||
type: s.type,
|
||||
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 map = new Map<string, Server>()
|
||||
@@ -480,69 +420,21 @@ export default function CertificatesPage() {
|
||||
return map
|
||||
}, [isLive, serverList])
|
||||
|
||||
const loadLive = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!isLive) return
|
||||
if (!silent) setLoadState("loading")
|
||||
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 loadLive = catalog.reload
|
||||
const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
|
||||
const issueBusy = issue.busy
|
||||
const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
|
||||
|
||||
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],
|
||||
)
|
||||
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(() => {
|
||||
return scopedCerts.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
@@ -558,93 +450,19 @@ export default function CertificatesPage() {
|
||||
}, [scopedCerts, search, statusFilter])
|
||||
|
||||
async function handleRefresh() {
|
||||
if (!liveReady) return
|
||||
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("Таймаут ожидания выпуска сертификата")
|
||||
await loadLive()
|
||||
}
|
||||
|
||||
async function handleIssue() {
|
||||
if (!liveReady) return
|
||||
const domains = [
|
||||
issueCommonName.trim(),
|
||||
...issueSans.split(/[\n,;]+/).map((s) => s.trim()),
|
||||
].filter(Boolean)
|
||||
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)
|
||||
}
|
||||
if (issue.checkpoint) { await issue.run(); return }
|
||||
if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
|
||||
const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
|
||||
if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
|
||||
await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
|
||||
}
|
||||
|
||||
async function handleSaveAcmeSettings() {
|
||||
if (!liveReady) return
|
||||
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 не удалась")
|
||||
}
|
||||
function startNewIssue() {
|
||||
if (!issue.reset()) return
|
||||
setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -655,7 +473,8 @@ export default function CertificatesPage() {
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||||
preserveSelection
|
||||
loading={isLive && loading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
@@ -665,47 +484,59 @@ export default function CertificatesPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!liveReady || loadState === "loading"}
|
||||
disabled={!isLive || loading || (!canRead && !canReadServers)}
|
||||
onClick={() => {
|
||||
void handleRefresh()
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!liveReady || issueBusy}
|
||||
disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
|
||||
onClick={() => {
|
||||
setIssueStep(1)
|
||||
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||
setIssueStep(issue.checkpoint ? 4 : 1)
|
||||
if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||
setIssueOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Выпустить сертификат
|
||||
{issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
|
||||
</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 && (
|
||||
<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-операции отключены.
|
||||
</div>
|
||||
)}
|
||||
{loadError && isLive && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{loadError}
|
||||
<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">
|
||||
{catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
|
||||
</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) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
|
||||
@@ -713,49 +544,38 @@ export default function CertificatesPage() {
|
||||
</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} />
|
||||
|
||||
{liveReady && (
|
||||
<CertPartAcmeSettings
|
||||
acmeDirectoryUrl={acmeDirectoryUrl}
|
||||
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
|
||||
acmeZoneId={acmeZoneId}
|
||||
setAcmeZoneId={setAcmeZoneId}
|
||||
acmeTokenDraft={acmeTokenDraft}
|
||||
setAcmeTokenDraft={setAcmeTokenDraft}
|
||||
acmeTokenConfigured={acmeTokenConfigured}
|
||||
acmeSaveBusy={acmeSaveBusy}
|
||||
onTest={() => {
|
||||
void handleTestAcme()
|
||||
}}
|
||||
onSave={() => {
|
||||
void handleSaveAcmeSettings()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||
|
||||
<CertificateAcmeSettings backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
||||
segmented={{
|
||||
value: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
options: [
|
||||
filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
{ value: "all", label: "Все" },
|
||||
{ value: "valid", label: "Действующие" },
|
||||
{ value: "expired", label: "Истёкшие" },
|
||||
{ value: "revoked", label: "Отозванные" },
|
||||
],
|
||||
}}
|
||||
countLabel={`${filtered.length} сертификатов`}
|
||||
{ value: "unknown", label: "Неизвестные" },
|
||||
]}
|
||||
/></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}
|
||||
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>
|
||||
|
||||
<CertPartReference />
|
||||
@@ -763,7 +583,7 @@ export default function CertificatesPage() {
|
||||
</ServerRailLayout>
|
||||
|
||||
<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">
|
||||
<SheetTitle>Выпуск сертификата</SheetTitle>
|
||||
<SheetDescription>
|
||||
@@ -771,6 +591,11 @@ export default function CertificatesPage() {
|
||||
</SheetDescription>
|
||||
</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">
|
||||
<StepperNav className="mb-5">
|
||||
{[
|
||||
@@ -791,30 +616,33 @@ export default function CertificatesPage() {
|
||||
<StepperPanel className="flex-1 overflow-y-auto">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<StepperContent key={s} value={s}>
|
||||
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
|
||||
<CertPartIssueForm
|
||||
step={s as 1 | 2 | 3 | 4}
|
||||
serverList={displayServers}
|
||||
issueServerId={issueServerId}
|
||||
issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
|
||||
setIssueServerId={setIssueServerId}
|
||||
issueCertName={issueCertName}
|
||||
issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
|
||||
setIssueCertName={setIssueCertName}
|
||||
issueCommonName={issueCommonName}
|
||||
issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
|
||||
setIssueCommonName={setIssueCommonName}
|
||||
issueSans={issueSans}
|
||||
issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
|
||||
setIssueSans={setIssueSans}
|
||||
issueTrustWww={issueTrustWww}
|
||||
issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
|
||||
setIssueTrustWww={setIssueTrustWww}
|
||||
issueTrustApi={issueTrustApi}
|
||||
issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
|
||||
setIssueTrustApi={setIssueTrustApi}
|
||||
/>
|
||||
</fieldset>
|
||||
</StepperContent>
|
||||
))}
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
|
||||
Отмена
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||
{issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>
|
||||
{issue.checkpoint ? "Закрыть" : "Отмена"}
|
||||
</SheetClose>
|
||||
{issueStep > 1 && (
|
||||
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
||||
@@ -824,7 +652,7 @@ export default function CertificatesPage() {
|
||||
{issueStep < 4 ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||
disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||
onClick={() => setIssueStep((s) => s + 1)}
|
||||
>
|
||||
Далее
|
||||
@@ -832,26 +660,16 @@ export default function CertificatesPage() {
|
||||
) : (
|
||||
<Button
|
||||
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() }}
|
||||
>
|
||||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||||
{issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</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"
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { useEffect, useRef, useState, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import {
|
||||
@@ -8,21 +8,20 @@ import {
|
||||
type CommunityRow,
|
||||
TYPE_LABELS,
|
||||
ACTION_LABELS,
|
||||
ACTION_COLOR,
|
||||
} from "@/components/data-grids/communities-data-grid"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
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"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
@@ -98,24 +97,31 @@ const COMMUNITIES: Community[] = [
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommunitiesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
const { sourceKey } = useEvoBGP()
|
||||
return <CommunitiesContent key={sourceKey} />
|
||||
}
|
||||
function CommunitiesContent() {
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||
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[] => {
|
||||
if (!useEvoCatalog) return COMMUNITIES
|
||||
if (mode === "mock") return COMMUNITIES
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.communities ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
}, [mode, loading, snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null)
|
||||
}, [useEvoCatalog])
|
||||
const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
@@ -126,10 +132,24 @@ export default function CommunitiesPage() {
|
||||
})
|
||||
}, [search, typeFilter, listData])
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
setCopied(value)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
const handleCopy = async (value: string) => {
|
||||
const state = copyRequest.current
|
||||
if (!state.active || state.pending) return
|
||||
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
|
||||
@@ -138,8 +158,7 @@ export default function CommunitiesPage() {
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
actions={mode === "live" ? null : <Button size="sm">
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
}
|
||||
@@ -147,19 +166,10 @@ export default function CommunitiesPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs flex items-center gap-1.5 -mt-1",
|
||||
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>
|
||||
)}
|
||||
<EvoBgpCatalogStatus />
|
||||
{copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
|
||||
{copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
|
||||
{copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
|
||||
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка communities"
|
||||
@@ -167,35 +177,35 @@ export default function CommunitiesPage() {
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего communities",
|
||||
value: String(listData.length),
|
||||
value: known ? String(listData.length) : "—",
|
||||
icon: <TagIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
label: "Активных",
|
||||
value: String(listData.filter((c) => c.enabled).length),
|
||||
value: known ? String(listData.filter((c) => c.enabled).length) : "—",
|
||||
icon: <CheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
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" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "filters",
|
||||
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" />,
|
||||
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 ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* toolbar */}
|
||||
@@ -204,7 +214,7 @@ export default function CommunitiesPage() {
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
||||
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
||||
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
||||
@@ -216,13 +226,14 @@ export default function CommunitiesPage() {
|
||||
|
||||
{/* list */}
|
||||
<DataPageCard>
|
||||
<CommunitiesDataGrid
|
||||
{known ? <CommunitiesDataGrid
|
||||
communities={filtered}
|
||||
selectedId={selected?.id}
|
||||
copiedValue={copied}
|
||||
onSelect={setSelected}
|
||||
copying={copying}
|
||||
onSelect={row => setSelectedId(row.id)}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
|
||||
@@ -234,8 +245,8 @@ export default function CommunitiesPage() {
|
||||
description={selected.name}
|
||||
headerRight={
|
||||
<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" 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"><PencilIcon 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>
|
||||
}
|
||||
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
||||
@@ -256,7 +267,7 @@ export default function CommunitiesPage() {
|
||||
{[
|
||||
["Тип", TYPE_LABELS[selected.type]],
|
||||
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
||||
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")],
|
||||
["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
|
||||
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
||||
].map(([k, v]) => (
|
||||
<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">
|
||||
<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" />}
|
||||
Скопировать значение
|
||||
</Button>
|
||||
|
||||
+369
-182
@@ -1,15 +1,20 @@
|
||||
"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 { routerContainers, servers } from "@/lib/data"
|
||||
import type { RouterContainer } from "@/lib/data"
|
||||
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||
import type { RouterContainer, Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
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 { toast } from "sonner"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
@@ -18,124 +23,110 @@ import {
|
||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||
TerminalIcon, AlertCircleIcon,
|
||||
TerminalIcon, AlertCircleIcon, RefreshCwIcon,
|
||||
} 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 { 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) {
|
||||
return servers.find((s) => s.id === id)
|
||||
interface BackendServer {
|
||||
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 {
|
||||
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: {
|
||||
dot: "bg-emerald-500 animate-pulse",
|
||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
label: "Running",
|
||||
dot: "bg-success",
|
||||
badge: "bg-success/10 text-success border-success/20",
|
||||
label: "Работает",
|
||||
},
|
||||
stopped: {
|
||||
dot: "bg-muted-foreground",
|
||||
badge: "bg-muted/50 text-muted-foreground border-border",
|
||||
label: "Stopped",
|
||||
label: "Остановлен",
|
||||
},
|
||||
error: {
|
||||
dot: "bg-red-500 animate-pulse",
|
||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
label: "Error",
|
||||
dot: "bg-destructive",
|
||||
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
label: "Ошибка",
|
||||
},
|
||||
}[status]
|
||||
}
|
||||
|
||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateContainerRsc(c: RouterContainer): string {
|
||||
const srv = serverFor(c.serverId)
|
||||
const lines: string[] = []
|
||||
lines.push(`# RouterOS Container — ${c.name}`)
|
||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||||
lines.push(`# Образ: ${c.image}:${c.tag}`)
|
||||
lines.push(`# RouterOS 7.4+ · /container`)
|
||||
lines.push(``)
|
||||
|
||||
// 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")
|
||||
type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
|
||||
function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
|
||||
const formats = useMemo(() => [{
|
||||
id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
|
||||
code: selection ? JSON.stringify(selection, null, 2) : "",
|
||||
}], [selection])
|
||||
return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
|
||||
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
|
||||
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>}
|
||||
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
|
||||
formats={formats} />
|
||||
}
|
||||
|
||||
// ─── 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({
|
||||
container,
|
||||
server,
|
||||
live,
|
||||
busy,
|
||||
onExport,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
onRemove,
|
||||
}: {
|
||||
container: RouterContainer
|
||||
container: ContainerView
|
||||
server?: ContainerServer
|
||||
live: boolean
|
||||
busy: boolean
|
||||
onExport: () => void
|
||||
onStart: () => void
|
||||
onStop: () => void
|
||||
onRestart: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const srv = serverFor(container.serverId)
|
||||
const cfg = statusConfig(container.status)
|
||||
const canMutate = live && Boolean(container.rosId) && !busy
|
||||
|
||||
return (
|
||||
<Frame dense className="w-full overflow-hidden">
|
||||
@@ -150,51 +141,56 @@ function ContainerCard({
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
|
||||
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
{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><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
|
||||
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
||||
<Trash2Icon className="size-4" />Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-3">
|
||||
{/* image */}
|
||||
<div className="flex items-center gap-2">
|
||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs text-foreground/80">
|
||||
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
|
||||
<BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
|
||||
{container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* server */}
|
||||
{srv && (
|
||||
{server && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ServerIcon className="size-3.5 shrink-0" />
|
||||
<Flag code={srv.country} size={12} />
|
||||
<span className="font-mono">{srv.name}</span>
|
||||
<ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||
<Flag code={server.country} size={12} />
|
||||
<span className="font-mono truncate" title={server.name}>{server.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* uptime + stats */}
|
||||
{container.status === "running" && (
|
||||
{container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||||
{container.uptime && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -202,7 +198,7 @@ function ContainerCard({
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU </span>
|
||||
<span className={cn("font-mono font-medium",
|
||||
container.cpu > 50 ? "text-amber-500" : "text-foreground")}>
|
||||
container.cpu > 50 ? "text-warning" : "text-foreground")}>
|
||||
{container.cpu}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -210,13 +206,12 @@ function ContainerCard({
|
||||
{container.memMb !== undefined && (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* interfaces */}
|
||||
{container.interfaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{container.interfaces.map((i) => (
|
||||
@@ -227,7 +222,6 @@ function ContainerCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* mounts */}
|
||||
{container.mounts.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{container.mounts.map((m, idx) => (
|
||||
@@ -241,7 +235,7 @@ function ContainerCard({
|
||||
)}
|
||||
|
||||
{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>
|
||||
</FramePanel>
|
||||
@@ -249,42 +243,225 @@ function ContainerCard({
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function ContainersPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
||||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
||||
export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
|
||||
function ContainersSource() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
|
||||
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(() => {
|
||||
return routerContainers.filter((c) => {
|
||||
return scoped.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
c.name.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 stopped = routerContainers.filter((c) => c.status === "stopped").length
|
||||
const errors = routerContainers.filter((c) => c.status === "error").length
|
||||
const running = scoped.filter((c) => c.status === "running").length
|
||||
const stopped = scoped.filter((c) => c.status === "stopped").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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<BoxIcon className="size-4" />Новый контейнер
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
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">
|
||||
<div className="flex flex-col gap-5">
|
||||
{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>}
|
||||
{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
|
||||
aria-label="Сводка контейнеров"
|
||||
@@ -292,97 +469,100 @@ export default function ContainersPage() {
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: routerContainers.length,
|
||||
icon: <BoxIcon className="size-4" />,
|
||||
value: known ? scoped.length : "—",
|
||||
icon: <BoxIcon aria-hidden="true" className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "running",
|
||||
label: "Running",
|
||||
value: running,
|
||||
icon: <PlayIcon className="size-4" />,
|
||||
label: "Работает",
|
||||
value: statusKnown ? running : "—",
|
||||
icon: <PlayIcon aria-hidden="true" className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "stopped",
|
||||
label: "Stopped",
|
||||
value: stopped,
|
||||
icon: <StopCircleIcon className="size-4" />,
|
||||
label: "Остановлен",
|
||||
value: statusKnown ? stopped : "—",
|
||||
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "errors",
|
||||
label: "Ошибок",
|
||||
value: errors,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
value: statusKnown ? errors : "—",
|
||||
icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
|
||||
iconClassName: "text-destructive",
|
||||
variant: errors > 0 ? "destructive" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info banner */}
|
||||
<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 className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
||||
<div className="flex items-start gap-3 rounded-lg bg-info/5 border border-info/20 px-4 py-3 text-sm">
|
||||
<BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
|
||||
<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">
|
||||
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
||||
Интерфейсы veth создаются автоматически.
|
||||
Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<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]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<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 aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, образу, серверу…"
|
||||
className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{(["all", "running", "stopped", "error"] as const).map((s) => (
|
||||
<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", "unknown"] as const).map((s) => (
|
||||
<button key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||||
statusFilter === s
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
|
||||
{s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Grid */}
|
||||
{filtered.length === 0 ? (
|
||||
{filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
|
||||
<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-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
|
||||
<p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
|
||||
</div>
|
||||
) : (
|
||||
)) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((c) => (
|
||||
<ContainerCard
|
||||
key={c.id}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* RouterOS reference */}
|
||||
<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">
|
||||
{[
|
||||
@@ -433,7 +613,7 @@ export default function ContainersPage() {
|
||||
].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">
|
||||
<pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -442,13 +622,20 @@ export default function ContainersPage() {
|
||||
</OpsPanel>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<ExportSheet
|
||||
open={!!exportContainer}
|
||||
container={exportContainer}
|
||||
onClose={() => setExportContainer(null)}
|
||||
/>
|
||||
</div>
|
||||
{deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
|
||||
{operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
|
||||
<AlertDialogFooter>
|
||||
{(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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+268
-851
File diff suppressed because it is too large.
Load diff
+348
-435
File diff suppressed because it is too large.
Load diff
+13
-28
@@ -8,25 +8,22 @@ import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { domains as mockDomains } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
const useEvoCatalog = mode === "live"
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockDomains
|
||||
if (mode === "mock") return mockDomains
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.domains ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
}, [mode, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
@@ -43,8 +40,7 @@ export default function DomainsPage() {
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||
actions={
|
||||
<>
|
||||
actions={mode === "live" ? null : <>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />Импорт
|
||||
</Button>
|
||||
@@ -58,34 +54,23 @@ export default function DomainsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
|
||||
Домены из каталога для BGP-фильтрации
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<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 className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
countLabel={`${filtered.length} доменов`}
|
||||
countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
|
||||
/>
|
||||
<DomainsDataGrid
|
||||
{known || loading ? <DomainsDataGrid
|
||||
domains={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</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 { ipRanges as mockIpRanges } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function IpRangesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
const useEvoCatalog = mode === "live"
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockIpRanges
|
||||
if (mode === "mock") return mockIpRanges
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.ipRanges ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
}, [mode, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
@@ -44,8 +41,7 @@ export default function IpRangesPage() {
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||
actions={
|
||||
<>
|
||||
actions={mode === "live" ? null : <>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />Импорт
|
||||
</Button>
|
||||
@@ -59,34 +55,23 @@ export default function IpRangesPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
|
||||
CIDR-блоки из каталога для сетевых фильтров
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<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 className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
countLabel={`${filtered.length} диапазонов`}
|
||||
countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
|
||||
/>
|
||||
<IpRangesDataGrid
|
||||
{known || loading ? <IpRangesDataGrid
|
||||
ipRanges={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,7 +22,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { LogsWorkspace } from "@/components/logs/logs-workspace"
|
||||
export default function LogsPage() { return <LogsWorkspace /> }
|
||||
+1658
-470
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"
|
||||
|
||||
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 { FormToggle } from "@/components/form-kit"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
@@ -11,21 +15,21 @@ import {
|
||||
type SchedRule,
|
||||
type SchedType,
|
||||
} from "@/components/data-grids/probes-schedule-data-grid"
|
||||
import {
|
||||
ProbesSpeedProbesDataGrid,
|
||||
type SpeedProbeApiRow,
|
||||
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
||||
import {ProbesSpeedSchedule} from "@/components/probes-speed-schedule"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
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 {
|
||||
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
||||
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
||||
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
|
||||
LoaderCircleIcon, AlertCircleIcon,
|
||||
ZapIcon, CheckIcon, TerminalIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
@@ -33,7 +37,7 @@ import { requestJson } from "@/shared/api/http-client"
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
|
||||
|
||||
@@ -66,12 +70,12 @@ const TOOL_META: Record<DiagTool, {
|
||||
color: string
|
||||
description: string
|
||||
}> = {
|
||||
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" },
|
||||
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" },
|
||||
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" },
|
||||
dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" },
|
||||
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" },
|
||||
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" },
|
||||
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-info", description: "Проверка связи и RTT" },
|
||||
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-info", description: "Трассировка маршрута" },
|
||||
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-success", description: "Пропускная способность" },
|
||||
dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-warning", description: "DNS на хосте приложения" },
|
||||
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-info", description: "Поиск активного маршрута" },
|
||||
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-warning", description: "Определение MTU пути" },
|
||||
}
|
||||
|
||||
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"] {
|
||||
const l = line.toLowerCase()
|
||||
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 (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
|
||||
return "normal"
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -321,7 +319,7 @@ function buildCommand(
|
||||
case "bandwidth":
|
||||
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
|
||||
case "dns":
|
||||
return `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1`
|
||||
return "DNS приложения: " + target + " · " + (opts.dnsType ?? "A") + " · резолвер хоста backend"
|
||||
case "route":
|
||||
return `/ip route lookup ip=${target}`
|
||||
case "mtu":
|
||||
@@ -331,11 +329,11 @@ function buildCommand(
|
||||
|
||||
// ─── small UI components ──────────────────────────────────────────────────────
|
||||
|
||||
function NativeSelect({ value, onChange, children, className }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||
function NativeSelect({ value, onChange, children, className, ariaLabel }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<select value={value} onChange={e => onChange(e.target.value)}
|
||||
<select aria-label={ariaLabel} value={value} onChange={e => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"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",
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}) {
|
||||
return (
|
||||
<button onClick={() => onClick(value)}
|
||||
<button type="button" aria-pressed={value === current} onClick={() => onClick(value)}
|
||||
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",
|
||||
)}>
|
||||
{children}
|
||||
@@ -368,107 +367,18 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
|
||||
// ─── terminal output ──────────────────────────────────────────────────────────
|
||||
|
||||
function TerminalOutput({ test }: { test: DiagTest }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const visible = test.lines.slice(0, test.totalLines)
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
|
||||
}, [visible.length])
|
||||
|
||||
return (
|
||||
<div ref={ref}
|
||||
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>
|
||||
)
|
||||
const ref=useRef<HTMLDivElement>(null),follow=useRef(true)
|
||||
const visible=test.lines.slice(0,test.totalLines)
|
||||
useEffect(()=>{if(ref.current&&follow.current)ref.current.scrollTop=ref.current.scrollHeight},[visible.length])
|
||||
return <div ref={ref} role="region" aria-label={"Вывод "+TOOL_META[test.tool].label+" · "+test.srcServerName} tabIndex={0}
|
||||
onScroll={()=>{const el=ref.current;if(el)follow.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}}
|
||||
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>
|
||||
{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>
|
||||
}
|
||||
|
||||
// ─── 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_RULES: SchedRule[] = [
|
||||
@@ -492,21 +402,12 @@ function ScheduleTab({
|
||||
}) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
|
||||
const [addTun, setAddTun] = useState("")
|
||||
const [selectedTunnel, setAddTun] = useState("")
|
||||
const [addType, setAddType] = useState<SchedType>("ping")
|
||||
const [addMin, setAddMin] = useState(10)
|
||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||
|
||||
useEffect(() => {
|
||||
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 addTun = addTunnels.some(t=>t.id===selectedTunnel) ? selectedTunnel : addTunnels[0]?.id ?? ""
|
||||
|
||||
const tunnelName = (srcId: string, tunnelId: string) =>
|
||||
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
|
||||
@@ -528,21 +429,21 @@ function ScheduleTab({
|
||||
<Frame dense className="w-full 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">
|
||||
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
|
||||
<PlusIcon aria-hidden="true" className="size-4 text-muted-foreground" />Новое правило
|
||||
</div>
|
||||
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
|
||||
<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>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<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>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<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="bandwidth">BW-тест</option>
|
||||
<option value="both">Ping + BW</option>
|
||||
@@ -566,7 +467,7 @@ function ScheduleTab({
|
||||
</Frame>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
||||
<PlusIcon className="size-4" />Добавить правило
|
||||
<PlusIcon aria-hidden="true" className="size-4" />Добавить правило
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -576,16 +477,40 @@ function ScheduleTab({
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 canRead = can("mm:servers:read")
|
||||
const canRun = can("mm:servers:write")
|
||||
const canReadGre = can("mm:filters:read")
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
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 [tool, setTool] = useState<DiagTool>("ping")
|
||||
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
|
||||
const [target, setTarget] = useState("8.8.8.8")
|
||||
const [pingCount, setPingCount] = useState(5)
|
||||
const [pingSize, setPingSize] = useState(64)
|
||||
@@ -594,44 +519,40 @@ export default function ProbesPage() {
|
||||
const [traceUseDns, setTraceUseDns] = useState(false)
|
||||
const [traceHops] = useState(30)
|
||||
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 [bwDuration, setBwDuration] = useState(10)
|
||||
const [runBusy, setRunBusy] = useState(false)
|
||||
/** В 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(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => setLiveLoad("idle"))
|
||||
return
|
||||
}
|
||||
setLiveLoad("loading")
|
||||
void apiFetch<BackendServerRow[]>("/api/servers")
|
||||
.then(rows => {
|
||||
const mapped: Server[] = rows.map(s => ({
|
||||
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,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}))
|
||||
setLiveServers(mapped)
|
||||
if (!isLive || !canRead) return
|
||||
let cancelled = false
|
||||
const ctrl = new AbortController()
|
||||
queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
|
||||
void apiFetch<unknown>("/api/servers", { signal: ctrl.signal })
|
||||
.then(raw => {
|
||||
const result = probeServersSchema.safeParse(raw)
|
||||
if (!result.success) throw new Error("Некорректный ответ API: каталог устройств не подтверждён.")
|
||||
if (cancelled) return
|
||||
setLiveServers(result.data.map(s => ({
|
||||
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,
|
||||
enabled: s.enabled, status: s.status ?? "unknown",
|
||||
latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0,
|
||||
})))
|
||||
setCatalogReadAt(Date.now())
|
||||
setLiveLoad("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setLiveServers([])
|
||||
.catch(error => {
|
||||
if (cancelled) return
|
||||
setCatalogError(error instanceof Error ? error.message : "Не удалось прочитать устройства.")
|
||||
setLiveLoad("error")
|
||||
})
|
||||
}, [isLive, apiFetch])
|
||||
return () => { cancelled = true; ctrl.abort() }
|
||||
}, [isLive, canRead, apiFetch, catalogRevision])
|
||||
|
||||
const allServers = useMemo(() => {
|
||||
if (!isLive) return servers
|
||||
@@ -652,72 +573,71 @@ export default function ProbesPage() {
|
||||
}))
|
||||
), [allServers])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
setRosSrcV4(undefined)
|
||||
return
|
||||
}
|
||||
const n = Number.parseInt(srcId, 10)
|
||||
if (!Number.isFinite(n)) {
|
||||
setRosSrcV4(undefined)
|
||||
return
|
||||
}
|
||||
if (!isLive || !canReadGre || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) return
|
||||
let cancelled = false
|
||||
setRosSrcV4(undefined)
|
||||
void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`)
|
||||
.then((r) => {
|
||||
if (!cancelled) setRosSrcV4(r.ipv4)
|
||||
const ctrl = new AbortController()
|
||||
queueMicrotask(() => { if (!cancelled) setGreState({ id: srcId, state: "loading" }) })
|
||||
void apiFetch<unknown>("/api/filters/gre-tunnels?serverId=" + encodeURIComponent(srcId), { signal: ctrl.signal })
|
||||
.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(() => {
|
||||
if (!cancelled) setRosSrcV4(null)
|
||||
.catch(error => {
|
||||
if (cancelled) return
|
||||
setGreState({ id: srcId, state: "error", error: error instanceof Error ? error.message : "Не удалось прочитать GRE." })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, srcId, apiFetch])
|
||||
|
||||
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])
|
||||
return () => { cancelled = true; ctrl.abort() }
|
||||
}, [isLive, canReadGre, liveLoad, allServers, srcId, apiFetch, greRevision])
|
||||
|
||||
const tunnelsForSrc = useMemo(() => {
|
||||
if (isLive) return greByServer[srcId] ?? []
|
||||
return greTunnels.filter(t => t.serverId === 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 ──
|
||||
const [tests, setTests] = useState<DiagTest[]>([])
|
||||
const [tab, setTab] = useState<"history" | "schedule">("history")
|
||||
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
|
||||
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 */
|
||||
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 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 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])
|
||||
|
||||
// current command preview
|
||||
@@ -726,37 +646,21 @@ export default function ProbesPage() {
|
||||
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
|
||||
}, 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 ──
|
||||
const runTest = useCallback(async () => {
|
||||
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 numericServerId = Number.parseInt(srcId, 10)
|
||||
|
||||
if (isLive) {
|
||||
if (!Number.isFinite(numericServerId)) return
|
||||
runLock.current = true
|
||||
setRunBusy(true)
|
||||
const abortCtrl = new AbortController()
|
||||
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => { timedOut = true; abortCtrl.abort() }, 60_000)
|
||||
setTests(p => [{
|
||||
id,
|
||||
tool,
|
||||
@@ -766,7 +670,7 @@ export default function ProbesPage() {
|
||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||
command: cmdPreview,
|
||||
startedAt: Date.now(),
|
||||
lines: [{ text: "Выполняется запрос к MikroTik через API…", kind: "dim" }],
|
||||
lines: [{ text: tool === "dns" ? "Выполняется DNS-запрос приложения…" : "Выполняется запрос к MikroTik через API…", kind: "dim" }],
|
||||
totalLines: 1,
|
||||
source: "live",
|
||||
}, ...p.slice(0, 9)])
|
||||
@@ -793,32 +697,34 @@ export default function ProbesPage() {
|
||||
bwProto,
|
||||
bwDuration,
|
||||
}
|
||||
const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, {
|
||||
const res = await apiFetch<unknown>(`/api/servers/${numericServerId}/probes/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
signal: abortCtrl.signal,
|
||||
})
|
||||
const lines = parseProbeOutput(res.output ?? "")
|
||||
const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:")
|
||||
if (!alive.current || abortCtrl.signal.aborted) return
|
||||
const output = readProbeOutput(res)
|
||||
const lines = parseProbeOutput(output || "Запрос выполнен: API вернул пустой вывод.")
|
||||
setTests(p => p.map(t => (t.id === id
|
||||
? {
|
||||
...t,
|
||||
lines,
|
||||
totalLines: lines.length,
|
||||
status: looksErr ? "error" : "done",
|
||||
status: "done",
|
||||
}
|
||||
: t)))
|
||||
} catch (e) {
|
||||
const aborted =
|
||||
if (!alive.current) return
|
||||
const aborted = abortCtrl.signal.aborted ||
|
||||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|
||||
|| (e instanceof Error && e.name === "AbortError")
|
||||
if (aborted) {
|
||||
setTests(p => p.map(t => (t.id === id
|
||||
? {
|
||||
...t,
|
||||
lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }],
|
||||
lines: [{ text: timedOut ? "Время ожидания истекло. Результат на устройстве не подтверждён; повтор выполняется вручную." : "Ожидание отменено. Остановка на устройстве не подтверждена.", kind: timedOut ? "err" : "dim" }],
|
||||
totalLines: 1,
|
||||
status: "done",
|
||||
status: timedOut ? "error" : "cancelled",
|
||||
}
|
||||
: t)))
|
||||
} else {
|
||||
@@ -833,8 +739,13 @@ export default function ProbesPage() {
|
||||
: t)))
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
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
|
||||
}
|
||||
@@ -867,6 +778,10 @@ export default function ProbesPage() {
|
||||
setTab("history")
|
||||
}, [
|
||||
isLive,
|
||||
liveLoad,
|
||||
canRun,
|
||||
canReadGre,
|
||||
greReady,
|
||||
allServers,
|
||||
srcId,
|
||||
tool,
|
||||
@@ -879,7 +794,6 @@ export default function ProbesPage() {
|
||||
traceHops,
|
||||
dnsType,
|
||||
bwTun,
|
||||
bwTunId,
|
||||
bwProto,
|
||||
bwDuration,
|
||||
cmdPreview,
|
||||
@@ -887,21 +801,27 @@ export default function ProbesPage() {
|
||||
])
|
||||
|
||||
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"
|
||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||
? { ...t, status: "cancelled" as const, totalLines: t.lines.length }
|
||||
: 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")
|
||||
|
||||
if (isLive && liveLoad === "loading") {
|
||||
if (!canRead) return <p role="status">Для просмотра устройств требуется mm:servers:read.</p>
|
||||
|
||||
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -912,7 +832,8 @@ export default function ProbesPage() {
|
||||
<ServerRailLayout
|
||||
items={probeRailItems}
|
||||
selectedId={srcId}
|
||||
onSelect={setSrcId}
|
||||
onSelect={selectServer}
|
||||
preserveSelection
|
||||
showAll={false}
|
||||
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
||||
header={
|
||||
@@ -921,14 +842,12 @@ export default function ProbesPage() {
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить устройства диагностики" disabled={liveLoad === "loading"} onClick={() => setCatalogRevision(n => n + 1)}>Обновить</Button>}
|
||||
{running.length > 0
|
||||
? <Button variant="outline" size="sm" onClick={() => {
|
||||
liveProbeRunRef.current?.ctrl.abort()
|
||||
setTests(p => p.map(t => (t.status === "running"
|
||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||
: t)))
|
||||
running.forEach(test => stopTest(test.id))
|
||||
}}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
<SquareIcon aria-hidden="true" className="size-4" />Остановить все
|
||||
</Button>
|
||||
: null}
|
||||
</>
|
||||
@@ -938,35 +857,61 @@ export default function ProbesPage() {
|
||||
>
|
||||
<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" && (
|
||||
<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">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
Бекенд недоступен — переключитесь в режим «Демо» в настройках источника данных или проверьте URL API.
|
||||
<div role="alert" aria-label="Каталог диагностики" className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||
<p>{catalogReadAt === null ? "Устройства пока неизвестны." : "Не удалось обновить устройства. Последний список сохранён; запуск приостановлен."}</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{isLive && (
|
||||
<p 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.
|
||||
</p>
|
||||
{isLive && srcServer?.enabled && sourceRequired && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{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>
|
||||
: !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 ── */}
|
||||
<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 */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
|
||||
const active = tool === t
|
||||
return (
|
||||
<button key={t} onClick={() => setTool(t)}
|
||||
<button key={t} type="button" aria-pressed={active} title={m.description} onClick={() => setTool(t)}
|
||||
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
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "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}
|
||||
</button>
|
||||
)
|
||||
@@ -979,11 +924,12 @@ export default function ProbesPage() {
|
||||
{/* target — all tools except bandwidth */}
|
||||
{tool !== "bandwidth" && (
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
<OptionLabel>
|
||||
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"}
|
||||
<OptionLabel htmlFor="probe-target">
|
||||
{tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель (IP или домен)"}
|
||||
</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"}
|
||||
aria-label={tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель диагностики"}
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={e => e.key === "Enter" && runTest()}
|
||||
/>
|
||||
@@ -994,7 +940,8 @@ export default function ProbesPage() {
|
||||
{tool === "bandwidth" && (
|
||||
<div>
|
||||
<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>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
@@ -1004,14 +951,14 @@ export default function ProbesPage() {
|
||||
{tool === "ping" && (
|
||||
<>
|
||||
<div>
|
||||
<OptionLabel>Кол-во</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
<OptionLabel>Количество пакетов</OptionLabel>
|
||||
<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>)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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>)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1033,7 +980,7 @@ export default function ProbesPage() {
|
||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||
</p>
|
||||
</div>
|
||||
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
<FormToggle ariaLabel="Разрешать имена хопов на устройстве" checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1042,7 +989,7 @@ export default function ProbesPage() {
|
||||
<div>
|
||||
<OptionLabel>Тип записи</OptionLabel>
|
||||
<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>
|
||||
)}
|
||||
@@ -1064,25 +1011,25 @@ export default function ProbesPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
<Button ref={runButtonRef} type="button" aria-busy={runBusy}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<TerminalIcon className="size-3 text-zinc-500 shrink-0" />
|
||||
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span>
|
||||
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span>
|
||||
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span>
|
||||
<button onClick={() => navigator.clipboard.writeText(cmdPreview)}
|
||||
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto">
|
||||
<CopyIcon className="size-3" />
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-muted/30 px-3 py-2 border">
|
||||
<TerminalIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||
<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="hidden md:inline text-success text-xs font-mono shrink-0">$</span>
|
||||
<span className="min-w-0 flex-1 text-foreground text-xs font-mono [overflow-wrap:anywhere]">{cmdPreview}</span>
|
||||
<button type="button" aria-label="Скопировать параметры диагностики" onClick={() => void copyProbeText(cmdPreview)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors ml-auto">
|
||||
<CopyIcon aria-hidden="true" className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
@@ -1094,7 +1041,7 @@ export default function ProbesPage() {
|
||||
["history", "История тестов"],
|
||||
["schedule", "Расписание"],
|
||||
] 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(
|
||||
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
|
||||
tab === t
|
||||
@@ -1116,7 +1063,7 @@ export default function ProbesPage() {
|
||||
{tab === "history" && (
|
||||
tests.length === 0 ? (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1124,52 +1071,46 @@ export default function ProbesPage() {
|
||||
{tests.map(test => {
|
||||
const { Icon, color, label } = TOOL_META[test.tool]
|
||||
return (
|
||||
<Frame key={test.id} dense className="w-full overflow-hidden">
|
||||
<FramePanel className="p-0 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 flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className={cn("size-4 shrink-0", color)} />
|
||||
<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 flex-wrap items-center gap-2 min-w-0 w-full md:w-auto">
|
||||
<Icon aria-hidden="true" className={cn("size-4 shrink-0", color)} />
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span>
|
||||
<span className="text-[10px] text-muted-foreground">← {test.srcServerName}</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="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 className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
{test.status === "running" && (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
запущен
|
||||
<span role="status" className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full bg-info motion-safe:animate-pulse" />
|
||||
ожидаем ответ
|
||||
</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)}>
|
||||
<SquareIcon className="size-3" />Стоп
|
||||
<SquareIcon aria-hidden="true" className="size-3" />Стоп
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{test.status === "done" && test.source !== "live" && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{((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 === "done" && <span role="status" className="text-xs text-muted-foreground">{test.source === "live" ? "ответ API" : "демо"}</span>}
|
||||
{test.status === "cancelled" && <span role="status" className="text-[11px] text-muted-foreground">ожидание отменено</span>}
|
||||
{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"))}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
|
||||
<CopyIcon className="size-3.5" />
|
||||
<button type="button" aria-label={"Скопировать показанный вывод · " + label + " · " + test.id} onClick={() => void copyProbeText(test.lines.map(l => l.text).join("\n"))}
|
||||
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 aria-hidden="true" className="size-3.5" />
|
||||
</button>
|
||||
<button 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">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
<button type="button" aria-label={"Удалить результат · " + label + " · " + test.id} onClick={() => clearTest(test.id)}
|
||||
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 aria-hidden="true" className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* terminal output */}
|
||||
<div className="p-3">
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
<TerminalOutput test={test} />
|
||||
</div>
|
||||
</FramePanel>
|
||||
@@ -1183,9 +1124,10 @@ export default function ProbesPage() {
|
||||
{/* schedule */}
|
||||
{tab === "schedule" && (
|
||||
isLive
|
||||
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} />
|
||||
? <ProbesSpeedSchedule base={backendUrl} serversForName={allServers} />
|
||||
: (
|
||||
<ScheduleTab
|
||||
key={srcId}
|
||||
rules={rules}
|
||||
setRules={setRules}
|
||||
serverOptions={allServers}
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
"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 {
|
||||
RecursiveRoutesDataGrid,
|
||||
type RecursiveRouteGroup,
|
||||
inferCountry,
|
||||
} 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 { DataPageCard } from "@/components/data-page-card"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Flag } from "@/components/flag"
|
||||
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 { 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 { 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"
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type: "jump-host" | "exit-node" | "home-router"
|
||||
type?: "jump-host" | "exit-node" | "home-router"
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
status: "online" | "offline" | "degraded" | null
|
||||
latency: number | null
|
||||
}
|
||||
|
||||
interface GatewayOption {
|
||||
id: string
|
||||
name: string
|
||||
ip: string
|
||||
status: "up" | "down"
|
||||
}
|
||||
|
||||
const COUNTRY_OPTIONS = [
|
||||
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||
@@ -58,21 +59,7 @@ const COUNTRY_OPTIONS = [
|
||||
{ code: "NO", label: "Норвегия" },
|
||||
]
|
||||
|
||||
interface RecursiveRouteRow {
|
||||
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 {}
|
||||
type RouteGroup = RecursiveRouteGroup
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
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 {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
@@ -92,7 +79,7 @@ function mapBackendServer(s: BackendServer): Server {
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
status: s.status,
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}
|
||||
@@ -107,6 +94,7 @@ interface RouteForm {
|
||||
|
||||
interface RouteEndpointForm {
|
||||
id: string
|
||||
disabled: boolean
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
@@ -117,6 +105,7 @@ interface RouteEndpointForm {
|
||||
|
||||
const newEndpoint = (): RouteEndpointForm => ({
|
||||
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
disabled: false,
|
||||
gateway: "",
|
||||
distance: 1,
|
||||
scope: null,
|
||||
@@ -144,7 +133,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
||||
<label className="text-sm font-medium">Страна</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input placeholder="Поиск или код (RU, DE…)" value={query}
|
||||
<Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
|
||||
onChange={e => {
|
||||
const v = e.target.value.toUpperCase().slice(0, 3)
|
||||
setQuery(v)
|
||||
@@ -167,7 +156,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
||||
</span>
|
||||
)}
|
||||
</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 => (
|
||||
<button key={c.code} type="button"
|
||||
onClick={() => { onChange(c.code); setQuery("") }}
|
||||
@@ -188,133 +177,129 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
||||
}
|
||||
|
||||
function RouteSheet({
|
||||
open, mode, initial, onSave, onClose, gateways,
|
||||
open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
|
||||
}: {
|
||||
open: boolean
|
||||
mode: "create" | "edit"
|
||||
initial: RouteForm
|
||||
onSave: (v: RouteForm) => void
|
||||
onSave: (v: RouteForm) => Promise<boolean>
|
||||
onClose: () => void
|
||||
gateways: GatewayOption[]
|
||||
busy: boolean
|
||||
saveError: string | null
|
||||
returnFocus: () => HTMLElement | null
|
||||
}) {
|
||||
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)
|
||||
|
||||
// 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 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) }))
|
||||
|
||||
function handleSave() {
|
||||
const saveLock = useRef(false)
|
||||
async function handleSave() {
|
||||
if (busy || saveLock.current) return
|
||||
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); 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)
|
||||
onSave(form)
|
||||
saveLock.current = true
|
||||
try { await onSave(form) } finally { saveLock.current = false }
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
||||
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
|
||||
<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">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
||||
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
||||
</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>
|
||||
</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">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">
|
||||
{form.endpoints.map((ep, idx) => (
|
||||
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||||
{form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
|
||||
<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">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Endpoint {idx + 1}
|
||||
Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
||||
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
||||
{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>
|
||||
<RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
|
||||
<PlusIcon className="size-3.5" />Добавить endpoint
|
||||
<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 aria-hidden="true" className="size-3.5" />Добавить endpoint
|
||||
</Button>
|
||||
{gateways.length === 0 && (
|
||||
<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">
|
||||
<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="Комментарий">
|
||||
<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>
|
||||
</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>}
|
||||
</div>
|
||||
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
|
||||
{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">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
|
||||
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</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>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -344,37 +331,81 @@ function RouteSheet({
|
||||
|
||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
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",
|
||||
)}>
|
||||
<Badge size="xs" className="shrink-0 text-foreground"
|
||||
variant={type === "home-router" ? "success-light" : type === "jump-host" ? "primary-light" : "info-light"}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
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 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 apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||
const [servers, setServers] = useState<Array<Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] }>>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
|
||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
||||
const search = context.q
|
||||
const setSearch = (q: string) => patchUrl({ q }, true)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
||||
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
||||
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 [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 */
|
||||
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
||||
const [inventoryTick, setInventoryTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
@@ -385,126 +416,175 @@ export default function RecursiveRoutesPage() {
|
||||
setRows([])
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLiveServerListReady(false)
|
||||
apiFetch<BackendServer[]>("/api/servers")
|
||||
let active = true
|
||||
apiFetch<unknown>("/api/servers")
|
||||
.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)
|
||||
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([])
|
||||
setSelectedServerId("")
|
||||
})
|
||||
.finally(() => {
|
||||
setLiveServerListReady(true)
|
||||
if (active) setLiveServerListReady(true)
|
||||
})
|
||||
}, [isLive, apiFetch])
|
||||
return () => { active = false }
|
||||
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
|
||||
|
||||
const loadRoutes = useCallback(async () => {
|
||||
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("load")
|
||||
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId) || writeLock.current) return
|
||||
const epoch = ++readEpoch.current
|
||||
setOpError(null); setBusy("load"); setLiveStale(true)
|
||||
try {
|
||||
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`)
|
||||
setRows(res.routes)
|
||||
} catch (e) {
|
||||
setRows([])
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
|
||||
const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||
if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
|
||||
const res = parsed.data
|
||||
if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
|
||||
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 {
|
||||
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 () => {
|
||||
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
|
||||
const epoch = ++gatewayEpoch.current
|
||||
try {
|
||||
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
|
||||
setGatewayOptions(res.gateways)
|
||||
} catch {
|
||||
setGatewayOptions([])
|
||||
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||
if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
|
||||
const res = parsed.data
|
||||
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(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadRoutes()
|
||||
}, [loadRoutes])
|
||||
void loadRoutes(); void loadGateways()
|
||||
const reads = readEpoch, gateways = gatewayEpoch
|
||||
return () => { reads.current++; gateways.current++ }
|
||||
}, [loadRoutes, loadGateways, readVersion])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadGateways()
|
||||
}, [loadGateways])
|
||||
|
||||
const saveToDb = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("save")
|
||||
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
||||
if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || sharedBusy || writeLock.current || !can("mm:network:write")) return false
|
||||
if (!beginWrite()) return false
|
||||
writeLock.current = true; readEpoch.current++
|
||||
setOpError(null); setSaveError(null); setBusy("apply")
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ serverId: selectedServerId, routes: rows }),
|
||||
})
|
||||
await loadRoutes()
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
|
||||
const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
|
||||
const parsed = recursiveReceiptSchema.safeParse(raw)
|
||||
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId || parsed.data.pushedRoutes !== next.length) throw new Error("Сервер не подтвердил применение маршрутов")
|
||||
if (!alive.current) return false
|
||||
setRows(parsed.data.routes); setLiveStale(false); setWriteAt(new Date().toISOString())
|
||||
toast.success("Маршруты применены на роутер")
|
||||
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 {
|
||||
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 () => {
|
||||
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])
|
||||
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||
|
||||
const syncToRouter = useCallback(async () => {
|
||||
const loadRevisions = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("to")
|
||||
const epoch = ++historyEpoch.current
|
||||
setHistoryLoading(true)
|
||||
setHistoryError(null)
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: selectedServerId }),
|
||||
})
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
|
||||
const res = await apiFetch<unknown>(
|
||||
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||
)
|
||||
if (epoch !== historyEpoch.current) return
|
||||
setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes"))
|
||||
} catch (error) {
|
||||
if (epoch !== historyEpoch.current) return
|
||||
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||
}
|
||||
}, [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 {
|
||||
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() {
|
||||
if (mutationsLocked) return
|
||||
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
setSaveError(null)
|
||||
setSheetMode("create")
|
||||
setEditingGroupKey(null)
|
||||
setSheetInitial(emptyForm())
|
||||
setSheetOpen(true)
|
||||
}
|
||||
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")
|
||||
setEditingGroupKey(group.key)
|
||||
setSheetInitial({
|
||||
dstAddress: group.dstAddress,
|
||||
routingTable: group.routingTable,
|
||||
comment: group.comment,
|
||||
endpoints: group.endpoints.map(ep => ({
|
||||
endpoints: endpoints.map(ep => ({
|
||||
id: ep.id,
|
||||
disabled: ep.disabled,
|
||||
gateway: ep.gateway,
|
||||
distance: ep.distance,
|
||||
scope: ep.scope,
|
||||
@@ -515,7 +595,7 @@ export default function RecursiveRoutesPage() {
|
||||
})
|
||||
setSheetOpen(true)
|
||||
}
|
||||
function handleSaveSheet(v: RouteForm) {
|
||||
async function handleSaveSheet(v: RouteForm) {
|
||||
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
|
||||
id,
|
||||
dstAddress: v.dstAddress,
|
||||
@@ -526,25 +606,31 @@ export default function RecursiveRoutesPage() {
|
||||
routingTable: v.routingTable,
|
||||
checkGateway: ep.checkGateway,
|
||||
comment: v.comment,
|
||||
disabled: false,
|
||||
country: ep.country || inferCountry(ep.gateway) || "",
|
||||
disabled: ep.disabled,
|
||||
country: ep.country || "",
|
||||
})
|
||||
let next: RecursiveRouteRow[]
|
||||
if (sheetMode === "create") {
|
||||
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
setRows(prev => [...prev, ...expanded])
|
||||
next = [...rows, ...expanded]
|
||||
} else if (editingGroupKey) {
|
||||
setRows(prev => {
|
||||
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey)
|
||||
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
return [...kept, ...expanded]
|
||||
})
|
||||
const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
|
||||
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
next = [...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 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[]>(() => (
|
||||
servers.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -552,7 +638,7 @@ export default function RecursiveRoutesPage() {
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
status: s.status ?? undefined,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
}))
|
||||
@@ -592,7 +678,15 @@ export default function RecursiveRoutesPage() {
|
||||
<ServerRailLayout
|
||||
items={rrRailItems}
|
||||
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}
|
||||
showCount={false}
|
||||
loading={isLive && !liveServerListReady}
|
||||
@@ -602,17 +696,34 @@ export default function RecursiveRoutesPage() {
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
||||
<SaveIcon className="size-4" />Сохранить в БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
type="button" variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
|
||||
disabled={busy !== null}
|
||||
title="Прочитать маршруты с роутера"
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
|
||||
Обновить
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
@@ -621,26 +732,39 @@ export default function RecursiveRoutesPage() {
|
||||
banner={
|
||||
<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">
|
||||
<SearchIcon 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…"
|
||||
<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 aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{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">
|
||||
<XIcon className="size-3.5" />
|
||||
<XIcon aria-hidden="true" className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<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>
|
||||
<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>
|
||||
{opError && (
|
||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{opError}
|
||||
{busy === "load" && <p role="status" className="w-full text-xs text-muted-foreground">Обновление маршрутов…</p>}
|
||||
{sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
|
||||
{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>
|
||||
@@ -655,35 +779,43 @@ export default function RecursiveRoutesPage() {
|
||||
) : (
|
||||
<DataPageCard>
|
||||
{currentServer && (
|
||||
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||
{currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
|
||||
<Flag code={currentServer.country} size={16} />
|
||||
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||
<TypeChip type={currentServer.type} />
|
||||
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
||||
<span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||
{currentServer.type && <TypeChip type={currentServer.type} />}
|
||||
<code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
||||
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
|
||||
{currentServer.latency !== null && (
|
||||
<span className={cn(
|
||||
"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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
<RecursiveRoutesDataGrid
|
||||
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
||||
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||
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}
|
||||
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}
|
||||
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">
|
||||
<PlusIcon className="size-3.5" />
|
||||
<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 disabled:opacity-50">
|
||||
<PlusIcon aria-hidden="true" className="size-3.5" />
|
||||
Добавить маршрут
|
||||
</button>
|
||||
</DataPageCard>
|
||||
@@ -691,13 +823,30 @@ export default function RecursiveRoutesPage() {
|
||||
</ServerRailLayout>
|
||||
|
||||
<RouteSheet
|
||||
returnFocus={returnSheetFocus}
|
||||
busy={busy !== null}
|
||||
saveError={saveError}
|
||||
open={sheetOpen}
|
||||
mode={sheetMode}
|
||||
initial={sheetInitial}
|
||||
onSave={handleSaveSheet}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
|
||||
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 {
|
||||
formatCommitSubject,
|
||||
getReleaseCommitPreview,
|
||||
RELEASE_COMMIT_PREVIEW_LIMIT,
|
||||
} from "@/lib/release-commits"
|
||||
import { readReleaseManifest } from "@/lib/release-manifest"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -16,23 +15,23 @@ export const dynamic = "force-dynamic"
|
||||
|
||||
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
||||
if (value) {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value))
|
||||
}
|
||||
return isProdBuild ? "Прод-сборка" : "Локальная сборка"
|
||||
return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
|
||||
}
|
||||
|
||||
export default function ReleasesPage() {
|
||||
const manifest = readReleaseManifest()
|
||||
const version = getAppVersion()
|
||||
const isProdBuild = !version.endsWith("-dev")
|
||||
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
||||
const commits = getReleaseCommitPreview(manifest.commits)
|
||||
const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
|
||||
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex min-h-0 flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
||||
actions={
|
||||
@@ -40,53 +39,55 @@ export default function ReleasesPage() {
|
||||
<Link
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Открыть в Gitea
|
||||
<ExternalLinkIcon className="size-4" aria-hidden />
|
||||
Открыть релиз
|
||||
</Link>
|
||||
) : 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">
|
||||
<OpsPanel
|
||||
title="Текущая версия"
|
||||
description="Сборка MikrotikManager, опубликованная через CI/CD."
|
||||
description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
|
||||
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
||||
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
|
||||
<span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
|
||||
<span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||
</p>
|
||||
</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
|
||||
title="Сводка коммитов"
|
||||
description={
|
||||
commits.length > 0
|
||||
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."
|
||||
? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
|
||||
: manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
|
||||
}
|
||||
contentClassName="px-5 pb-5"
|
||||
>
|
||||
{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">
|
||||
{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">
|
||||
<span className="font-mono uppercase">{commit.type}</span>
|
||||
<span className="font-mono">{commit.shortSha}</span>
|
||||
<span>{commit.author}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p>
|
||||
<p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
+288
-316
File diff suppressed because it is too large.
Load diff
+262
-76
@@ -1,13 +1,21 @@
|
||||
"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 { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
|
||||
import type { Filter } from "@/components/reui/filters"
|
||||
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
|
||||
import { useServerUrlState } from "@/hooks/use-server-url-state"
|
||||
import { ServerFilters } from "@/components/data-grids/server-filters"
|
||||
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 type { ServerType, Server, WanUplink } from "@/lib/data"
|
||||
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
||||
@@ -45,9 +53,9 @@ import {
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
} from "@/components/reui-kit/accessible-stepper"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||
RefreshCwIcon, PlusIcon, TrashIcon,
|
||||
EyeIcon, EyeOffIcon,
|
||||
ChevronRightIcon, ChevronDownIcon,
|
||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||
@@ -213,15 +221,18 @@ type TypeFilter = "all" | ServerType
|
||||
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 isLive = mode === "live"
|
||||
|
||||
const [serverList, setServerList] = useState<Server[]>([])
|
||||
const [_backendOk, setBackendOk] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||
const [listState, setListState] = useState<"loading" | "ready" | "error">("loading")
|
||||
const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
@@ -231,35 +242,92 @@ export default function ServersPage() {
|
||||
const [testMsg, setTestMsg] = useState("")
|
||||
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
|
||||
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 }))
|
||||
}
|
||||
|
||||
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 ───────────────────────────────
|
||||
|
||||
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") {
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServerList(initialServers)
|
||||
setBackendOk(false)
|
||||
setListState("ready")
|
||||
})
|
||||
return
|
||||
return invalidate
|
||||
}
|
||||
listServers(backendUrl)
|
||||
.then(data => {
|
||||
setBackendOk(true)
|
||||
if (cancelled || version !== contextVersion.current) return
|
||||
setListState("ready")
|
||||
setServerList(data.map(toFrontendServer))
|
||||
})
|
||||
.catch(() => {
|
||||
setBackendOk(false)
|
||||
.catch((error) => {
|
||||
if (cancelled || version !== contextVersion.current) return
|
||||
setOperationError(serverOperationError(error))
|
||||
setListState("error")
|
||||
setServerList([])
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return invalidate
|
||||
}, [mode, backendUrl])
|
||||
|
||||
// ── actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function openAdd() {
|
||||
if (!requireWrite()) return
|
||||
if (gate.current.has("save")) return
|
||||
formVersion.current++; testVersion.current++
|
||||
setFormError(""); setDetailsLoading(false); setDetailsReady(true)
|
||||
setSheetMode("add"); setEditingId(null)
|
||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||
setSheetStep(1)
|
||||
@@ -267,11 +335,16 @@ export default function ServersPage() {
|
||||
}
|
||||
|
||||
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)
|
||||
setForm({
|
||||
...defaultForm,
|
||||
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 ?? "",
|
||||
lanSubnet: s.lanSubnet ?? "",
|
||||
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
||||
@@ -282,9 +355,11 @@ export default function ServersPage() {
|
||||
if (isLive) {
|
||||
try {
|
||||
const full = await getServer(backendUrl, s.id)
|
||||
if (version !== formVersion.current) return
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
username: full.username ?? "",
|
||||
enabled: full.enabled,
|
||||
password: full.password ?? "",
|
||||
port: String(full.port ?? 443),
|
||||
proto: full.useSsl ? "https" : "http",
|
||||
@@ -295,16 +370,24 @@ export default function ServersPage() {
|
||||
? JSON.parse(JSON.stringify(full.wanUplinks))
|
||||
: prev.wanUplinks,
|
||||
}))
|
||||
} catch {
|
||||
// ignore — user can fill in manually
|
||||
setDetailsReady(true)
|
||||
} catch (error) {
|
||||
if (version === formVersion.current) setFormError(serverOperationError(error))
|
||||
} finally {
|
||||
if (version === formVersion.current) setDetailsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
host: form.host,
|
||||
port: Number(form.port) || (form.proto === "https" ? 443 : 80),
|
||||
port: Number(form.port),
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
useSsl: form.proto === "https",
|
||||
@@ -320,18 +403,32 @@ export default function ServersPage() {
|
||||
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) {
|
||||
try {
|
||||
if (sheetMode === "edit" && editingId) {
|
||||
const updatedPayload: ServerUpdate = payload
|
||||
const updated = await updateServer(backendUrl, editingId, updatedPayload)
|
||||
if (context !== contextVersion.current) return
|
||||
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
|
||||
} else {
|
||||
const created = await createServer(backendUrl, payload)
|
||||
if (context !== contextVersion.current) return
|
||||
setServerList(list => [...list, toFrontendServer(created)])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Ошибка сохранения:", e)
|
||||
if (context === contextVersion.current) setFormError(serverOperationError(e))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// offline mode — update local state only
|
||||
@@ -349,7 +446,7 @@ export default function ServersPage() {
|
||||
comment: form.comment,
|
||||
lanSubnet: form.lanSubnet || undefined,
|
||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline",
|
||||
enabled: form.enabled,
|
||||
}
|
||||
: s
|
||||
))
|
||||
@@ -368,57 +465,85 @@ export default function ServersPage() {
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
formVersion.current++; testVersion.current++
|
||||
setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.")
|
||||
} finally {
|
||||
gate.current.release("save"); setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleStatus(id: string) {
|
||||
setServerList(list => list.map(s =>
|
||||
s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s
|
||||
))
|
||||
async function handleToggleStatus(id: string) {
|
||||
if (!requireWrite()) return
|
||||
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) {
|
||||
if (isLive) {
|
||||
try {
|
||||
await deleteServer(backendUrl, id)
|
||||
} catch (e) {
|
||||
console.error("Ошибка удаления:", e)
|
||||
}
|
||||
}
|
||||
setServerList(list => list.filter(s => s.id !== id))
|
||||
if (!requireWrite(setDeleteError)) return
|
||||
if (!gate.current.acquire("rows")) return
|
||||
const context = contextVersion.current
|
||||
setDeleting(true); setRowBusy(true); setDeleteError("")
|
||||
try {
|
||||
if (isLive) await deleteServer(backendUrl, id)
|
||||
if (context !== contextVersion.current) return
|
||||
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) {
|
||||
if (!isLive) return
|
||||
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 })
|
||||
}
|
||||
await runPoll([id])
|
||||
}
|
||||
|
||||
async function handlePollAll() {
|
||||
if (!isLive) return
|
||||
setPollAllBusy(true)
|
||||
await runPoll(serverList.map(s => s.id))
|
||||
}
|
||||
|
||||
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 {
|
||||
const ids = serverList.map(s => s.id)
|
||||
await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {})))
|
||||
const data = await listServers(backendUrl)
|
||||
setServerList(data.map(toFrontendServer))
|
||||
const results = await pollServers(ids, async id => {
|
||||
if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.")
|
||||
if (!can("mm:servers:write")) throw new Error(accessDenied)
|
||||
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 {
|
||||
setPollAllBusy(false)
|
||||
gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
|
||||
}
|
||||
gate.current.acquire("test")
|
||||
setTestState("testing"); setTestMsg("")
|
||||
try {
|
||||
const data = await testServerConnection(backendUrl, {
|
||||
@@ -430,28 +555,36 @@ export default function ServersPage() {
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
})
|
||||
if (version !== testVersion.current) return
|
||||
if (data.success) {
|
||||
setTestState("ok"); setTestMsg(data.message)
|
||||
} else {
|
||||
setTestState("error"); setTestMsg(data.message)
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (version !== testVersion.current) return
|
||||
setTestState("error")
|
||||
setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`)
|
||||
setTestMsg(serverOperationError(error))
|
||||
} finally {
|
||||
gate.current.release("test")
|
||||
}
|
||||
}
|
||||
|
||||
// ── derived ──────────────────────────────────────────────────────────────
|
||||
|
||||
const selectedServer = serverList.find(server => server.id === selectedServerId)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (urlError) return []
|
||||
const base = serverList.filter(sv => {
|
||||
if (selectedServerId !== null && sv.id !== selectedServerId) return false
|
||||
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
||||
})
|
||||
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||
}, [serverList, search, typeFilter, advancedFilters])
|
||||
return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||
}, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError])
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
all: serverList.length,
|
||||
@@ -478,11 +611,10 @@ export default function ServersPage() {
|
||||
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || pollAllBusy}>
|
||||
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />Проверить все
|
||||
<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")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm" onClick={openAdd}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||
<Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><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 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
|
||||
aria-label="Сводка серверов"
|
||||
items={[
|
||||
@@ -525,6 +683,8 @@ export default function ServersPage() {
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
@@ -536,9 +696,7 @@ export default function ServersPage() {
|
||||
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||
})),
|
||||
}}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={setAdvancedFilters}
|
||||
filterFields={SERVER_FILTER_FIELDS}
|
||||
filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, хосту…"
|
||||
@@ -546,11 +704,14 @@ export default function ServersPage() {
|
||||
/>
|
||||
<ServersDataGrid
|
||||
servers={filtered}
|
||||
hasUnfilteredRows={serverList.length > 0}
|
||||
isLive={isLive}
|
||||
pollingIds={pollingIds}
|
||||
busy={controlsBusy}
|
||||
canWrite={canWrite}
|
||||
onPoll={handlePoll}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
/>
|
||||
</DataPageCard>
|
||||
@@ -558,13 +719,19 @@ export default function ServersPage() {
|
||||
</div>
|
||||
|
||||
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
<Sheet open={open} onOpenChange={closeForm}>
|
||||
<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">
|
||||
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
|
||||
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||
</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">
|
||||
<StepperNav className="mb-5">
|
||||
<StepperItem step={1}>
|
||||
@@ -605,6 +772,7 @@ export default function ServersPage() {
|
||||
|
||||
<FormField label="Тип узла" required>
|
||||
<SegmentedControl
|
||||
className="max-w-full flex-wrap"
|
||||
value={form.type}
|
||||
onChange={v => set("type", v)}
|
||||
options={[
|
||||
@@ -725,7 +893,7 @@ export default function ServersPage() {
|
||||
|
||||
<div className="flex flex-col 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"
|
||||
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
|
||||
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
||||
@@ -776,22 +944,40 @@ export default function ServersPage() {
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
</fieldset>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||
<SheetClose render={<Button variant="outline" disabled={saving} />}>Отмена</SheetClose>
|
||||
{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 ? (
|
||||
<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}>
|
||||
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||
<Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
|
||||
{saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ServersPage() {
|
||||
return <Suspense fallback={<p role="status" className="p-6">Загрузка серверов…</p>}><ServersPageContent /></Suspense>
|
||||
}
|
||||
+49
-765
@@ -1,273 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { SystemDatabaseSection } from "@/components/settings/system-database-section"
|
||||
import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
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,
|
||||
CheckIcon,
|
||||
AlertCircleIcon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
||||
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
|
||||
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 }) {
|
||||
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">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
<div className="min-w-0 max-w-full">{children}</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}. Текущие данные SQLite на бекенде будут полностью заменены.
|
||||
Рекомендуется сначала скачать актуальный бэкап.
|
||||
</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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [section, setSection] = useState<NavSection>("Общие")
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
// 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)
|
||||
|
||||
// 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 { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const renderContent = () => {
|
||||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||
|
||||
// ── Общие ──
|
||||
if (section === "Общие") return (
|
||||
@@ -278,18 +49,18 @@ export default function SettingsPage() {
|
||||
title="Источник данных"
|
||||
description={
|
||||
mockModeAvailable
|
||||
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
|
||||
: "В продакшен-образе доступны только живые данные от бекенда"
|
||||
? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
|
||||
: "Источник живых данных задан конфигурацией приложения."
|
||||
}
|
||||
contentClassName="divide-y px-5"
|
||||
>
|
||||
|
||||
{/* mode toggle */}
|
||||
{mockModeAvailable ? (
|
||||
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд">
|
||||
<SettingRow label="Режим данных" description="Демонстрационный режим использует тестовые данные. Живой режим обращается к серверу.">
|
||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||
{([["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(
|
||||
"px-3 text-xs transition-colors border-input",
|
||||
i === 0 && "border-r",
|
||||
@@ -315,31 +86,11 @@ export default function SettingsPage() {
|
||||
label="URL бекенда"
|
||||
description={
|
||||
backendUrlLocked
|
||||
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра."
|
||||
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)"
|
||||
? "Адрес подключения задан конфигурацией приложения."
|
||||
: "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<BackendUrlField key={backendUrl} />
|
||||
</SettingRow>
|
||||
|
||||
{/* connection status */}
|
||||
@@ -348,11 +99,11 @@ export default function SettingsPage() {
|
||||
{backendStatus === undefined && (
|
||||
<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" />
|
||||
Не проверялось
|
||||
{backendChecking ? "Проверка…" : "Не проверялось"}
|
||||
</span>
|
||||
)}
|
||||
{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" />Доступен
|
||||
</span>
|
||||
)}
|
||||
@@ -370,501 +121,49 @@ export default function SettingsPage() {
|
||||
|
||||
{/* hint when live but backend unreachable */}
|
||||
{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" />
|
||||
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
|
||||
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
|
||||
</div>
|
||||
)}
|
||||
</OpsPanel>
|
||||
|
||||
{/* ── Основные настройки ── */}
|
||||
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5">
|
||||
<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={lang} onChange={e => setLang(e.target.value)}>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
</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 title="Интерфейс" description="Тема применяется сразу в этом браузере." contentClassName="divide-y px-5">
|
||||
<SettingRow label="Язык интерфейса"><span className="text-sm">Русский</span></SettingRow>
|
||||
<SettingRow label="Тема оформления"><div className="flex flex-wrap gap-2">
|
||||
{([["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>)}
|
||||
</div></SettingRow>
|
||||
<p className="py-3 text-xs text-muted-foreground">Периоды сбора данных и параметры диагностики настраиваются в соответствующих разделах.</p>
|
||||
<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>
|
||||
</OpsPanel>
|
||||
|
||||
<OpsPanel
|
||||
title="База данных приложения"
|
||||
description="Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик сбора данных приостанавливается."
|
||||
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="Консистентная копия файла mikrotik.db"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
||||
onClick={() => { void handleSystemDatabaseBackup() }}
|
||||
>
|
||||
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
||||
{dbBackupBusy ? "Подготовка…" : "Скачать"}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Восстановить из файла"
|
||||
description="Полностью заменяет текущую базу SQLite"
|
||||
>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
||||
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>
|
||||
</OpsPanel>
|
||||
<SystemDatabaseSection />
|
||||
|
||||
<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>
|
||||
<OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── EvoBGP ──
|
||||
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>
|
||||
)}
|
||||
if (section === "EvoBGP") return <EvoBgpSettingsSection />
|
||||
|
||||
<OpsPanel
|
||||
title="EvoBGP API"
|
||||
description={
|
||||
<>
|
||||
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 и ключ хранятся в SQLite на сервере бекенда (
|
||||
<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="Отправка уведомлений на admin@routerlists.io">
|
||||
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||||
</SettingRow>
|
||||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="admin@routerlists.io" /></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>
|
||||
)
|
||||
if (section === "Уведомления") return <OpsPanel title="Оповещения" description="Каналы и правила оповещений настраиваются в отдельном разделе." contentClassName="px-5 py-4"><Link href="/alerts" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оповещения</Link></OpsPanel>
|
||||
if (section === "API-ключи") return <OpsPanel title="API-ключи" contentClassName="px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Создание и управление отдельными API-ключами в приложении пока недоступны.</p></OpsPanel>
|
||||
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>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
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="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 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 => (
|
||||
<button key={s} onClick={() => setSection(s)}
|
||||
<button key={s} aria-current={s===section?"page":undefined} onClick={() => setSection(s)}
|
||||
className={cn(
|
||||
"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",
|
||||
@@ -879,33 +178,18 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* delete confirm */}
|
||||
{dbRestoreFile && (
|
||||
<DatabaseRestoreConfirm
|
||||
open={!!dbRestoreFile}
|
||||
filename={dbRestoreFile.name}
|
||||
busy={dbRestoreBusy}
|
||||
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
|
||||
onCancel={() => {
|
||||
if (dbRestoreBusy) return
|
||||
setDbRestoreFile(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FileImportDialog
|
||||
open={dbRestoreDialogOpen}
|
||||
onOpenChange={setDbRestoreDialogOpen}
|
||||
title="Восстановление базы данных"
|
||||
description="Выберите файл SQLite (.db) — текущая база будет полностью заменена"
|
||||
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
||||
onImport={async (files) => {
|
||||
const file = files[0]
|
||||
if (!file) return
|
||||
setDbRestoreFile(file)
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import {
|
||||
ActivityIcon,
|
||||
DatabaseIcon,
|
||||
GaugeIcon,
|
||||
ServerIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { SegmentedControl } from "@/components/form-kit"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
|
||||
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 {
|
||||
StatisticsBreakdownDataGrid,
|
||||
type StatisticsSliceKind,
|
||||
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { StatisticsFilters } from "@/components/statistics/statistics-filters"
|
||||
import {
|
||||
isStatisticsPivotDim,
|
||||
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 { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||
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"
|
||||
|
||||
/**
|
||||
* BI-куб трафика: критерий → остальные разрезы + pivot.
|
||||
* 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/solution-analytics-8
|
||||
* · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
|
||||
const EMPTY: StatisticsDto = {
|
||||
from: "",
|
||||
to: "",
|
||||
grain: "day",
|
||||
kpis: {
|
||||
bytes: 0,
|
||||
packets: 0,
|
||||
avgBps: 0,
|
||||
users: 0,
|
||||
servers: 0,
|
||||
ifaces: 0,
|
||||
topCountry: "",
|
||||
topService: "",
|
||||
},
|
||||
series: [],
|
||||
users: [],
|
||||
servers: [],
|
||||
interfaces: [],
|
||||
countries: [],
|
||||
services: [],
|
||||
asns: [],
|
||||
}
|
||||
|
||||
const EMPTY_PIVOT: StatisticsPivotDto = {
|
||||
rowDim: "country",
|
||||
colDim: "service",
|
||||
metric: "bytes",
|
||||
columns: [],
|
||||
rows: [],
|
||||
otherBytes: 0,
|
||||
}
|
||||
|
||||
interface CubeSlices {
|
||||
country?: string
|
||||
service?: string
|
||||
asn?: string
|
||||
serverId?: string
|
||||
userId?: string
|
||||
iface?: string
|
||||
}
|
||||
|
||||
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
||||
|
||||
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
||||
const t = sp.get("dim") ?? sp.get("tab")
|
||||
return t && isStatisticsSliceKind(t) ? t : "users"
|
||||
}
|
||||
|
||||
function readView(sp: URLSearchParams): "explore" | "pivot" {
|
||||
return sp.get("view") === "pivot" ? "pivot" : "explore"
|
||||
}
|
||||
|
||||
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 {
|
||||
const next: CubeSlices = {}
|
||||
for (const key of SLICE_KEYS) {
|
||||
const v = sp.get(key)?.trim()
|
||||
if (v) next[key] = v
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
|
||||
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
||||
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
||||
return {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
serverId: Number.isFinite(serverId) && (serverId ?? 0) > 0 ? serverId : undefined,
|
||||
userId: slices.userId,
|
||||
iface: slices.iface,
|
||||
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
|
||||
service: slices.service,
|
||||
asn: Number.isFinite(asn) ? asn : undefined,
|
||||
planes,
|
||||
}
|
||||
}
|
||||
|
||||
function selectedIdForKind(kind: StatisticsSliceKind, slices: CubeSlices): string | undefined {
|
||||
if (kind === "users") return slices.userId
|
||||
if (kind === "servers") return slices.serverId
|
||||
if (kind === "countries") return slices.country
|
||||
if (kind === "services") return slices.service
|
||||
if (kind === "asns") return slices.asn
|
||||
if (kind === "interfaces" && slices.serverId && slices.iface) {
|
||||
return `${slices.serverId}:${slices.iface}`
|
||||
}
|
||||
if (kind === "interfaces") return slices.iface
|
||||
return undefined
|
||||
}
|
||||
|
||||
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind) {
|
||||
if (kind === "users") return data.users
|
||||
if (kind === "servers") return data.servers
|
||||
if (kind === "interfaces") return data.interfaces
|
||||
if (kind === "countries") return data.countries
|
||||
if (kind === "services") return data.services
|
||||
return data.asns
|
||||
}
|
||||
|
||||
function hasAnySlice(slices: CubeSlices): boolean {
|
||||
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 { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams])
|
||||
const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams])
|
||||
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
||||
const dim = useMemo(() => readDim(searchParams), [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 [retry, setRetry] = useState(0)
|
||||
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError, paramsError })
|
||||
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 updateParams = useCallback(
|
||||
(patch: Record<string, string | undefined>) => {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v) sp.set(k, v)
|
||||
else sp.delete(k)
|
||||
}
|
||||
const qs = sp.toString()
|
||||
if (qs === searchParams.toString()) return
|
||||
window.history.pushState(null, "", (qs ? `/statistics?${qs}` : "/statistics") + window.location.hash)
|
||||
},
|
||||
[searchParams],
|
||||
)
|
||||
|
||||
const setRange = useCallback(
|
||||
(next: DateRangeYmd) => {
|
||||
updateParams({ from: next.from, to: next.to })
|
||||
},
|
||||
[updateParams],
|
||||
)
|
||||
|
||||
const setSlices = useCallback(
|
||||
(next: CubeSlices) => {
|
||||
updateParams({
|
||||
country: next.country,
|
||||
service: next.service,
|
||||
asn: next.asn,
|
||||
serverId: next.serverId,
|
||||
userId: next.userId,
|
||||
iface: next.iface,
|
||||
})
|
||||
},
|
||||
[updateParams],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsHydrated || !isLive || rangeError || paramsError) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const query = toQuery(range, slices, planes)
|
||||
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) {
|
||||
if (!cancelled) {
|
||||
setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError, paramsError])
|
||||
|
||||
const viewData = dataReady ? current.data : EMPTY
|
||||
const sliced = hasAnySlice(slices)
|
||||
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
||||
|
||||
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
|
||||
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
|
||||
if (kind === "interfaces" && row.label.includes("· дубль")) return
|
||||
setSlices(applyDimValue(slices, kind, row.id))
|
||||
}
|
||||
|
||||
function handlePivotCell(rowId: string, colId: string) {
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
||||
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">
|
||||
{!isLive ? (
|
||||
<Alert>
|
||||
<AlertTitle>Живые данные выключены</AlertTitle>
|
||||
<AlertDescription>
|
||||
Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{!slices.serverId && dataReady && !emptyCube ? (
|
||||
<Alert>
|
||||
<AlertTitle>Уникальный объём</AlertTitle>
|
||||
<AlertDescription>
|
||||
Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{(dataReady || loading) && <KpiStatGrid
|
||||
aria-label="Сводка трафика"
|
||||
isLoading={loading}
|
||||
skeletonCount={5}
|
||||
items={[
|
||||
{
|
||||
id: "bytes",
|
||||
label: "Объём",
|
||||
value: formatBytes(kpis.bytes),
|
||||
hint: "GRE/WG клиентов, без hops",
|
||||
icon: <DatabaseIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "packets",
|
||||
label: "Пакеты",
|
||||
value: kpis.packets.toLocaleString("ru-RU"),
|
||||
hint: kpis.topService ? `топ: ${kpis.topService}` : undefined,
|
||||
icon: <ActivityIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "bps",
|
||||
label: "Средний bitrate",
|
||||
value: fmtBps(kpis.avgBps),
|
||||
icon: <GaugeIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
value: String(kpis.users),
|
||||
icon: <UsersIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверы",
|
||||
value: String(kpis.servers),
|
||||
hint: slices.serverId
|
||||
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
|
||||
: planes === "all"
|
||||
? "WAN и дубли в списке"
|
||||
: "без WAN и overlay",
|
||||
icon: <ServerIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
]}
|
||||
/>}
|
||||
|
||||
{dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
|
||||
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
leading={
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl
|
||||
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}
|
||||
/>
|
||||
<SliceChips
|
||||
chips={chips}
|
||||
onRemove={(key) => {
|
||||
const next = { ...slices }
|
||||
delete next[key as keyof CubeSlices]
|
||||
setSlices(next)
|
||||
}}
|
||||
/>
|
||||
{isLive && !dataReady ? (
|
||||
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
|
||||
) : emptyCube ? (
|
||||
<EmptyState
|
||||
title="Нет данных куба"
|
||||
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
||||
/>
|
||||
) : view === "pivot" ? (
|
||||
<StatisticsPivotGrid data={isLive ? pivot : EMPTY_PIVOT} onCellClick={handlePivotCell} isLoading={loading} />
|
||||
) : sliced ? (
|
||||
<BreakdownDashboard
|
||||
data={viewData}
|
||||
hidden={hiddenKinds(slices)}
|
||||
selectedIdFor={(kind) => selectedIdForKind(kind, slices)}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={loading}
|
||||
/>
|
||||
) : (
|
||||
<StatisticsBreakdownDataGrid
|
||||
rows={rowsForKind(viewData, dim)}
|
||||
kind={dim}
|
||||
selectedId={selectedIdForKind(dim, slices)}
|
||||
onRowClick={(row) => handleRowClick(dim, row)}
|
||||
isLoading={loading}
|
||||
/>
|
||||
)}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<StatisticsPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
+111
-421
@@ -1,27 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { useState, useRef, useEffect, Suspense } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { requestJson } from "@/shared/api/http-client"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
|
||||
import {
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
TrashIcon, RefreshCwIcon,
|
||||
} from "lucide-react"
|
||||
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)`
|
||||
}
|
||||
|
||||
// ─── MOTD builders ────────────────────────────────────────────────────────────
|
||||
|
||||
function mockMotd(server: TermServer): string {
|
||||
return [
|
||||
"",
|
||||
" MMM MMM KKK TTTTTTTTTTT KKK",
|
||||
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK",
|
||||
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK",
|
||||
"",
|
||||
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
||||
"",
|
||||
"[?] Gives the list of available commands",
|
||||
"[Tab] Completes the command/word",
|
||||
"[/] Move up to base level",
|
||||
"[..] Move up one level",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function liveMotd(server: TermServer): string {
|
||||
return [
|
||||
"",
|
||||
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
||||
"",
|
||||
` Connected to ${server.name} (${server.host})`,
|
||||
"",
|
||||
"[?] Gives the list of available commands — type help or ?",
|
||||
"[Tab] Completes the command/word",
|
||||
"[/] Move up to base level",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// ─── terminal component ───────────────────────────────────────────────────────
|
||||
|
||||
function Terminal({
|
||||
server,
|
||||
isLive,
|
||||
backendUrl,
|
||||
}: {
|
||||
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([])
|
||||
}
|
||||
function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) {
|
||||
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)
|
||||
const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
|
||||
const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(null),followOutput=useRef(true),pendingFocus=useRef(false)
|
||||
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])
|
||||
// Focus only after React has committed the re-enabled input.
|
||||
useEffect(()=>{
|
||||
if(executing||!pendingFocus.current)return
|
||||
pendingFocus.current=false
|
||||
if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
|
||||
},[executing,lines])
|
||||
function append(kind:TermLine["kind"],text:string) {
|
||||
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})
|
||||
}
|
||||
async function submit(){
|
||||
const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return
|
||||
if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return}
|
||||
lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1)
|
||||
append("prompt",server.name+" > "+command)
|
||||
setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100))
|
||||
const controller=new AbortController();reader.current.controller=controller
|
||||
let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000)
|
||||
try {
|
||||
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
|
||||
if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.")
|
||||
setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true
|
||||
} catch(err) {
|
||||
if(!reader.current.alive)return
|
||||
const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды."
|
||||
append("error",message);setError(message)
|
||||
} finally {
|
||||
window.clearTimeout(timer);lock.current=false
|
||||
if(reader.current.alive){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
|
||||
}
|
||||
|
||||
const lineColor = (kind: TermLine["kind"]) => {
|
||||
if (kind === "error") return "text-red-400"
|
||||
if (kind === "prompt") return "text-emerald-400"
|
||||
if (kind === "info") return "text-sky-400"
|
||||
return "text-[#c9d1d9]"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{/* title bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
|
||||
<CircleIcon className="size-3 text-red-500 fill-red-500" />
|
||||
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
|
||||
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
|
||||
<span className="mx-auto text-[#8b949e] text-[11px]">
|
||||
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
|
||||
</span>
|
||||
{isLive && (
|
||||
<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">
|
||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
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>
|
||||
)
|
||||
}
|
||||
function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
|
||||
if(e.key==="Enter"){e.preventDefault();void submit()}
|
||||
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])}
|
||||
else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")}
|
||||
else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])}
|
||||
}
|
||||
const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null)
|
||||
return <Frame spacing="sm" className="h-full min-h-0 min-w-0">
|
||||
<FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
|
||||
<FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
|
||||
<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>
|
||||
</FrameHeader>
|
||||
<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">
|
||||
{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>}
|
||||
</div>
|
||||
<FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
|
||||
<p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
|
||||
{error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
|
||||
{blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
|
||||
<label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
|
||||
<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>
|
||||
<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>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
}
|
||||
|
||||
// ─── quick commands ───────────────────────────────────────────────────────────
|
||||
@@ -423,201 +267,47 @@ function mockServersToTermServers(): TermServer[] {
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
country: s.country,
|
||||
status: s.status as TermServer["status"],
|
||||
status: s.status==="unknown"?null:s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os ?? null,
|
||||
identityName: s.name,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Backend ServerRead shape (only fields we need) */
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
country: string
|
||||
status: "online" | "offline" | null
|
||||
enabled: boolean
|
||||
os: string | null
|
||||
identityName: string | null
|
||||
|
||||
export default function TerminalPage(){
|
||||
return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||
const [serversLoading, setServersLoading] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive || !prefsHydrated) return
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServersLoading(true)
|
||||
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
backendId: s.id,
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
country: s.country || "",
|
||||
status: s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os,
|
||||
identityName: s.identityName,
|
||||
})))
|
||||
setServersLoading(false)
|
||||
})
|
||||
.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>
|
||||
)
|
||||
function TerminalRoute(){
|
||||
const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||
function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)}
|
||||
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>
|
||||
return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
|
||||
}
|
||||
function TerminalContent(){
|
||||
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)
|
||||
const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write")
|
||||
useEffect(()=>{
|
||||
if(!isLive||!canRead)return
|
||||
let cancelled=false;const controller=new AbortController()
|
||||
queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson<unknown>(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{
|
||||
const rows=terminalServersSchema.parse(raw);if(cancelled)return
|
||||
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)
|
||||
}).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
|
||||
return()=>{cancelled=true;controller.abort()}
|
||||
},[isLive,backendUrl,canRead,refresh])
|
||||
const termServers=isLive?liveServers:mockServersToTermServers()
|
||||
const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
|
||||
const selected=matches.length===1?matches[0]:null
|
||||
const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
|
||||
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(" · ")}))
|
||||
function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
|
||||
const blocked=!!error||loading||isLive&&!loaded
|
||||
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"
|
||||
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>}</>}/>}
|
||||
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>}
|
||||
</ServerRailLayout>
|
||||
}
|
||||
+432
-330
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"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
@@ -23,22 +24,25 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
INIT_USERS,
|
||||
bindingDiffKey,
|
||||
userInitials,
|
||||
type AppUser,
|
||||
type AppUserForm,
|
||||
type UserServerOption,
|
||||
} 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 {
|
||||
userApiError,
|
||||
createAppUser,
|
||||
getAppUser,
|
||||
createUserBinding,
|
||||
deleteAppUser,
|
||||
deleteUserBinding,
|
||||
listAppUsers,
|
||||
updateAppUser,
|
||||
} 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 {
|
||||
CableIcon,
|
||||
@@ -55,168 +59,222 @@ function toServerOptionsFromMock(): UserServerOption[] {
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
status: s.status === "unknown" ? null : s.status,
|
||||
}))
|
||||
}
|
||||
|
||||
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 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 [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [users, setUsers] = useState<AppUser[]>(() => isLive ? [] : INIT_USERS)
|
||||
const [userSearch, setUserSearch] = useState("")
|
||||
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 [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 [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 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)
|
||||
try {
|
||||
const [list, srvs] = await Promise.all([
|
||||
listAppUsers(backendUrl),
|
||||
listServers(backendUrl),
|
||||
])
|
||||
setUsers(list)
|
||||
setServerOptions(srvs.map((s) => ({
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
})))
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [backendUrl])
|
||||
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||
const [list, srvs] = await Promise.allSettled([
|
||||
listAppUsers(backendUrl, { signal }).then(rows => {
|
||||
if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
|
||||
return rows
|
||||
}).catch(error => {
|
||||
if (current()) setUsersError(userApiError(error))
|
||||
throw error
|
||||
}),
|
||||
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
|
||||
const rows = serverListSchema.parse(raw)
|
||||
if (new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => row.id <= 0)) throw new Error("Некорректный каталог серверов.")
|
||||
if (current()) {
|
||||
setServerOptions(rows.map(s => ({
|
||||
id: String(s.id), name: s.name || s.host, host: s.host, site: s.site, country: s.country, status: s.status,
|
||||
})))
|
||||
setServersReadAt(Date.now()); setServersError(null)
|
||||
}
|
||||
return rows
|
||||
}).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(() => {
|
||||
if (!isLive) {
|
||||
setUsers(INIT_USERS)
|
||||
setServerOptions(toServerOptionsFromMock())
|
||||
return
|
||||
}
|
||||
void loadLive()
|
||||
}, [isLive, loadLive])
|
||||
let cancelled = false
|
||||
if (isLive && canRead) queueMicrotask(() => { if (!cancelled) void loadLive() })
|
||||
return () => { cancelled = true; loadController.current?.abort() }
|
||||
}, [isLive, canRead, 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 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) => {
|
||||
if (!writeReady || mutationLock.current || saveBlocked) return
|
||||
if (!isLive) {
|
||||
if (!editUser) {
|
||||
const id = `u${Date.now()}`
|
||||
const created: AppUser = {
|
||||
id,
|
||||
...form,
|
||||
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
|
||||
const id = "u" + Date.now()
|
||||
setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }])
|
||||
} else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||
setSheetOpen(false); toast.success("Профиль сохранён в демо."); return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
mutationLock.current = 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 {
|
||||
if (!editUser) {
|
||||
const created = await createAppUser(backendUrl, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(created.id, form.bindings, [])
|
||||
await loadLive()
|
||||
} else {
|
||||
await updateAppUser(backendUrl, editUser.id, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
|
||||
await loadLive()
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
|
||||
toast.error(msg)
|
||||
const saved = await saveUserSteps(form, checkpoint, {
|
||||
create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
|
||||
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
|
||||
read: id => getAppUser(backendUrl, id, init()),
|
||||
removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
|
||||
addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment,
|
||||
}, init()),
|
||||
}, current, state => {
|
||||
if (!current()) return
|
||||
setSaveBlocked(!!state.createUncertain)
|
||||
if (state.user) {
|
||||
if (!editUser) setSaveResume(true)
|
||||
const confirmed = state.user
|
||||
setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed])
|
||||
}
|
||||
})
|
||||
if (!current() || !saved) return
|
||||
setSheetOpen(false); saveCheckpoint.current = null
|
||||
setOperationNotice("Профиль и привязки подтверждены API.")
|
||||
toast.success("Пользователь сохранён.")
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
const detail = userApiError(error)
|
||||
setSaveError(checkpoint.createUncertain
|
||||
? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail
|
||||
: checkpoint.bindingsNeedRead
|
||||
? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail
|
||||
: detail)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
if (!deleteTarget || !writeReady || mutationLock.current) return
|
||||
const target = deleteTarget
|
||||
if (!isLive) {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
|
||||
setDeleteTarget(null)
|
||||
toast.success("Пользователь удалён")
|
||||
setUsers(prev => prev.filter(u => u.id !== target.id)); setDeleteTarget(null); setDeleteError(null)
|
||||
toast.success("Пользователь удалён в демо.")
|
||||
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 {
|
||||
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)
|
||||
await loadLive()
|
||||
toast.success("Пользователь удалён")
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
|
||||
setOperationNotice("Удаление пользователя подтверждено API.")
|
||||
toast.success("Пользователь удалён.")
|
||||
const refreshed = await loadLive()
|
||||
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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full min-w-0">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||
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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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 */}
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка пользователей"
|
||||
@@ -224,21 +282,21 @@ export default function UsersPage() {
|
||||
{
|
||||
id: "all",
|
||||
label: "Пользователи",
|
||||
value: users.length,
|
||||
value: usersKnown ? users.length : "—",
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активные",
|
||||
value: activeCount,
|
||||
value: usersKnown ? activeCount : "—",
|
||||
icon: <UserCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Привязанные ifaces",
|
||||
value: boundCount,
|
||||
label: "Привязанные интерфейсы",
|
||||
value: usersKnown ? boundCount : "—",
|
||||
icon: <CableIcon className="size-4" />,
|
||||
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 */}
|
||||
<DataPageCard>
|
||||
{canRead && (usersKnown || loading) && <DataPageCard>
|
||||
<DataPageToolbar search={userSearch} onSearchChange={setUserSearch} searchLabel="Поиск по имени, логину или email" searchPlaceholder="Поиск по имени, логину или email…" countLabel={usersKnown ? `${visibleUsers.length} из ${users.length}` : "Данные пока неизвестны"} />
|
||||
<UsersDataGrid
|
||||
users={users}
|
||||
serversCount={serverOptions.length}
|
||||
users={visibleUsers}
|
||||
emptyTitle={userSearch.trim() ? "Нет совпадений" : "Нет пользователей"}
|
||||
emptyDescription={userSearch.trim() ? "Измените поиск" : "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"}
|
||||
serversCount={serversKnown ? serverOptions.length : null}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
isLoading={loading}
|
||||
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
|
||||
onDelete={setDeleteTarget}
|
||||
isLoading={loading && !usersKnown}
|
||||
actionsDisabled={!writeReady || saving || deleting}
|
||||
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>
|
||||
|
||||
<UserSheet
|
||||
returnFocus={formReturnFocus}
|
||||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||
open={sheetOpen}
|
||||
user={editUser}
|
||||
@@ -267,12 +330,16 @@ export default function UsersPage() {
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
saveBlocked={saveBlocked}
|
||||
saveResume={saveResume}
|
||||
onCheckCatalogs={() => void loadLive()}
|
||||
onSave={(f) => { void handleSave(f) }}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
|
||||
/>
|
||||
|
||||
{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">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
@@ -284,9 +351,10 @@ export default function UsersPage() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
|
||||
Удалить
|
||||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||
<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>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
+292
-139
@@ -1,77 +1,94 @@
|
||||
"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 { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
|
||||
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import {
|
||||
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
||||
NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function serverFor(id: string) {
|
||||
return servers.find((s) => s.id === id)
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
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 {
|
||||
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(``)
|
||||
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||||
|
||||
// FDB entries for remote VTEPs
|
||||
for (const vtep of t.remoteVteps) {
|
||||
lines.push(`/interface/vxlan/vteps/add \\`)
|
||||
lines.push(` interface=${t.name} \\`)
|
||||
lines.push(` remote-ip=${vtep}`)
|
||||
lines.push(``)
|
||||
function mapBackendServer(s: BackendServer): InventoryServer {
|
||||
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 ?? "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 }: {
|
||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||||
function ExportSheet({
|
||||
open, tunnel, onClose, serverById, receivedAt, stale, mock,
|
||||
}: {
|
||||
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 (
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
|
||||
title="Экспорт VXLAN"
|
||||
description="RouterOS 7.x · /interface/vxlan + vteps"
|
||||
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
@@ -84,38 +101,200 @@ function ExportSheet({ open, tunnel, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
export default function VxlanPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||||
function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: {
|
||||
selectedServerId: string
|
||||
selectedTunnelId: string | 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(() => {
|
||||
if (!search) return vxlanTunnels
|
||||
if (!search) return scopedTunnels
|
||||
const q = search.toLowerCase()
|
||||
return vxlanTunnels.filter((t) =>
|
||||
return scopedTunnels.filter((t) =>
|
||||
t.name.includes(q) ||
|
||||
String(t.vni).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 vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
|
||||
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 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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />Новый VXLAN
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
preserveSelection
|
||||
onSelect={onSelectServer}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loading && displayServers.length === 0}
|
||||
header={
|
||||
<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">
|
||||
{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
|
||||
aria-label="Сводка VXLAN"
|
||||
@@ -123,122 +302,96 @@ export default function VxlanPage() {
|
||||
{
|
||||
id: "tunnels",
|
||||
label: "Туннелей",
|
||||
value: vxlanTunnels.length,
|
||||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активных",
|
||||
value: upCount,
|
||||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "vni",
|
||||
label: "Уникальных VNI",
|
||||
value: vnis,
|
||||
label: "VNI",
|
||||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
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" />,
|
||||
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">
|
||||
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||
<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">
|
||||
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215).
|
||||
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
|
||||
VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||||
countLabel={`${filtered.length} туннелей`}
|
||||
countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`}
|
||||
/>
|
||||
<VxlanDataGrid
|
||||
{(!isLive || hasLoaded) && <VxlanDataGrid
|
||||
tunnels={filtered}
|
||||
servers={servers}
|
||||
onExport={setExportTunnel}
|
||||
/>
|
||||
failedServerIds={failedServerIds}
|
||||
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>
|
||||
|
||||
{/* Reference */}
|
||||
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
|
||||
<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 title="Справка 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>
|
||||
</OpsPanel>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<ExportSheet
|
||||
open={!!exportTunnel}
|
||||
tunnel={exportTunnel}
|
||||
onClose={() => setExportTunnel(null)}
|
||||
open={!!exportSnapshot}
|
||||
tunnel={exportSnapshot?.tunnel ?? 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"
|
||||
|
||||
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 { servers as mockServers } from "@/lib/data"
|
||||
import type { Server } from "@/lib/data"
|
||||
@@ -44,6 +51,7 @@ import {
|
||||
exportWireGuard,
|
||||
importWireGuard,
|
||||
listWireGuard,
|
||||
listWireGuardRevisions,
|
||||
patchWireGuardInterface,
|
||||
} from "@/shared/api/wireguard"
|
||||
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
|
||||
@@ -57,14 +65,15 @@ import {
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
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 {
|
||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
||||
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
||||
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
|
||||
type WgStatusFilter = "all" | "up" | "down"
|
||||
type WgFailure = { serverId: string; serverName?: string; error: string }
|
||||
type PendingDelete =
|
||||
| { kind: "iface"; iface: WgIfaceWithServer }
|
||||
@@ -129,7 +138,7 @@ interface BackendServer {
|
||||
country: string
|
||||
type?: Server["type"]
|
||||
enabled: boolean
|
||||
status?: Server["status"]
|
||||
status?: Server["status"] | null
|
||||
latency?: number | null
|
||||
asn?: string
|
||||
}
|
||||
@@ -146,7 +155,7 @@ function mapBackendServer(s: BackendServer): Server {
|
||||
asn: s.asn ?? "",
|
||||
type: s.type ?? "exit-node",
|
||||
enabled: s.enabled,
|
||||
status: s.status ?? "online",
|
||||
status: s.status ?? "unknown",
|
||||
latency: s.latency ?? null,
|
||||
sessions: 0,
|
||||
}
|
||||
@@ -167,56 +176,96 @@ function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][nu
|
||||
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 isLive = mode === "live"
|
||||
const canWrite = !isLive || can("mm:network:write")
|
||||
|
||||
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
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 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 [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
|
||||
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const { tab: workspaceTab, setTab: setWorkspaceTab, status: statusFilter, setStatus: setStatusFilter, search, setSearch, error: urlError, resetFilters } = useWireGuardUrlState()
|
||||
const [createOpen, setCreateOpen] = 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 [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
|
||||
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
|
||||
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | 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<{
|
||||
rsc?: string
|
||||
conf?: string
|
||||
peerConf?: string
|
||||
} | null>(null)
|
||||
const [exportBusy, setExportBusy] = useState(false)
|
||||
const exportEpoch = useRef(0)
|
||||
const exportPending = useRef<number | null>(null)
|
||||
useEffect(() => () => { exportEpoch.current += 1 }, [])
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
if (!isLive || !active.current) return
|
||||
const revision = ++readRevision.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const [wg, servers] = await Promise.all([
|
||||
listWireGuard(backendUrl),
|
||||
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||
requestJson<unknown>(backendUrl, "/api/servers"),
|
||||
])
|
||||
setLiveIfaces(wg.interfaces.map(dtoToRow))
|
||||
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||
if (!active.current || revision !== readRevision.current) return
|
||||
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 ?? [])
|
||||
setLoadError(false)
|
||||
if (wg.failures?.length) {
|
||||
toast.warning(
|
||||
if (active.current) toast.warning(
|
||||
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
||||
setLiveIfaces([])
|
||||
setFailures([])
|
||||
if (!active.current || revision !== readRevision.current) return
|
||||
setReadError(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
||||
setLoadError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (active.current && revision === readRevision.current) setLoading(false)
|
||||
}
|
||||
}, [isLive, backendUrl])
|
||||
|
||||
@@ -237,15 +286,61 @@ export default function WireGuardPage() {
|
||||
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||
|
||||
const effectiveServerId =
|
||||
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||
? selectedServerId
|
||||
: ALL_SERVERS_ID
|
||||
const effectiveServerId = selectedServerId
|
||||
const selectedSourceError = !!urlError || isLive && (!catalogKnown || loadError || failures.some(f => f.serverId === selectedServerId))
|
||||
|
||||
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(() => {
|
||||
if (urlError) return []
|
||||
if (selectedInterfaceId !== null) return displayIfaces.filter(i => i.serverId === effectiveServerId && i.id === selectedInterfaceId)
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
|
||||
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
|
||||
}, [displayIfaces, effectiveServerId])
|
||||
}, [displayIfaces, effectiveServerId, selectedInterfaceId, urlError])
|
||||
|
||||
const selectedInterface = selectedInterfaceId === null ? undefined : scopedIfaces[0]
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
let up = 0
|
||||
@@ -309,8 +404,8 @@ export default function WireGuardPage() {
|
||||
}, [scopedIfaces, search])
|
||||
|
||||
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||
const onlinePeers = scopedIfaces.reduce(
|
||||
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
|
||||
const peersWithHandshakeData = scopedIfaces.reduce(
|
||||
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake?.trim()).length,
|
||||
0,
|
||||
)
|
||||
const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
|
||||
@@ -336,19 +431,23 @@ export default function WireGuardPage() {
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
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,
|
||||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||
}))
|
||||
}, [displayServers, displayIfaces])
|
||||
}, [displayServers, displayIfaces, catalogKnown, failures])
|
||||
|
||||
async function handleCreate(form: WgCreateFormState) {
|
||||
if (urlError || !catalogKnown || loadError || formLock.current) return
|
||||
if (!isLive) {
|
||||
toast.info("Создание на роутер доступно только в live-режиме")
|
||||
if (active.current) toast.info("Создание на роутер доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
formLock.current = true
|
||||
setCreateError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||
const ep = parseEndpoint(form.peerEndpoint)
|
||||
await createWireGuardInterface(backendUrl, {
|
||||
serverId: form.serverId,
|
||||
@@ -372,12 +471,13 @@ export default function WireGuardPage() {
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
toast.success(`Интерфейс ${form.name} создан`)
|
||||
if (active.current) toast.success(`Интерфейс ${form.name} создан`)
|
||||
setCreateOpen(false)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка создания")
|
||||
setCreateError(e instanceof Error ? e.message : "Ошибка создания")
|
||||
} finally {
|
||||
formLock.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -388,19 +488,27 @@ export default function WireGuardPage() {
|
||||
format: "auto" | "rsc" | "conf"
|
||||
dryRun: boolean
|
||||
}) {
|
||||
if (formLock.current) return
|
||||
if (!isLive) {
|
||||
toast.info("Импорт на роутер доступен только в live-режиме")
|
||||
if (active.current) toast.info("Импорт на роутер доступен только в live-режиме")
|
||||
return
|
||||
}
|
||||
formLock.current = true
|
||||
setImportError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||
const res = await importWireGuard(backendUrl, {
|
||||
serverId: args.serverId,
|
||||
content: args.content,
|
||||
format: args.format,
|
||||
dryRun: args.dryRun,
|
||||
})
|
||||
toast.success(
|
||||
if (res.dryRun) {
|
||||
if (active.current) toast.info("Конфигурация проверена без применения на роутер")
|
||||
return
|
||||
}
|
||||
if (active.current) toast.success(
|
||||
res.applied
|
||||
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
|
||||
: "Импорт выполнен",
|
||||
@@ -408,61 +516,95 @@ export default function WireGuardPage() {
|
||||
setImportOpen(false)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка импорта")
|
||||
setImportError(e instanceof Error ? e.message : "Ошибка импорта")
|
||||
} finally {
|
||||
formLock.current = 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) {
|
||||
if (toggleLock.current) return
|
||||
if (sourceUnavailable(iface.serverId)) return
|
||||
if (!isLive || !iface.rosId) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
if (active.current) toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
toggleLock.current = true
|
||||
const context = `${iface.name} · ${iface.serverName}`
|
||||
setTogglePending(context)
|
||||
setToggleError(null)
|
||||
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,
|
||||
})
|
||||
toast.success(iface.enabled ? "Отключено" : "Включено")
|
||||
if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение интерфейса")
|
||||
if (active.current) toast.success(iface.enabled ? "Отключено" : "Включено")
|
||||
await loadLive()
|
||||
} 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() {
|
||||
if (!pendingDelete) return
|
||||
if (!pendingDelete || deleteLock.current) return
|
||||
if (sourceUnavailable(pendingDelete.iface.serverId)) return
|
||||
if (!isLive) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
if (active.current) toast.info("Доступно только в live-режиме")
|
||||
setPendingDelete(null)
|
||||
return
|
||||
}
|
||||
deleteLock.current = true
|
||||
setDeleteBusy(true)
|
||||
setDeleteError(null)
|
||||
try {
|
||||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||
if (pendingDelete.kind === "iface") {
|
||||
if (!pendingDelete.iface.rosId) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
if (active.current) toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
|
||||
toast.success("Удалено")
|
||||
const result = await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
|
||||
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
|
||||
if (active.current) toast.success("Удалено")
|
||||
} else {
|
||||
await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId)
|
||||
toast.success("Пир удалён")
|
||||
const peer = pendingDelete.iface.peers.find(p => p.id === pendingDelete.peerId || p.rosId === pendingDelete.peerId)
|
||||
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)
|
||||
await loadLive()
|
||||
} 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) {
|
||||
if (formLock.current) return
|
||||
if (sourceUnavailable(peerIface?.serverId)) return
|
||||
if (!isLive || !peerIface) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
if (active.current) toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
formLock.current = true
|
||||
setPeerError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||||
const ep = parseEndpoint(form.endpoint)
|
||||
await createWireGuardPeer(backendUrl, {
|
||||
serverId: peerIface.serverId,
|
||||
@@ -477,27 +619,34 @@ export default function WireGuardPage() {
|
||||
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
|
||||
comment: form.comment || undefined,
|
||||
})
|
||||
toast.success("Пир добавлен")
|
||||
if (active.current) toast.success("Пир добавлен")
|
||||
setPeerIface(null)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
||||
setPeerError(e instanceof Error ? e.message : "Ошибка")
|
||||
} finally {
|
||||
formLock.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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, {
|
||||
serverId: exportIface.serverId,
|
||||
interfaceName: exportIface.name,
|
||||
format,
|
||||
includePrivateKey: format !== "peer-conf",
|
||||
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
|
||||
peerId: peer?.rosId ?? peer?.id,
|
||||
})
|
||||
if (epoch !== exportEpoch.current) return
|
||||
setLiveExport((prev) => ({
|
||||
...prev,
|
||||
...(format === "rsc"
|
||||
@@ -506,15 +655,22 @@ export default function WireGuardPage() {
|
||||
? { conf: res.content }
|
||||
: { peerConf: res.content }),
|
||||
}))
|
||||
toast.success("Конфиг загружен с роутера")
|
||||
if (active.current) toast.success("Конфиг загружен с роутера")
|
||||
} 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 {
|
||||
setExportBusy(false)
|
||||
if (epoch === exportEpoch.current) {
|
||||
exportPending.current = null
|
||||
setExportBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
|
||||
exportEpoch.current += 1
|
||||
exportPending.current = null
|
||||
setExportBusy(false)
|
||||
setLiveExport(null)
|
||||
setExportInitialTab(tab)
|
||||
setExportPeerId(peerId ?? null)
|
||||
@@ -522,7 +678,7 @@ export default function WireGuardPage() {
|
||||
}
|
||||
|
||||
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" />
|
||||
Новый интерфейс
|
||||
</Button>
|
||||
@@ -533,10 +689,12 @@ export default function WireGuardPage() {
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
preserveSelection
|
||||
onSelect={onSelectServer}
|
||||
showAll
|
||||
allCount={displayIfaces.length}
|
||||
allCount={completeScope ? displayIfaces.length : undefined}
|
||||
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={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||
@@ -544,6 +702,7 @@ export default function WireGuardPage() {
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -553,12 +712,26 @@ export default function WireGuardPage() {
|
||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Обновить
|
||||
</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" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
|
||||
<PlusIcon className="size-4" />
|
||||
Новый интерфейс
|
||||
</Button>
|
||||
@@ -568,34 +741,51 @@ export default function WireGuardPage() {
|
||||
}
|
||||
>
|
||||
<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
|
||||
aria-label="Сводка WireGuard"
|
||||
items={[
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Интерфейсов",
|
||||
value: scopedIfaces.length,
|
||||
value: completeScope ? scopedIfaces.length : "—",
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активных (UP)",
|
||||
value: upIfaces,
|
||||
value: completeScope ? upIfaces : "—",
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "peers",
|
||||
label: "Всего пиров",
|
||||
value: totalPeers,
|
||||
value: completeScope ? totalPeers : "—",
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "online",
|
||||
label: "Пиров онлайн",
|
||||
value: `${onlinePeers}/${totalPeers}`,
|
||||
id: "handshake-data",
|
||||
label: "Данные рукопожатия",
|
||||
value: completeScope ? `${peersWithHandshakeData}/${totalPeers}` : "—",
|
||||
hint: <span className="block text-xs text-muted-foreground">Доступность пиров не проверена</span>,
|
||||
icon: <KeyRoundIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
@@ -648,7 +838,7 @@ export default function WireGuardPage() {
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||
countLabel={`${filteredIfaces.length} интерфейсов`}
|
||||
countLabel={catalogKnown && !urlError ? `${filteredIfaces.length} интерфейсов` : "Количество неизвестно"}
|
||||
segmented={{
|
||||
value: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
@@ -659,17 +849,19 @@ export default function WireGuardPage() {
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<WireguardDataGrid
|
||||
{catalogKnown && !urlError && (completeScope || filteredIfaces.length > 0) ? <WireguardDataGrid
|
||||
interfaces={filteredIfaces}
|
||||
canWrite={canWrite}
|
||||
compactServer={compactServer}
|
||||
emptyAction={emptyCreateAction}
|
||||
onExport={(iface) => openExport(iface, "rsc")}
|
||||
onAddPeer={setPeerIface}
|
||||
onToggle={handleToggle}
|
||||
onDelete={(iface) => setPendingDelete({ kind: "iface", iface })}
|
||||
onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
|
||||
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
|
||||
/>
|
||||
onAddPeer={!canWrite || selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }}
|
||||
onToggle={!canWrite || selectedSourceError ? undefined : handleToggle}
|
||||
toggleBusy={togglePending !== null}
|
||||
onDelete={!canWrite || selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
|
||||
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>
|
||||
</TabsContent>
|
||||
|
||||
@@ -679,30 +871,30 @@ export default function WireGuardPage() {
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по ключу, IP, endpoint…"
|
||||
countLabel={`${peerRows.length} пиров`}
|
||||
countLabel={catalogKnown && !urlError ? `${peerRows.length} пиров` : "Количество неизвестно"}
|
||||
/>
|
||||
<WireguardPeersGrid
|
||||
{catalogKnown && !urlError && (completeScope || peerRows.length > 0) ? <WireguardPeersGrid
|
||||
peers={peerRows}
|
||||
compactServer={compactServer}
|
||||
emptyAction={
|
||||
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" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
) : emptyCreateAction
|
||||
}
|
||||
onDeletePeer={(row) => {
|
||||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
|
||||
onDeletePeer={!canWrite || selectedSourceError ? undefined : (row) => {
|
||||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
|
||||
if (!iface) return
|
||||
setPendingDelete({ kind: "peer", iface, peerId: row.id })
|
||||
}}
|
||||
onExportPeer={(row) => {
|
||||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
|
||||
onExportPeer={canWrite ? (row) => {
|
||||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
|
||||
if (!iface) return
|
||||
openExport(iface, "peer", row.id)
|
||||
}}
|
||||
/>
|
||||
} : undefined}
|
||||
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог пиров пока неизвестен</p>}
|
||||
</DataPageCard>
|
||||
</TabsContent>
|
||||
|
||||
@@ -760,7 +952,8 @@ export default function WireGuardPage() {
|
||||
|
||||
<WgCreateSheet
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onOpenChange={v => { if (!formLock.current) setCreateOpen(v) }}
|
||||
error={createError}
|
||||
servers={serverOptions}
|
||||
defaultServerId={sheetServerId}
|
||||
busy={busy}
|
||||
@@ -768,7 +961,8 @@ export default function WireGuardPage() {
|
||||
/>
|
||||
<WgImportSheet
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onOpenChange={v => { if (!formLock.current) setImportOpen(v) }}
|
||||
error={importError}
|
||||
servers={serverOptions}
|
||||
defaultServerId={sheetServerId}
|
||||
busy={busy}
|
||||
@@ -778,15 +972,20 @@ export default function WireGuardPage() {
|
||||
open={!!peerIface}
|
||||
iface={peerIface}
|
||||
busy={busy}
|
||||
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
|
||||
onOpenChange={(v) => { if (!v && !formLock.current) setPeerIface(null) }}
|
||||
error={peerError}
|
||||
onSubmit={handleAddPeer}
|
||||
/>
|
||||
<WgExportSheet
|
||||
open={!!exportIface}
|
||||
iface={exportIface}
|
||||
serverHost={displayServers.find(server => server.id === exportIface?.serverId)?.host}
|
||||
initialTab={exportInitialTab}
|
||||
peerId={exportPeerId}
|
||||
onClose={() => {
|
||||
exportEpoch.current += 1
|
||||
exportPending.current = null
|
||||
setExportBusy(false)
|
||||
setExportIface(null)
|
||||
setExportPeerId(null)
|
||||
setExportInitialTab("rsc")
|
||||
@@ -797,7 +996,7 @@ export default function WireGuardPage() {
|
||||
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">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
@@ -806,22 +1005,59 @@ export default function WireGuardPage() {
|
||||
<AlertDialogTitle>
|
||||
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<AlertDialogDescription className="break-all">
|
||||
{pendingDelete?.kind === "iface"
|
||||
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.`
|
||||
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры и IP-адреса на роутере.`
|
||||
: pendingDelete
|
||||
? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
|
||||
? `Пир «${pendingPeer?.name || pendingPeer?.publicKey || pendingDelete.peerId}» (ID ${pendingDelete.peerId}) на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
|
||||
: null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{deleteBusy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения удаления…</p>}
|
||||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}>
|
||||
Удалить
|
||||
<AlertDialogCancel disabled={deleteBusy}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction type="button" variant="destructive" disabled={deleteBusy} onClick={() => void confirmDelete()}>
|
||||
{deleteBusy ? "Удаление…" : "Удалить"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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 {
|
||||
clearPortalHandoffFlag,
|
||||
clearToken,
|
||||
can,
|
||||
ensureAuthConfig,
|
||||
firstAllowedPath,
|
||||
getClaims,
|
||||
getToken,
|
||||
parseHashToken,
|
||||
permissionForPath,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
setToken,
|
||||
} from "@/lib/auth"
|
||||
import { permittedAuthReturnPath } from "@/lib/auth-return-path"
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const router = useRouter()
|
||||
@@ -28,6 +31,10 @@ export default function AuthCallbackPage() {
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
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") {
|
||||
redirectToPortalLoginInteractive()
|
||||
return
|
||||
@@ -62,7 +69,7 @@ export default function AuthCallbackPage() {
|
||||
/* ignore network — proceed */
|
||||
}
|
||||
|
||||
const next = firstAllowedPath()
|
||||
const next = returnPath()
|
||||
if (next === "/access-denied") {
|
||||
router.replace("/access-denied")
|
||||
return
|
||||
@@ -77,11 +84,13 @@ export default function AuthCallbackPage() {
|
||||
router.replace("/access-denied")
|
||||
return
|
||||
}
|
||||
router.replace(firstAllowedPath())
|
||||
router.replace(returnPath())
|
||||
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()
|
||||
})()
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
@import "tw-animate-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 *));
|
||||
|
||||
@theme inline {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Path to SQLite database file
|
||||
# PostgreSQL connection string
|
||||
DATABASE_URL=postgres://mmapp:mmapp@127.0.0.1:5432/mmapp
|
||||
|
||||
# SQLite file used only for one-shot boot ETL (empty PG + existing mikrotik.db)
|
||||
DATABASE_PATH=./mikrotik.db
|
||||
# SQLITE_IMPORT_FULL_HISTORY=true
|
||||
|
||||
# Port for the Fastify server
|
||||
PORT=8000
|
||||
@@ -13,3 +17,5 @@ AUTH_REQUIRED=false
|
||||
AUTH_JWT_SECRET=dev-secret-change-me
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
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
|
||||
@@ -5,3 +5,4 @@ dist/
|
||||
*.db-wal
|
||||
.env
|
||||
storage/backups/
|
||||
storage/geoip/
|
||||
+11
-1
@@ -37,6 +37,15 @@ RUN npm run build -w @mmapp/contracts \
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8000
|
||||
@@ -47,8 +56,9 @@ COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
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
|
||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 8000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
@@ -6,9 +6,9 @@ config()
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_PATH ?? "./mikrotik.db",
|
||||
url: process.env.DATABASE_URL ?? "postgres://mmapp:mmapp@127.0.0.1:5432/mmapp",
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
CREATE TABLE `server_snapshots` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`server_id` integer NOT NULL,
|
||||
`polled_at` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`latency_ms` real,
|
||||
`ros_version` text,
|
||||
`board_name` text,
|
||||
`uptime` text,
|
||||
`cpu_load` integer,
|
||||
`free_memory` integer,
|
||||
`total_memory` integer,
|
||||
`identity_name` text,
|
||||
`raw_interfaces` text,
|
||||
`raw_ip_addresses` text,
|
||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `servers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`host` text NOT NULL,
|
||||
`port` integer DEFAULT 443 NOT NULL,
|
||||
`username` text DEFAULT 'admin' NOT NULL,
|
||||
`password` text DEFAULT '' NOT NULL,
|
||||
`use_ssl` integer DEFAULT true NOT NULL,
|
||||
`verify_ssl` integer DEFAULT false NOT NULL,
|
||||
`type` text DEFAULT 'home-router' NOT NULL,
|
||||
`site` text DEFAULT '' NOT NULL,
|
||||
`country` text DEFAULT '' NOT NULL,
|
||||
`asn` text DEFAULT '' NOT NULL,
|
||||
`comment` text DEFAULT '' NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,626 @@
|
||||
-- MikrotikManager PostgreSQL 18 schema (not 1:1 SQLite).
|
||||
-- Applied once at backend boot via src/db/migrate.ts
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
type TEXT NOT NULL DEFAULT 'home-router'
|
||||
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id BIGINT,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||
last_datagram_at TIMESTAMPTZ,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||
peers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||
server_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
last_source_finished_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_migration (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
sqlite_imported_at TIMESTAMPTZ,
|
||||
sqlite_path TEXT,
|
||||
sqlite_sha256 TEXT,
|
||||
report_json JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
polled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||
latency_ms DOUBLE PRECISION,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory BIGINT,
|
||||
total_memory BIGINT,
|
||||
identity_name TEXT,
|
||||
raw_interfaces JSONB,
|
||||
raw_ip_addresses JSONB,
|
||||
PRIMARY KEY (id, polled_at)
|
||||
) PARTITION BY RANGE (polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_polled_brin ON server_snapshots USING BRIN (polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time ON traffic_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_sampled_brin ON traffic_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
ok BOOLEAN NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id ON servers_rest_ping_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_brin ON servers_rest_ping_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
src TEXT NOT NULL,
|
||||
dst TEXT NOT NULL,
|
||||
proto INTEGER NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop TEXT NOT NULL DEFAULT '',
|
||||
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_bucket_brin ON flow_buckets USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_stats_brin ON flow_minute_stats USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_brin ON flow_minute_dims USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
) PARTITION BY RANGE (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time ON uptime_probe_samples(probe_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_brin ON uptime_probe_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
board_name TEXT NOT NULL DEFAULT '',
|
||||
ros_version TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time ON uptime_resource_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_brin ON uptime_resource_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_tx_avg_mbps DOUBLE PRECISION,
|
||||
last_rx_avg_mbps DOUBLE PRECISION,
|
||||
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||
last_error TEXT,
|
||||
last_ping_rtt_ms INTEGER,
|
||||
last_ping_loss_pct INTEGER,
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_ping_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps DOUBLE PRECISION,
|
||||
rx_avg_mbps DOUBLE PRECISION,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names JSONB NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown_override TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
confirm_stability_sec INTEGER,
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||
recovery_stability_sec INTEGER,
|
||||
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
group_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
message TEXT NOT NULL,
|
||||
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
fired_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ,
|
||||
sections_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
servers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_brin ON internet_path_snapshots USING BRIN (sampled_at);
|
||||
|
||||
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,620 @@
|
||||
-- Compact PostgreSQL 18 schema: wipe all app tables (not schema_migrations),
|
||||
-- recreate with smaller time-series rows. SQLite is re-imported after this.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename <> 'schema_migrations'
|
||||
LOOP
|
||||
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
type TEXT NOT NULL DEFAULT 'home-router'
|
||||
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id BIGINT,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||
last_datagram_at TIMESTAMPTZ,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||
peers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||
server_ids_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
last_source_finished_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_migration (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
sqlite_imported_at TIMESTAMPTZ,
|
||||
sqlite_path TEXT,
|
||||
sqlite_sha256 TEXT,
|
||||
report_json JSONB COMPRESSION lz4
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
polled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||
latency_ms DOUBLE PRECISION,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory BIGINT,
|
||||
total_memory BIGINT,
|
||||
identity_name TEXT,
|
||||
raw_interfaces JSONB COMPRESSION lz4,
|
||||
raw_ip_addresses JSONB COMPRESSION lz4,
|
||||
PRIMARY KEY (id, polled_at)
|
||||
) PARTITION BY RANGE (polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
flags SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, sampled_at, interface_name, peer_public_key)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
ok BOOLEAN NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
PRIMARY KEY (server_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
src INET NOT NULL,
|
||||
dst INET NOT NULL,
|
||||
proto SMALLINT NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop INET,
|
||||
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
) PARTITION BY RANGE (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||
PRIMARY KEY (probe_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_tx_avg_mbps DOUBLE PRECISION,
|
||||
last_rx_avg_mbps DOUBLE PRECISION,
|
||||
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||
last_error TEXT,
|
||||
last_ping_rtt_ms INTEGER,
|
||||
last_ping_loss_pct INTEGER,
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_ping_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps DOUBLE PRECISION,
|
||||
rx_avg_mbps DOUBLE PRECISION,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names JSONB COMPRESSION lz4 NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown_override TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
confirm_stability_sec INTEGER,
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||
recovery_stability_sec INTEGER,
|
||||
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
group_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
message TEXT NOT NULL,
|
||||
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
fired_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB COMPRESSION lz4
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json JSONB COMPRESSION lz4
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ,
|
||||
sections_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
servers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||
|
||||
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- S3-compatible storage for RouterOS backups
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_storage_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
|
||||
s3_endpoint TEXT NOT NULL DEFAULT '',
|
||||
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
|
||||
s3_bucket TEXT NOT NULL DEFAULT '',
|
||||
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
|
||||
s3_access_key_id TEXT NOT NULL DEFAULT '',
|
||||
s3_secret_access_key TEXT NOT NULL DEFAULT '',
|
||||
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_test_at TIMESTAMPTZ,
|
||||
last_test_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO backup_storage_settings (id)
|
||||
VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS s3_key TEXT;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS upload_error TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
|
||||
) THEN
|
||||
ALTER TABLE backup_entries
|
||||
ADD CONSTRAINT backup_entries_storage_check
|
||||
CHECK (storage IN ('local', 's3', 'both'));
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geoip_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
|
||||
last_check_at TIMESTAMPTZ,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
country_build_at TIMESTAMPTZ,
|
||||
asn_build_at TIMESTAMPTZ,
|
||||
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO geoip_settings (id)
|
||||
VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
|
||||
-- Compact types. FILLFACTOR/autovacuum нельзя на partitioned parent (PG 42809) —
|
||||
-- задаются на листовых партициях в ensurePartitionFor.
|
||||
-- Retention: DROP partitions only (see PARTITION_SPECS).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_hour_facts (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
iface TEXT NOT NULL,
|
||||
country CHAR(2) NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
asn INTEGER NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_facts (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
iface TEXT NOT NULL,
|
||||
country CHAR(2) NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
asn INTEGER NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, iface, country, service, asn)
|
||||
) PARTITION BY RANGE (day);
|
||||
@@ -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);
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"version": "7",
|
||||
"when": 1777572014210,
|
||||
"tag": "0000_living_xorn",
|
||||
"tag": "0000_postgresql",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+38
-7
@@ -1,23 +1,51 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite",
|
||||
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
|
||||
"type": "module",
|
||||
"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",
|
||||
"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",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.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-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-analytics.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
|
||||
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.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:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.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: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: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: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: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: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": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
@@ -28,12 +56,15 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"maxmind": "^5.0.7",
|
||||
"pg": "^8.23.0",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/pg": "^8.23.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jose": "^6.2.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
|
||||
@@ -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
|
||||
Loaded 100 of 2501 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user