Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2820683cba | ||
|
|
37167f78e3 | ||
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 | ||
|
|
63bed28251 | ||
|
|
95dcd3df58 | ||
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 | ||
|
|
b3e50a1f5f | ||
|
|
fe32c9313a | ||
|
|
6123660346 | ||
|
|
b680f882cc | ||
|
|
5e0c16e808 | ||
|
|
66509b26bd | ||
|
|
15ad53af1f | ||
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd | ||
|
|
f2df990746 | ||
|
|
77bc174e43 | ||
|
|
0e9349e508 | ||
|
|
0208aa4d7c | ||
|
|
42a2e18047 | ||
|
|
9df3971f6c | ||
|
|
7ed269cf2c | ||
|
|
7f81c304ae | ||
|
|
5f29cfaf0f | ||
|
|
d2de8d3188 | ||
|
|
d3a2d38b37 | ||
|
|
a1a9124f3d | ||
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 | ||
|
|
7dc4836c71 | ||
|
|
efc3812e12 | ||
|
|
8d5fd84962 | ||
|
|
f69e65b014 | ||
|
|
49d14a00af | ||
|
|
9ab2418a5f | ||
|
|
63aa9d424b | ||
|
|
43ab17b253 | ||
|
|
4b8cf83ee9 | ||
|
|
cdaf3deb03 | ||
|
|
3ce4179f3c | ||
|
|
1a47905beb | ||
|
|
b51d979a1c | ||
|
|
bc01da1708 | ||
|
|
500a543045 | ||
|
|
60024facab | ||
|
|
53ea0e74a3 | ||
|
|
6c8ca17590 | ||
|
|
d62d212c86 | ||
|
|
bfceb07a23 | ||
|
|
e7a8ad0910 | ||
|
|
0435608ef9 | ||
|
|
c2a8a08618 |
No files matched your search
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
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 `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 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
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [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 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)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||||
|
|
||||||
|
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||||
|
|
||||||
|
## Preserve the design - don't over-customize
|
||||||
|
|
||||||
|
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||||
|
|
||||||
|
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||||
|
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||||
|
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||||
|
|
||||||
|
## Reuse the parts: examples and the block's own elements
|
||||||
|
|
||||||
|
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||||
|
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||||
|
|
||||||
|
## Don't invent (read, don't guess)
|
||||||
|
|
||||||
|
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||||
|
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||||
|
|
||||||
|
## What to change vs leave alone
|
||||||
|
|
||||||
|
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||||
|
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||||
|
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||||
|
|
||||||
|
## Demo data -> real data
|
||||||
|
|
||||||
|
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||||
|
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||||
|
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||||
|
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||||
|
|
||||||
|
## Believable content (no AI tells)
|
||||||
|
|
||||||
|
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||||
|
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
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" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||||
|
|
||||||
|
1. Add the key to `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REUI_LICENSE_KEY=your-license-key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Switch `components.json` to the authenticated object form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${REUI_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?ref=skill
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Use the project's package runner (check `packageManager`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes # npm
|
||||||
|
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||||
|
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||||
|
```
|
||||||
|
|
||||||
|
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||||
|
|
||||||
|
## Handling prompts and conflicts
|
||||||
|
|
||||||
|
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||||
|
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||||
|
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||||
|
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||||
|
|
||||||
|
## Free vs premium boundary
|
||||||
|
|
||||||
|
- 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?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
|
## kanban
|
||||||
|
|
||||||
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||||
|
<KanbanBoard>
|
||||||
|
{Object.entries(cols).map(([id, items]) => (
|
||||||
|
<KanbanColumn key={id} value={id}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<KanbanColumnContent value={id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<KanbanItem key={i.id} value={i.id}>
|
||||||
|
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||||
|
|
||||||
|
## sortable
|
||||||
|
|
||||||
|
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<SortableItem key={i.id} value={i.id}>
|
||||||
|
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||||
|
{i.label}
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</Sortable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||||
|
|
||||||
|
## filters
|
||||||
|
|
||||||
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const fields: FilterField[] = [
|
||||||
|
{ id: "title", label: "Title", type: "text" },
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Status",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
|
## date-selector
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange` to capture the value.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||||
|
|
||||||
|
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||||
|
|
||||||
|
## tree
|
||||||
|
|
||||||
|
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tree tree={tree}>
|
||||||
|
{tree.getItems().map((item) => (
|
||||||
|
<TreeItem key={item.getId()} item={item}>
|
||||||
|
<TreeItemLabel />
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</Tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||||
|
|
||||||
|
## stepper
|
||||||
|
|
||||||
|
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stepper defaultValue={1}>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||||
|
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||||
|
|
||||||
|
## timeline
|
||||||
|
|
||||||
|
**Required:** `TimelineItem step` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>March 2024</TimelineDate>
|
||||||
|
<TimelineTitle>Project initialized</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||||
|
|
||||||
|
## autocomplete
|
||||||
|
|
||||||
|
**Required:** `items` (array; each item has at least `value`)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Autocomplete items={items}>
|
||||||
|
<AutocompleteInput placeholder="Search..." />
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||||
|
|
||||||
|
## phone-input
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||||
|
|
||||||
|
## number-field
|
||||||
|
|
||||||
|
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NumberField defaultValue={0}>
|
||||||
|
<NumberFieldScrubArea label="Quantity" />
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||||
|
|
||||||
|
## rating
|
||||||
|
|
||||||
|
**Required:** `rating` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||||
|
|
||||||
|
## scrollspy
|
||||||
|
|
||||||
|
**Required:** `targetRef` (the scroll container ref)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Scrollspy targetRef={containerRef}>
|
||||||
|
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||||
|
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||||
|
</Scrollspy>
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<div id="s1">...</div>
|
||||||
|
<div id="s2">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## icon-stack
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<InboxIcon className="size-4" />
|
||||||
|
</IconStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<AlertTitle>Security update</AlertTitle>
|
||||||
|
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||||
|
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||||
|
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||||
|
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a point of view
|
||||||
|
|
||||||
|
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## The bar
|
||||||
|
|
||||||
|
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## Keep icons purposeful
|
||||||
|
|
||||||
|
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||||
|
|
||||||
|
## Motion Icons (the `@reui/icons/...` set)
|
||||||
|
|
||||||
|
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
Finding icons:
|
||||||
|
|
||||||
|
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||||
|
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||||
|
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||||
|
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||||
|
- Every non-submit button is `type="button"`.
|
||||||
|
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## Scroll mechanics
|
||||||
|
|
||||||
|
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||||
|
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ReUI registry structure
|
||||||
|
|
||||||
|
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 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).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||||
|
|
||||||
|
## Know your base: base or radix
|
||||||
|
|
||||||
|
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||||
|
|
||||||
|
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||||
|
|
||||||
|
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Workflow: find -> install -> read API -> adapt
|
||||||
|
|
||||||
|
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
|
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 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`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
|
Run the result's `install` command from the project root, non-interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||||
|
|
||||||
|
## 3. Read the API (do not guess props)
|
||||||
|
|
||||||
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
|
||||||
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
## 4. Adapt (reuse-first) - do not skip
|
||||||
|
|
||||||
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||||
|
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||||
|
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||||
|
8. **Typecheck / lint**.
|
||||||
|
|
||||||
|
## If no single block fits
|
||||||
|
|
||||||
|
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ReUI MCP: full reference
|
||||||
|
|
||||||
|
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||||
|
|
||||||
|
## Golden path (token-optimal - follow this order)
|
||||||
|
|
||||||
|
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||||
|
|
||||||
|
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||||
|
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||||
|
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||||
|
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||||
|
5. **`get_audit_checklist()`** before declaring done.
|
||||||
|
|
||||||
|
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||||
|
|
||||||
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## Token + speed rules
|
||||||
|
|
||||||
|
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||||
|
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||||
|
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||||
|
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||||
|
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||||
|
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||||
|
|
||||||
|
## Result shapes (so you don't re-fetch)
|
||||||
|
|
||||||
|
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||||
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## 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?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.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- 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?ref=skill
|
||||||
@@ -1,190 +1,50 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.0",
|
"version": "1.3.4",
|
||||||
"tag": "v1.1.0",
|
"tag": "v1.3.4",
|
||||||
"publishedAt": "2026-05-12T10:30:14.398Z",
|
"publishedAt": "2026-05-12T13:50:06.198Z",
|
||||||
"releaseUrl": "https://git.shts.su/denozord/MikrotikManager-3/releases/tag/v1.1.0",
|
"releaseUrl": "",
|
||||||
"commits": [
|
"commits": [
|
||||||
{
|
{
|
||||||
"sha": "4b92a6eb81ce64fad2ca61f5fa94342959a45349",
|
"sha": "3ce4179f3cda44d908560f53b51b960d07326a81",
|
||||||
"shortSha": "4b92a6e",
|
"shortSha": "3ce4179",
|
||||||
"subject": "feat: release v1.1.0 with enhancements and refactoring",
|
"subject": "chore(package): обновить зависимости и добавить поддержку swc для Windows",
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "feat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "9ccc8459d77032dae8e7f67ac0df0287d7553341",
|
|
||||||
"shortSha": "9ccc845",
|
|
||||||
"subject": "feat: update application versioning and enhance release management",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "feat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "f282585b525fe787ab6bdbd2f83c9c848b376771",
|
|
||||||
"shortSha": "f282585",
|
|
||||||
"subject": "feat: enhance network configuration handling in updater script",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "feat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "40e2040fb05717283d0ee2b7d0a6b3315abecf73",
|
|
||||||
"shortSha": "40e2040",
|
|
||||||
"subject": "feat: refactor theme management and remove next-themes dependency",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "feat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "bb07ba0f69966e4b5533f6b4f0d7d96cef0916f1",
|
|
||||||
"shortSha": "bb07ba0",
|
|
||||||
"subject": "feat: implement database backup and restore functionality in settings page",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "feat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "1fefed2aa0dbd51f893750e2e179f65bd9386372",
|
|
||||||
"shortSha": "1fefed2",
|
|
||||||
"subject": "chore: enhance data source configuration and mock data handling",
|
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "chore"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "4489dfb2d62e836b5415b9ead409851bef995eaf",
|
"sha": "1a47905beb74f3e7a7e57b8f86bea51c19e95d00",
|
||||||
"shortSha": "4489dfb",
|
"shortSha": "1a47905",
|
||||||
"subject": "chore: enhance frontend configuration for backend integration",
|
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "4b240d70d0fbc2143cc657f78f932d003ad5d406",
|
"sha": "b51d979a1c2f9fedd6615d682b0e7a4749efe983",
|
||||||
"shortSha": "4b240d7",
|
"shortSha": "b51d979",
|
||||||
"subject": "chore: add server and server_snapshots tables to database schema",
|
"subject": "fix(backend): улучшить обработку хранилищ доверия для сертификатов",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "63252ae85ed03b772ede2be97a27f1605cc16d0d",
|
"sha": "bc01da1708092c7f6f70e905cd7e61b901a357ba",
|
||||||
"shortSha": "63252ae",
|
"shortSha": "bc01da1",
|
||||||
"subject": "chore: enhance image reference resolution in updater script",
|
"subject": "fix(backend): добавить функции для работы с IP-адресами и DNS-записями",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "a359874a42add6b0f4f2f4844ba6e19268bdc05f",
|
"sha": "500a5430456fdcf3403896fe78f4bb2afe0d8b69",
|
||||||
"shortSha": "a359874",
|
"shortSha": "500a543",
|
||||||
"subject": "chore: enhance Dockerfile for backend to rebuild better-sqlite3",
|
"subject": "fix(backend): улучшить уникальность идентификаторов сертификатов",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "4ec247f2c02edfccd775c3e5c644c28844358c90",
|
"sha": "60024facabb23c28a7bde1305fc107ac1573fef2",
|
||||||
"shortSha": "4ec247f",
|
"shortSha": "60024fa",
|
||||||
"subject": "chore: update Docker Compose configuration for data volume management",
|
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "2697a35c0a719396fbe5fdaf6045cb9ca49c28fa",
|
|
||||||
"shortSha": "2697a35",
|
|
||||||
"subject": "chore: streamline Docker workflow and enhance build efficiency",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "chore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "7295545cde2fc7b0674ba17b3447d89be2f85218",
|
|
||||||
"shortSha": "7295545",
|
|
||||||
"subject": "chore: add updater image configuration to Docker workflow",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "chore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "a5b5a2a3d390bf4f6fb0986a917d43532844a346",
|
|
||||||
"shortSha": "a5b5a2a",
|
|
||||||
"subject": "chore: enhance Dockerfiles and package.json for improved build process",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "chore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "01596864dc7801275eefcb67aad67b58785d3a28",
|
|
||||||
"shortSha": "0159686",
|
|
||||||
"subject": "chore: update Dockerfiles to ignore scripts during npm install",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "chore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "02119a3acde256a92edd06cf4d00337bb53088ee",
|
|
||||||
"shortSha": "02119a3",
|
|
||||||
"subject": "chore: configure dynamic image naming for frontend and backend in Docker workflow",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "chore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "c36994c36b78063068aff5fa509d1d26c522de51",
|
|
||||||
"shortSha": "c36994c",
|
|
||||||
"subject": "chore: update configuration and dependencies",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "chore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "dfbaa859ad7616159e373ca50632efd2ad77e8e3",
|
|
||||||
"shortSha": "dfbaa85",
|
|
||||||
"subject": "chore: isolate frontend and backend via npm workspaces and @mmapp/contracts",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "chore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "b9a75b6831e4ef27726bedbab37f385366c33067",
|
|
||||||
"shortSha": "b9a75b6",
|
|
||||||
"subject": "feat: implement internet path functionality with backend support",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "feat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "11ad94f67d4a3f5567d3e6e9cd8fe7a587e86df1",
|
|
||||||
"shortSha": "11ad94f",
|
|
||||||
"subject": "feat: integrate sonner for toast notifications and enhance UI feedback",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "feat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "84ecd4f0619cb434918e3d36d1d2d8d61dc0c759",
|
|
||||||
"shortSha": "84ecd4f",
|
|
||||||
"subject": "feat: enhance backups page with live data loading and backup job management",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "feat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "6d8379501caf263fa2def609c91601d8b4bb4298",
|
|
||||||
"shortSha": "6d83795",
|
|
||||||
"subject": "refactor: replace custom API fetch logic with requestJson utility across multiple pages",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "refactor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "5f31bb47fb11592ef1dd1c576105efb07cff584e",
|
|
||||||
"shortSha": "5f31bb4",
|
|
||||||
"subject": "chore: synchronize pending app/backend updates and repository hygiene",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "chore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "bdb9b72fac5e9cf03faa6e8df463759a9defbf24",
|
|
||||||
"shortSha": "bdb9b72",
|
|
||||||
"subject": "Init 2",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "other"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "ce00c4c671834df0c3277480ecf1a7bbd03cf3f4",
|
|
||||||
"shortSha": "ce00c4c",
|
|
||||||
"subject": "Update .gitignore to include additional build and log files",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "other"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha": "f3f831653f48c24ae02d15cf10b3e929d4d63e14",
|
|
||||||
"shortSha": "f3f8316",
|
|
||||||
"subject": "Init commit",
|
|
||||||
"author": "Denozordec",
|
|
||||||
"type": "other"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,41 +1,18 @@
|
|||||||
# v1.1.0
|
# v1.3.4
|
||||||
|
|
||||||
Версия **1.1.0**.
|
Версия **1.3.4**.
|
||||||
|
|
||||||
## feat
|
## fix
|
||||||
|
|
||||||
- feat: release v1.1.0 with enhancements and refactoring (`4b92a6e`, Denozordec)
|
- fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов (`1a47905`, Denozordec)
|
||||||
- feat: update application versioning and enhance release management (`9ccc845`, Denozordec)
|
- fix(backend): улучшить обработку хранилищ доверия для сертификатов (`b51d979`, Denozordec)
|
||||||
- feat: enhance network configuration handling in updater script (`f282585`, Denozordec)
|
- fix(backend): добавить функции для работы с IP-адресами и DNS-записями (`bc01da1`, Denozordec)
|
||||||
- feat: refactor theme management and remove next-themes dependency (`40e2040`, Denozordec)
|
- fix(backend): улучшить уникальность идентификаторов сертификатов (`500a543`, Denozordec)
|
||||||
- feat: implement database backup and restore functionality in settings page (`bb07ba0`, Denozordec)
|
- fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов (`60024fa`, Denozordec)
|
||||||
- feat: implement internet path functionality with backend support (`b9a75b6`, Denozordec)
|
- fix(ui): добавить новые компоненты для управления сертификатами (`53ea0e7`, Denozordec)
|
||||||
- feat: integrate sonner for toast notifications and enhance UI feedback (`11ad94f`, Denozordec)
|
- fix(ui): улучшить отображение таблицы сертификатов и добавить классы для сетки (`6c8ca17`, Denozordec)
|
||||||
- feat: enhance backups page with live data loading and backup job management (`84ecd4f`, Denozordec)
|
|
||||||
|
|
||||||
## chore
|
## chore
|
||||||
|
|
||||||
- chore: enhance data source configuration and mock data handling (`1fefed2`, Denozordec)
|
- chore(package): обновить зависимости и добавить поддержку swc для Windows (`3ce4179`, Denozordec)
|
||||||
- chore: enhance frontend configuration for backend integration (`4489dfb`, Denozordec)
|
- chore(backend): добавить поддержку ACME и управление сертификатами (`d62d212`, Denozordec)
|
||||||
- chore: add server and server_snapshots tables to database schema (`4b240d7`, Denozordec)
|
|
||||||
- chore: enhance image reference resolution in updater script (`63252ae`, Denozordec)
|
|
||||||
- chore: enhance Dockerfile for backend to rebuild better-sqlite3 (`a359874`, Denozordec)
|
|
||||||
- chore: update Docker Compose configuration for data volume management (`4ec247f`, Denozordec)
|
|
||||||
- chore: streamline Docker workflow and enhance build efficiency (`2697a35`, Denozordec)
|
|
||||||
- chore: add updater image configuration to Docker workflow (`7295545`, Denozordec)
|
|
||||||
- chore: enhance Dockerfiles and package.json for improved build process (`a5b5a2a`, Denozordec)
|
|
||||||
- chore: update Dockerfiles to ignore scripts during npm install (`0159686`, Denozordec)
|
|
||||||
- chore: configure dynamic image naming for frontend and backend in Docker workflow (`02119a3`, Denozordec)
|
|
||||||
- chore: update configuration and dependencies (`c36994c`, Denozordec)
|
|
||||||
- chore: isolate frontend and backend via npm workspaces and @mmapp/contracts (`dfbaa85`, Denozordec)
|
|
||||||
- chore: synchronize pending app/backend updates and repository hygiene (`5f31bb4`, Denozordec)
|
|
||||||
|
|
||||||
## refactor
|
|
||||||
|
|
||||||
- refactor: replace custom API fetch logic with requestJson utility across multiple pages (`6d83795`, Denozordec)
|
|
||||||
|
|
||||||
## other
|
|
||||||
|
|
||||||
- Init 2 (`bdb9b72`, Denozordec)
|
|
||||||
- Update .gitignore to include additional build and log files (`ce00c4c`, Denozordec)
|
|
||||||
- Init commit (`f3f8316`, Denozordec)
|
|
||||||
@@ -11,8 +11,9 @@ const outputDir = process.env.RELEASE_OUTPUT_DIR
|
|||||||
? join(repoRoot, process.env.RELEASE_OUTPUT_DIR)
|
? join(repoRoot, process.env.RELEASE_OUTPUT_DIR)
|
||||||
: join(repoRoot, ".ci", "release")
|
: join(repoRoot, ".ci", "release")
|
||||||
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
|
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
|
||||||
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "")
|
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shx.one").replace(/\/$/, "")
|
||||||
|
|
||||||
|
const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6)
|
||||||
const CONVENTIONAL_RE =
|
const CONVENTIONAL_RE =
|
||||||
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i
|
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i
|
||||||
const MINOR_TYPES = new Set(["feat"])
|
const MINOR_TYPES = new Set(["feat"])
|
||||||
@@ -53,6 +54,12 @@ function formatSemver(parts) {
|
|||||||
|
|
||||||
function bumpVersion(version, bump) {
|
function bumpVersion(version, bump) {
|
||||||
const parts = parseSemver(version)
|
const parts = parseSemver(version)
|
||||||
|
if (bump === "major") {
|
||||||
|
parts.major += 1
|
||||||
|
parts.minor = 0
|
||||||
|
parts.patch = 0
|
||||||
|
return formatSemver(parts)
|
||||||
|
}
|
||||||
if (bump === "minor") {
|
if (bump === "minor") {
|
||||||
parts.minor += 1
|
parts.minor += 1
|
||||||
parts.patch = 0
|
parts.patch = 0
|
||||||
@@ -65,8 +72,8 @@ function bumpVersion(version, bump) {
|
|||||||
function parseCommit(subject, body = "") {
|
function parseCommit(subject, body = "") {
|
||||||
const match = CONVENTIONAL_RE.exec(subject.trim())
|
const match = CONVENTIONAL_RE.exec(subject.trim())
|
||||||
const breaking =
|
const breaking =
|
||||||
/BREAKING CHANGE/i.test(body) ||
|
/BREAKING[ -]CHANGE:/i.test(body) ||
|
||||||
(match?.[1] && subject.includes(`${match[1]}!:`))
|
(match !== null && /^[^:]+!:\s/.test(subject.trim()))
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
||||||
@@ -77,7 +84,9 @@ function parseCommit(subject, body = "") {
|
|||||||
const description = match[3].trim()
|
const description = match[3].trim()
|
||||||
let bump = "patch"
|
let bump = "patch"
|
||||||
|
|
||||||
if (breaking || MINOR_TYPES.has(type)) {
|
if (breaking) {
|
||||||
|
bump = "major"
|
||||||
|
} else if (MINOR_TYPES.has(type)) {
|
||||||
bump = "minor"
|
bump = "minor"
|
||||||
} else if (PATCH_TYPES.has(type)) {
|
} else if (PATCH_TYPES.has(type)) {
|
||||||
bump = "patch"
|
bump = "patch"
|
||||||
@@ -139,6 +148,23 @@ function getCommitsSince(tag) {
|
|||||||
)
|
)
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
|
|
||||||
|
return parseCommitLog(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecentCommits(limit = MANIFEST_COMMIT_LIMIT) {
|
||||||
|
const raw = git(
|
||||||
|
"log",
|
||||||
|
"-n",
|
||||||
|
String(limit),
|
||||||
|
"HEAD",
|
||||||
|
"--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e",
|
||||||
|
)
|
||||||
|
if (!raw) return []
|
||||||
|
|
||||||
|
return parseCommitLog(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommitLog(raw) {
|
||||||
return raw
|
return raw
|
||||||
.split("\x1e")
|
.split("\x1e")
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
@@ -210,7 +236,7 @@ function main() {
|
|||||||
if (commits.length === 0) {
|
if (commits.length === 0) {
|
||||||
const deployTag = latestTag ?? `v${baseFromTag}`
|
const deployTag = latestTag ?? `v${baseFromTag}`
|
||||||
const deployVersion = baseFromTag
|
const deployVersion = baseFromTag
|
||||||
const displayCommits = latestTag ? getCommitsSince(getPreviousTag(latestTag)) : []
|
const displayCommits = getRecentCommits()
|
||||||
const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null
|
const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null
|
||||||
|
|
||||||
mkdirSync(outputDir, { recursive: true })
|
mkdirSync(outputDir, { recursive: true })
|
||||||
@@ -233,7 +259,9 @@ function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
const bump = commits.some((commit) => commit.bump === "major")
|
||||||
|
? "major"
|
||||||
|
: commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
||||||
const version = bumpVersion(baseFromTag, bump)
|
const version = bumpVersion(baseFromTag, bump)
|
||||||
const tag = `v${version}`
|
const tag = `v${version}`
|
||||||
const publishedAt = new Date().toISOString()
|
const publishedAt = new Date().toISOString()
|
||||||
@@ -244,7 +272,7 @@ function main() {
|
|||||||
tag,
|
tag,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
releaseUrl,
|
releaseUrl,
|
||||||
commits: toManifestCommits(commits),
|
commits: toManifestCommits(getRecentCommits()),
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdirSync(outputDir, { recursive: true })
|
mkdirSync(outputDir, { recursive: true })
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { execFileSync } from "node:child_process"
|
||||||
|
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { basename, dirname, join, resolve } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
const source = fileURLToPath(new URL("./compute-release.mjs", import.meta.url))
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{ name: "исправление повышает patch", commits: [["fix(ui): исправить отображение"]], version: "1.47.7", bump: "patch" },
|
||||||
|
{ name: "новая возможность повышает minor", commits: [["feat(ui): добавить раздел"]], version: "1.48.0", bump: "minor" },
|
||||||
|
{ name: "breaking со scope повышает major", commits: [["chore(release)!: подготовить вторую основную версию"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "breaking без scope повышает major", commits: [["feat!: заменить контракт"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "footer со пробелом повышает major", commits: [["fix(api): заменить контракт", "BREAKING CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "footer с дефисом повышает major", commits: [["fix(api): заменить контракт", "BREAKING-CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "major имеет приоритет в смешанной истории", commits: [["chore(release)!: подготовить выпуск"], ["feat(ui): добавить раздел"], ["fix(ui): исправить отображение"]], version: "2.0.0", bump: "major" },
|
||||||
|
{ name: "после второй версии сохраняется patch", base: "2.0.0", commits: [["fix(ui): исправить отображение"]], version: "2.0.1", bump: "patch" },
|
||||||
|
{ name: "после второй версии сохраняется minor", base: "2.0.0", commits: [["feat(ui): добавить раздел"]], version: "2.1.0", bump: "minor" },
|
||||||
|
{ name: "готовый тег не выпускается повторно", base: "2.0.0", commits: [], version: "2.0.0", bump: "none" },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const scenario of cases) test(scenario.name, () => {
|
||||||
|
const parent = resolve(tmpdir())
|
||||||
|
const root = mkdtempSync(join(parent, "mm-release-"))
|
||||||
|
const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" })
|
||||||
|
try {
|
||||||
|
mkdirSync(join(root, ".ci/scripts"), { recursive: true })
|
||||||
|
copyFileSync(source, join(root, ".ci/scripts/compute-release.mjs"))
|
||||||
|
git("init", "--initial-branch=main")
|
||||||
|
git("config", "user.name", "Release Test")
|
||||||
|
git("config", "user.email", "[email protected]")
|
||||||
|
git("config", "core.hooksPath", ".git/hooks")
|
||||||
|
writeFileSync(join(root, "fixture"), "baseline")
|
||||||
|
git("add", "fixture")
|
||||||
|
git("commit", "-m", "chore(test): создать исходную базу")
|
||||||
|
git("tag", `v${scenario.base ?? "1.47.6"}`)
|
||||||
|
for (const [index, [subject, body]] of scenario.commits.entries()) {
|
||||||
|
writeFileSync(join(root, "fixture"), `change ${index}`)
|
||||||
|
git("add", "fixture")
|
||||||
|
git("commit", "-m", subject, ...(body ? ["-m", body] : []))
|
||||||
|
}
|
||||||
|
execFileSync(process.execPath, [join(root, ".ci/scripts/compute-release.mjs")], {
|
||||||
|
cwd: root, stdio: "pipe",
|
||||||
|
env: { ...process.env, BASE_VERSION: "1.0.0", RELEASE_OUTPUT_DIR: ".ci/release", GITHUB_OUTPUT: join(root, "outputs"), GITEA_REPOSITORY: "test/repo", GITEA_SERVER_URL: "https://example.invalid" },
|
||||||
|
})
|
||||||
|
const manifest = JSON.parse(readFileSync(join(root, ".ci/release/release-manifest.json"), "utf8"))
|
||||||
|
const output = readFileSync(join(root, "outputs"), "utf8")
|
||||||
|
assert.equal(manifest.version, scenario.version)
|
||||||
|
assert.equal(manifest.tag, `v${scenario.version}`)
|
||||||
|
assert.equal(manifest.releaseUrl, `https://example.invalid/test/repo/releases/tag/v${scenario.version}`)
|
||||||
|
assert.match(output, new RegExp(`^bump=${scenario.bump}$`, "m"))
|
||||||
|
assert.match(output, new RegExp(`^should_release=${scenario.bump !== "none"}$`, "m"))
|
||||||
|
if (scenario.bump !== "none") assert.match(readFileSync(join(root, ".ci/release/release_notes.md"), "utf8"), new RegExp(`# v${scenario.version.replaceAll(".", "\\.")}`))
|
||||||
|
} finally {
|
||||||
|
assert.equal(dirname(resolve(root)), parent)
|
||||||
|
assert.ok(basename(root).startsWith("mm-release-"))
|
||||||
|
rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { execFileSync, spawnSync } from "node:child_process"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..")
|
||||||
|
const gitCheck = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (gitCheck.status !== 0) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLevel = gitCheck.stdout.trim()
|
||||||
|
|
||||||
|
execFileSync("git", ["config", "core.hooksPath", ".githooks"], {
|
||||||
|
cwd: topLevel,
|
||||||
|
stdio: "ignore",
|
||||||
|
})
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'import { Fragment, useEffect, useMemo, useState } from "react"\n'
|
||||||
|
'import { PageHeader } from "@/components/page-header"\n'
|
||||||
|
'import { StatusBadge } from "@/components/status-badge"',
|
||||||
|
'import { useEffect, useMemo, useState } from "react"\n'
|
||||||
|
'import { PageHeader } from "@/components/page-header"\n'
|
||||||
|
'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n'
|
||||||
|
'import { DataPageToolbar } from "@/components/data-page-toolbar"\n'
|
||||||
|
'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"""import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
|
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
""",
|
||||||
|
"""import {
|
||||||
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui/stepper"
|
||||||
|
""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"""import {
|
||||||
|
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
|
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
||||||
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
|
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
||||||
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
|
} from "lucide-react\"""",
|
||||||
|
"""import {
|
||||||
|
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
|
EyeIcon, EyeOffIcon,
|
||||||
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
|
ShieldIcon, WifiIcon,
|
||||||
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
|
} from "lucide-react\"""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"// ─── RouterOS version utilities.*?// ─── Countries ─",
|
||||||
|
"// ─── Countries ─",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"// ─── Type config ─.*?// ─── Shared small components ─",
|
||||||
|
"// ─── Shared small components ─",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field",
|
||||||
|
"// ─── Country field",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S | re.M,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"const [expandedId, setExpandedId] = useState<string | null>(null)",
|
||||||
|
"const [sheetStep, setSheetStep] = useState(1)",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)',
|
||||||
|
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'setTestState("idle"); setTestMsg(""); setOpen(true)',
|
||||||
|
'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(r"<Field\b", "<FormField", text)
|
||||||
|
text = re.sub(r"</Field>", "</FormField>", text)
|
||||||
|
text = re.sub(r"<Toggle\b", "<FormToggle", text)
|
||||||
|
|
||||||
|
new_table = """ {/* Table */}
|
||||||
|
<Card>
|
||||||
|
<DataPageToolbar
|
||||||
|
segmented={{
|
||||||
|
value: typeFilter,
|
||||||
|
onChange: setTypeFilter,
|
||||||
|
options: tabs.map((tab) => ({
|
||||||
|
value: tab.value,
|
||||||
|
label: tab.label,
|
||||||
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
|
countLabel={`${filtered.length} серверов`}
|
||||||
|
/>
|
||||||
|
<ServersDataGrid
|
||||||
|
servers={filtered}
|
||||||
|
isLive={isLive}
|
||||||
|
pollingIds={pollingIds}
|
||||||
|
onPoll={handlePoll}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r" \{/\* Table \*/\}\n <Card>.*?</Card>\n",
|
||||||
|
new_table,
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print("patched", path)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Wrap sheet form in stepper
|
||||||
|
text = text.replace(
|
||||||
|
""" <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* 1. Основные */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Основные</SectionTitle>""",
|
||||||
|
""" <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}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>1</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Основные</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>2</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">WAN</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={3}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>3</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">API</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={4}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>4</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
<StepperContent value={1} className="flex flex-col gap-4">""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
{/* 2. WAN-аплинки (только для home-router) */}
|
||||||
|
{isHomeRouter && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. Подключение (API) */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={3} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
{/* 4. Дополнительно */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={4} className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
|
<Button className="flex-1" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||||
|
{sheetStep > 1 && (
|
||||||
|
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
|
)}
|
||||||
|
{sheetStep < 4 ? (
|
||||||
|
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="ml-auto" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetFooter>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# WAN step 2: show message when not home router
|
||||||
|
text = text.replace(
|
||||||
|
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
<WanUplinkEditor""",
|
||||||
|
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
{!isHomeRouter ? (
|
||||||
|
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||||
|
) : (
|
||||||
|
<WanUplinkEditor""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" />
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3}""",
|
||||||
|
""" />
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3}""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print("stepper patched")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs"
|
||||||
|
|
||||||
|
const messagePath = process.argv[2]
|
||||||
|
|
||||||
|
if (!messagePath) {
|
||||||
|
console.error("validate-commit-message: не указан файл сообщения коммита")
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.SKIP_COMMIT_MESSAGE_RU === "1") {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(messagePath, "utf8")
|
||||||
|
const subject = content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0 && !line.startsWith("#"))
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipPatterns = [
|
||||||
|
/^Merge\b/i,
|
||||||
|
/^Revert\b/i,
|
||||||
|
/^fixup!/i,
|
||||||
|
/^squash!/i,
|
||||||
|
/^amend!/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (skipPatterns.some((pattern) => pattern.test(subject))) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cyrillic = /[\u0400-\u04FF\u0500-\u052F]/
|
||||||
|
|
||||||
|
if (!cyrillic.test(subject)) {
|
||||||
|
console.error("")
|
||||||
|
console.error("Коммит отклонён: subject должен быть на русском.")
|
||||||
|
console.error("Формат: fix(scope): краткое описание на русском")
|
||||||
|
console.error("Правила: .cursor/rules/commit-messages-ru.mdc")
|
||||||
|
console.error("Обход только для аварий: SKIP_COMMIT_MESSAGE_RU=1")
|
||||||
|
console.error("")
|
||||||
|
console.error(`Subject: ${subject}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const conventional =
|
||||||
|
/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?:\s*(.+)$/
|
||||||
|
|
||||||
|
const match = subject.match(conventional)
|
||||||
|
|
||||||
|
if (match && !cyrillic.test(match[3])) {
|
||||||
|
console.error("")
|
||||||
|
console.error("Коммит отклонён: текст после «:» должен быть на русском.")
|
||||||
|
console.error(`Subject: ${subject}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
|
||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
|
||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
|
||||||
"WebFetch(domain:git.shts.su)",
|
"WebFetch(domain:git.shx.one)",
|
||||||
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
|
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
|
||||||
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
|
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
|
||||||
"Bash(node -e ' *)",
|
"Bash(node -e ' *)",
|
||||||
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
|
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
|
||||||
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
|
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
|
||||||
@@ -63,5 +63,8 @@
|
|||||||
"Bash(xargs grep *)",
|
"Bash(xargs grep *)",
|
||||||
"Bash(npx shadcn@latest add popover --yes)"
|
"Bash(npx shadcn@latest add popover --yes)"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"REUI_LICENSE_KEY": "REUI-EAF4-EA40-6C5C-5D86"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
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 `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 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
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [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 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)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||||
|
|
||||||
|
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||||
|
|
||||||
|
## Preserve the design - don't over-customize
|
||||||
|
|
||||||
|
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||||
|
|
||||||
|
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||||
|
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||||
|
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||||
|
|
||||||
|
## Reuse the parts: examples and the block's own elements
|
||||||
|
|
||||||
|
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||||
|
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||||
|
|
||||||
|
## Don't invent (read, don't guess)
|
||||||
|
|
||||||
|
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||||
|
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||||
|
|
||||||
|
## What to change vs leave alone
|
||||||
|
|
||||||
|
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||||
|
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||||
|
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||||
|
|
||||||
|
## Demo data -> real data
|
||||||
|
|
||||||
|
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||||
|
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||||
|
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||||
|
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||||
|
|
||||||
|
## Believable content (no AI tells)
|
||||||
|
|
||||||
|
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||||
|
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
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" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||||
|
|
||||||
|
1. Add the key to `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REUI_LICENSE_KEY=your-license-key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Switch `components.json` to the authenticated object form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${REUI_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?ref=skill
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Use the project's package runner (check `packageManager`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes # npm
|
||||||
|
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||||
|
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||||
|
```
|
||||||
|
|
||||||
|
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||||
|
|
||||||
|
## Handling prompts and conflicts
|
||||||
|
|
||||||
|
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||||
|
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||||
|
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||||
|
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||||
|
|
||||||
|
## Free vs premium boundary
|
||||||
|
|
||||||
|
- 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?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
|
## kanban
|
||||||
|
|
||||||
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||||
|
<KanbanBoard>
|
||||||
|
{Object.entries(cols).map(([id, items]) => (
|
||||||
|
<KanbanColumn key={id} value={id}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<KanbanColumnContent value={id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<KanbanItem key={i.id} value={i.id}>
|
||||||
|
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||||
|
|
||||||
|
## sortable
|
||||||
|
|
||||||
|
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<SortableItem key={i.id} value={i.id}>
|
||||||
|
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||||
|
{i.label}
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</Sortable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||||
|
|
||||||
|
## filters
|
||||||
|
|
||||||
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const fields: FilterField[] = [
|
||||||
|
{ id: "title", label: "Title", type: "text" },
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Status",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
|
## date-selector
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange` to capture the value.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||||
|
|
||||||
|
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||||
|
|
||||||
|
## tree
|
||||||
|
|
||||||
|
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tree tree={tree}>
|
||||||
|
{tree.getItems().map((item) => (
|
||||||
|
<TreeItem key={item.getId()} item={item}>
|
||||||
|
<TreeItemLabel />
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</Tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||||
|
|
||||||
|
## stepper
|
||||||
|
|
||||||
|
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stepper defaultValue={1}>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||||
|
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||||
|
|
||||||
|
## timeline
|
||||||
|
|
||||||
|
**Required:** `TimelineItem step` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>March 2024</TimelineDate>
|
||||||
|
<TimelineTitle>Project initialized</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||||
|
|
||||||
|
## autocomplete
|
||||||
|
|
||||||
|
**Required:** `items` (array; each item has at least `value`)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Autocomplete items={items}>
|
||||||
|
<AutocompleteInput placeholder="Search..." />
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||||
|
|
||||||
|
## phone-input
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||||
|
|
||||||
|
## number-field
|
||||||
|
|
||||||
|
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NumberField defaultValue={0}>
|
||||||
|
<NumberFieldScrubArea label="Quantity" />
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||||
|
|
||||||
|
## rating
|
||||||
|
|
||||||
|
**Required:** `rating` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||||
|
|
||||||
|
## scrollspy
|
||||||
|
|
||||||
|
**Required:** `targetRef` (the scroll container ref)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Scrollspy targetRef={containerRef}>
|
||||||
|
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||||
|
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||||
|
</Scrollspy>
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<div id="s1">...</div>
|
||||||
|
<div id="s2">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## icon-stack
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<InboxIcon className="size-4" />
|
||||||
|
</IconStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<AlertTitle>Security update</AlertTitle>
|
||||||
|
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||||
|
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||||
|
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||||
|
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a point of view
|
||||||
|
|
||||||
|
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## The bar
|
||||||
|
|
||||||
|
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## Keep icons purposeful
|
||||||
|
|
||||||
|
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||||
|
|
||||||
|
## Motion Icons (the `@reui/icons/...` set)
|
||||||
|
|
||||||
|
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
Finding icons:
|
||||||
|
|
||||||
|
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||||
|
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||||
|
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||||
|
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||||
|
- Every non-submit button is `type="button"`.
|
||||||
|
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## Scroll mechanics
|
||||||
|
|
||||||
|
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||||
|
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ReUI registry structure
|
||||||
|
|
||||||
|
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 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).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||||
|
|
||||||
|
## Know your base: base or radix
|
||||||
|
|
||||||
|
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||||
|
|
||||||
|
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||||
|
|
||||||
|
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Workflow: find -> install -> read API -> adapt
|
||||||
|
|
||||||
|
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
|
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 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`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
|
Run the result's `install` command from the project root, non-interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||||
|
|
||||||
|
## 3. Read the API (do not guess props)
|
||||||
|
|
||||||
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
|
||||||
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
## 4. Adapt (reuse-first) - do not skip
|
||||||
|
|
||||||
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||||
|
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||||
|
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||||
|
8. **Typecheck / lint**.
|
||||||
|
|
||||||
|
## If no single block fits
|
||||||
|
|
||||||
|
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ReUI MCP: full reference
|
||||||
|
|
||||||
|
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||||
|
|
||||||
|
## Golden path (token-optimal - follow this order)
|
||||||
|
|
||||||
|
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||||
|
|
||||||
|
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||||
|
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||||
|
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||||
|
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||||
|
5. **`get_audit_checklist()`** before declaring done.
|
||||||
|
|
||||||
|
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||||
|
|
||||||
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## Token + speed rules
|
||||||
|
|
||||||
|
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||||
|
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||||
|
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||||
|
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||||
|
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||||
|
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||||
|
|
||||||
|
## Result shapes (so you don't re-fetch)
|
||||||
|
|
||||||
|
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||||
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## 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?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.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- 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?ref=skill
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[mcp_servers.reui]
|
||||||
|
url = "https://mcp.reui.io/api/mcp?style=base-nova"
|
||||||
|
bearer_token_env_var = "REUI_LICENSE_KEY"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
description: "Generate Commit Message: русский subject, fix vs feat, Conventional Commits"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git commit message (Generate Commit Message)
|
||||||
|
|
||||||
|
При **Generate Commit Message**, автогенерации subject и предложении `git commit`:
|
||||||
|
|
||||||
|
## Обязательно
|
||||||
|
|
||||||
|
- Subject и body — **только на русском** (после `:` в conventional commit).
|
||||||
|
- Локальный hook `.githooks/commit-msg` **отклоняет** коммиты без кириллицы в subject.
|
||||||
|
- Префикс и scope — английский: `fix(ui):`, `chore(rules):`.
|
||||||
|
|
||||||
|
## Тип: сначала не `feat`
|
||||||
|
|
||||||
|
- **`fix`** — баг, регрессия, правка вёрстки/модалки, некорректное поведение, CI/CD, мелкий дефект.
|
||||||
|
- **`chore` / `docs` / `ci` / `build`** — правила Cursor, README, workflow, зависимости, рефакторинг без новой UX-фичи.
|
||||||
|
- **`feat`** — только **новая** пользовательская возможность (страница, API, сценарий), которой не было.
|
||||||
|
|
||||||
|
Если сомневаешься между `feat` и `fix` — выбирай **`fix`** или **`chore`**, не `feat`.
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
- `fix(ui): выровнять модалку сводки релиза`
|
||||||
|
- `fix(ci): исправить сборку release manifest`
|
||||||
|
- `chore(rules): усилить правила русских commit message`
|
||||||
|
- `feat(releases): добавить страницу релизов`
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
|
||||||
|
- `feat: update layout`, `fix: add modal`, `chore: update rules` — английский subject.
|
||||||
|
- `feat` для правки UI, правил, документации и багфиксов.
|
||||||
@@ -5,15 +5,40 @@ alwaysApply: true
|
|||||||
|
|
||||||
# Локальный запуск проекта
|
# Локальный запуск проекта
|
||||||
|
|
||||||
Для запуска всего проекта в dev-режиме поднимать два процесса:
|
## Требования
|
||||||
|
|
||||||
|
- **Node.js 22**, npm с workspaces.
|
||||||
|
- Первый раз (или после смены зависимостей): `npm install` из корня репозитория.
|
||||||
|
- Backend: скопировать `backend/.env.example` → `backend/.env` (по умолчанию `PORT=8000`, `CORS_ORIGIN=http://localhost:3000`).
|
||||||
|
|
||||||
|
## Запуск (два процесса)
|
||||||
|
|
||||||
|
Из корня репозитория поднять **два** long-running процесса в **отдельных** терминалах:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run dev
|
npm run dev
|
||||||
npm --prefix backend run dev
|
npm --prefix backend run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Frontend: `http://localhost:3000`
|
| Сервис | URL | Проверка |
|
||||||
- Backend: `http://localhost:8000`
|
|--------|-----|----------|
|
||||||
- Health check backend: `http://localhost:8000/health`
|
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
|
||||||
|
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
|
||||||
|
|
||||||
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
|
Проверка backend в PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8000/health -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый ответ: `{"status":"ok",...}`.
|
||||||
|
|
||||||
|
## Поведение агента
|
||||||
|
|
||||||
|
Если пользователь просит «запусти проект», «запусти фронт и бэк» или похожее:
|
||||||
|
|
||||||
|
1. Сначала проверить уже запущенные терминалы — не дублировать процессы.
|
||||||
|
2. Запустить обе команды выше как фоновые long-running процессы.
|
||||||
|
3. Дождаться готовности: frontend — `Ready`, backend — `Server listening` / успешный `/health`.
|
||||||
|
|
||||||
|
Подробности архитектуры и env — `README.md`, раздел «Запуск».
|
||||||
@@ -18,7 +18,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Эталон UI
|
## Эталон UI
|
||||||
|
|
||||||
- **`app/(main)/servers`** — главный эталон layout, сетки, типографики, композиции и взаимодействий. Не вводить новый UI-паттерн, если на `/servers` уже есть эквивалент.
|
- **`app/(main)/servers`** — эталон **поведения** CRUD. Оболочка ops: **Frame + DataGrid**, не Card-shell. Не копировать `DataPageCard` на новые экраны.
|
||||||
|
|
||||||
## Качество и совместимость
|
## Качество и совместимость
|
||||||
|
|
||||||
@@ -27,4 +27,4 @@ alwaysApply: false
|
|||||||
## Связь с проектом
|
## Связь с проектом
|
||||||
|
|
||||||
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
||||||
- Сообщения коммитов и ожидаемый semver bump — в **`release-versioning.mdc`**.
|
- Сообщения коммитов: **`commit-messages-ru.mdc`** (язык и `fix` vs `feat`), semver bump — **`release-versioning.mdc`**.
|
||||||
@@ -18,7 +18,7 @@ alwaysApply: false
|
|||||||
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
||||||
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
||||||
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
||||||
- При завершении задачи предлагать subject коммита по **`release-versioning.mdc`** (`fix:` для patch, `feat:` для minor).
|
- При завершении задачи предлагать subject по **`commit-messages-ru.mdc`**: правки UI/багов → `fix`, инфраструктура/правила → `chore`, новая фича → `feat`; описание **на русском**.
|
||||||
|
|
||||||
## Формат вывода (строго)
|
## Формат вывода (строго)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI только с эталоном /servers"
|
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI с CRUD /servers и оболочкой Frame+DataGrid"
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Агент: UI GUARDIAN (хранитель UI)
|
# Агент: UI GUARDIAN (хранитель UI)
|
||||||
|
|
||||||
Ты отвечаешь **только** за **визуальную и UX-согласованность** с эталоном **`/servers`**.
|
Ты отвечаешь **только** за **визуальную и UX-согласованность**.
|
||||||
|
|
||||||
## Язык
|
## Язык
|
||||||
|
|
||||||
@@ -13,31 +13,31 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Эталон
|
## Эталон
|
||||||
|
|
||||||
- Каталог **`app/(main)/servers`** и связанные компоненты страницы — **единственный** эталон для:
|
Два слоя (не смешивать):
|
||||||
- структуры layout (области страницы, карточки, секции);
|
|
||||||
- сетки и отступов;
|
1. **Поведение CRUD** — каталог **`app/(main)/servers`**: фильтры, таблица, диалоги, формы, плотность, паттерны кнопок.
|
||||||
- типографики (заголовки, подписи, плотность текста);
|
2. **Оболочка ops** — **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. KPI — `IconTile` `variant="elevated"` `className="size-10.5"`. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
|
||||||
- композиции shadcn-компонентов;
|
|
||||||
- паттернов взаимодействия (кнопки, таблицы, диалоги, формы — как на `/servers`).
|
`DataPageCard` — ops list shell на ReUI Frame; новые экраны не копируют Card-shell.
|
||||||
|
|
||||||
## Обязанности
|
## Обязанности
|
||||||
|
|
||||||
- Сравнить **каждое** спорное UI-решение в изменениях с тем, как сделано на `/servers`.
|
- Сравнить **каждое** спорное UI-решение: CRUD — с `/servers`; оболочка — с Frame+DataGrid.
|
||||||
- Требовать **переиспользование** существующих компонентов/обёрток из этой зоны, если они покрывают задачу.
|
- Требовать **переиспользование** существующих компонентов/обёрток, если они покрывают задачу.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
- **Нельзя** принимать UI, который **системно расходится** с `/servers` без явной технической необходимости (которую нужно назвать).
|
- **Нельзя** принимать новый Card page-shell без явной технической необходимости (назвать её).
|
||||||
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
|
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
|
||||||
|
|
||||||
## Формат вывода (строго)
|
## Формат вывода (строго)
|
||||||
|
|
||||||
1. **Несоответствия** эталону (конкретно: что именно отличается).
|
1. **Несоответствия** эталону (конкретно: CRUD vs Frame-shell).
|
||||||
2. **Обязательные правки** (что поменять).
|
2. **Обязательные правки** (что поменять).
|
||||||
3. **Что переиспользовать** с `/servers`: файлы/компоненты и паттерн.
|
3. **Что переиспользовать**: файлы/компоненты и паттерн.
|
||||||
|
|
||||||
Если расхождений нет — явно: **«Расхождений с эталоном /servers не выявлено»** и кратко перечисли проверенные аспекты.
|
Если расхождений нет — явно: **«Расхождений с эталоном CRUD /servers и Frame+DataGrid не выявлено»** и кратко перечисли проверенные аспекты.
|
||||||
|
|
||||||
## Граница ответственности
|
## Граница ответственности
|
||||||
|
|
||||||
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности с /servers** и **reuse** UI.
|
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности** и **reuse** UI.
|
||||||
@@ -9,16 +9,19 @@ alwaysApply: true
|
|||||||
|
|
||||||
**Язык:** все ответы пользователю — **только на русском**.
|
**Язык:** все ответы пользователю — **только на русском**.
|
||||||
|
|
||||||
|
**Git / Generate Commit Message:** subject и body — **на русском**; `feat` только для новой UX-фичи, иначе `fix`/`chore`. См. **`commit-messages-ru.mdc`** и **`release-versioning.mdc`**.
|
||||||
|
|
||||||
## 1. Документация в первую очередь
|
## 1. Документация в первую очередь
|
||||||
|
|
||||||
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
||||||
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
|
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
|
||||||
|
|
||||||
## 2. Эталон UI/UX: страница `/servers` (критично)
|
## 2. Эталон UI/UX: `/servers` (CRUD) + Frame+DataGrid (оболочка)
|
||||||
|
|
||||||
- **`app/(main)/servers`** (и связанные компоненты) — **главный эталон** дизайна и поведения.
|
- **`app/(main)/servers`** — эталон **поведения** CRUD: фильтры, сортировка, пагинация, диалоги, формы, плотность.
|
||||||
- Выравнивать: layout, отступы, сетку, типографику, структуру компонентов, паттерны взаимодействия.
|
- **Оболочка ops-списков / dashboard / KPI:** **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
|
||||||
- Переиспользовать оттуда же компоненты и паттерны; **не вводить новый UI-паттерн**, если эквивалент уже есть на `/servers`.
|
- `DataPageCard` — ops list shell на ReUI Frame (`components/data-page-card.tsx`). Внутренние панели — `OpsPanel` / Frame, не shadcn Card как page shell.
|
||||||
|
- Переиспользовать паттерны `/servers` для CRUD; **не** вводить Card-grid как новый SoT.
|
||||||
|
|
||||||
## 3. Большой репозиторий (критично)
|
## 3. Большой репозиторий (критично)
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
В ответе явно указать:
|
В ответе явно указать:
|
||||||
|
|
||||||
- **Почему** это согласуется с Next.js, shadcn/ui и эталоном **`/servers`**.
|
- **Почему** это согласуется с Next.js, shadcn/ui, CRUD `/servers` и оболочкой Frame+DataGrid.
|
||||||
- **Server vs Client Component** и обоснование.
|
- **Server vs Client Component** и обоснование.
|
||||||
- Что **переиспользовано** из проекта (паттерны/компоненты).
|
- Что **переиспользовано** из проекта (паттерны/компоненты).
|
||||||
- Какие **файлы проанализированы**.
|
- Какие **файлы проанализированы**.
|
||||||
@@ -66,7 +69,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
1. **Шаг 0:** список проанализированных файлов.
|
1. **Шаг 0:** список проанализированных файлов.
|
||||||
2. **Шаг 1:** что изменено.
|
2. **Шаг 1:** что изменено.
|
||||||
3. **Шаг 2:** почему (Next.js + shadcn/ui + `/servers` + паттерны кодовой базы).
|
3. **Шаг 2:** почему (Next.js + shadcn/ui + CRUD `/servers` + Frame+DataGrid + паттерны кодовой базы).
|
||||||
4. **Шаг 3:** анализ влияния (что ещё затронуто).
|
4. **Шаг 3:** анализ влияния (что ещё затронуто).
|
||||||
5. **Шаг 4:** код (диффы или несколько файлов).
|
5. **Шаг 4:** код (диффы или несколько файлов).
|
||||||
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
|
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
|
||||||
@@ -78,4 +81,4 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Цель
|
## Цель
|
||||||
|
|
||||||
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, выравнивание с `/servers`, безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
|
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, CRUD как на `/servers`, оболочка Frame+DataGrid (не Card-shell), безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
|
||||||
@@ -1,32 +1,49 @@
|
|||||||
---
|
---
|
||||||
description: "Conventional Commits и semver для patch/minor релизов RouterLists"
|
description: "Conventional Commits, semver релизов MikrotikManager и русские git commit message (Generate Commit Message)"
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Версионирование релизов
|
# Версионирование релизов
|
||||||
|
|
||||||
|
## Generate Commit Message (критично)
|
||||||
|
|
||||||
|
Для **Generate Commit Message** в Cursor, предложения subject в чате, `git commit` и любой автогенерации сообщения коммита:
|
||||||
|
|
||||||
|
- Формат: `тип(scope): описание на русском` — префикс Conventional Commits и scope **на английском**, текст после `:` **только на русском**.
|
||||||
|
- Body и footer (если нужны) — **только на русском**.
|
||||||
|
- В описании после `:` не использовать английские слова: `add`, `update`, `implement`, `rename` и т.п.
|
||||||
|
- **`feat` не по умолчанию.** Сначала проверь `fix` / `chore` / `ci` / `docs`; `feat` — только новая пользовательская возможность.
|
||||||
|
- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`; `feat(releases): добавить страницу релизов`.
|
||||||
|
- Детальный чеклист типов — **`commit-messages-ru.mdc`**.
|
||||||
|
|
||||||
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
|
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
|
||||||
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
||||||
|
- **Язык коммитов:** subject и body — **только на русском**; префикс Conventional Commits (`feat`, `fix`, `chore` и т.д.) и scope — на английском.
|
||||||
|
|
||||||
## Patch (`1.0.x`)
|
## Patch (`1.0.x`)
|
||||||
|
|
||||||
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
||||||
|
|
||||||
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов.
|
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов, **на русском**.
|
||||||
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
||||||
|
|
||||||
## Minor (`1.x.0`)
|
## Minor (`1.x.0`)
|
||||||
|
|
||||||
`feat:`, `feat!`, footer `BREAKING CHANGE` — только для заметного core-функционала.
|
`feat:` — новая пользовательская возможность.
|
||||||
|
|
||||||
|
## Major (`2.0.0` после `1.x`)
|
||||||
|
|
||||||
|
`тип!:`, `тип(scope)!:` или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:` повышают major, обнуляя minor и patch. При смешанной истории major имеет приоритет над minor и patch. Версия, тег, manifest и Docker tags вычисляются CI; package.json вручную не меняется. Для согласованного перехода на вторую основную версию используется `chore(release)!:` с русским описанием.
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
- `fix(servers): исправить опрос API после таймаута` — patch
|
- `fix(servers): исправить опрос API после таймаута` — patch
|
||||||
- `feat(filters): добавить синхронизацию BGP in` — minor
|
- `feat(filters): добавить синхронизацию BGP in` — minor
|
||||||
- `chore: мелкие правки` — плохо; `chore(ci): добавить job prepare-release` — хорошо
|
- `chore(ci): добавить job prepare-release` — хорошо
|
||||||
|
- `feat: add release modal` — плохо (subject не на русском)
|
||||||
|
|
||||||
## Антипаттерны
|
## Антипаттерны
|
||||||
|
|
||||||
WIP, «update», смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
|
WIP, «update», subject на английском, смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
|
||||||
|
|
||||||
Подробности — раздел CI/CD в `README.md`.
|
Подробности — раздел CI/CD в `README.md`.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
description: Use the ReUI registry (blocks, primitives, icons) correctly
|
||||||
|
globs: ["**/*.tsx","**/*.ts"]
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
name: reui
|
||||||
|
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 `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 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
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [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 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)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
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 `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 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
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [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 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)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||||
|
|
||||||
|
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||||
|
|
||||||
|
## Preserve the design - don't over-customize
|
||||||
|
|
||||||
|
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||||
|
|
||||||
|
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||||
|
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||||
|
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||||
|
|
||||||
|
## Reuse the parts: examples and the block's own elements
|
||||||
|
|
||||||
|
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||||
|
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||||
|
|
||||||
|
## Don't invent (read, don't guess)
|
||||||
|
|
||||||
|
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||||
|
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||||
|
|
||||||
|
## What to change vs leave alone
|
||||||
|
|
||||||
|
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||||
|
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||||
|
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||||
|
|
||||||
|
## Demo data -> real data
|
||||||
|
|
||||||
|
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||||
|
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||||
|
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||||
|
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||||
|
|
||||||
|
## Believable content (no AI tells)
|
||||||
|
|
||||||
|
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||||
|
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
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" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||||
|
|
||||||
|
1. Add the key to `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REUI_LICENSE_KEY=your-license-key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Switch `components.json` to the authenticated object form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${REUI_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?ref=skill
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Use the project's package runner (check `packageManager`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes # npm
|
||||||
|
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||||
|
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||||
|
```
|
||||||
|
|
||||||
|
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||||
|
|
||||||
|
## Handling prompts and conflicts
|
||||||
|
|
||||||
|
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||||
|
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||||
|
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||||
|
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||||
|
|
||||||
|
## Free vs premium boundary
|
||||||
|
|
||||||
|
- 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?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
|
## kanban
|
||||||
|
|
||||||
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||||
|
<KanbanBoard>
|
||||||
|
{Object.entries(cols).map(([id, items]) => (
|
||||||
|
<KanbanColumn key={id} value={id}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<KanbanColumnContent value={id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<KanbanItem key={i.id} value={i.id}>
|
||||||
|
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||||
|
|
||||||
|
## sortable
|
||||||
|
|
||||||
|
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<SortableItem key={i.id} value={i.id}>
|
||||||
|
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||||
|
{i.label}
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</Sortable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||||
|
|
||||||
|
## filters
|
||||||
|
|
||||||
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const fields: FilterField[] = [
|
||||||
|
{ id: "title", label: "Title", type: "text" },
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Status",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
|
## date-selector
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange` to capture the value.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||||
|
|
||||||
|
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||||
|
|
||||||
|
## tree
|
||||||
|
|
||||||
|
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tree tree={tree}>
|
||||||
|
{tree.getItems().map((item) => (
|
||||||
|
<TreeItem key={item.getId()} item={item}>
|
||||||
|
<TreeItemLabel />
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</Tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||||
|
|
||||||
|
## stepper
|
||||||
|
|
||||||
|
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stepper defaultValue={1}>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||||
|
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||||
|
|
||||||
|
## timeline
|
||||||
|
|
||||||
|
**Required:** `TimelineItem step` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>March 2024</TimelineDate>
|
||||||
|
<TimelineTitle>Project initialized</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||||
|
|
||||||
|
## autocomplete
|
||||||
|
|
||||||
|
**Required:** `items` (array; each item has at least `value`)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Autocomplete items={items}>
|
||||||
|
<AutocompleteInput placeholder="Search..." />
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||||
|
|
||||||
|
## phone-input
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||||
|
|
||||||
|
## number-field
|
||||||
|
|
||||||
|
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NumberField defaultValue={0}>
|
||||||
|
<NumberFieldScrubArea label="Quantity" />
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||||
|
|
||||||
|
## rating
|
||||||
|
|
||||||
|
**Required:** `rating` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||||
|
|
||||||
|
## scrollspy
|
||||||
|
|
||||||
|
**Required:** `targetRef` (the scroll container ref)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Scrollspy targetRef={containerRef}>
|
||||||
|
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||||
|
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||||
|
</Scrollspy>
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<div id="s1">...</div>
|
||||||
|
<div id="s2">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## icon-stack
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<InboxIcon className="size-4" />
|
||||||
|
</IconStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<AlertTitle>Security update</AlertTitle>
|
||||||
|
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||||
|
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||||
|
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||||
|
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a point of view
|
||||||
|
|
||||||
|
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## The bar
|
||||||
|
|
||||||
|
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## Keep icons purposeful
|
||||||
|
|
||||||
|
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||||
|
|
||||||
|
## Motion Icons (the `@reui/icons/...` set)
|
||||||
|
|
||||||
|
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
Finding icons:
|
||||||
|
|
||||||
|
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||||
|
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||||
|
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||||
|
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||||
|
- Every non-submit button is `type="button"`.
|
||||||
|
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## Scroll mechanics
|
||||||
|
|
||||||
|
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||||
|
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ReUI registry structure
|
||||||
|
|
||||||
|
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 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).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||||
|
|
||||||
|
## Know your base: base or radix
|
||||||
|
|
||||||
|
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||||
|
|
||||||
|
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||||
|
|
||||||
|
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Workflow: find -> install -> read API -> adapt
|
||||||
|
|
||||||
|
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
|
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 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`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
|
Run the result's `install` command from the project root, non-interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||||
|
|
||||||
|
## 3. Read the API (do not guess props)
|
||||||
|
|
||||||
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
|
||||||
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
## 4. Adapt (reuse-first) - do not skip
|
||||||
|
|
||||||
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||||
|
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||||
|
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||||
|
8. **Typecheck / lint**.
|
||||||
|
|
||||||
|
## If no single block fits
|
||||||
|
|
||||||
|
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ReUI MCP: full reference
|
||||||
|
|
||||||
|
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||||
|
|
||||||
|
## Golden path (token-optimal - follow this order)
|
||||||
|
|
||||||
|
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||||
|
|
||||||
|
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||||
|
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||||
|
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||||
|
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||||
|
5. **`get_audit_checklist()`** before declaring done.
|
||||||
|
|
||||||
|
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||||
|
|
||||||
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## Token + speed rules
|
||||||
|
|
||||||
|
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||||
|
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||||
|
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||||
|
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||||
|
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||||
|
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||||
|
|
||||||
|
## Result shapes (so you don't re-fetch)
|
||||||
|
|
||||||
|
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||||
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## 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?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.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- 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?ref=skill
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Git commit message (Generate Commit Message)
|
||||||
|
|
||||||
|
Всегда генерируй subject коммита на русском.
|
||||||
|
|
||||||
|
- Формат: `тип(scope): описание на русском` — тип и scope на английском, текст после `:` только на русском.
|
||||||
|
- По умолчанию не `feat`: правки UI/багов/CI → `fix`; правила/README/workflow → `chore`/`ci`; `feat` только для новой пользовательской возможности.
|
||||||
|
- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`.
|
||||||
|
- Запрещено: `feat: update layout`, `fix: add modal`, любой английский subject.
|
||||||
|
|
||||||
|
Подробнее: `.cursor/rules/commit-messages-ru.mdc`, semver: `.cursor/rules/release-versioning.mdc`.
|
||||||
+101
-10
@@ -7,8 +7,8 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.shts.su
|
REGISTRY: git.shx.one
|
||||||
GITEA_SERVER_URL: https://git.shts.su
|
GITEA_SERVER_URL: https://git.shx.one
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-release:
|
prepare-release:
|
||||||
@@ -25,6 +25,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Test release version calculation
|
||||||
|
run: node --test .ci/scripts/compute-release.test.mjs
|
||||||
|
|
||||||
- name: Compute release metadata
|
- name: Compute release metadata
|
||||||
id: release
|
id: release
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -38,8 +41,85 @@ jobs:
|
|||||||
node .ci/scripts/compute-release.mjs
|
node .ci/scripts/compute-release.mjs
|
||||||
test -f .ci/release/release-manifest.json
|
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:
|
backend-image:
|
||||||
needs: prepare-release
|
needs:
|
||||||
|
- prepare-release
|
||||||
|
- backend-test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -61,7 +141,16 @@ jobs:
|
|||||||
STAGING=".ci/docker/backend"
|
STAGING=".ci/docker/backend"
|
||||||
rm -rf "$STAGING"
|
rm -rf "$STAGING"
|
||||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
||||||
cp package.json package-lock.json "$STAGING/"
|
cp package-lock.json "$STAGING/"
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||||
|
pkg.workspaces = ["packages/*", "backend"]
|
||||||
|
pkg.dependencies = {}
|
||||||
|
pkg.devDependencies = {}
|
||||||
|
delete pkg.scripts
|
||||||
|
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||||
|
NODE
|
||||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||||
@@ -70,13 +159,14 @@ jobs:
|
|||||||
cp -R backend/drizzle "$STAGING/backend/"
|
cp -R backend/drizzle "$STAGING/backend/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Gitea Registry
|
- name: Log in to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.shts.su
|
registry: git.shx.one
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
@@ -161,7 +251,7 @@ jobs:
|
|||||||
- name: Log in to Gitea Registry
|
- name: Log in to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.shts.su
|
registry: git.shx.one
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
@@ -177,6 +267,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
NEXT_PUBLIC_BACKEND_URL=same-origin
|
||||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||||
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||||
|
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
||||||
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -214,7 +305,7 @@ jobs:
|
|||||||
- name: Log in to Gitea Registry
|
- name: Log in to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.shts.su
|
registry: git.shx.one
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
@@ -325,17 +416,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
notify-webhook:
|
notify-webhook:
|
||||||
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }}
|
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
- backend-image
|
- backend-image
|
||||||
- frontend-image
|
- frontend-image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||||
steps:
|
steps:
|
||||||
- name: Notify deploy webhook
|
- name: Notify deploy webhook
|
||||||
|
if: env.WEBHOOK_URL != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl --fail --silent --show-error --request POST \
|
curl --fail --silent --show-error --request POST \
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
node "$(dirname "$0")/../.ci/scripts/validate-commit-message.mjs" "$1"
|
||||||
+13
@@ -31,6 +31,10 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# coverage
|
# coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
@@ -49,3 +53,12 @@ Thumbs.db
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
.ci/docker/
|
.ci/docker/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Local MCP configs (may contain REUI license Bearer)
|
||||||
|
.cursor/mcp.json
|
||||||
|
.mcp.json
|
||||||
|
opencode.json
|
||||||
|
|
||||||
|
# Local codegraph process
|
||||||
|
.codegraph/daemon.pid
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
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 `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 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
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [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 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)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||||
|
|
||||||
|
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||||
|
|
||||||
|
## Preserve the design - don't over-customize
|
||||||
|
|
||||||
|
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||||
|
|
||||||
|
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||||
|
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||||
|
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||||
|
|
||||||
|
## Reuse the parts: examples and the block's own elements
|
||||||
|
|
||||||
|
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||||
|
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||||
|
|
||||||
|
## Don't invent (read, don't guess)
|
||||||
|
|
||||||
|
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||||
|
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||||
|
|
||||||
|
## What to change vs leave alone
|
||||||
|
|
||||||
|
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||||
|
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||||
|
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||||
|
|
||||||
|
## Demo data -> real data
|
||||||
|
|
||||||
|
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||||
|
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||||
|
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||||
|
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||||
|
|
||||||
|
## Believable content (no AI tells)
|
||||||
|
|
||||||
|
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||||
|
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
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" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||||
|
|
||||||
|
1. Add the key to `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REUI_LICENSE_KEY=your-license-key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Switch `components.json` to the authenticated object form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${REUI_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?ref=skill
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Use the project's package runner (check `packageManager`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes # npm
|
||||||
|
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||||
|
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||||
|
```
|
||||||
|
|
||||||
|
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||||
|
|
||||||
|
## Handling prompts and conflicts
|
||||||
|
|
||||||
|
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||||
|
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||||
|
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||||
|
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||||
|
|
||||||
|
## Free vs premium boundary
|
||||||
|
|
||||||
|
- 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?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
|
## kanban
|
||||||
|
|
||||||
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||||
|
<KanbanBoard>
|
||||||
|
{Object.entries(cols).map(([id, items]) => (
|
||||||
|
<KanbanColumn key={id} value={id}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<KanbanColumnContent value={id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<KanbanItem key={i.id} value={i.id}>
|
||||||
|
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||||
|
|
||||||
|
## sortable
|
||||||
|
|
||||||
|
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<SortableItem key={i.id} value={i.id}>
|
||||||
|
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||||
|
{i.label}
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</Sortable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||||
|
|
||||||
|
## filters
|
||||||
|
|
||||||
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const fields: FilterField[] = [
|
||||||
|
{ id: "title", label: "Title", type: "text" },
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Status",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
|
## date-selector
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange` to capture the value.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||||
|
|
||||||
|
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||||
|
|
||||||
|
## tree
|
||||||
|
|
||||||
|
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tree tree={tree}>
|
||||||
|
{tree.getItems().map((item) => (
|
||||||
|
<TreeItem key={item.getId()} item={item}>
|
||||||
|
<TreeItemLabel />
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</Tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||||
|
|
||||||
|
## stepper
|
||||||
|
|
||||||
|
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stepper defaultValue={1}>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||||
|
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||||
|
|
||||||
|
## timeline
|
||||||
|
|
||||||
|
**Required:** `TimelineItem step` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>March 2024</TimelineDate>
|
||||||
|
<TimelineTitle>Project initialized</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||||
|
|
||||||
|
## autocomplete
|
||||||
|
|
||||||
|
**Required:** `items` (array; each item has at least `value`)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Autocomplete items={items}>
|
||||||
|
<AutocompleteInput placeholder="Search..." />
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||||
|
|
||||||
|
## phone-input
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||||
|
|
||||||
|
## number-field
|
||||||
|
|
||||||
|
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NumberField defaultValue={0}>
|
||||||
|
<NumberFieldScrubArea label="Quantity" />
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||||
|
|
||||||
|
## rating
|
||||||
|
|
||||||
|
**Required:** `rating` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||||
|
|
||||||
|
## scrollspy
|
||||||
|
|
||||||
|
**Required:** `targetRef` (the scroll container ref)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Scrollspy targetRef={containerRef}>
|
||||||
|
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||||
|
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||||
|
</Scrollspy>
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<div id="s1">...</div>
|
||||||
|
<div id="s2">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## icon-stack
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<InboxIcon className="size-4" />
|
||||||
|
</IconStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<AlertTitle>Security update</AlertTitle>
|
||||||
|
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||||
|
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||||
|
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||||
|
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a point of view
|
||||||
|
|
||||||
|
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## The bar
|
||||||
|
|
||||||
|
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## Keep icons purposeful
|
||||||
|
|
||||||
|
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||||
|
|
||||||
|
## Motion Icons (the `@reui/icons/...` set)
|
||||||
|
|
||||||
|
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
Finding icons:
|
||||||
|
|
||||||
|
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||||
|
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||||
|
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||||
|
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||||
|
- Every non-submit button is `type="button"`.
|
||||||
|
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## Scroll mechanics
|
||||||
|
|
||||||
|
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||||
|
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ReUI registry structure
|
||||||
|
|
||||||
|
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 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).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||||
|
|
||||||
|
## Know your base: base or radix
|
||||||
|
|
||||||
|
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||||
|
|
||||||
|
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||||
|
|
||||||
|
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Workflow: find -> install -> read API -> adapt
|
||||||
|
|
||||||
|
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
|
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 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`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
|
Run the result's `install` command from the project root, non-interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||||
|
|
||||||
|
## 3. Read the API (do not guess props)
|
||||||
|
|
||||||
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
|
||||||
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
## 4. Adapt (reuse-first) - do not skip
|
||||||
|
|
||||||
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||||
|
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||||
|
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||||
|
8. **Typecheck / lint**.
|
||||||
|
|
||||||
|
## If no single block fits
|
||||||
|
|
||||||
|
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ReUI MCP: full reference
|
||||||
|
|
||||||
|
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||||
|
|
||||||
|
## Golden path (token-optimal - follow this order)
|
||||||
|
|
||||||
|
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||||
|
|
||||||
|
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||||
|
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||||
|
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||||
|
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||||
|
5. **`get_audit_checklist()`** before declaring done.
|
||||||
|
|
||||||
|
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||||
|
|
||||||
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## Token + speed rules
|
||||||
|
|
||||||
|
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||||
|
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||||
|
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||||
|
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||||
|
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||||
|
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||||
|
|
||||||
|
## Result shapes (so you don't re-fetch)
|
||||||
|
|
||||||
|
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||||
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## 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?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.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- 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?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): …`.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# MikrotikManager — инструкции для агента
|
||||||
|
|
||||||
|
## Сообщения коммитов
|
||||||
|
|
||||||
|
При **Generate Commit Message** и любой автогенерации commit message:
|
||||||
|
|
||||||
|
1. Subject после `:` — **только на русском**.
|
||||||
|
2. **`feat` не по умолчанию.** Правка UI/бага/CI → `fix`; правила/README/workflow → `chore`/`ci`; новая пользовательская фича → `feat`.
|
||||||
|
3. Формат: `fix(ui): выровнять модалку релиза`.
|
||||||
|
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
||||||
|
|
||||||
|
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
|
||||||
|
|
||||||
|
## ReUI
|
||||||
|
|
||||||
|
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 -` из корня проекта.
|
||||||
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
|||||||
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||||
ARG NEXT_PUBLIC_APP_VERSION=dev
|
ARG NEXT_PUBLIC_APP_VERSION=dev
|
||||||
ARG NEXT_PUBLIC_RELEASE_URL=
|
ARG NEXT_PUBLIC_RELEASE_URL=
|
||||||
|
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
||||||
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
||||||
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
|
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
|
||||||
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
|
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
|
||||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||||
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
|
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
|
||||||
|
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
|
||||||
COPY packages/contracts packages/contracts
|
COPY packages/contracts packages/contracts
|
||||||
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
||||||
COPY app app
|
COPY app app
|
||||||
@@ -40,6 +42,7 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
ENV BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
COPY --from=build /app/public ./public
|
COPY --from=build /app/public ./public
|
||||||
COPY --from=build /app/.next/standalone ./
|
COPY --from=build /app/.next/standalone ./
|
||||||
COPY --from=build /app/.next/static ./.next/static
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
|
|||||||
@@ -19,17 +19,17 @@
|
|||||||
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|
||||||
|-----------|------|------|----------------|--------------|
|
|-----------|------|------|----------------|--------------|
|
||||||
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
|
| 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 |
|
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
|
||||||
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
|
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
|
||||||
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
|
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
|
||||||
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shts.su` |
|
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shx.one` |
|
||||||
|
|
||||||
Имена образов в registry для репозитория `denozord/MikrotikManager-3`:
|
Имена образов в registry для репозитория `denozord/MikrotikManager-3`:
|
||||||
|
|
||||||
- `git.shts.su/denozord/mikrotikmanager-backend`
|
- `git.shx.one/denozord/mikrotikmanager-backend`
|
||||||
- `git.shts.su/denozord/mikrotikmanager-frontend`
|
- `git.shx.one/denozord/mikrotikmanager-frontend`
|
||||||
- `git.shts.su/denozord/mikrotikmanager-updater`
|
- `git.shx.one/denozord/mikrotikmanager-updater`
|
||||||
|
|
||||||
Для другого owner/repo подставьте имя по правилу CI (см. [CI/CD](#cicd-gitea-actions)).
|
Для другого owner/repo подставьте имя по правилу CI (см. [CI/CD](#cicd-gitea-actions)).
|
||||||
|
|
||||||
@@ -43,24 +43,25 @@ flowchart TB
|
|||||||
Browser["Браузер"]
|
Browser["Браузер"]
|
||||||
NextDev["Next.js :3000"]
|
NextDev["Next.js :3000"]
|
||||||
FastifyDev["Fastify :8000"]
|
FastifyDev["Fastify :8000"]
|
||||||
SqliteDev["SQLite файл"]
|
PostgresDev["PostgreSQL 18"]
|
||||||
Contracts["@mmapp/contracts"]
|
Contracts["@mmapp/contracts"]
|
||||||
Browser --> NextDev
|
Browser --> NextDev
|
||||||
Browser -->|"fetch /api, CORS"| FastifyDev
|
Browser -->|"fetch /api, CORS"| FastifyDev
|
||||||
FastifyDev --> SqliteDev
|
FastifyDev --> PostgresDev
|
||||||
Contracts --> NextDev
|
Contracts --> NextDev
|
||||||
Contracts --> FastifyDev
|
Contracts --> FastifyDev
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph ci [Gitea Actions]
|
subgraph ci [Gitea Actions]
|
||||||
WF[".gitea/workflows/docker.yml"]
|
WF[".gitea/workflows/docker.yml"]
|
||||||
WF --> Reg["git.shts.su registry"]
|
WF --> Reg["git.shx.one registry"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph prod [Прод-сервер]
|
subgraph prod [Прод-сервер]
|
||||||
FE["mmapp-frontend :3000"]
|
FE["mmapp-frontend :3000"]
|
||||||
BE["mmapp-backend :8000"]
|
BE["mmapp-backend :8000"]
|
||||||
DBVol["volume backend-data /app/data"]
|
PG["mmapp-postgres"]
|
||||||
|
DBVol["volume sqlite ETL /app/data"]
|
||||||
UPD["mmapp-updater"]
|
UPD["mmapp-updater"]
|
||||||
Sock["/var/run/docker.sock"]
|
Sock["/var/run/docker.sock"]
|
||||||
Reg --> FE
|
Reg --> FE
|
||||||
@@ -69,6 +70,7 @@ flowchart TB
|
|||||||
UPD --> Sock
|
UPD --> Sock
|
||||||
UPD --> FE
|
UPD --> FE
|
||||||
UPD --> BE
|
UPD --> BE
|
||||||
|
BE --> PG
|
||||||
BE --> DBVol
|
BE --> DBVol
|
||||||
BrowserProd["Клиент"] --> FE
|
BrowserProd["Клиент"] --> FE
|
||||||
BrowserProd -->|"live + backendUrl"| BE
|
BrowserProd -->|"live + backendUrl"| BE
|
||||||
@@ -117,7 +119,7 @@ sequenceDiagram
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
|
- Триггер 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**.
|
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
|
||||||
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
|
- На сервере образы подтягиваются вручную (`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). |
|
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
||||||
|
|
||||||
## Локальная разработка
|
## Локальная разработка
|
||||||
@@ -134,7 +137,9 @@ sequenceDiagram
|
|||||||
|
|
||||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
- **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`.
|
||||||
|
|
||||||
### Запуск
|
### Запуск
|
||||||
|
|
||||||
@@ -160,7 +165,8 @@ Backend — скопировать `backend/.env.example` в `backend/.env`:
|
|||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|------------|--------------|------------|
|
|------------|--------------|------------|
|
||||||
| `DATABASE_PATH` | `./mikrotik.db` | путь к файлу SQLite |
|
| `DATABASE_URL` | `postgres://mmapp:[email protected]:5432/mmapp` | подключение к PostgreSQL |
|
||||||
|
| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
|
||||||
| `PORT` | `8000` | порт Fastify |
|
| `PORT` | `8000` | порт Fastify |
|
||||||
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
|
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
|
||||||
|
|
||||||
@@ -178,23 +184,29 @@ Frontend — в репозитории нет корневого `.env.example`.
|
|||||||
npm run build -w @mmapp/contracts
|
npm run build -w @mmapp/contracts
|
||||||
```
|
```
|
||||||
|
|
||||||
После изменения схем Drizzle:
|
Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm --prefix backend run db:generate
|
npm --prefix backend run db:migrate-from-sqlite
|
||||||
npm --prefix backend run db:migrate
|
|
||||||
npm --prefix backend run db:studio
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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)
|
## CI/CD (Gitea Actions)
|
||||||
|
|
||||||
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
|
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
|
||||||
|
|
||||||
| Job | Build context | Dockerfile | Имя образа |
|
| Job | Build context | Dockerfile | Имя образа |
|
||||||
|-----|---------------|------------|------------|
|
|-----|---------------|------------|------------|
|
||||||
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shts.su/<owner>/<stem>-backend` |
|
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shx.one/<owner>/<stem>-backend` |
|
||||||
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shts.su/<owner>/<stem>-frontend` |
|
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shx.one/<owner>/<stem>-frontend` |
|
||||||
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shts.su/<owner>/<stem>-updater` |
|
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shx.one/<owner>/<stem>-updater` |
|
||||||
|
|
||||||
- `<owner>` — первая часть `gitea.repository`, lower case.
|
- `<owner>` — первая часть `gitea.repository`, lower case.
|
||||||
- `<stem>` — имя репозитория lower case без суффикса `-<цифры>` в конце (например `MikrotikManager-3` → `mikrotikmanager`).
|
- `<stem>` — имя репозитория lower case без суффикса `-<цифры>` в конце (например `MikrotikManager-3` → `mikrotikmanager`).
|
||||||
@@ -207,27 +219,83 @@ 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**.
|
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
|
||||||
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
|
- **`!` после типа/scope или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:`** → major (`2.0.0` после `1.x`); **`feat`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
|
||||||
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
|
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
|
||||||
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
|
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
|
||||||
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
||||||
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
|
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
|
||||||
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
|
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
|
||||||
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc).
|
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
|
||||||
|
|
||||||
## Прод-развёртывание Docker
|
## Прод-развёртывание Docker
|
||||||
|
|
||||||
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
|
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
|
||||||
|
|
||||||
|
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
|
||||||
|
|
||||||
|
### CDN Manager + MikrotikManager (один Traefik)
|
||||||
|
|
||||||
|
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
|
||||||
|
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
|
||||||
|
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
|
||||||
|
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
|
||||||
|
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||||
|
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
|
||||||
|
docker login git.shx.one
|
||||||
|
cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||||
|
curl -fsS https://cdn.shnt.top/health
|
||||||
|
curl -fsS https://mm.shnt.top/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
|
||||||
|
|
||||||
|
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
|
||||||
|
|
||||||
|
| Способ | Файл |
|
||||||
|
|--------|------|
|
||||||
|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
|
||||||
|
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
|
||||||
|
|
||||||
|
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compose (рекомендуется)
|
||||||
|
mkdir -p /opt/mmapp/{data,state,updater}
|
||||||
|
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
|
||||||
|
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
|
||||||
|
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||||
|
cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||||
|
|
||||||
|
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
|
||||||
|
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
|
||||||
|
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
|
||||||
|
chmod +x /tmp/run-beside-cdn-traefik.sh
|
||||||
|
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
|
||||||
|
|
||||||
|
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
|
||||||
|
|
||||||
|
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
|
||||||
|
|
||||||
### Прод-контейнеры
|
### Прод-контейнеры
|
||||||
|
|
||||||
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|
||||||
|--------|------------------|----------------|----------------------|------|-----------|
|
|--------|------------------|----------------|----------------------|------|-----------|
|
||||||
| backend | `mmapp-backend` | `git.shts.su/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data` → `/app/data` | `unless-stopped` |
|
| postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
|
||||||
| frontend | `mmapp-frontend` | `git.shts.su/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
|
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
|
||||||
| updater | `mmapp-updater` | `git.shts.su/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `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` |
|
||||||
|
|
||||||
Метки для updater на backend и frontend:
|
Метки для updater на backend и frontend:
|
||||||
|
|
||||||
@@ -241,11 +309,14 @@ npm --prefix backend run db:studio
|
|||||||
|
|
||||||
### Переменные окружения (прод)
|
### Переменные окружения (прод)
|
||||||
|
|
||||||
**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 | Назначение |
|
| Переменная | Источник в compose | Назначение |
|
||||||
|------------|-------------------|------------|
|
|------------|-------------------|------------|
|
||||||
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
|
| `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.
|
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
|
||||||
|
|
||||||
@@ -256,16 +327,16 @@ npm --prefix backend run db:studio
|
|||||||
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
|
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
|
||||||
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
|
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
|
||||||
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
|
| `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` |
|
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
|
||||||
| `REGISTRY` | `git.shts.su` | registry для `docker login` |
|
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
|
||||||
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
|
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
|
||||||
| `REGISTRY_PASSWORD` | пусто | пароль registry |
|
| `REGISTRY_PASSWORD` | пусто | пароль registry |
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login git.shts.su
|
docker login git.shx.one
|
||||||
export CORS_ORIGIN=http://<хост>:3000
|
export CORS_ORIGIN=http://<хост>:3000
|
||||||
export REGISTRY_USERNAME=<user>
|
export REGISTRY_USERNAME=<user>
|
||||||
export REGISTRY_PASSWORD=<token>
|
export REGISTRY_PASSWORD=<token>
|
||||||
@@ -307,8 +378,8 @@ docker run -d \
|
|||||||
-v backend-data:/app/data \
|
-v backend-data:/app/data \
|
||||||
--label mmapp.updater.managed=true \
|
--label mmapp.updater.managed=true \
|
||||||
--label mmapp.updater.target=backend \
|
--label mmapp.updater.target=backend \
|
||||||
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-backend:latest \
|
--label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-backend:latest \
|
||||||
git.shts.su/denozord/mikrotikmanager-backend:latest
|
git.shx.one/denozord/mikrotikmanager-backend:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
@@ -320,8 +391,8 @@ docker run -d \
|
|||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
--label mmapp.updater.managed=true \
|
--label mmapp.updater.managed=true \
|
||||||
--label mmapp.updater.target=frontend \
|
--label mmapp.updater.target=frontend \
|
||||||
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-frontend:latest \
|
--label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:latest \
|
||||||
git.shts.su/denozord/mikrotikmanager-frontend:latest
|
git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Updater:**
|
**Updater:**
|
||||||
@@ -331,7 +402,7 @@ docker volume create updater-state
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name mmapp-updater \
|
--name mmapp-updater \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
-e REGISTRY=git.shts.su \
|
-e REGISTRY=git.shx.one \
|
||||||
-e REGISTRY_USERNAME=<user> \
|
-e REGISTRY_USERNAME=<user> \
|
||||||
-e REGISTRY_PASSWORD=<token> \
|
-e REGISTRY_PASSWORD=<token> \
|
||||||
-e POLL_INTERVAL_SECONDS=300 \
|
-e POLL_INTERVAL_SECONDS=300 \
|
||||||
@@ -340,7 +411,7 @@ docker run -d \
|
|||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-v updater-state:/state \
|
-v updater-state:/state \
|
||||||
-v /path/to/targets.json:/etc/updater/targets.json:ro \
|
-v /path/to/targets.json:/etc/updater/targets.json:ro \
|
||||||
git.shts.su/denozord/mikrotikmanager-updater:latest
|
git.shx.one/denozord/mikrotikmanager-updater:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Проверка после деплоя
|
### Проверка после деплоя
|
||||||
@@ -399,7 +470,7 @@ bash deploy/updater/validate.sh
|
|||||||
|
|
||||||
## Первичная настройка сервера
|
## Первичная настройка сервера
|
||||||
|
|
||||||
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shts.su`.
|
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shx.one`.
|
||||||
|
|
||||||
1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива).
|
1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива).
|
||||||
2. Проверить:
|
2. Проверить:
|
||||||
@@ -413,7 +484,7 @@ docker compose version
|
|||||||
4. Войти в registry:
|
4. Войти в registry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login git.shts.su
|
docker login git.shx.one
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Получить файлы деплоя: клонировать репозиторий или скопировать каталог `deploy/` и подготовить `deploy/updater/targets.json`.
|
5. Получить файлы деплоя: клонировать репозиторий или скопировать каталог `deploy/` и подготовить `deploy/updater/targets.json`.
|
||||||
@@ -443,8 +514,8 @@ Updater опрашивает registry с интервалом `POLL_INTERVAL_SEC
|
|||||||
### Ручное обновление
|
### Ручное обновление
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.shts.su/denozord/mikrotikmanager-backend:latest
|
docker pull git.shx.one/denozord/mikrotikmanager-backend:latest
|
||||||
docker pull git.shts.su/denozord/mikrotikmanager-frontend:latest
|
docker pull git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Через compose (пересоздание при смене образа):
|
Через compose (пересоздание при смене образа):
|
||||||
@@ -460,18 +531,20 @@ docker compose up -d --force-recreate
|
|||||||
### Откат
|
### Откат
|
||||||
|
|
||||||
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
|
- **Автоматически:** 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
|
```bash
|
||||||
docker stop mmapp-backend
|
docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
|
||||||
|
|
||||||
|
Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
|
||||||
|
|
||||||
### Отладка
|
### Отладка
|
||||||
|
|
||||||
| Действие | Команда |
|
| Действие | Команда |
|
||||||
@@ -485,7 +558,7 @@ docker start mmapp-backend
|
|||||||
|
|
||||||
1. Установить Docker Engine и Compose plugin; проверить `docker version` и `docker compose version`.
|
1. Установить Docker Engine и Compose plugin; проверить `docker version` и `docker compose version`.
|
||||||
2. Открыть порты 3000 и 8000 (или обеспечить доступ клиентов к UI и API).
|
2. Открыть порты 3000 и 8000 (или обеспечить доступ клиентов к UI и API).
|
||||||
3. Выполнить `docker login git.shts.su`.
|
3. Выполнить `docker login git.shx.one`.
|
||||||
4. Склонировать репозиторий или скопировать `deploy/`.
|
4. Склонировать репозиторий или скопировать `deploy/`.
|
||||||
5. Задать `CORS_ORIGIN` (origin фронтенда для браузера).
|
5. Задать `CORS_ORIGIN` (origin фронтенда для браузера).
|
||||||
6. Задать `REGISTRY_USERNAME` и `REGISTRY_PASSWORD` для updater при приватном registry.
|
6. Задать `REGISTRY_USERNAME` и `REGISTRY_PASSWORD` для updater при приватном registry.
|
||||||
|
|||||||
+369
-729
File diff suppressed because it is too large.
Load diff
+54
-76
@@ -1,34 +1,49 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { asns as mockAsns } from "@/lib/data"
|
import { asns as mockAsns } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { toast } from "sonner"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export default function AsnsPage() {
|
export default function AsnsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live"
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!useEvoCatalog) return mockAsns
|
if (mode === "mock") return mockAsns
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.asns ?? []
|
return snapshot?.asns ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return rows
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.asn.toLowerCase().includes(q) ||
|
||||||
|
r.org.toLowerCase().includes(q) ||
|
||||||
|
(r.prefixes === null ? "" : String(r.prefixes)).includes(q),
|
||||||
|
)
|
||||||
|
}, [rows, search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||||
actions={
|
actions={mode === "live" ? null : <>
|
||||||
<>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
||||||
</>
|
</>
|
||||||
@@ -41,71 +56,34 @@ export default function AsnsPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Автономные системы — источники маршрутов для BGP-фильтров
|
Автономные системы — источники маршрутов для BGP-фильтров
|
||||||
</p>
|
</p>
|
||||||
{useEvoCatalog && (
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
<p className={cn(
|
|
||||||
"text-xs mt-2 flex items-center gap-1.5",
|
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
||||||
{error
|
|
||||||
? `EvoBGP: ${error}`
|
|
||||||
: snapshot?.fetchedAt
|
|
||||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
||||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataPageCard>
|
||||||
data={rows}
|
<DataPageToolbar
|
||||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
search={search}
|
||||||
searchKeys={["asn", "org", "prefixes"]}
|
onSearchChange={setSearch}
|
||||||
columns={[
|
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||||
{
|
countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
|
||||||
key: "asn",
|
/>
|
||||||
label: "ASN",
|
{known || loading ? <AsnsDataGrid
|
||||||
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
|
asns={filtered}
|
||||||
},
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
{
|
pagination={useEvoCatalog}
|
||||||
key: "org",
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
label: "Имя / организация",
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
render: (d) => (
|
</DataPageCard>
|
||||||
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
|
|
||||||
{d.org}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "prefixes",
|
|
||||||
label: "Префиксов",
|
|
||||||
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "filter",
|
|
||||||
label: "Фильтр",
|
|
||||||
render: (d) => (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
|
||||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "updated",
|
|
||||||
label: "Обновлён",
|
|
||||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "enabled",
|
|
||||||
label: "Статус",
|
|
||||||
render: (d) => (
|
|
||||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
|
||||||
{d.enabled ? "Активен" : "Отключён"}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт ASN"
|
||||||
|
description="Загрузите CSV или JSON со списком автономных систем"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+538
-707
File diff suppressed because it is too large.
Load diff
+364
-482
File diff suppressed because it is too large.
Load diff
+615
-337
File diff suppressed because it is too large.
Load diff
+126
-197
@@ -1,39 +1,33 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from "react"
|
import { useEffect, useRef, useState, useMemo } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import {
|
import {
|
||||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
CommunitiesDataGrid,
|
||||||
} from "@/components/ui/card"
|
type CommunityRow,
|
||||||
|
TYPE_LABELS,
|
||||||
|
ACTION_LABELS,
|
||||||
|
} 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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||||
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||||
LoaderCircleIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { filters } from "@/lib/data"
|
import { filters } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
type Community = CommunityRow
|
||||||
|
type CommType = CommunityRow["type"]
|
||||||
interface Community {
|
|
||||||
id: string
|
|
||||||
value: string // e.g. "65001:100"
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
type: CommType
|
|
||||||
filterIds: string[] // which filters use this community
|
|
||||||
serverCount: number
|
|
||||||
prefixCount: number
|
|
||||||
action: "permit" | "deny" | "local-pref" | "metric"
|
|
||||||
actionValue?: number // e.g. local-pref value
|
|
||||||
enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -100,49 +94,34 @@ const COMMUNITIES: Community[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const TYPE_LABELS: Record<CommType, string> = {
|
|
||||||
"standard": "Стандартный",
|
|
||||||
"no-export": "No-export",
|
|
||||||
"no-advertise":"No-advertise",
|
|
||||||
"local-as": "Local-AS",
|
|
||||||
"custom": "Кастомный",
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTION_LABELS: Record<Community["action"], string> = {
|
|
||||||
"permit": "Permit",
|
|
||||||
"deny": "Deny",
|
|
||||||
"local-pref": "Local-pref",
|
|
||||||
"metric": "MED/Metric",
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTION_COLOR: Record<Community["action"], string> = {
|
|
||||||
"permit": "text-emerald-500",
|
|
||||||
"deny": "text-red-500",
|
|
||||||
"local-pref": "text-blue-500",
|
|
||||||
"metric": "text-amber-500",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CommunitiesPage() {
|
export default function CommunitiesPage() {
|
||||||
const { mode } = useDataSource()
|
const { sourceKey } = useEvoBGP()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
return <CommunitiesContent key={sourceKey} />
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
}
|
||||||
|
function CommunitiesContent() {
|
||||||
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
|
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||||
const [copied, setCopied] = useState<string | null>(null)
|
const [copied, setCopied] = useState<string | null>(null)
|
||||||
const [selected, setSelected] = useState<Community | null>(null)
|
const [copying, setCopying] = useState(false)
|
||||||
|
const [copyError, setCopyError] = useState<string | null>(null)
|
||||||
|
const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType<typeof setTimeout> | null })
|
||||||
|
useEffect(() => {
|
||||||
|
const state = copyRequest.current; state.active = true
|
||||||
|
return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) }
|
||||||
|
}, [])
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
|
||||||
const listData = useMemo((): Community[] => {
|
const listData = useMemo((): Community[] => {
|
||||||
if (!useEvoCatalog) return COMMUNITIES
|
if (mode === "mock") return COMMUNITIES
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.communities ?? []
|
return snapshot?.communities ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
useEffect(() => {
|
const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
|
||||||
setSelected(null)
|
|
||||||
}, [useEvoCatalog])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
@@ -153,10 +132,24 @@ export default function CommunitiesPage() {
|
|||||||
})
|
})
|
||||||
}, [search, typeFilter, listData])
|
}, [search, typeFilter, listData])
|
||||||
|
|
||||||
const handleCopy = (value: string) => {
|
const handleCopy = async (value: string) => {
|
||||||
navigator.clipboard.writeText(value).catch(() => {})
|
const state = copyRequest.current
|
||||||
setCopied(value)
|
if (!state.active || state.pending) return
|
||||||
setTimeout(() => setCopied(null), 1500)
|
state.pending = true; const revision = ++state.revision
|
||||||
|
if (state.timer) clearTimeout(state.timer)
|
||||||
|
const current = () => state.active && state.revision === revision
|
||||||
|
setCopying(true); setCopied(null); setCopyError(null)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
if (!current()) return
|
||||||
|
setCopied(value)
|
||||||
|
state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500)
|
||||||
|
} catch {
|
||||||
|
if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.")
|
||||||
|
} finally {
|
||||||
|
state.pending = false
|
||||||
|
if (current()) setCopying(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
||||||
@@ -165,8 +158,7 @@ export default function CommunitiesPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
||||||
actions={
|
actions={mode === "live" ? null : <Button size="sm">
|
||||||
<Button size="sm">
|
|
||||||
<PlusIcon className="size-4" />Добавить
|
<PlusIcon className="size-4" />Добавить
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -174,38 +166,46 @@ export default function CommunitiesPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{useEvoCatalog && (
|
<EvoBgpCatalogStatus />
|
||||||
<p className={cn(
|
{copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
|
||||||
"text-xs flex items-center gap-1.5 -mt-1",
|
{copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
{copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
|
||||||
)}>
|
|
||||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
||||||
{error
|
|
||||||
? `EvoBGP: ${error}`
|
|
||||||
: snapshot?.fetchedAt
|
|
||||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
||||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── summary ── */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
aria-label="Сводка communities"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего communities", value: String(listData.length) },
|
{
|
||||||
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
id: "all",
|
||||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
label: "Всего communities",
|
||||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
value: known ? String(listData.length) : "—",
|
||||||
].map(({ label, value }) => (
|
icon: <TagIcon className="size-4" />,
|
||||||
<Card key={label}>
|
iconClassName: "text-muted-foreground",
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
},
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
{
|
||||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
id: "enabled",
|
||||||
</CardContent>
|
label: "Активных",
|
||||||
</Card>
|
value: known ? String(listData.filter((c) => c.enabled).length) : "—",
|
||||||
))}
|
icon: <CheckIcon className="size-4" />,
|
||||||
</div>
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "standard",
|
||||||
|
label: "Стандартных",
|
||||||
|
value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
|
||||||
|
icon: <TagIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filters",
|
||||||
|
label: "Использует фильтры",
|
||||||
|
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 ── */}
|
{/* ── main table ── */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* toolbar */}
|
{/* toolbar */}
|
||||||
@@ -214,7 +214,7 @@ export default function CommunitiesPage() {
|
|||||||
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
||||||
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
||||||
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
||||||
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
||||||
@@ -225,119 +225,49 @@ export default function CommunitiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* list */}
|
{/* list */}
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
<div className="overflow-x-auto">
|
{known ? <CommunitiesDataGrid
|
||||||
<table className="w-full text-sm">
|
communities={filtered}
|
||||||
<thead>
|
selectedId={selected?.id}
|
||||||
<tr className="border-b text-xs text-muted-foreground">
|
copiedValue={copied}
|
||||||
<th className="text-left font-medium px-4 py-2.5">Community</th>
|
copying={copying}
|
||||||
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
|
onSelect={row => setSelectedId(row.id)}
|
||||||
<th className="text-left font-medium px-4 py-2.5">Тип</th>
|
onCopy={handleCopy}
|
||||||
<th className="text-left font-medium px-4 py-2.5">Действие</th>
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
|
</DataPageCard>
|
||||||
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
|
|
||||||
<th className="px-4 py-2.5" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map(c => (
|
|
||||||
<tr
|
|
||||||
key={c.id}
|
|
||||||
onClick={() => setSelected(c)}
|
|
||||||
className={cn(
|
|
||||||
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
|
|
||||||
selected?.id === c.id && "bg-primary/5",
|
|
||||||
!c.enabled && "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
|
||||||
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
|
||||||
{c.value}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
|
|
||||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{copied === c.value
|
|
||||||
? <CheckIcon className="size-3" />
|
|
||||||
: <CopyIcon className="size-3" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<p className="font-medium text-xs">{c.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
|
||||||
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
|
|
||||||
{c.prefixCount.toLocaleString("ru-RU")}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<ServerIcon className="size-3 text-muted-foreground" />
|
|
||||||
<span className="font-mono text-xs">{c.serverCount.toLocaleString("ru-RU")}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
|
||||||
<TagIcon className="size-8 opacity-30" />
|
|
||||||
<p className="text-sm">Ничего не найдено</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── detail panel ── */}
|
{/* ── detail panel ── */}
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<Card className="h-fit sticky top-0">
|
<OpsPanel
|
||||||
<CardHeader className="pb-3">
|
className="h-fit sticky top-0"
|
||||||
<div className="flex items-start justify-between gap-2">
|
title={<span className="font-mono">{selected.value}</span>}
|
||||||
<div>
|
description={selected.name}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
headerRight={
|
||||||
<span className={cn(
|
|
||||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
|
||||||
selected.enabled
|
|
||||||
? "bg-emerald-500/10 text-emerald-600"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{selected.enabled ? "Активен" : "Выключен"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
|
|
||||||
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
<Button size="sm" variant="ghost" aria-label="Изменить community" disabled className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
<Button size="sm" variant="ghost" aria-label="Удалить community" disabled className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||||
|
selected.enabled
|
||||||
|
? "bg-emerald-500/10 text-emerald-600"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{selected.enabled ? "Активен" : "Выключен"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 text-sm">
|
|
||||||
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
["Тип", TYPE_LABELS[selected.type]],
|
["Тип", TYPE_LABELS[selected.type]],
|
||||||
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
||||||
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")],
|
["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
|
||||||
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k} className="flex justify-between gap-2">
|
<div key={k} className="flex justify-between gap-2">
|
||||||
@@ -363,20 +293,19 @@ export default function CommunitiesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t flex flex-col gap-2">
|
<div className="pt-2 border-t flex flex-col gap-2">
|
||||||
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}>
|
<Button size="sm" variant="outline" className="w-full justify-start gap-2" disabled={copying} onClick={() => { void handleCopy(selected.value) }}>
|
||||||
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||||
Скопировать значение
|
Скопировать значение
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<Card className="h-fit">
|
<Frame dense className="w-full h-fit">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
<FramePanel className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||||
<TagIcon className="size-8 text-muted-foreground/30" />
|
<TagIcon className="size-8 text-muted-foreground/30" />
|
||||||
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
|
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+406
-238
@@ -1,13 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { routerContainers, servers } from "@/lib/data"
|
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||||
import type { RouterContainer } from "@/lib/data"
|
import type { RouterContainer, Server } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
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 { Button } from "@/components/ui/button"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
DropdownMenuItem, DropdownMenuSeparator,
|
DropdownMenuItem, DropdownMenuSeparator,
|
||||||
@@ -15,161 +22,115 @@ import {
|
|||||||
import {
|
import {
|
||||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||||
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
|
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||||
TerminalIcon, AlertCircleIcon,
|
TerminalIcon, AlertCircleIcon, RefreshCwIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
import { useDataSource } from "@/lib/data-source"
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
} from "@/components/ui/sheet"
|
import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
|
||||||
|
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||||
|
import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { userApiError } from "@/shared/api/users"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
type ContainerView = Omit<RouterContainer, "status" | "startOnBoot"> & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
|
||||||
|
type ContainerServer = Omit<Server, "status"> & {status: Server["status"] | null}
|
||||||
|
|
||||||
function serverFor(id: string) {
|
interface BackendServer {
|
||||||
return servers.find((s) => s.id === id)
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"] | null
|
||||||
|
latency?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusConfig(status: RouterContainer["status"]) {
|
function mapBackendServer(s: BackendServer): ContainerServer {
|
||||||
return {
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? null,
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusConfig(status: ContainerView["status"]) {
|
||||||
|
return {
|
||||||
|
unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
|
||||||
running: {
|
running: {
|
||||||
dot: "bg-emerald-500 animate-pulse",
|
dot: "bg-success",
|
||||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
badge: "bg-success/10 text-success border-success/20",
|
||||||
label: "Running",
|
label: "Работает",
|
||||||
},
|
},
|
||||||
stopped: {
|
stopped: {
|
||||||
dot: "bg-muted-foreground",
|
dot: "bg-muted-foreground",
|
||||||
badge: "bg-muted/50 text-muted-foreground border-border",
|
badge: "bg-muted/50 text-muted-foreground border-border",
|
||||||
label: "Stopped",
|
label: "Остановлен",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
dot: "bg-red-500 animate-pulse",
|
dot: "bg-destructive",
|
||||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
||||||
label: "Error",
|
label: "Ошибка",
|
||||||
},
|
},
|
||||||
}[status]
|
}[status]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
|
||||||
|
function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
|
||||||
function generateContainerRsc(c: RouterContainer): string {
|
const formats = useMemo(() => [{
|
||||||
const srv = serverFor(c.serverId)
|
id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
|
||||||
const lines: string[] = []
|
code: selection ? JSON.stringify(selection, null, 2) : "",
|
||||||
lines.push(`# RouterOS Container — ${c.name}`)
|
}], [selection])
|
||||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
|
||||||
lines.push(`# Образ: ${c.image}:${c.tag}`)
|
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
|
||||||
lines.push(`# RouterOS 7.4+ · /container`)
|
beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
|
||||||
lines.push(``)
|
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
|
||||||
|
formats={formats} />
|
||||||
// interface
|
|
||||||
for (const iface of c.interfaces) {
|
|
||||||
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
|
|
||||||
}
|
|
||||||
lines.push(``)
|
|
||||||
|
|
||||||
// envs
|
|
||||||
if (c.envs.length > 0) {
|
|
||||||
lines.push(`/container/envs/add name=${c.name}-envs \\`)
|
|
||||||
for (const { key, value } of c.envs) {
|
|
||||||
lines.push(` ${key}="${value}" \\`)
|
|
||||||
}
|
|
||||||
lines.push(``)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mounts
|
|
||||||
for (const m of c.mounts) {
|
|
||||||
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
|
|
||||||
if (m.src) lines.push(` src=${m.src} \\`)
|
|
||||||
lines.push(` dst=${m.dst}`)
|
|
||||||
lines.push(``)
|
|
||||||
}
|
|
||||||
|
|
||||||
// container
|
|
||||||
lines.push(`/container/add \\`)
|
|
||||||
lines.push(` remote-image=${c.image}:${c.tag} \\`)
|
|
||||||
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
|
|
||||||
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
|
|
||||||
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
|
|
||||||
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
|
|
||||||
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
|
|
||||||
if (c.comment) lines.push(` comment="${c.comment}" \\`)
|
|
||||||
lines.push(` logging=yes`)
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ExportSheet({ open, container, onClose }: {
|
|
||||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
|
||||||
}) {
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
|
||||||
|
|
||||||
function handleCopy() {
|
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
||||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
|
||||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<SheetTitle>Экспорт Container</SheetTitle>
|
|
||||||
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
|
||||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isCmd = /^\//.test(line.trimStart())
|
|
||||||
const isParam = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isComment ? "text-muted-foreground"
|
|
||||||
: isCmd ? "text-sky-400"
|
|
||||||
: isParam ? "text-violet-300"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1" onClick={handleCopy}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Container card ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ContainerCard({
|
function ContainerCard({
|
||||||
container,
|
container,
|
||||||
|
server,
|
||||||
|
live,
|
||||||
|
busy,
|
||||||
onExport,
|
onExport,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onRestart,
|
||||||
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
container: RouterContainer
|
container: ContainerView
|
||||||
|
server?: ContainerServer
|
||||||
|
live: boolean
|
||||||
|
busy: boolean
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onStart: () => void
|
||||||
|
onStop: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const srv = serverFor(container.serverId)
|
|
||||||
const cfg = statusConfig(container.status)
|
const cfg = statusConfig(container.status)
|
||||||
|
const canMutate = live && Boolean(container.rosId) && !busy
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
|
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
|
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
|
||||||
@@ -180,51 +141,56 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={
|
<DropdownMenuTrigger render={
|
||||||
<Button variant="ghost" size="icon" className="size-7 shrink-0">
|
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
|
||||||
<MoreHorizontalIcon className="size-4" />
|
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
} />
|
||||||
<DropdownMenuContent side="bottom" align="end">
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
{container.status === "running" ? (
|
{container.status === "running" ? (
|
||||||
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
||||||
|
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
|
||||||
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
|
||||||
|
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
<DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
|
||||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
<DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
<DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
|
||||||
|
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
||||||
|
<Trash2Icon className="size-4" />Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="px-4 py-3 flex flex-col gap-3">
|
<div className="px-4 py-3 flex flex-col gap-3">
|
||||||
{/* image */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
<BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<span className="font-mono text-xs text-foreground/80">
|
<span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
|
||||||
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
|
{container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* server */}
|
{server && (
|
||||||
{srv && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<ServerIcon className="size-3.5 shrink-0" />
|
<ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
<Flag code={srv.country} size={12} />
|
<Flag code={server.country} size={12} />
|
||||||
<span className="font-mono">{srv.name}</span>
|
<span className="font-mono truncate" title={server.name}>{server.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* uptime + stats */}
|
{container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
|
||||||
{container.status === "running" && (
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||||||
{container.uptime && (
|
{container.uptime && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ActivityIcon className="size-3 text-emerald-500" />
|
<ActivityIcon aria-hidden="true" className="size-3 text-success" />
|
||||||
<span>{container.uptime}</span>
|
<span>{container.uptime}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -232,7 +198,7 @@ function ContainerCard({
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">CPU </span>
|
<span className="text-muted-foreground">CPU </span>
|
||||||
<span className={cn("font-mono font-medium",
|
<span className={cn("font-mono font-medium",
|
||||||
container.cpu > 50 ? "text-amber-500" : "text-foreground")}>
|
container.cpu > 50 ? "text-warning" : "text-foreground")}>
|
||||||
{container.cpu}%
|
{container.cpu}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,13 +206,12 @@ function ContainerCard({
|
|||||||
{container.memMb !== undefined && (
|
{container.memMb !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">RAM </span>
|
<span className="text-muted-foreground">RAM </span>
|
||||||
<span className="font-mono font-medium">{container.memMb} МБ</span>
|
<span className="font-mono font-medium">{container.memMb} МиБ</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* interfaces */}
|
|
||||||
{container.interfaces.length > 0 && (
|
{container.interfaces.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{container.interfaces.map((i) => (
|
{container.interfaces.map((i) => (
|
||||||
@@ -257,7 +222,6 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* mounts */}
|
|
||||||
{container.mounts.length > 0 && (
|
{container.mounts.length > 0 && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{container.mounts.map((m, idx) => (
|
{container.mounts.map((m, idx) => (
|
||||||
@@ -271,137 +235,335 @@ function ContainerCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{container.comment && (
|
{container.comment && (
|
||||||
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
|
<p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
|
||||||
export default function ContainersPage() {
|
function ContainersSource() {
|
||||||
const [search, setSearch] = useState("")
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
|
||||||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
|
||||||
|
}
|
||||||
|
function ContainersContent() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
const canRead = can("mm:dashboard:read")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
const canWrite = can("mm:servers:write")
|
||||||
|
const params = useSearchParams()
|
||||||
|
const context = readContainersUrl(params)
|
||||||
|
const search = context.search
|
||||||
|
const statusFilter = context.status
|
||||||
|
const selectedServerId = context.server
|
||||||
|
const urlError = context.error
|
||||||
|
function writeContext(changes: Record<string, string | null>, replace = false) {
|
||||||
|
const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||||
|
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
||||||
|
}
|
||||||
|
const setSearch = (value: string) => writeContext({ q: value }, true)
|
||||||
|
const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
||||||
|
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
||||||
|
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
|
||||||
|
const [state, setState] = useState<ContainersState | null>(null)
|
||||||
|
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
|
||||||
|
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
||||||
|
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
|
||||||
|
const deleteReturnLabel = useRef<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteTarget || busyId || !deleteReturnLabel.current) return
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
|
||||||
|
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [deleteTarget, busyId])
|
||||||
|
const [operationError, setOperationError] = useState<string | null>(null)
|
||||||
|
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||||
|
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
|
||||||
|
const mutationLock = useRef(false)
|
||||||
|
const mutationController = useRef<AbortController | null>(null)
|
||||||
|
const alive = useRef(true)
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
const [serversError, setServersError] = useState<string | null>(null)
|
||||||
|
const [readAt, setReadAt] = useState<number | null>(null)
|
||||||
|
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
||||||
|
const loadController = useRef<AbortController | null>(null)
|
||||||
|
const loadRevision = useRef(0)
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive) return false
|
||||||
|
loadController.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
loadController.current = controller
|
||||||
|
const revision = ++loadRevision.current
|
||||||
|
const current = () => !controller.signal.aborted && loadRevision.current === revision
|
||||||
|
setLoading(true)
|
||||||
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||||
|
let stateComplete = !canRead
|
||||||
|
const tasks = await Promise.allSettled([
|
||||||
|
canRead ? readContainersState(backendUrl, { signal }).then(result => {
|
||||||
|
stateComplete = result.servers.every(node => node.availability !== "error")
|
||||||
|
if (current()) {
|
||||||
|
setState(result); setReadAt(Date.now()); setLiveError(null)
|
||||||
|
if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
|
||||||
|
setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
|
||||||
|
}
|
||||||
|
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||||
|
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
||||||
|
const result = serverListSchema.parse(payload)
|
||||||
|
if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
|
||||||
|
if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
|
||||||
|
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||||
|
])
|
||||||
|
if (current()) setLoading(false)
|
||||||
|
return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
|
||||||
|
}, [isLive, backendUrl, canRead, canReadServers])
|
||||||
|
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
|
||||||
|
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
|
||||||
|
const effectiveServerId = selectedServerId
|
||||||
|
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
|
||||||
|
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
|
||||||
|
const stale = isLive && !!liveError
|
||||||
|
const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
|
||||||
|
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
|
||||||
|
const scoped = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||||
|
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
||||||
|
}, [displayContainers, effectiveServerId])
|
||||||
|
|
||||||
|
const serverById = useMemo(
|
||||||
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
|
[displayServers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status ?? "unknown",
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length),
|
||||||
|
}))
|
||||||
|
), [displayServers, displayContainers, state, isLive])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return routerContainers.filter((c) => {
|
return scoped.filter((c) => {
|
||||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return (
|
return (
|
||||||
c.name.toLowerCase().includes(q) ||
|
c.name.toLowerCase().includes(q) ||
|
||||||
c.image.toLowerCase().includes(q) ||
|
c.image.toLowerCase().includes(q) ||
|
||||||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
|
(serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [search, statusFilter])
|
}, [search, statusFilter, scoped, serverById])
|
||||||
|
|
||||||
const running = routerContainers.filter((c) => c.status === "running").length
|
const running = scoped.filter((c) => c.status === "running").length
|
||||||
const stopped = routerContainers.filter((c) => c.status === "stopped").length
|
const stopped = scoped.filter((c) => c.status === "stopped").length
|
||||||
const errors = routerContainers.filter((c) => c.status === "error").length
|
const errors = scoped.filter((c) => c.status === "error").length
|
||||||
|
const statusKnown = known && scoped.every(container => container.status !== "unknown")
|
||||||
|
const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined
|
||||||
|
const deleteStopped = currentDeleteTarget?.status === "stopped"
|
||||||
|
|
||||||
|
async function mutate(c: ContainerView, action: ContainerAction) {
|
||||||
|
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return
|
||||||
|
if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return
|
||||||
|
mutationLock.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
mutationController.current = controller
|
||||||
|
const current = () => alive.current && !controller.signal.aborted
|
||||||
|
setBusyId(c.id); setOperationError(null); setOperationNotice(null)
|
||||||
|
try {
|
||||||
|
await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||||
|
if (!current()) return
|
||||||
|
const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
|
||||||
|
const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
|
||||||
|
setOperationNotice(message)
|
||||||
|
if (action === "remove") {
|
||||||
|
setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
toast.success("Команда подтверждена API.")
|
||||||
|
const refreshed = await loadLive()
|
||||||
|
if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
|
||||||
|
} catch (error) {
|
||||||
|
if (!current()) return
|
||||||
|
const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
|
||||||
|
setCommandNeedsRead(uncertain)
|
||||||
|
setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
|
||||||
|
} finally {
|
||||||
|
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||||
|
if (alive.current) setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
items={railItems}
|
||||||
actions={
|
selectedId={effectiveServerId}
|
||||||
<Button size="sm">
|
onSelect={setSelectedServerId}
|
||||||
<BoxIcon className="size-4" />Новый контейнер
|
showAll
|
||||||
</Button>
|
preserveSelection
|
||||||
}
|
allCount={displayServers.length}
|
||||||
/>
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void loadLive() }}
|
||||||
|
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
|
||||||
|
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
{urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
|
||||||
<div className="flex flex-col gap-5">
|
{operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
|
||||||
|
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||||
|
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
|
||||||
|
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
|
||||||
|
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
|
||||||
|
{isLive && serversReadAt && <p role="status" className="text-xs text-muted-foreground">Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}</p>}
|
||||||
|
{isLive && liveError && <Alert variant="warning" role="alert" aria-label="Каталог контейнеров"><AlertDescription>{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}</AlertDescription></Alert>}
|
||||||
|
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
|
||||||
|
{isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? <Alert key={node.serverId} variant="warning" role="alert" aria-label={"Контейнеры сервера " + node.serverId}><AlertDescription>Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."}</AlertDescription></Alert> : node.availability === "unsupported" ? <p key={node.serverId} role="status">Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.</p> : null)}
|
||||||
|
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
|
||||||
|
{mode === "mock" && (
|
||||||
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
|
Моковые данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* KPI */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
aria-label="Сводка контейнеров"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
|
{
|
||||||
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
|
id: "all",
|
||||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
label: "Всего",
|
||||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
value: known ? scoped.length : "—",
|
||||||
].map((s) => (
|
icon: <BoxIcon aria-hidden="true" className="size-4" />,
|
||||||
<Card key={s.label}>
|
iconClassName: "text-muted-foreground",
|
||||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
},
|
||||||
<div>
|
{
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
id: "running",
|
||||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
label: "Работает",
|
||||||
</div>
|
value: statusKnown ? running : "—",
|
||||||
<div className="mt-0.5">{s.icon}</div>
|
icon: <PlayIcon aria-hidden="true" className="size-4" />,
|
||||||
</CardContent>
|
iconClassName: "text-success",
|
||||||
</Card>
|
},
|
||||||
))}
|
{
|
||||||
</div>
|
id: "stopped",
|
||||||
|
label: "Остановлен",
|
||||||
|
value: statusKnown ? stopped : "—",
|
||||||
|
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "errors",
|
||||||
|
label: "Ошибок",
|
||||||
|
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-info/5 border border-info/20 px-4 py-3 text-sm">
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
<BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
|
||||||
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p>
|
<p className="font-medium text-foreground">Контейнеры доступны с RouterOS 7.4+</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
||||||
Интерфейсы veth создаются автоматически.
|
Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-0 w-full sm:w-auto">
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
<SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<input
|
<input
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||||
placeholder="Поиск по имени, образу, серверу…"
|
aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
<div role="group" aria-label="Статус контейнеров" className="flex flex-wrap items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||||
{(["all", "running", "stopped", "error"] as const).map((s) => (
|
{(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
|
||||||
<button key={s}
|
<button key={s}
|
||||||
onClick={() => setStatusFilter(s)}
|
type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||||||
statusFilter === s
|
statusFilter === s
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}>
|
)}>
|
||||||
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
|
{s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
<span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||||
<BoxIcon className="size-10 mb-3 opacity-20" />
|
<BoxIcon aria-hidden="true" className="size-10 mb-3 opacity-20" />
|
||||||
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
||||||
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
|
<p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filtered.map((c) => (
|
{filtered.map((c) => (
|
||||||
<ContainerCard
|
<ContainerCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
container={c}
|
container={c}
|
||||||
onExport={() => setExportContainer(c)}
|
server={serverById[c.serverId]}
|
||||||
|
live={isLive && !!nodeFresh(c.serverId)}
|
||||||
|
busy={!!busyId}
|
||||||
|
onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
|
||||||
|
onStart={() => { void mutate(c, "start") }}
|
||||||
|
onStop={() => { void mutate(c, "stop") }}
|
||||||
|
onRestart={() => { void mutate(c, "restart") }}
|
||||||
|
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
||||||
<Card>
|
|
||||||
<CardContent className="px-5 py-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
|
||||||
RouterOS 7.4+ · /container — быстрые команды
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -451,23 +613,29 @@ export default function ContainersPage() {
|
|||||||
].map((b) => (
|
].map((b) => (
|
||||||
<div key={b.title}>
|
<div key={b.title}>
|
||||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
<pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||||
{b.lines.join("\n")}
|
{b.lines.join("\n")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<ExportSheet
|
{deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
|
||||||
open={!!exportContainer}
|
<AlertDialogContent>
|
||||||
container={exportContainer}
|
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
|
||||||
onClose={() => setExportContainer(null)}
|
{operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
|
||||||
/>
|
<AlertDialogFooter>
|
||||||
</div>
|
{(commandNeedsRead || !deleteStopped) && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
|
||||||
|
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !deleteStopped || !nodeFresh(deleteTarget.serverId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>}
|
||||||
|
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+315
-1027
File diff suppressed because it is too large.
Load diff
+631
-747
File diff suppressed because it is too large.
Load diff
+55
-80
@@ -1,34 +1,49 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { domains as mockDomains } from "@/lib/data"
|
import { domains as mockDomains } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { toast } from "sonner"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live"
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!useEvoCatalog) return mockDomains
|
if (mode === "mock") return mockDomains
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.domains ?? []
|
return snapshot?.domains ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return rows
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.domain.toLowerCase().includes(q) ||
|
||||||
|
r.asn.toLowerCase().includes(q) ||
|
||||||
|
r.filter.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [rows, search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||||
actions={
|
actions={mode === "live" ? null : <>
|
||||||
<>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
||||||
</>
|
</>
|
||||||
@@ -39,76 +54,36 @@ export default function DomainsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
|
Домены из каталога для BGP-фильтрации
|
||||||
</p>
|
</p>
|
||||||
{useEvoCatalog && (
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
<p className={cn(
|
|
||||||
"text-xs mt-2 flex items-center gap-1.5",
|
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
||||||
{error
|
|
||||||
? `EvoBGP: ${error}`
|
|
||||||
: snapshot?.fetchedAt
|
|
||||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
||||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataPageCard>
|
||||||
data={rows}
|
<DataPageToolbar
|
||||||
searchPlaceholder="Поиск по домену…"
|
search={search}
|
||||||
searchKeys={["domain", "asn", "filter"]}
|
onSearchChange={setSearch}
|
||||||
columns={[
|
searchPlaceholder="Поиск по домену…"
|
||||||
{
|
countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
|
||||||
key: "domain",
|
/>
|
||||||
label: "Домен",
|
{known || loading ? <DomainsDataGrid
|
||||||
render: (d) => <span className="font-medium">{d.domain}</span>,
|
domains={filtered}
|
||||||
},
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
{
|
pagination={useEvoCatalog}
|
||||||
key: "resolvedIp",
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
label: "Resolved IP",
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
|
</DataPageCard>
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "asn",
|
|
||||||
label: "ASN",
|
|
||||||
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "purpose",
|
|
||||||
label: "Назначение",
|
|
||||||
render: (d) => (
|
|
||||||
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "filter",
|
|
||||||
label: "Фильтр",
|
|
||||||
render: (d) => (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
|
||||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "updated",
|
|
||||||
label: "Обновлён",
|
|
||||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "enabled",
|
|
||||||
label: "Статус",
|
|
||||||
render: (d) => (
|
|
||||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
|
||||||
{d.enabled ? "Активен" : "Отключён"}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт доменов"
|
||||||
|
description="Загрузите CSV или JSON со списком доменов"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+724
-808
File diff suppressed because it is too large.
Load diff
+1348
-579
File diff suppressed because it is too large.
Load diff
+718
-767
File diff suppressed because it is too large.
Load diff
@@ -1,35 +1,50 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
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 { ipRanges as mockIpRanges } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { toast } from "sonner"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export default function IpRangesPage() {
|
export default function IpRangesPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
const useEvoCatalog = mode === "live"
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!useEvoCatalog) return mockIpRanges
|
if (mode === "mock") return mockIpRanges
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.ipRanges ?? []
|
return snapshot?.ipRanges ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return rows
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.cidr.toLowerCase().includes(q) ||
|
||||||
|
r.asn.toLowerCase().includes(q) ||
|
||||||
|
r.country.toLowerCase().includes(q) ||
|
||||||
|
r.filter.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [rows, search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||||
actions={
|
actions={mode === "live" ? null : <>
|
||||||
<>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
||||||
</>
|
</>
|
||||||
@@ -40,74 +55,36 @@ export default function IpRangesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
|
CIDR-блоки из каталога для сетевых фильтров
|
||||||
</p>
|
</p>
|
||||||
{useEvoCatalog && (
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
<p className={cn(
|
|
||||||
"text-xs mt-2 flex items-center gap-1.5",
|
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
||||||
{error
|
|
||||||
? `EvoBGP: ${error}`
|
|
||||||
: snapshot?.fetchedAt
|
|
||||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
||||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataPageCard>
|
||||||
data={rows}
|
<DataPageToolbar
|
||||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
search={search}
|
||||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
onSearchChange={setSearch}
|
||||||
columns={[
|
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||||
{
|
countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
|
||||||
key: "cidr",
|
/>
|
||||||
label: "CIDR",
|
{known || loading ? <IpRangesDataGrid
|
||||||
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
|
ipRanges={filtered}
|
||||||
},
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
{
|
pagination={useEvoCatalog}
|
||||||
key: "asn",
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
label: "ASN",
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
|
</DataPageCard>
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "country",
|
|
||||||
label: "Страна",
|
|
||||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "purpose",
|
|
||||||
label: "Назначение",
|
|
||||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "filter",
|
|
||||||
label: "Фильтр",
|
|
||||||
render: (d) => (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
|
||||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "updated",
|
|
||||||
label: "Обновлён",
|
|
||||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "enabled",
|
|
||||||
label: "Статус",
|
|
||||||
render: (d) => (
|
|
||||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
|
||||||
{d.enabled ? "Активен" : "Отключён"}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт IP-диапазонов"
|
||||||
|
description="Загрузите CSV или JSON со списком CIDR-блоков"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+26
-10
@@ -1,19 +1,35 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { CommandPalette } from "@/components/command-palette"
|
import { CommandPalette } from "@/components/command-palette"
|
||||||
|
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||||
import { DataSourceProvider } from "@/lib/data-source"
|
import { DataSourceProvider } from "@/lib/data-source"
|
||||||
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
const SKIP_TO_CONTENT_CLASS =
|
||||||
|
"bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2"
|
||||||
|
|
||||||
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DataSourceProvider>
|
<AuthGuard>
|
||||||
<EvoBGPProvider>
|
<DataSourceProvider>
|
||||||
<SidebarProvider>
|
<EvoBGPProvider>
|
||||||
<AppSidebar />
|
<SidebarProvider
|
||||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||||
<CommandPalette />
|
>
|
||||||
</SidebarProvider>
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
</EvoBGPProvider>
|
К содержимому
|
||||||
</DataSourceProvider>
|
</a>
|
||||||
|
<AppSidebar />
|
||||||
|
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
|
<CommandPalette />
|
||||||
|
<ReleaseNotesModal />
|
||||||
|
</SidebarProvider>
|
||||||
|
</EvoBGPProvider>
|
||||||
|
</DataSourceProvider>
|
||||||
|
</AuthGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function MainLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 p-6">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
|
||||||
|
<Skeleton className="h-96 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1658
-474
File diff suppressed because it is too large.
Load diff
+574
-644
File diff suppressed because it is too large.
Load diff
+344
-485
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,12 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
|
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
|
||||||
|
import {
|
||||||
|
formatCommitSubject,
|
||||||
|
getReleaseCommitPreview,
|
||||||
|
} from "@/lib/release-commits"
|
||||||
import { readReleaseManifest } from "@/lib/release-manifest"
|
import { readReleaseManifest } from "@/lib/release-manifest"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ExternalLinkIcon } from "lucide-react"
|
import { ExternalLinkIcon } from "lucide-react"
|
||||||
@@ -11,23 +15,23 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
||||||
if (value) {
|
if (value) {
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(new Date(value))
|
}).format(new Date(value))
|
||||||
}
|
}
|
||||||
return isProdBuild ? "Прод-сборка" : "Локальная сборка"
|
return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReleasesPage() {
|
export default function ReleasesPage() {
|
||||||
const manifest = readReleaseManifest()
|
const manifest = readReleaseManifest()
|
||||||
const version = getAppVersion()
|
const version = getAppVersion()
|
||||||
const isProdBuild = !version.endsWith("-dev")
|
const isProdBuild = !version.endsWith("-dev")
|
||||||
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
|
||||||
const commits = manifest.commits
|
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex min-h-0 flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
||||||
actions={
|
actions={
|
||||||
@@ -35,62 +39,60 @@ export default function ReleasesPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={releaseUrl}
|
href={releaseUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" />
|
<ExternalLinkIcon className="size-4" aria-hidden />
|
||||||
Открыть в Gitea
|
Открыть релиз
|
||||||
</Link>
|
</Link>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
<div className="max-w-[960px] mx-auto space-y-6">
|
<div className="max-w-[960px] mx-auto space-y-6">
|
||||||
<Card>
|
<OpsPanel
|
||||||
<CardHeader>
|
title="Текущая версия"
|
||||||
<CardTitle>Текущая версия</CardTitle>
|
description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
|
||||||
<CardDescription>Сборка RouterLists, опубликованная через CI/CD.</CardDescription>
|
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
<span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
|
||||||
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
|
<span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
{!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}
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Сводка коммитов</CardTitle>
|
<OpsPanel
|
||||||
<CardDescription>
|
title="Сводка коммитов"
|
||||||
{commits.length > 0
|
description={
|
||||||
? `Изменения, вошедшие в ${formatAppVersionLabel(manifest.version)}.`
|
commits.length > 0
|
||||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
|
? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
|
||||||
</CardDescription>
|
: manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent>
|
contentClassName="px-5 pb-5"
|
||||||
|
>
|
||||||
{commits.length === 0 ? (
|
{commits.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
|
<p className="text-sm text-muted-foreground">{manifest ? "Коммитов для отображения пока нет." : "Нет подтверждённых метаданных."}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{commits.map((commit) => (
|
{commits.map((commit) => (
|
||||||
<li key={commit.sha} className="rounded-md border px-3 py-2">
|
<li key={commit.sha} className="rounded-md border px-3 py-2 break-words">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="font-mono uppercase">{commit.type}</span>
|
<span className="font-mono uppercase">{commit.type}</span>
|
||||||
<span className="font-mono">{commit.shortSha}</span>
|
<span className="font-mono">{commit.shortSha}</span>
|
||||||
<span>{commit.author}</span>
|
<span>{commit.author}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm">{commit.subject}</p>
|
<p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</OpsPanel>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+348
-760
File diff suppressed because it is too large.
Load diff
+438
-550
File diff suppressed because it is too large.
Load diff
+64
-1738
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+120
-428
@@ -1,13 +1,30 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
import { useState, useRef, useEffect, Suspense } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { servers as mockServers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import {useSearchParams} from "next/navigation"
|
||||||
|
import {can} from "@/lib/auth"
|
||||||
|
import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "@/lib/terminal-response"
|
||||||
|
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
||||||
|
import {Input} from "@/components/ui/input"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
|
||||||
|
import {
|
||||||
|
TrashIcon, RefreshCwIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -157,229 +174,72 @@ function mockResolve(raw: string, serverName: string): string {
|
|||||||
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MOTD builders ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function mockMotd(server: TermServer): string {
|
function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) {
|
||||||
return [
|
const [lines,setLines]=useState<TermLine[]>([{id:0,kind:"info",text:isLive?"REST: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}])
|
||||||
"",
|
const [input,setInput]=useState(""),[history,setHistory]=useState<string[]>([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState<string|null>(null),[completed,setCompleted]=useState<string|null>(null)
|
||||||
" MMM MMM KKK TTTTTTTTTTT KKK",
|
const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
|
||||||
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK",
|
const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(null),followOutput=useRef(true),pendingFocus=useRef(false)
|
||||||
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK",
|
useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.controller?.abort()}},[])
|
||||||
"",
|
useEffect(()=>{if(followOutput.current)scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:"auto"})},[lines])
|
||||||
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
// Focus only after React has committed the re-enabled input.
|
||||||
"",
|
useEffect(()=>{
|
||||||
"[?] Gives the list of available commands",
|
if(executing||!pendingFocus.current)return
|
||||||
"[Tab] Completes the command/word",
|
pendingFocus.current=false
|
||||||
"[/] Move up to base level",
|
if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
|
||||||
"[..] Move up one level",
|
},[executing,lines])
|
||||||
"",
|
function append(kind:TermLine["kind"],text:string) {
|
||||||
].join("\n")
|
const added=text.split("\n").map(text=>({id:nextId.current++,kind,text}))
|
||||||
}
|
setLines(previous=>{const merged=[...previous.filter(line=>line.id!==-1),...added];return merged.length>1000||previous.some(line=>line.id===-1)?[{id:-1,kind:"info",text:"Показаны последние 1000 строк; ранний вывод удалён."},...merged.slice(-1000)]:merged})
|
||||||
|
}
|
||||||
function liveMotd(server: TermServer): string {
|
async function submit(){
|
||||||
return [
|
const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return
|
||||||
"",
|
if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return}
|
||||||
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1)
|
||||||
"",
|
append("prompt",server.name+" > "+command)
|
||||||
` Connected to ${server.name} (${server.host})`,
|
setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100))
|
||||||
"",
|
const controller=new AbortController();reader.current.controller=controller
|
||||||
"[?] Gives the list of available commands — type help or ?",
|
let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000)
|
||||||
"[Tab] Completes the command/word",
|
try {
|
||||||
"[/] Move up to base level",
|
const output=isLive?readTerminalOutput(await requestJson<unknown>(backendUrl,"/api/servers/"+server.backendId+"/exec",{method:"POST",body:JSON.stringify({command}),signal:controller.signal})):mockResolve(command,server.name)
|
||||||
"",
|
if(!reader.current.alive)return
|
||||||
].join("\n")
|
if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.")
|
||||||
}
|
setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true
|
||||||
|
} catch(err) {
|
||||||
// ─── terminal component ───────────────────────────────────────────────────────
|
if(!reader.current.alive)return
|
||||||
|
const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды."
|
||||||
function Terminal({
|
append("error",message);setError(message)
|
||||||
server,
|
} finally {
|
||||||
isLive,
|
window.clearTimeout(timer);lock.current=false
|
||||||
backendUrl,
|
if(reader.current.alive){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
|
||||||
}: {
|
|
||||||
server: TermServer
|
|
||||||
isLive: boolean
|
|
||||||
backendUrl: string
|
|
||||||
}) {
|
|
||||||
const [lines, setLines] = useState<TermLine[]>([])
|
|
||||||
const [input, setInput] = useState("")
|
|
||||||
const [history, setHistory] = useState<string[]>([])
|
|
||||||
const [_histIdx, setHistIdx] = useState(-1)
|
|
||||||
const [_idCtr, setIdCtr] = useState(0)
|
|
||||||
const [executing, setExecuting] = useState(false)
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const nextId = useCallback((): number => {
|
|
||||||
let id = 0
|
|
||||||
setIdCtr(prev => { id = prev + 1; return prev + 1 })
|
|
||||||
return id
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Initialize MOTD on mount / server change
|
|
||||||
useEffect(() => {
|
|
||||||
const motd = isLive ? liveMotd(server) : mockMotd(server)
|
|
||||||
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
|
|
||||||
setLines(init)
|
|
||||||
setIdCtr(init.length)
|
|
||||||
setInput("")
|
|
||||||
setHistory([])
|
|
||||||
setHistIdx(-1)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [server.uid, isLive])
|
|
||||||
|
|
||||||
// Auto-scroll
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
|
|
||||||
}, [lines])
|
|
||||||
|
|
||||||
const prompt = `[admin@${server.identityName ?? server.name}] > `
|
|
||||||
|
|
||||||
const submit = useCallback(async () => {
|
|
||||||
if (executing) return
|
|
||||||
const cmd = input.trim()
|
|
||||||
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
|
|
||||||
setInput("")
|
|
||||||
setHistIdx(-1)
|
|
||||||
|
|
||||||
if (!cmd) return
|
|
||||||
|
|
||||||
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
|
|
||||||
|
|
||||||
// local always-available commands
|
|
||||||
const lower = cmd.toLowerCase()
|
|
||||||
if (lower === "clear" || lower === "/clear") { setLines([]); return }
|
|
||||||
if (lower === "quit" || lower === "exit") {
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLive && server.backendId !== null) {
|
|
||||||
setExecuting(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ command: cmd }),
|
|
||||||
})
|
|
||||||
const data = await res.json() as { output?: string; error?: string }
|
|
||||||
const text = data.output ?? data.error ?? "(empty response)"
|
|
||||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
|
||||||
text.split("\n").forEach(line =>
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
|
|
||||||
} finally {
|
|
||||||
setExecuting(false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// mock mode
|
|
||||||
const result = mockResolve(cmd, server.name)
|
|
||||||
if (result === "__CLEAR__") { setLines([]); return }
|
|
||||||
if (result) {
|
|
||||||
result.split("\n").forEach(line =>
|
|
||||||
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
|
|
||||||
|
|
||||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault(); void submit()
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault()
|
|
||||||
setHistIdx(i => {
|
|
||||||
const next = Math.min(i + 1, history.length - 1)
|
|
||||||
if (history[next] !== undefined) setInput(history[next])
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
} else if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault()
|
|
||||||
setHistIdx(i => {
|
|
||||||
const next = i - 1
|
|
||||||
if (next < 0) { setInput(""); return -1 }
|
|
||||||
if (history[next] !== undefined) setInput(history[next])
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
} else if (e.key === "l" && e.ctrlKey) {
|
|
||||||
e.preventDefault(); setLines([])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const lineColor = (kind: TermLine["kind"]) => {
|
function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
|
||||||
if (kind === "error") return "text-red-400"
|
if(e.key==="Enter"){e.preventDefault();void submit()}
|
||||||
if (kind === "prompt") return "text-emerald-400"
|
else if(e.key==="ArrowUp"){e.preventDefault();const index=Math.min(histIdx+1,history.length-1);setHistIdx(index);if(history[index]!==undefined)setInput(history[index])}
|
||||||
if (kind === "info") return "text-sky-400"
|
else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")}
|
||||||
return "text-[#c9d1d9]"
|
else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])}
|
||||||
}
|
}
|
||||||
|
const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null)
|
||||||
return (
|
return <Frame spacing="sm" className="h-full min-h-0 min-w-0">
|
||||||
<div
|
<FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
|
||||||
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
|
<FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
|
||||||
onClick={() => inputRef.current?.focus()}
|
<div className="min-w-0"><FrameTitle className="[overflow-wrap:anywhere]">{server.name}</FrameTitle><FrameDescription className="[overflow-wrap:anywhere]">{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}</FrameDescription></div>
|
||||||
>
|
<Button type="button" variant="ghost" size="icon-sm" aria-label="Очистить вывод терминала" onClick={()=>setLines([])}><TrashIcon aria-hidden="true" className="size-4"/></Button>
|
||||||
{/* title bar */}
|
</FrameHeader>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
|
<div ref={scrollRef} role="region" aria-label="Вывод терминала" tabIndex={0} onScroll={e=>{const el=e.currentTarget;followOutput.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}} className="flex-1 min-h-0 min-w-0 overflow-auto p-3 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring">
|
||||||
<CircleIcon className="size-3 text-red-500 fill-red-500" />
|
{lines.length?lines.map(line=><div key={line.id} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="error"?"text-destructive":line.kind==="prompt"?"text-primary":line.kind==="info"?"text-muted-foreground":"text-foreground")}>{line.text}</div>):<p className="text-muted-foreground">Вывод очищен. Следующая команда добавит новый результат.</p>}
|
||||||
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
|
</div>
|
||||||
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
|
<FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
|
||||||
<span className="mx-auto text-[#8b949e] text-[11px]">
|
<p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
|
||||||
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
|
{error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
|
||||||
</span>
|
{blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
|
||||||
{isLive && (
|
<label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
|
||||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400">
|
<div className="flex gap-2 min-w-0"><Input ref={inputRef} id="terminal-command" value={input} maxLength={1024} onChange={e=>setInput(e.target.value)} onKeyDown={handleKey} disabled={executing||!!blocked} className="flex-1 min-w-0 font-mono text-xs" autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}/><Button type="button" size="sm" disabled={executing||!!blocked||!input.trim()} onClick={()=>void submit()}>{executing?"Ожидание…":"Выполнить"}</Button></div>
|
||||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
<div className="flex flex-col sm:flex-row gap-2 text-xs text-muted-foreground"><select aria-label="Быстрая команда" value="" disabled={executing||!!blocked} className="min-w-0 rounded-md border bg-background px-2 py-1" onChange={e=>{setInput(e.target.value);setHistIdx(-1);inputRef.current?.focus()}}><option value="">Подставить команду…</option>{QUICK_CMDS.map(item=><option key={item.cmd} value={item.cmd}>{item.label}</option>)}</select><p>↑↓ — история · Ctrl+L — очистить · до 1024 символов</p></div>
|
||||||
</span>
|
</FrameFooter>
|
||||||
)}
|
</FramePanel>
|
||||||
<button
|
</Frame>
|
||||||
onClick={e => { e.stopPropagation(); setLines([]) }}
|
|
||||||
className="text-[#8b949e] hover:text-white transition-colors ml-1"
|
|
||||||
title="Очистить"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* output */}
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
|
|
||||||
{lines.map((line, i) => (
|
|
||||||
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
|
|
||||||
{line.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* executing indicator */}
|
|
||||||
{executing && (
|
|
||||||
<div className="flex items-center gap-2 text-amber-400 opacity-80">
|
|
||||||
<Loader2Icon className="size-3 animate-spin" />
|
|
||||||
<span>executing…</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* input line */}
|
|
||||||
{!executing && (
|
|
||||||
<div className="flex items-center gap-0">
|
|
||||||
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
value={input}
|
|
||||||
onChange={e => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKey}
|
|
||||||
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── quick commands ───────────────────────────────────────────────────────────
|
// ─── quick commands ───────────────────────────────────────────────────────────
|
||||||
@@ -407,215 +267,47 @@ function mockServersToTermServers(): TermServer[] {
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
host: s.host,
|
host: s.host,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status as TermServer["status"],
|
status: s.status==="unknown"?null:s.status,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
rosVersion: s.os ?? null,
|
rosVersion: s.os ?? null,
|
||||||
identityName: s.name,
|
identityName: s.name,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Backend ServerRead shape (only fields we need) */
|
|
||||||
interface BackendServer {
|
export default function TerminalPage(){
|
||||||
id: number
|
return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
|
||||||
name: string
|
|
||||||
host: string
|
|
||||||
country: string
|
|
||||||
status: "online" | "offline" | null
|
|
||||||
enabled: boolean
|
|
||||||
os: string | null
|
|
||||||
identityName: string | null
|
|
||||||
}
|
}
|
||||||
|
function TerminalRoute(){
|
||||||
export default function TerminalPage() {
|
const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)}
|
||||||
const isLive = mode === "live" && backendStatus === true
|
if(url.error)return <Alert variant="destructive"><AlertTitle>Некорректная ссылка терминала</AlertTitle><AlertDescription>{url.error}<Button type="button" variant="outline" onClick={reset}>Сбросить контекст терминала</Button></AlertDescription></Alert>
|
||||||
|
if(!prefsHydrated)return <p role="status" className="p-4">Загружаем источник…</p>
|
||||||
// Server list state
|
return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
|
||||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
}
|
||||||
const [serversLoading, setServersLoading] = useState(false)
|
function TerminalContent(){
|
||||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||||
|
const [liveServers,setLiveServers]=useState<TermServer[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState<string|null>(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState<string|null>(null),[refresh,setRefresh]=useState(0)
|
||||||
// Load servers from backend when in live mode
|
const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write")
|
||||||
useEffect(() => {
|
useEffect(()=>{
|
||||||
if (!isLive) return
|
if(!isLive||!canRead)return
|
||||||
let cancelled = false
|
let cancelled=false;const controller=new AbortController()
|
||||||
queueMicrotask(() => {
|
queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson<unknown>(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{
|
||||||
if (cancelled) return
|
const rows=terminalServersSchema.parse(raw);if(cancelled)return
|
||||||
setServersLoading(true)
|
setLiveServers(rows.map(row=>({uid:String(row.id),backendId:row.id,name:row.name||row.host,host:row.host,country:row.country,status:row.status,enabled:row.enabled,rosVersion:row.os??null,identityName:row.identityName??null})));setLoaded(true);setReadAt(new Date().toLocaleTimeString("ru-RU"));setError(null)
|
||||||
fetch(`${backendUrl}/api/servers`)
|
}).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
|
||||||
.then(r => r.json() as Promise<BackendServer[]>)
|
return()=>{cancelled=true;controller.abort()}
|
||||||
.then(data => {
|
},[isLive,backendUrl,canRead,refresh])
|
||||||
if (cancelled) return
|
const termServers=isLive?liveServers:mockServersToTermServers()
|
||||||
setLiveServers(data.map(s => ({
|
const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
|
||||||
uid: String(s.id),
|
const selected=matches.length===1?matches[0]:null
|
||||||
backendId: s.id,
|
const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
|
||||||
name: s.name || s.host,
|
const items=termServers.map(server=>({id:server.uid,name:server.name,host:server.host,country:server.country||undefined,status:server.status??"unknown" as const,enabled:server.enabled,selectable:true,title:[server.name,server.host].join(" · ")}))
|
||||||
host: s.host,
|
function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
|
||||||
country: s.country || "",
|
const blocked=!!error||loading||isLive&&!loaded
|
||||||
status: s.status,
|
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"
|
||||||
enabled: s.enabled,
|
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>}</>}/>}
|
||||||
rosVersion: s.os,
|
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>}>
|
||||||
identityName: s.identityName,
|
{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>
|
||||||
setServersLoading(false)
|
|
||||||
})
|
|
||||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
|
||||||
})
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [isLive, backendUrl, refreshKey])
|
|
||||||
|
|
||||||
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"}`
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<PageHeader
|
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" size="sm" onClick={() => {
|
|
||||||
setRefreshKey(k => k + 1)
|
|
||||||
if (isLive) setSelectedUid("")
|
|
||||||
}}>
|
|
||||||
<RefreshCwIcon className="size-4" />Переподключить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden p-6">
|
|
||||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
|
||||||
|
|
||||||
{/* ── sidebar ── */}
|
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
|
||||||
|
|
||||||
{/* server picker */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
|
||||||
{isLive && serversLoading && (
|
|
||||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{isLive && !serversLoading && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
|
||||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLive && !serversLoading && liveServers.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground px-2.5">
|
|
||||||
Нет доступных серверов
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{termServers.map(s => {
|
|
||||||
const isOffline = s.status === "offline"
|
|
||||||
const isSelected = s.uid === selectedUid
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.uid}
|
|
||||||
disabled={isOffline || !s.enabled}
|
|
||||||
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
|
||||||
className={cn(
|
|
||||||
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
|
||||||
"flex items-center gap-2",
|
|
||||||
isSelected
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted",
|
|
||||||
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
"inline-block size-1.5 rounded-full shrink-0",
|
|
||||||
s.status === "online" ? "bg-emerald-500" :
|
|
||||||
s.status === "degraded" ? "bg-amber-400" :
|
|
||||||
s.status === null ? "bg-sky-400" : "bg-red-500",
|
|
||||||
)} />
|
|
||||||
{s.country && <Flag code={s.country} />}
|
|
||||||
<span className="truncate font-mono">{s.name}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* quick commands */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
||||||
Быстрые команды
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
|
||||||
<button
|
|
||||||
key={cmd}
|
|
||||||
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
|
||||||
onClick={() => injectCommand(cmd)}
|
|
||||||
title={cmd}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* hints */}
|
|
||||||
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
|
||||||
<p>↑↓ — история команд</p>
|
|
||||||
<p>Ctrl+L — очистить экран</p>
|
|
||||||
{isLive
|
|
||||||
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
|
||||||
: <p>Режим: mock-данные</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── terminal ── */}
|
|
||||||
{selected ? (
|
|
||||||
<Terminal
|
|
||||||
key={termKey}
|
|
||||||
server={selected}
|
|
||||||
isLive={isLive}
|
|
||||||
backendUrl={backendUrl}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
|
||||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
+889
-470
File diff suppressed because it is too large.
Load diff
Loaded 100 of 2567 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user