mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-29 11:22:18 +03:00
Compare commits
2402 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867a145979 | ||
|
|
6185db586a | ||
|
|
4c71669815 | ||
|
|
c6123f9628 | ||
|
|
2ed85aadda | ||
|
|
b71ed1e3ee | ||
|
|
95aebf1d83 | ||
|
|
09df07ddf5 | ||
|
|
c5b71041d3 | ||
|
|
9c60ed7ea8 | ||
|
|
edf0f36940 | ||
|
|
237b7c898d | ||
|
|
7368359924 | ||
|
|
f2f5d584b3 | ||
|
|
3d1d75d65a | ||
|
|
6e2816d035 | ||
|
|
7fc7c14ac1 | ||
|
|
5f318f3b16 | ||
|
|
9f80cfedab | ||
|
|
1b436bb3e0 | ||
|
|
5b5ac3f04b | ||
|
|
3827d7d061 | ||
|
|
d7f47d8b6a | ||
|
|
fd3770c8c9 | ||
|
|
758e1ad050 | ||
|
|
121b6e0bd0 | ||
|
|
bb5ea3af05 | ||
|
|
b36e5e0869 | ||
|
|
788c979ad1 | ||
|
|
66f946ee54 | ||
|
|
6000bc7134 | ||
|
|
85e2ded0e1 | ||
|
|
f9ae0347c6 | ||
|
|
2928b52b04 | ||
|
|
07cdb82027 | ||
|
|
f00f82b392 | ||
|
|
5a1019534f | ||
|
|
78f1719c6d | ||
|
|
5cf8a08540 | ||
|
|
79a9be7b22 | ||
|
|
19d50bd16c | ||
|
|
3af45c1462 | ||
|
|
6badd829df | ||
|
|
b79abc8bc9 | ||
|
|
05b68c3b13 | ||
|
|
f3c7660f84 | ||
|
|
9b0fd047cb | ||
|
|
e4218a1029 | ||
|
|
7065d41be6 | ||
|
|
1284756f8a | ||
|
|
1f052c0e8f | ||
|
|
ae6f13b533 | ||
|
|
1cf2582e6d | ||
|
|
eacb9f63b0 | ||
|
|
e7035b56fe | ||
|
|
5f526e5201 | ||
|
|
bd8d33980f | ||
|
|
5dc02a9af3 | ||
|
|
033c5993e0 | ||
|
|
2204c8231d | ||
|
|
01a7dc807b | ||
|
|
6bf4a2c4f0 | ||
|
|
21058eb63c | ||
|
|
194de8869e | ||
|
|
26accfd8f7 | ||
|
|
2551a673c3 | ||
|
|
ce4c42e09c | ||
|
|
18614bd6ea | ||
|
|
e564c9283d | ||
|
|
933567d423 | ||
|
|
771bc7c8ef | ||
|
|
61ab602887 | ||
|
|
adc262a238 | ||
|
|
67b098dfd3 | ||
|
|
5543466fcc | ||
|
|
b10a9f1de7 | ||
|
|
4399fe2a85 | ||
|
|
6c6b40e063 | ||
|
|
b97ff40ad6 | ||
|
|
46b6f8c66c | ||
|
|
102df7a290 | ||
|
|
f29c8a5e29 | ||
|
|
ad81649c16 | ||
|
|
b47f794ed0 | ||
|
|
4e1b597914 | ||
|
|
bbefe91011 | ||
|
|
e40554a7d5 | ||
|
|
3569b1be73 | ||
|
|
38da210ded | ||
|
|
9fc47b3d41 | ||
|
|
210c25cf13 | ||
|
|
5dd7e44594 | ||
|
|
4e4a8e9ff7 | ||
|
|
23970e72a7 | ||
|
|
8bdb093d6e | ||
|
|
3b0bcb910e | ||
|
|
428f1333ac | ||
|
|
406cb6dbc0 | ||
|
|
5fb36d34c9 | ||
|
|
4884a2972a | ||
|
|
6e12329d9d | ||
|
|
9f7e8178d4 | ||
|
|
60e6b12f4c | ||
|
|
0dbadf82c0 | ||
|
|
48e90bba51 | ||
|
|
6de9b24229 | ||
|
|
07bc74a521 | ||
|
|
f570b991e7 | ||
|
|
80031e67cc | ||
|
|
fdaa65ad7e | ||
|
|
d86e87ed30 | ||
|
|
89a8f549f2 | ||
|
|
355bb4c9c0 | ||
|
|
9feeccffc0 | ||
|
|
cb962175c2 | ||
|
|
8f3202f431 | ||
|
|
0cb6568fd5 | ||
|
|
6a90f98412 | ||
|
|
9318c2105f | ||
|
|
e642f7324e | ||
|
|
7214ffafc5 | ||
|
|
88061bac10 | ||
|
|
b5479f3f30 | ||
|
|
d8aedcdde4 | ||
|
|
5f3e9ed0ea | ||
|
|
3e8a0eb93e | ||
|
|
4c2915586c | ||
|
|
9f06bffbea | ||
|
|
e20d73ba7e | ||
|
|
8834e5fbbe | ||
|
|
6d732d8d32 | ||
|
|
e4900f1bd4 | ||
|
|
04828246fc | ||
|
|
c1efc48694 | ||
|
|
f1760b0a28 | ||
|
|
745e394c74 | ||
|
|
737300b14b | ||
|
|
30469fcd10 | ||
|
|
887fca86ec | ||
|
|
6efc4b0665 | ||
|
|
94a7dbfe3c | ||
|
|
e2649f98df | ||
|
|
3d839e0ee1 | ||
|
|
a96612f595 | ||
|
|
8e7d215b4a | ||
|
|
24cd271486 | ||
|
|
dee2525d5f | ||
|
|
81b4ae5661 | ||
|
|
9cbba130ab | ||
|
|
cf5767acd1 | ||
|
|
444b05cac9 | ||
|
|
f70e131dfe | ||
|
|
14165fc54d | ||
|
|
7cd26a0583 | ||
|
|
267fb1c866 | ||
|
|
5ac88271af | ||
|
|
b776b33497 | ||
|
|
1478124712 | ||
|
|
9735d26b3d | ||
|
|
113a29733e | ||
|
|
3505430e57 | ||
|
|
f68a14a3ca | ||
|
|
60e2af088d | ||
|
|
917f9b307e | ||
|
|
61c84e8223 | ||
|
|
72d8ebd269 | ||
|
|
b885a1f8a6 | ||
|
|
439f4cf1e8 | ||
|
|
bc00d37ad8 | ||
|
|
12c10dbd98 | ||
|
|
2fd2cd0af1 | ||
|
|
37fb48ffff | ||
|
|
d8198f543b | ||
|
|
f2bc4938b7 | ||
|
|
7f703f927e | ||
|
|
f2c79b57fa | ||
|
|
c394938f01 | ||
|
|
b84b58ef21 | ||
|
|
10ebc6cbdc | ||
|
|
a1b2382877 | ||
|
|
59c55dfc92 | ||
|
|
28a3dddb60 | ||
|
|
39bf31bd56 | ||
|
|
42b2ebc00b | ||
|
|
3b64a62137 | ||
|
|
79a7e7a5b5 | ||
|
|
3349dcbc13 | ||
|
|
ad30298700 | ||
|
|
9be11e109e | ||
|
|
7117d19fd1 | ||
|
|
c88627a839 | ||
|
|
c718e7ca5b | ||
|
|
6a483fa987 | ||
|
|
47163c1418 | ||
|
|
09f4f09b84 | ||
|
|
3313086071 | ||
|
|
03d8ad4d5a | ||
|
|
a8dff126c7 | ||
|
|
74e97fec4c | ||
|
|
50603fd430 | ||
|
|
8bea0fde2b | ||
|
|
b2d32f588f | ||
|
|
8177f6dc66 | ||
|
|
77d94b25d0 | ||
|
|
32b7ada549 | ||
|
|
6099a07ff0 | ||
|
|
e9806832ec | ||
|
|
15ebf3df10 | ||
|
|
d44b70682c | ||
|
|
fb75e3d7c7 | ||
|
|
e9979b6774 | ||
|
|
2b83dc047b | ||
|
|
c90f8a05bf | ||
|
|
9f96ef83ec | ||
|
|
e19061d513 | ||
|
|
51e2fb6dbf | ||
|
|
f21ed92296 | ||
|
|
22de983752 | ||
|
|
0b5c239f98 | ||
|
|
03393c9f52 | ||
|
|
b56db67759 | ||
|
|
6d05702d00 | ||
|
|
9791b05a4e | ||
|
|
0aca2d3b3d | ||
|
|
8529f4f0cf | ||
|
|
abc5cf3439 | ||
|
|
a7e7788e29 | ||
|
|
8620344925 | ||
|
|
47e229e323 | ||
|
|
4521beab7c | ||
|
|
a62c637632 | ||
|
|
35609b7b13 | ||
|
|
a4b1b3d06d | ||
|
|
5f7c7c5f3d | ||
|
|
6bcaf61c44 | ||
|
|
ff25072690 | ||
|
|
530c1597b8 | ||
|
|
c8e16d8c41 | ||
|
|
17f67ef3a5 | ||
|
|
eb4791a1cd | ||
|
|
71ac920436 | ||
|
|
e6d0c33937 | ||
|
|
eef2d311f4 | ||
|
|
772d2b6de4 | ||
|
|
8f30d14716 | ||
|
|
9611c9def6 | ||
|
|
292eb992f4 | ||
|
|
814e6ad69c | ||
|
|
0a38624ba7 | ||
|
|
b86473df02 | ||
|
|
15be803da9 | ||
|
|
c79b45e512 | ||
|
|
86a8eb16b4 | ||
|
|
0fd0389d5c | ||
|
|
2983ac3f8e | ||
|
|
975d6d1bad | ||
|
|
ab7a7f7c6b | ||
|
|
733f44ef0f | ||
|
|
faec3ca038 | ||
|
|
df163854bd | ||
|
|
1af795fad8 | ||
|
|
085cb8c216 | ||
|
|
2a9ba2badc | ||
|
|
53fb4fe8f9 | ||
|
|
8d512d55e5 | ||
|
|
a9d8905393 | ||
|
|
ca2fd3814f | ||
|
|
394fafd29b | ||
|
|
9f0055d193 | ||
|
|
88dafa6cdf | ||
|
|
2b3b2770b4 | ||
|
|
094ea9faaa | ||
|
|
eb16cca551 | ||
|
|
aef0503f8f | ||
|
|
86304226a9 | ||
|
|
6d0e7ec495 | ||
|
|
04b4fb4384 | ||
|
|
ae5ad505d0 | ||
|
|
c188056f64 | ||
|
|
0a424a9f16 | ||
|
|
36b2a58675 | ||
|
|
59e9859225 | ||
|
|
4e5f144def | ||
|
|
ea53da9341 | ||
|
|
3e1a102e9d | ||
|
|
d580086361 | ||
|
|
1e3b366fba | ||
|
|
c2a2a36f56 | ||
|
|
e986a133f8 | ||
|
|
7466916e02 | ||
|
|
96b568b838 | ||
|
|
fec714a243 | ||
|
|
e02f78ac68 | ||
|
|
169b216d7e | ||
|
|
7e6d80efa5 | ||
|
|
38d87230d3 | ||
|
|
f0f98c7122 | ||
|
|
554981d9d3 | ||
|
|
a08f1c6c13 | ||
|
|
7f7ae0c547 | ||
|
|
60abeaad66 | ||
|
|
a6d0100381 | ||
|
|
6767f76ccf | ||
|
|
e4add73c9e | ||
|
|
ff72090e1a | ||
|
|
a3e1bd59df | ||
|
|
5bbb48a8fd | ||
|
|
ee84d585f9 | ||
|
|
7b03346cfc | ||
|
|
258b08fff3 | ||
|
|
a2097ad062 | ||
|
|
52fdf5d429 | ||
|
|
34d8885075 | ||
|
|
5740996436 | ||
|
|
874aae8080 | ||
|
|
842fae18d7 | ||
|
|
ccd223aeea | ||
|
|
96b8fe472c | ||
|
|
59b695ba83 | ||
|
|
159b85f979 | ||
|
|
3ec5b3589f | ||
|
|
2b1d3e7347 | ||
|
|
37f0880f8f | ||
|
|
5b796672e9 | ||
|
|
3fa0da38c9 | ||
|
|
8eb1225734 | ||
|
|
e5c0fe3edf | ||
|
|
f4057989f5 | ||
|
|
84013b0b3f | ||
|
|
511adffc5b | ||
|
|
fc6344b840 | ||
|
|
b3555ce1b8 | ||
|
|
c2f409c3c4 | ||
|
|
0994f8756f | ||
|
|
4779939424 | ||
|
|
4a455aa532 | ||
|
|
25f64738e4 | ||
|
|
5bb87fd3d4 | ||
|
|
491e3f9f8b | ||
|
|
d8fb09faae | ||
|
|
f87c68ea68 | ||
|
|
687e8cf1ba | ||
|
|
03f04194f2 | ||
|
|
248700a8a3 | ||
|
|
ff128a7275 | ||
|
|
e8d2973be7 | ||
|
|
f3d47ebb3f | ||
|
|
06c49b92f8 | ||
|
|
e35213bc73 | ||
|
|
aa6a886977 | ||
|
|
9d603c5ad2 | ||
|
|
a973fa6d68 | ||
|
|
3af6497577 | ||
|
|
c59f54bb0e | ||
|
|
6b3da4fe5e | ||
|
|
ea0da32e81 | ||
|
|
d5ea8d0f38 | ||
|
|
fd5f591737 | ||
|
|
8a4c9a98cb | ||
|
|
70b365171f | ||
|
|
328ba3b45e | ||
|
|
5370b6943a | ||
|
|
d8c783a296 | ||
|
|
809f69729a | ||
|
|
93b7ce199f | ||
|
|
2a76cec804 | ||
|
|
88eab032be | ||
|
|
20ec863f51 | ||
|
|
2f4018bbe5 | ||
|
|
f273708f6d | ||
|
|
e6318d57e4 | ||
|
|
77fa976ee9 | ||
|
|
8098d2b1b1 | ||
|
|
a691eaea8d | ||
|
|
da447e5669 | ||
|
|
f8c9aac97c | ||
|
|
e42c17f2b2 | ||
|
|
427b7b67d8 | ||
|
|
ccf08086ac | ||
|
|
7b0a3929ff | ||
|
|
570ab8e5e0 | ||
|
|
1240e4c962 | ||
|
|
c117b8b272 | ||
|
|
6041d10e3d | ||
|
|
4800f8fb70 | ||
|
|
a9770e1da2 | ||
|
|
3f15d21f13 | ||
|
|
a6b3623634 | ||
|
|
947fd4fae1 | ||
|
|
e69a31dd59 | ||
|
|
719ae0e014 | ||
|
|
5bcf6a8aeb | ||
|
|
945fefde12 | ||
|
|
313a2acbf6 | ||
|
|
b747730211 | ||
|
|
692a73788a | ||
|
|
3287fa4d80 | ||
|
|
1393f981bc | ||
|
|
9a2c1c6b43 | ||
|
|
278aa1c85c | ||
|
|
8fe297ef9d | ||
|
|
c881d1015a | ||
|
|
c061337ce7 | ||
|
|
260eedf8c4 | ||
|
|
69ccdba734 | ||
|
|
4c797dc154 | ||
|
|
f000322a06 | ||
|
|
0ea8b5352a | ||
|
|
68240061aa | ||
|
|
0695f677ba | ||
|
|
70f6d6b21a | ||
|
|
e8c509c720 | ||
|
|
83a1c721c7 | ||
|
|
7ccc0877a1 | ||
|
|
ad659e48cf | ||
|
|
784ed39930 | ||
|
|
538f7fd5d7 | ||
|
|
cf38226b5d | ||
|
|
575ee854c8 | ||
|
|
9936af80dd | ||
|
|
4a75bd0a48 | ||
|
|
b0c223c631 | ||
|
|
313b51f96f | ||
|
|
020cd63e22 | ||
|
|
6e46e9b16e | ||
|
|
713a7328f6 | ||
|
|
01d4a7488d | ||
|
|
2b2ed3349a | ||
|
|
d8523bbdac | ||
|
|
8afa39144e | ||
|
|
00baeffe74 | ||
|
|
b578a33518 | ||
|
|
8153e0ac05 | ||
|
|
2eb9d2e2e8 | ||
|
|
a824875c4f | ||
|
|
cafcb250ec | ||
|
|
e7cfee570b | ||
|
|
90c3529301 | ||
|
|
b65ec83c39 | ||
|
|
28a17a80ec | ||
|
|
3056583388 | ||
|
|
172f2ddaa7 | ||
|
|
d69af328dc | ||
|
|
ee0e3093ba | ||
|
|
89def9aee6 | ||
|
|
b2b0024648 | ||
|
|
5822758b7c | ||
|
|
49430b3991 | ||
|
|
104526aab2 | ||
|
|
a0c07241c0 | ||
|
|
adf3242602 | ||
|
|
3f62592e4b | ||
|
|
02bff4db6c | ||
|
|
8ff4e1ff31 | ||
|
|
26c6438ec2 | ||
|
|
b3e96230c4 | ||
|
|
1016f3b4f9 | ||
|
|
020bc9d77c | ||
|
|
5620d739c6 | ||
|
|
d518979e4f | ||
|
|
83f8a03b50 | ||
|
|
b45e63a14a | ||
|
|
3007bcff97 | ||
|
|
55f1d72af5 | ||
|
|
806ecbd7c5 | ||
|
|
ae79b43cdb | ||
|
|
e64e6327ef | ||
|
|
9f024b9e6a | ||
|
|
eacfbc86b5 | ||
|
|
37c17357fc | ||
|
|
b35d339665 | ||
|
|
5e7a3db873 | ||
|
|
6ced549dea | ||
|
|
f60682a6b7 | ||
|
|
50bd7a8040 | ||
|
|
7465768ff7 | ||
|
|
5b00a52c65 | ||
|
|
151f1173a1 | ||
|
|
e262132b9d | ||
|
|
ca0a7aeb5a | ||
|
|
7447cec17e | ||
|
|
0ffd27c0aa | ||
|
|
054cb1dea0 | ||
|
|
3757ae0b11 | ||
|
|
e3883fca87 | ||
|
|
b46a0b404b | ||
|
|
0ce58a095a | ||
|
|
59ea2645db | ||
|
|
8c8d280f14 | ||
|
|
c720008187 | ||
|
|
170d24499e | ||
|
|
99c79d4056 | ||
|
|
fcdeb1fc79 | ||
|
|
0a58b5e745 | ||
|
|
db7e7dcd29 | ||
|
|
01b8a27996 | ||
|
|
3764ece26c | ||
|
|
d7efc2aef9 | ||
|
|
2eb8abf61e | ||
|
|
299572a4c2 | ||
|
|
22afa50901 | ||
|
|
bc274d1e1f | ||
|
|
dc21f41932 | ||
|
|
f137b1af76 | ||
|
|
c4871ef8fe | ||
|
|
ecfffa882a | ||
|
|
3af5026abe | ||
|
|
1de7accd7c | ||
|
|
76afff2a6f | ||
|
|
9623e87511 | ||
|
|
bc0518391e | ||
|
|
5408a2f82c | ||
|
|
c8d71ea748 | ||
|
|
46de886b53 | ||
|
|
6d41320ed7 | ||
|
|
bf9d2e6aeb | ||
|
|
ed96fa090b | ||
|
|
3ac1d7f546 | ||
|
|
10025ffa66 | ||
|
|
5ee62b25ca | ||
|
|
311d11a3c1 | ||
|
|
40b6d7707a | ||
|
|
cbf316db31 | ||
|
|
33a36ada4b | ||
|
|
82ddd10627 | ||
|
|
2401c99817 | ||
|
|
2f36a4047c | ||
|
|
dc3b0d218a | ||
|
|
610d29765a | ||
|
|
b1ea8005e4 | ||
|
|
3f0bfa2472 | ||
|
|
1e2ff650ad | ||
|
|
c2d6dd923f | ||
|
|
723ec25fb2 | ||
|
|
7dc52e9a53 | ||
|
|
fe9f0d1d0e | ||
|
|
18d74d54ca | ||
|
|
c7ba6ae909 | ||
|
|
3edf79e589 | ||
|
|
5420e643cf | ||
|
|
9fcd0387ca | ||
|
|
7b039d219e | ||
|
|
dbec28b915 | ||
|
|
e5126806d7 | ||
|
|
b008ff4ad2 | ||
|
|
da6b89fdcd | ||
|
|
d7882c25d1 | ||
|
|
ed2a0a0bcf | ||
|
|
4a0914cb1e | ||
|
|
664269d513 | ||
|
|
d0796b26c9 | ||
|
|
2750f46c01 | ||
|
|
023eb513e4 | ||
|
|
0c7b59ed47 | ||
|
|
3087c1b123 | ||
|
|
2198397197 | ||
|
|
d10c312e62 | ||
|
|
24a3411465 | ||
|
|
2198e7a28f | ||
|
|
6b23b416a7 | ||
|
|
16f53ce4c2 | ||
|
|
27445b30e9 | ||
|
|
3d0212c21d | ||
|
|
978755960f | ||
|
|
9b51e9a5c5 | ||
|
|
6879a8fbcb | ||
|
|
7258841491 | ||
|
|
23dd80fbb0 | ||
|
|
6556884c7f | ||
|
|
d5c532c64f | ||
|
|
ad5f774a1e | ||
|
|
aa285914fa | ||
|
|
4d02756e1e | ||
|
|
825d93d95f | ||
|
|
5ea6386815 | ||
|
|
d064e85ecd | ||
|
|
9fc03bd10a | ||
|
|
ae08a29cde | ||
|
|
4f25eb230e | ||
|
|
ce72d53d1a | ||
|
|
5e641ff9e8 | ||
|
|
58898e5758 | ||
|
|
569550d5f6 | ||
|
|
419ea63dd0 | ||
|
|
6a17285935 | ||
|
|
4b03e9d919 | ||
|
|
7e9c3bdbaf | ||
|
|
957f3dbb54 | ||
|
|
05e60af283 | ||
|
|
5e40458116 | ||
|
|
baf6fdd29d | ||
|
|
45f78d3521 | ||
|
|
01f984e054 | ||
|
|
e4ba5ba53a | ||
|
|
6ff555c8bb | ||
|
|
3c1634ca7c | ||
|
|
561c4810be | ||
|
|
eb1b96643d | ||
|
|
de5314c01f | ||
|
|
1088d1faf3 | ||
|
|
267024c43f | ||
|
|
0d595f56e4 | ||
|
|
a4c4f9efb3 | ||
|
|
73a5722cca | ||
|
|
30264043f8 | ||
|
|
c6062eb15c | ||
|
|
f1b7944828 | ||
|
|
7a57b31ff3 | ||
|
|
6e1b949081 | ||
|
|
0ad708b1b6 | ||
|
|
71f13ebcbd | ||
|
|
f5f4a530cc | ||
|
|
702f03e4b7 | ||
|
|
487ec74e0b | ||
|
|
b4dae36345 | ||
|
|
761728255c | ||
|
|
b1ab156e42 | ||
|
|
fa45bf87de | ||
|
|
75416eebd7 | ||
|
|
87042d77ba | ||
|
|
011e0f309a | ||
|
|
b7164805f8 | ||
|
|
bbdeb65291 | ||
|
|
038cf34219 | ||
|
|
98a1517470 | ||
|
|
ce76cedb0d | ||
|
|
24a313d605 | ||
|
|
c81c27073c | ||
|
|
5d11e6e13f | ||
|
|
f3d0b92e4a | ||
|
|
c8c0e77714 | ||
|
|
49b8f46864 | ||
|
|
cad07be847 | ||
|
|
4b20f16024 | ||
|
|
d642774a44 | ||
|
|
1644904755 | ||
|
|
5c10035bd9 | ||
|
|
2e6faf69e6 | ||
|
|
f88b7b07f0 | ||
|
|
e5752239f4 | ||
|
|
cb22b4ad47 | ||
|
|
6a2e0071cf | ||
|
|
f86219f4de | ||
|
|
e272c160b1 | ||
|
|
ba50c99c10 | ||
|
|
00b61de646 | ||
|
|
dff4ad31ff | ||
|
|
13baf77893 | ||
|
|
4531574de3 | ||
|
|
d1e07954c5 | ||
|
|
d9922d93af | ||
|
|
c7d315f848 | ||
|
|
1781790dce | ||
|
|
29f950046a | ||
|
|
5dae785786 | ||
|
|
1b1cbfff42 | ||
|
|
c93467b852 | ||
|
|
c988d55256 | ||
|
|
ef625c75d8 | ||
|
|
182e591c48 | ||
|
|
3666d1193f | ||
|
|
7a5a833af3 | ||
|
|
58f978bb0a | ||
|
|
6d47496069 | ||
|
|
e5c19759db | ||
|
|
295a8b6e37 | ||
|
|
384e23aeb2 | ||
|
|
23293813bb | ||
|
|
c15ec5315a | ||
|
|
1ddfe4aba3 | ||
|
|
fe3b1c9b52 | ||
|
|
d39ccf4b8f | ||
|
|
1aed2d8cdc | ||
|
|
c3084aaece | ||
|
|
13cf7271d6 | ||
|
|
63edc63ab0 | ||
|
|
85cbad3ef4 | ||
|
|
3d54e33051 | ||
|
|
01be9fec95 | ||
|
|
0306e75c2a | ||
|
|
255ff9cc20 | ||
|
|
2fbb1ca6c9 | ||
|
|
3b47028060 | ||
|
|
d9ab8b4ce4 | ||
|
|
e6389f3fb3 | ||
|
|
96fd7d0e7c | ||
|
|
cf02f02210 | ||
|
|
4dc8974af0 | ||
|
|
b527a528ea | ||
|
|
1a53af0434 | ||
|
|
be8d55dadb | ||
|
|
d54e7a9b14 | ||
|
|
45c3d730d4 | ||
|
|
aab01ff11a | ||
|
|
236dddf482 | ||
|
|
8e472838d8 | ||
|
|
b75a1ef5e1 | ||
|
|
d956f78347 | ||
|
|
8ef447a997 | ||
|
|
520f7a2d15 | ||
|
|
3ded4ee658 | ||
|
|
bea19a263d | ||
|
|
878e0d02cd | ||
|
|
b15ea1f74d | ||
|
|
431d7350a5 | ||
|
|
127bea7f73 | ||
|
|
7c58bcbb46 | ||
|
|
fec9b25248 | ||
|
|
728166bd1a | ||
|
|
d376ce057c | ||
|
|
5e6e900e64 | ||
|
|
19f7938617 | ||
|
|
fe791b6e99 | ||
|
|
a02bf3195d | ||
|
|
3ea05d30c1 | ||
|
|
40ebf2902e | ||
|
|
14253c3586 | ||
|
|
7b15274c84 | ||
|
|
6545d8b61d | ||
|
|
6f4eefe601 | ||
|
|
510c35f450 | ||
|
|
00addb0dd9 | ||
|
|
db140a1e9b | ||
|
|
db945e2fbd | ||
|
|
667fac15f4 | ||
|
|
29033a7828 | ||
|
|
2ffde55f8f | ||
|
|
6e5ed881f2 | ||
|
|
d52c50fd9e | ||
|
|
fa5fb927c1 | ||
|
|
f7198c4c2f | ||
|
|
21e7d45b54 | ||
|
|
db62a07fb8 | ||
|
|
e3120c4028 | ||
|
|
7ae855e7c9 | ||
|
|
b9307c6c9c | ||
|
|
d30cdbf49a | ||
|
|
cac00224db | ||
|
|
b68f0a206c | ||
|
|
0bde51b91e | ||
|
|
280a22b57d | ||
|
|
315d852087 | ||
|
|
6a0d2e0a29 | ||
|
|
a811225610 | ||
|
|
1893c3814d | ||
|
|
422c391f96 | ||
|
|
f7f95ffbae | ||
|
|
f408bd7c77 | ||
|
|
ad13ce6cde | ||
|
|
c35179d924 | ||
|
|
cedc7f0fb8 | ||
|
|
64fa0e97a3 | ||
|
|
a45e9de472 | ||
|
|
101e9ebf35 | ||
|
|
17a76d2843 | ||
|
|
a23a5de540 | ||
|
|
a16e83468b | ||
|
|
1c59afe031 | ||
|
|
c49ec9a74c | ||
|
|
a0dd101d97 | ||
|
|
700cf9c10b | ||
|
|
697cd5e6d9 | ||
|
|
c6d27a4463 | ||
|
|
751f564c4a | ||
|
|
6658f648e6 | ||
|
|
d6f9f3f6d3 | ||
|
|
fad6c497eb | ||
|
|
42fa64770b | ||
|
|
0a207b8a2c | ||
|
|
26bf693dbd | ||
|
|
7483fb2ec5 | ||
|
|
2d8cca3a2e | ||
|
|
cf7fec1351 | ||
|
|
361849b9db | ||
|
|
c13db7922e | ||
|
|
d6d05a9b4d | ||
|
|
3caace2cb6 | ||
|
|
99f26be30d | ||
|
|
b0edd24c52 | ||
|
|
f0cfd48f66 | ||
|
|
f5aea03765 | ||
|
|
14cdde371f | ||
|
|
99a23f25d5 | ||
|
|
653ec90451 | ||
|
|
91a84db479 | ||
|
|
0f97eca314 | ||
|
|
fb79081aa1 | ||
|
|
ea19fb8ff6 | ||
|
|
f4cfe9eb63 | ||
|
|
4def70a006 | ||
|
|
e0e9e2681a | ||
|
|
31e1581d6b | ||
|
|
018e98a510 | ||
|
|
21ea673c30 | ||
|
|
08b55da408 | ||
|
|
7a3ee69a7f | ||
|
|
664bd9b596 | ||
|
|
ceb1217121 | ||
|
|
e754523689 | ||
|
|
e84503feec | ||
|
|
1bbf31df9f | ||
|
|
49bfff9fa5 | ||
|
|
d18a1a37ce | ||
|
|
04c6b2722b | ||
|
|
94d651fc93 | ||
|
|
aae0cb37b7 | ||
|
|
b922d986d6 | ||
|
|
8a7cffd63f | ||
|
|
c8e8c97afc | ||
|
|
46ba4c4518 | ||
|
|
a787ab497c | ||
|
|
3be204f272 | ||
|
|
de13729a97 | ||
|
|
468eb8b908 | ||
|
|
e95a748e77 | ||
|
|
34e2d961f5 | ||
|
|
b4a1d81444 | ||
|
|
46ef506aa6 | ||
|
|
51220917c4 | ||
|
|
b34956647b | ||
|
|
5c4e2dfd39 | ||
|
|
dd4c2adb37 | ||
|
|
2dec7f48f5 | ||
|
|
04cf250a54 | ||
|
|
ac9ab828b5 | ||
|
|
4dd40f6f19 | ||
|
|
7911eeb69f | ||
|
|
6e9180a665 | ||
|
|
66fe84181b | ||
|
|
7b7eb98acb | ||
|
|
7eb5afdd8d | ||
|
|
522ccda71c | ||
|
|
f780efb430 | ||
|
|
783f1a073e | ||
|
|
a4c38ec8ae | ||
|
|
0c47771671 | ||
|
|
67920a1962 | ||
|
|
49d3957c07 | ||
|
|
ee946ceab2 | ||
|
|
b650064177 | ||
|
|
9fb9d7201e | ||
|
|
4a3b9b913d | ||
|
|
da674d44cf | ||
|
|
cc3252531b | ||
|
|
284731deeb | ||
|
|
9bc5c1d070 | ||
|
|
26a7700557 | ||
|
|
b6a919218a | ||
|
|
6cc07254e0 | ||
|
|
4ad5a5aba4 | ||
|
|
7ab8164de4 | ||
|
|
195effd177 | ||
|
|
747ad3b9c8 | ||
|
|
cf879f9527 | ||
|
|
04c658f1a0 | ||
|
|
2ab1a174db | ||
|
|
dfca2af997 | ||
|
|
0a7d15b48c | ||
|
|
518bc72f90 | ||
|
|
3c65209ce9 | ||
|
|
174535b05d | ||
|
|
fff54fe7f3 | ||
|
|
0859d230b0 | ||
|
|
02998c5467 | ||
|
|
3f38c42852 | ||
|
|
49295661fd | ||
|
|
e4301f8d93 | ||
|
|
c6586f8df2 | ||
|
|
8e81008cdc | ||
|
|
e33ad809d6 | ||
|
|
d804043a18 | ||
|
|
0fb0df7056 | ||
|
|
73e90e0eaa | ||
|
|
44ef1ac9a6 | ||
|
|
aeac7f2c8b | ||
|
|
39ef172b87 | ||
|
|
0df85cc3d9 | ||
|
|
f0f4f082ae | ||
|
|
b29bd993d4 | ||
|
|
127eaf69b6 | ||
|
|
36b0289bc6 | ||
|
|
0abd0be725 | ||
|
|
918a2b1533 | ||
|
|
88a17cd227 | ||
|
|
b60387accb | ||
|
|
049177024b | ||
|
|
9c63638af1 | ||
|
|
67dfe664a6 | ||
|
|
4efcdb3e01 | ||
|
|
ddc2cfacb9 | ||
|
|
337729529a | ||
|
|
4c4cc362b3 | ||
|
|
4e0aca16c2 | ||
|
|
749a426a71 | ||
|
|
b859327b8a | ||
|
|
3e8fc59213 | ||
|
|
462e02140d | ||
|
|
6b41df2d89 | ||
|
|
eb5ed5c0dd | ||
|
|
e0bbacf013 | ||
|
|
34af7f8bfa | ||
|
|
b569c21fec | ||
|
|
c4a5c059e3 | ||
|
|
c21ed90da0 | ||
|
|
9b58277945 | ||
|
|
5a4a42aeb8 | ||
|
|
9476472bf6 | ||
|
|
2ce9c3cc81 | ||
|
|
0a8bfc2725 | ||
|
|
c5bbb6b632 | ||
|
|
f497a8dbcc | ||
|
|
4290081486 | ||
|
|
ccda652e69 | ||
|
|
2982d809ab | ||
|
|
7ad4a3dffc | ||
|
|
c0ef53e542 | ||
|
|
b7d1c84cd0 | ||
|
|
111bfe5d2e | ||
|
|
6e59aa14b0 | ||
|
|
a4cf77422f | ||
|
|
35df2a0505 | ||
|
|
9f445686a4 | ||
|
|
0fc935e996 | ||
|
|
adb08a60cf | ||
|
|
e3576e8a85 | ||
|
|
9c065aed4e | ||
|
|
7abb092211 | ||
|
|
937bfb4c78 | ||
|
|
1bcdc54b68 | ||
|
|
eb58314c53 | ||
|
|
c158e6ec73 | ||
|
|
5ae587ee81 | ||
|
|
d40fa46851 | ||
|
|
19a31686da | ||
|
|
13f7e07128 | ||
|
|
0e3691fdbd | ||
|
|
e359b5c75e | ||
|
|
3b3bd3dea4 | ||
|
|
8f36b7ea84 | ||
|
|
569d99512c | ||
|
|
ac84553a68 | ||
|
|
610db7827d | ||
|
|
088b55c9ed | ||
|
|
c800e29900 | ||
|
|
14435db0d8 | ||
|
|
bd6402562e | ||
|
|
d16ad11136 | ||
|
|
6c27e4177d | ||
|
|
bebf83f06c | ||
|
|
07bf741b15 | ||
|
|
5e5851029d | ||
|
|
e6020850fc | ||
|
|
80183f61e8 | ||
|
|
1b9432ff37 | ||
|
|
f1f813269c | ||
|
|
a23f390402 | ||
|
|
2950ce0c17 | ||
|
|
514c4909a4 | ||
|
|
99cadf7652 | ||
|
|
4ca36d64a8 | ||
|
|
863009dcaa | ||
|
|
2ef5ccc2fd | ||
|
|
744583b4e7 | ||
|
|
b36032e22c | ||
|
|
ac7901abba | ||
|
|
dff2496d73 | ||
|
|
d97d36bb9e | ||
|
|
b0d2cb93e1 | ||
|
|
f98d78c356 | ||
|
|
d85226dc79 | ||
|
|
1c2b6095c9 | ||
|
|
5f8c8f4525 | ||
|
|
6a49e99a2c | ||
|
|
6062031de4 | ||
|
|
fb2b58110d | ||
|
|
c385662783 | ||
|
|
4a05188a7f | ||
|
|
1454c4ebc5 | ||
|
|
4b1c76e972 | ||
|
|
f1f5d323e8 | ||
|
|
e37f2d3222 | ||
|
|
4f2f855c04 | ||
|
|
dcab4e6f9c | ||
|
|
e703055793 | ||
|
|
7efe1d60d5 | ||
|
|
1d84284b6d | ||
|
|
8404b33232 | ||
|
|
41d39dfaa8 | ||
|
|
761eb5f384 | ||
|
|
e72f67ca54 | ||
|
|
b8df15171e | ||
|
|
5f531f2de1 | ||
|
|
8335238eb3 | ||
|
|
aaf68ecb21 | ||
|
|
db6781f311 | ||
|
|
a85b02c6ab | ||
|
|
19a832cad8 | ||
|
|
f0dd6152fd | ||
|
|
decac2ef74 | ||
|
|
c3ce1da0d6 | ||
|
|
02bc488c1c | ||
|
|
b22f859c38 | ||
|
|
98869c7169 | ||
|
|
150c89db72 | ||
|
|
fe0a8375a3 | ||
|
|
80cfbefd75 | ||
|
|
f2ee18235f | ||
|
|
f48df1e5c0 | ||
|
|
b24855082e | ||
|
|
27434f3235 | ||
|
|
cdb6eac0e6 | ||
|
|
9e13513205 | ||
|
|
b09f52357c | ||
|
|
ac08e86747 | ||
|
|
c9c8abe97b | ||
|
|
0b8beafc89 | ||
|
|
8b6e3491c4 | ||
|
|
6cf2b56f9b | ||
|
|
19b95829e0 | ||
|
|
4bea427c79 | ||
|
|
da7e4d51d6 | ||
|
|
43713fbdf8 | ||
|
|
dc29e858c9 | ||
|
|
c30c6f08f3 | ||
|
|
7c892ac051 | ||
|
|
75dd7b93f5 | ||
|
|
d5de8e1bf3 | ||
|
|
16b4795956 | ||
|
|
e5835c299c | ||
|
|
4fdef3cfde | ||
|
|
cf6a8bd463 | ||
|
|
59a84e844c | ||
|
|
6b0c9a5fad | ||
|
|
77edea5419 | ||
|
|
521870df0a | ||
|
|
dc1c1eb998 | ||
|
|
e78427245a | ||
|
|
fbcab5bc52 | ||
|
|
89c79c3ec3 | ||
|
|
566cd9e9c4 | ||
|
|
ad78cec7c7 | ||
|
|
176ab5f48e | ||
|
|
9c4fa23931 | ||
|
|
2938694c45 | ||
|
|
88d0fb9753 | ||
|
|
d23a7f81ef | ||
|
|
2e363445fc | ||
|
|
33d983bc20 | ||
|
|
3e7c7831bc | ||
|
|
374d49eb92 | ||
|
|
663cf5649f | ||
|
|
095ebccbb0 | ||
|
|
f4bb6b0517 | ||
|
|
575234ac37 | ||
|
|
16cbd86371 | ||
|
|
2d3666e339 | ||
|
|
47987b7785 | ||
|
|
2e1461e6dc | ||
|
|
272457740f | ||
|
|
58c721e7d2 | ||
|
|
b4baf35ed8 | ||
|
|
2001d96148 | ||
|
|
5c390341fb | ||
|
|
c8b1b162ff | ||
|
|
a55645584b | ||
|
|
f536307914 | ||
|
|
4494ffabb6 | ||
|
|
2dc59a601c | ||
|
|
4ad04e2032 | ||
|
|
f2ebe128cc | ||
|
|
f0165c1ad8 | ||
|
|
898f80cb30 | ||
|
|
5d7de0858c | ||
|
|
c0b88fe736 | ||
|
|
fa43248e30 | ||
|
|
cb3da25bc8 | ||
|
|
a40058bb0b | ||
|
|
6ab3bbe7bd | ||
|
|
9e73c82eb3 | ||
|
|
6b3b1b6cbc | ||
|
|
b3b433f84b | ||
|
|
7f16a53309 | ||
|
|
2471bda211 | ||
|
|
cd49971535 | ||
|
|
0013f8989b | ||
|
|
de8c80597f | ||
|
|
9dcc55ea1b | ||
|
|
8255390131 | ||
|
|
438a9684ee | ||
|
|
a6000f22a2 | ||
|
|
2ec5eeb442 | ||
|
|
d319476eb6 | ||
|
|
93d52bc86c | ||
|
|
bda5c2c915 | ||
|
|
b838fe2e74 | ||
|
|
604b9be4a0 | ||
|
|
7b8ef98846 | ||
|
|
2c7c6c260a | ||
|
|
b8c3555b09 | ||
|
|
2d2b30daf1 | ||
|
|
3e3ed4ed52 | ||
|
|
d8d9c64847 | ||
|
|
c489673130 | ||
|
|
2544305fb9 | ||
|
|
519d228db2 | ||
|
|
4cd89f4379 | ||
|
|
fdfc29f6cd | ||
|
|
4ec104c5ee | ||
|
|
bae89272b0 | ||
|
|
8408a45eff | ||
|
|
a37b1bde4c | ||
|
|
953b5d3dea | ||
|
|
c7906e8598 | ||
|
|
e1bc43da5f | ||
|
|
0630642849 | ||
|
|
8bd3827b41 | ||
|
|
24b367b82f | ||
|
|
011443bfc1 | ||
|
|
7417c52c8f | ||
|
|
4d4eef8d8a | ||
|
|
9de8b4acaf | ||
|
|
f21c293693 | ||
|
|
56159d9c52 | ||
|
|
6cbdf64013 | ||
|
|
bb9b9100a8 | ||
|
|
816adfc3ea | ||
|
|
0a95b0c7b2 | ||
|
|
d298f4ffbd | ||
|
|
315e8af025 | ||
|
|
de985263f5 | ||
|
|
dfe0bbd371 | ||
|
|
60cb328698 | ||
|
|
3d7f13225a | ||
|
|
76fdfb2ef2 | ||
|
|
8018023187 | ||
|
|
ea9d5dc2d5 | ||
|
|
96e43fa195 | ||
|
|
f1500a5d31 | ||
|
|
c9a218d060 | ||
|
|
6d18a15c4e | ||
|
|
c0f86d2f38 | ||
|
|
5227fefaeb | ||
|
|
7a51d2f2cc | ||
|
|
02ae61fe6b | ||
|
|
24b9e5bfa3 | ||
|
|
9ff7f14b6e | ||
|
|
c3b42b8ea4 | ||
|
|
5afb8d85fc | ||
|
|
767ee4ec2b | ||
|
|
21b64beb96 | ||
|
|
b84e3ef338 | ||
|
|
86586b7e8f | ||
|
|
f9792632d4 | ||
|
|
a9ec24f811 | ||
|
|
2da7dda794 | ||
|
|
39aae6fd16 | ||
|
|
f355ab5758 | ||
|
|
3847bc0a78 | ||
|
|
546d676472 | ||
|
|
6fb6241c3c | ||
|
|
f481ab993e | ||
|
|
3ef4ab423f | ||
|
|
b5a32ef57e | ||
|
|
4033001798 | ||
|
|
2486b5ff43 | ||
|
|
58647c6496 | ||
|
|
0b8a28d56d | ||
|
|
5d007435ab | ||
|
|
9c05aa514b | ||
|
|
7f2c11220f | ||
|
|
52b02fdef9 | ||
|
|
a4b76929f4 | ||
|
|
33082a271f | ||
|
|
28ede36a10 | ||
|
|
5036e9e28a | ||
|
|
4ca481d071 | ||
|
|
8259024fbe | ||
|
|
e275adbccd | ||
|
|
f96df5bc3d | ||
|
|
a09701c17b | ||
|
|
713285457f | ||
|
|
e1ef746cab | ||
|
|
fc714962ad | ||
|
|
2f46d42214 | ||
|
|
1b9360dfaf | ||
|
|
25b910c6d9 | ||
|
|
7ac79446c7 | ||
|
|
fdf805f264 | ||
|
|
baf8c94b2e | ||
|
|
adcfccbe45 | ||
|
|
c422214ae8 | ||
|
|
bd2d7bce62 | ||
|
|
ca4f83c7b1 | ||
|
|
b6bb0b1787 | ||
|
|
c0b5d5506f | ||
|
|
a2f6d3b8dc | ||
|
|
80cd793154 | ||
|
|
d070a82b3d | ||
|
|
5ec16301a6 | ||
|
|
07245d614a | ||
|
|
6e734553e2 | ||
|
|
275370e32c | ||
|
|
e7c59adc59 | ||
|
|
68c9b55447 | ||
|
|
70f9e32f28 | ||
|
|
5aae32c888 | ||
|
|
36cade2a02 | ||
|
|
907c30f743 | ||
|
|
5202fb2df4 | ||
|
|
73a19a45d7 | ||
|
|
0fb4094fe6 | ||
|
|
d9254fec03 | ||
|
|
2f02b71ed4 | ||
|
|
559aad9967 | ||
|
|
a1c15e9578 | ||
|
|
be9747dcbc | ||
|
|
94eb27d2c4 | ||
|
|
d1f67f7f2f | ||
|
|
bb6757df0f | ||
|
|
e094c351a3 | ||
|
|
fb31ecfa55 | ||
|
|
93a865f010 | ||
|
|
01a3a7b862 | ||
|
|
e233fdb092 | ||
|
|
55bf0c3e55 | ||
|
|
3129fdc103 | ||
|
|
2d20983690 | ||
|
|
490048a975 | ||
|
|
3950f596d8 | ||
|
|
835cf2801c | ||
|
|
e794d3d87f | ||
|
|
2cf762642b | ||
|
|
db24d21621 | ||
|
|
3d5c06bf08 | ||
|
|
a3a2d7a6a3 | ||
|
|
2cf2c451c2 | ||
|
|
2708c816a8 | ||
|
|
18cc3d5158 | ||
|
|
8e8a7d92e9 | ||
|
|
a99d031fe8 | ||
|
|
d0c9cebac1 | ||
|
|
d37dbb7dc8 | ||
|
|
a9f4c5fd77 | ||
|
|
8a9d9ae24c | ||
|
|
e02a199425 | ||
|
|
d712b44ea1 | ||
|
|
d85a23b6f2 | ||
|
|
a660ad6421 | ||
|
|
0e17521326 | ||
|
|
7118d92980 | ||
|
|
59708d6410 | ||
|
|
be8e78797a | ||
|
|
6d364638ca | ||
|
|
c147636133 | ||
|
|
fa7a6a3f99 | ||
|
|
620da1784b | ||
|
|
b13e1d666d | ||
|
|
2935eeb36a | ||
|
|
eb382cd5b4 | ||
|
|
f02c82677d | ||
|
|
5a5f7afb68 | ||
|
|
3a6bca794f | ||
|
|
4e20bb5f02 | ||
|
|
44b7eb881c | ||
|
|
9b1146780c | ||
|
|
866f7bd042 | ||
|
|
9454af46b7 | ||
|
|
9d18e925cc | ||
|
|
21d988133e | ||
|
|
0633a25d29 | ||
|
|
47243f0374 | ||
|
|
ede67f2d6c | ||
|
|
d759e09569 | ||
|
|
684e777628 | ||
|
|
1e902c2511 | ||
|
|
1a0cdfbfbc | ||
|
|
53e7608ddf | ||
|
|
43ec88bb24 | ||
|
|
c5b30d6c6c | ||
|
|
7fad81edab | ||
|
|
b9a5fd2c09 | ||
|
|
0bec29f2ba | ||
|
|
a7418d9708 | ||
|
|
abd69a253a | ||
|
|
2b420bd517 | ||
|
|
14d04e8ec9 | ||
|
|
1e2be10429 | ||
|
|
569c9428fb | ||
|
|
97489e743a | ||
|
|
c74efa1d43 | ||
|
|
18af7047f8 | ||
|
|
e7ae846823 | ||
|
|
b042f01e58 | ||
|
|
e43601ac08 | ||
|
|
bc29d7a252 | ||
|
|
dd21bb2db7 | ||
|
|
4d07b99fe7 | ||
|
|
8b5fe0b018 | ||
|
|
fc23af5db6 | ||
|
|
10f54cd937 | ||
|
|
b21758e6a6 | ||
|
|
903db95783 | ||
|
|
6fdc07a2d0 | ||
|
|
c9f245cb25 | ||
|
|
5dc95f8556 | ||
|
|
d9d8545cca | ||
|
|
4593147b65 | ||
|
|
020d1adc55 | ||
|
|
389b5408b1 | ||
|
|
037dbb5670 | ||
|
|
6af0d55ca9 | ||
|
|
0917eb2742 | ||
|
|
f8be7a7649 | ||
|
|
5b87b12535 | ||
|
|
8908e8b16a | ||
|
|
4f7af1e57a | ||
|
|
3af55cc5b4 | ||
|
|
ac5d8af4f9 | ||
|
|
01cd7539f9 | ||
|
|
102864525c | ||
|
|
e6254e23f2 | ||
|
|
5f3c2f781e | ||
|
|
6c73791cab | ||
|
|
f76e3ff94b | ||
|
|
7f0fc1b8ef | ||
|
|
d18eb7e4e4 | ||
|
|
d3377cd45e | ||
|
|
64a5a9f1bc | ||
|
|
32afd7200a | ||
|
|
ffa531f9ca | ||
|
|
05deb89451 | ||
|
|
6f807823b9 | ||
|
|
2f594ca7c9 | ||
|
|
ecae6e5ead | ||
|
|
34ab6ed7ee | ||
|
|
5ba9d6e118 | ||
|
|
c47a67975f | ||
|
|
6563d23f38 | ||
|
|
3a46c3302b | ||
|
|
1c97593360 | ||
|
|
95416cd553 | ||
|
|
d6cd0611d4 | ||
|
|
aad2cd8739 | ||
|
|
4cc0149317 | ||
|
|
836ee29b78 | ||
|
|
806b57f959 | ||
|
|
e827c1477c | ||
|
|
b827a4680d | ||
|
|
3fd124f76d | ||
|
|
3e8863f6ce | ||
|
|
9b026572cf | ||
|
|
6b34a3ae13 | ||
|
|
8b64180136 | ||
|
|
688ae4da10 | ||
|
|
0785da7223 | ||
|
|
2f1aad3e63 | ||
|
|
754b591e4f | ||
|
|
2b9d2d044c | ||
|
|
511eef54bb | ||
|
|
e705ae8e48 | ||
|
|
c7926d0bc0 | ||
|
|
034bc5e228 | ||
|
|
a39d07a68a | ||
|
|
81c9b4450b | ||
|
|
fc3ea2dd4b | ||
|
|
fe7a5f1813 | ||
|
|
3cd1b59a6c | ||
|
|
7ec6989c99 | ||
|
|
ee4d7a02a9 | ||
|
|
d6fd1c7ff0 | ||
|
|
5fbd5e8518 | ||
|
|
c31882cb92 | ||
|
|
81d47f7512 | ||
|
|
0baa204ce9 | ||
|
|
660e5ad101 | ||
|
|
aebf52efb2 | ||
|
|
c83a1db0c8 | ||
|
|
865d3e08e7 | ||
|
|
91ee6dc7cb | ||
|
|
7708bb9af2 | ||
|
|
03b7a34793 | ||
|
|
f3eb4f055d | ||
|
|
c61575ac9a | ||
|
|
f8796386dc | ||
|
|
ad38481312 | ||
|
|
937285ea3b | ||
|
|
328eeb8233 | ||
|
|
02239c8f2d | ||
|
|
70b3db074a | ||
|
|
d560cd9cc8 | ||
|
|
7526c4d969 | ||
|
|
766ef54b31 | ||
|
|
6b5535e60a | ||
|
|
fe00cfb09b | ||
|
|
2b4d6160c4 | ||
|
|
57029b1a40 | ||
|
|
61489077d7 | ||
|
|
4621933e5b | ||
|
|
fb76b2d500 | ||
|
|
9f6957ef3f | ||
|
|
d6e05d4a1a | ||
|
|
ea67b9760d | ||
|
|
3a503f12c8 | ||
|
|
2b7ad7cb9b | ||
|
|
9f38e19b81 | ||
|
|
73718a5dc5 | ||
|
|
bb9d00a0b3 | ||
|
|
3a1be63a40 | ||
|
|
4daaf0a647 | ||
|
|
f5dacd28e1 | ||
|
|
f65d3a5a98 | ||
|
|
13de2c6ca0 | ||
|
|
6cf29d5145 | ||
|
|
182710b86c | ||
|
|
4a1387ea83 | ||
|
|
c53cee31f5 | ||
|
|
222b9734ca | ||
|
|
c9ba393ce7 | ||
|
|
aa40016ec8 | ||
|
|
dc49304aa5 | ||
|
|
bb7b667467 | ||
|
|
d171850255 | ||
|
|
1207501405 | ||
|
|
2a2bf531ee | ||
|
|
9d724d34e1 | ||
|
|
618a566283 | ||
|
|
6804facabc | ||
|
|
98dd6bb949 | ||
|
|
f0e9aa0b8f | ||
|
|
68a16ef0e2 | ||
|
|
012775833a | ||
|
|
e4567a2b24 | ||
|
|
6c0775b120 | ||
|
|
9fbaede59f | ||
|
|
fd75cca266 | ||
|
|
9f904f8f47 | ||
|
|
78e1194ebb | ||
|
|
e04283c1fb | ||
|
|
a6742f395a | ||
|
|
9fba92d879 | ||
|
|
daa4354047 | ||
|
|
ec88053df0 | ||
|
|
08e259327b | ||
|
|
98384ac236 | ||
|
|
5f9058c84f | ||
|
|
979fdedbbe | ||
|
|
2463b99479 | ||
|
|
ff547a258d | ||
|
|
251ceeedba | ||
|
|
c1422be269 | ||
|
|
538fc9b365 | ||
|
|
b172d450e3 | ||
|
|
5c695ca652 | ||
|
|
e7ce8c8ddb | ||
|
|
4b9bbbc34b | ||
|
|
820e302aac | ||
|
|
ebaabf6150 | ||
|
|
a7bea936c5 | ||
|
|
38378fe36f | ||
|
|
7f13adbd05 | ||
|
|
74ba6d7825 | ||
|
|
7fd4015f17 | ||
|
|
589a8702aa | ||
|
|
91af54abf1 | ||
|
|
82244ced73 | ||
|
|
6856807726 | ||
|
|
2488adc042 | ||
|
|
62f08e877d | ||
|
|
3ed6fc4036 | ||
|
|
6550aa6bad | ||
|
|
2a20243751 | ||
|
|
fc56a1acac | ||
|
|
679dc69f6e | ||
|
|
ca2b3dc4fc | ||
|
|
c3d90c3f94 | ||
|
|
98cf1f2db6 | ||
|
|
4b5dd99555 | ||
|
|
491f7aecef | ||
|
|
590a8f07b9 | ||
|
|
594f004d2a | ||
|
|
bee690429f | ||
|
|
2111632702 | ||
|
|
a9229ecafe | ||
|
|
dadfa107af | ||
|
|
6feff78968 | ||
|
|
f035837aed | ||
|
|
b056c2dda0 | ||
|
|
97e6420018 | ||
|
|
abfcbf4226 | ||
|
|
1f9b3730d4 | ||
|
|
0824512a46 | ||
|
|
ee703ad857 | ||
|
|
722f5e716f | ||
|
|
fdf31d80e7 | ||
|
|
f8fccc057b | ||
|
|
4bb31b0af4 | ||
|
|
8b25e21f4e | ||
|
|
984b469c6f | ||
|
|
96c4cfeb23 | ||
|
|
e50b8db6c5 | ||
|
|
0734f4cd54 | ||
|
|
fbab0df504 | ||
|
|
5e3478f1c1 | ||
|
|
c76199514a | ||
|
|
31e9734414 | ||
|
|
ceee1e4277 | ||
|
|
b725ea7de5 | ||
|
|
0a4c8ffcf5 | ||
|
|
d305dd4e9f | ||
|
|
bbcab768ca | ||
|
|
954bf6fb5d | ||
|
|
77b83d81e2 | ||
|
|
10cd5159d1 | ||
|
|
b8b2acf853 | ||
|
|
4f3b93171a | ||
|
|
9261f9c665 | ||
|
|
6a41e19f7a | ||
|
|
0d2bdde149 | ||
|
|
bcfa47e2ad | ||
|
|
77776e1a62 | ||
|
|
c0d8f931da | ||
|
|
eec4d71097 | ||
|
|
a2bdf23940 | ||
|
|
d08aaa0068 | ||
|
|
1e1f947060 | ||
|
|
62ae7aaffa | ||
|
|
c234bbe9e1 | ||
|
|
ed38102f17 | ||
|
|
69651ecfb5 | ||
|
|
78d3680ced | ||
|
|
495bfb9683 | ||
|
|
9b60b0fd45 | ||
|
|
c6881e5149 | ||
|
|
5971e3f856 | ||
|
|
647b72e4fa | ||
|
|
b9b74139bf | ||
|
|
653e26dad6 | ||
|
|
0135be8757 | ||
|
|
76598dfa1a | ||
|
|
6a2019629b | ||
|
|
8d18c8e98f | ||
|
|
82e2241bdd | ||
|
|
24a0b143ae | ||
|
|
4b894760a1 | ||
|
|
e300fbc7cb | ||
|
|
038bbfaee1 | ||
|
|
f13bfabde7 | ||
|
|
775042459d | ||
|
|
af54b34f3a | ||
|
|
24ef749842 | ||
|
|
eaca3f2de0 | ||
|
|
c529b3aa08 | ||
|
|
25505a1915 | ||
|
|
b01f429e24 | ||
|
|
e3f1d3c892 | ||
|
|
9fbe80f87f | ||
|
|
0f05537a5d | ||
|
|
e1da2a2eed | ||
|
|
848e549c0c | ||
|
|
578ebf3cb5 | ||
|
|
1309504ff6 | ||
|
|
bd3ad7b0c2 | ||
|
|
4ca90c9071 | ||
|
|
523e49833e | ||
|
|
ad7d04feda | ||
|
|
070fd52d86 | ||
|
|
4cb67fd1c3 | ||
|
|
655e17e162 | ||
|
|
83769f963e | ||
|
|
4db42ba409 | ||
|
|
56e456ff47 | ||
|
|
0f1f3d8439 | ||
|
|
2088a4f815 | ||
|
|
bf267c2c48 | ||
|
|
07019d6594 | ||
|
|
e69c224ad3 | ||
|
|
6df393882e | ||
|
|
95200facde | ||
|
|
6411facf6a | ||
|
|
c980a06969 | ||
|
|
35feef650f | ||
|
|
38013e5ea9 | ||
|
|
e5fc20b8ae | ||
|
|
68ee4e003c | ||
|
|
f46ed6267c | ||
|
|
f2f066ac3b | ||
|
|
1efbb29ba5 | ||
|
|
784b93cfd0 | ||
|
|
5fbf8f0d53 | ||
|
|
bcc897640e | ||
|
|
5e47b4e949 | ||
|
|
549f230221 | ||
|
|
36cf7c0a8f | ||
|
|
0b55a4a001 | ||
|
|
e1cd6d9601 | ||
|
|
a7ffa42013 | ||
|
|
4ee986aa71 | ||
|
|
aa19637288 | ||
|
|
1bbef6d612 | ||
|
|
f7e439b930 | ||
|
|
e6c6c92ca2 | ||
|
|
c419eadf15 | ||
|
|
4d3bea48e1 | ||
|
|
d8c509b410 | ||
|
|
75fb66ade0 | ||
|
|
e677069d81 | ||
|
|
c19235b4dd | ||
|
|
fcacada1bf | ||
|
|
375814da27 | ||
|
|
ba2d02ae1d | ||
|
|
6a404ed6e8 | ||
|
|
4fca34f7b5 | ||
|
|
6367b1c69f | ||
|
|
7cb88d665f | ||
|
|
3b98550816 | ||
|
|
12dfe8a35f | ||
|
|
5bcaf7ba4b | ||
|
|
3eb232910a | ||
|
|
2a8da2ba3c | ||
|
|
a8b7063647 | ||
|
|
729d8549e2 | ||
|
|
f734c821d6 | ||
|
|
8dc23f97b6 | ||
|
|
45a6ced462 | ||
|
|
52614dc25c | ||
|
|
e77424a846 | ||
|
|
f305b980d9 | ||
|
|
02d0493c32 | ||
|
|
e70cde1c46 | ||
|
|
9bab8baad4 | ||
|
|
710ba4e53a | ||
|
|
abc590ae71 | ||
|
|
ad6d07326a | ||
|
|
d9fde8b64f | ||
|
|
673e97e4e2 | ||
|
|
60c0d8b93c | ||
|
|
0aa146381b | ||
|
|
6f09e548c7 | ||
|
|
2650f10314 | ||
|
|
1e874350ac | ||
|
|
cad6eeb9f5 | ||
|
|
ff55ddd036 | ||
|
|
e1a824e4a5 | ||
|
|
ee8bc6d6e3 | ||
|
|
d9b860db2a | ||
|
|
f537701b4a | ||
|
|
8e21d36bdc | ||
|
|
6e6424a085 | ||
|
|
1d4b5975b3 | ||
|
|
9f3efff88c | ||
|
|
7c1ecef589 | ||
|
|
44970e79b4 | ||
|
|
b0aaece6eb | ||
|
|
c0ef791f01 | ||
|
|
b63612ab98 | ||
|
|
7e7d80f8e1 | ||
|
|
e08ddc82ba | ||
|
|
e87fbbfde4 | ||
|
|
547b341d13 | ||
|
|
1bacf26c0a | ||
|
|
c9a6e2be86 | ||
|
|
2a354819c7 | ||
|
|
e926500870 | ||
|
|
0b96622b4c | ||
|
|
02c3bb90e9 | ||
|
|
77470ad8f2 | ||
|
|
60e9f01be8 | ||
|
|
d1f8796bb6 | ||
|
|
634111e815 | ||
|
|
084e26e353 | ||
|
|
86cfa14408 | ||
|
|
0927f44350 | ||
|
|
6bd0a2c772 | ||
|
|
73e97e1f42 | ||
|
|
6816b8085e | ||
|
|
80878517f6 | ||
|
|
388556a965 | ||
|
|
57cc9954af | ||
|
|
463eac1433 | ||
|
|
73be36869e | ||
|
|
45976e8114 | ||
|
|
be4a68bd8b | ||
|
|
4e6484caec | ||
|
|
30559007dd | ||
|
|
4ab5bf04eb | ||
|
|
a808170efb | ||
|
|
a7470a71e4 | ||
|
|
6765babe7b | ||
|
|
4b4be5f837 | ||
|
|
25430b7818 | ||
|
|
6aeedac051 | ||
|
|
9c231a41d1 | ||
|
|
ba673778ea | ||
|
|
8698024b61 | ||
|
|
e8b576fa65 | ||
|
|
c49147c5db | ||
|
|
149bd0ec51 | ||
|
|
f3280b46fe | ||
|
|
0f91e4d5f6 | ||
|
|
18617afd43 | ||
|
|
731e83a7da | ||
|
|
1c1f53267a | ||
|
|
8489f5f528 | ||
|
|
872974910c | ||
|
|
147999dd88 | ||
|
|
fe22cbd0e5 | ||
|
|
1277285d08 | ||
|
|
75df8a05f1 | ||
|
|
38e1d0f94e | ||
|
|
71c1a05386 | ||
|
|
4a6bd23873 | ||
|
|
8a1d647547 | ||
|
|
fb5180a890 | ||
|
|
d2c75b94cf | ||
|
|
edfc2d8d93 | ||
|
|
dd177b19f7 | ||
|
|
ae7283fc73 | ||
|
|
51a7c56cae | ||
|
|
c812168d6e | ||
|
|
fa10ae14f7 | ||
|
|
ed53df5ef3 | ||
|
|
b541290ded | ||
|
|
792fe6d9ef | ||
|
|
eb0c1dabf1 | ||
|
|
e00c3f1823 | ||
|
|
05bc655e16 | ||
|
|
7cddada8b4 | ||
|
|
24eb36715a | ||
|
|
22cf278ce2 | ||
|
|
5ab5986bd0 | ||
|
|
f0775abc67 | ||
|
|
d424b4bc32 | ||
|
|
329889ec00 | ||
|
|
95268dbc61 | ||
|
|
9a3200c9b5 | ||
|
|
c213fb6216 | ||
|
|
dd0217b46b | ||
|
|
b805bf6222 | ||
|
|
07b9474212 | ||
|
|
bf971911d5 | ||
|
|
c46ced0c4c | ||
|
|
dd3bbbc4af | ||
|
|
a97f90d225 | ||
|
|
6066edd510 | ||
|
|
eaec9e54ad | ||
|
|
fafcb2e8e7 | ||
|
|
145ea1e6f1 | ||
|
|
4cfed17650 | ||
|
|
c2b1fb4855 | ||
|
|
09807b39aa | ||
|
|
a0ec2f3972 | ||
|
|
e4b1dc20c3 | ||
|
|
2f05d4960e | ||
|
|
e63d2644bd | ||
|
|
56e4d13179 | ||
|
|
0dd0ba717f | ||
|
|
8050330e2e | ||
|
|
65e35c1711 | ||
|
|
0311ae4d05 | ||
|
|
6f09fae28b | ||
|
|
c2e9ee3665 | ||
|
|
1f78842b70 | ||
|
|
81a057d638 | ||
|
|
64d17bee70 | ||
|
|
6beb19b945 | ||
|
|
6b84d39700 | ||
|
|
5f3b91f3f1 | ||
|
|
9e433ea4c4 | ||
|
|
39537f6f44 | ||
|
|
44b34c437e | ||
|
|
467c5f807e | ||
|
|
1028319386 | ||
|
|
f726474a5d | ||
|
|
cd7a790637 | ||
|
|
4f74f5154b | ||
|
|
6e22aa59e7 | ||
|
|
85df1301dc | ||
|
|
e92aba0179 | ||
|
|
85fb2fda5e | ||
|
|
f57e693023 | ||
|
|
83f6f13b50 | ||
|
|
b833ed7992 | ||
|
|
5188d516e3 | ||
|
|
97925eeebe | ||
|
|
1328bb5aba | ||
|
|
4cc755c883 | ||
|
|
4e89c71095 | ||
|
|
d40e61fc45 | ||
|
|
c0f1a926e5 | ||
|
|
970dd0915e | ||
|
|
526e2578b0 | ||
|
|
ad9134bc1a | ||
|
|
b5657ab87d | ||
|
|
2c7ec3dc8a | ||
|
|
0fe35fde49 | ||
|
|
c7658973c3 | ||
|
|
dea61b839c | ||
|
|
9063336778 | ||
|
|
7fc3d37851 | ||
|
|
81aa3ed10e | ||
|
|
ca10623e40 | ||
|
|
547e38079f | ||
|
|
29e40a0bce | ||
|
|
4c1fa59453 | ||
|
|
84b0471ca0 | ||
|
|
1c785b930f | ||
|
|
c8ea9e43ec | ||
|
|
594d682e20 | ||
|
|
70f250dfe1 | ||
|
|
1030bcf321 | ||
|
|
fdc1124ea4 | ||
|
|
33a598366b | ||
|
|
459c19eee2 | ||
|
|
d73a995257 | ||
|
|
e4e0deeed4 | ||
|
|
48dce38e14 | ||
|
|
3eebd7c3e5 | ||
|
|
2fa151d52e | ||
|
|
c182f48079 | ||
|
|
94fad02737 | ||
|
|
d694e6eafc | ||
|
|
2a9cb6d29e | ||
|
|
c565a429af | ||
|
|
572d912858 | ||
|
|
6ae80fc992 | ||
|
|
ef7b979d53 | ||
|
|
b203067dfd | ||
|
|
1c9fc9422e | ||
|
|
6c26e40aea | ||
|
|
fe963d91ef | ||
|
|
c9461f1647 | ||
|
|
ea7fe09c27 | ||
|
|
8170b65db4 | ||
|
|
a2d8c98b0d | ||
|
|
8442836512 | ||
|
|
331f2adb1c | ||
|
|
45a8d6c95e | ||
|
|
1f2eb2ca1a | ||
|
|
de571a2da4 | ||
|
|
2c233dffa5 | ||
|
|
31339d6bf8 | ||
|
|
8865443438 | ||
|
|
835deb77f4 | ||
|
|
6a7c3716ac | ||
|
|
15211f81b1 | ||
|
|
b3f7a6572e | ||
|
|
6f28a3a2fe | ||
|
|
896cc5386c | ||
|
|
76f70ce1e9 | ||
|
|
6aa3c8d4a2 | ||
|
|
aff9d0ea15 | ||
|
|
526426e2dd | ||
|
|
2223a21cfc | ||
|
|
47ccc7b501 | ||
|
|
c38e1e0cfe | ||
|
|
f36034541e | ||
|
|
783fa856c3 | ||
|
|
6139effb8a | ||
|
|
8ae7f4a564 | ||
|
|
6f46f3e636 | ||
|
|
ba278a4269 | ||
|
|
3825e36ef7 | ||
|
|
f6e0e1b3cf | ||
|
|
769590d779 | ||
|
|
1fa9101b40 | ||
|
|
3f2e1aede9 | ||
|
|
235e6880c1 | ||
|
|
ffa23a43c6 | ||
|
|
66e3c505e3 | ||
|
|
0938519f49 | ||
|
|
5f489c3d08 | ||
|
|
f82d0051b2 | ||
|
|
76267b23a0 | ||
|
|
40a926a54a | ||
|
|
3a835fbeb8 | ||
|
|
f6461b8386 | ||
|
|
586663c840 | ||
|
|
82ead05093 | ||
|
|
d9b1b200ce | ||
|
|
786a3ac992 | ||
|
|
8c5648eb09 | ||
|
|
4dfe527f20 | ||
|
|
980ebd99ca | ||
|
|
9b7cddbec7 | ||
|
|
2bd706aa92 | ||
|
|
95e0d9a468 | ||
|
|
4865754b3d | ||
|
|
92eaff9608 | ||
|
|
0b7aa8a9e0 | ||
|
|
678962d4ca | ||
|
|
795835c54f | ||
|
|
91360a3f49 | ||
|
|
4831c2f1b2 | ||
|
|
3166d497f9 | ||
|
|
f50ccce9ec | ||
|
|
c7e300f14d | ||
|
|
419a1938ee | ||
|
|
a48745cb3e | ||
|
|
ac9408c37f | ||
|
|
3d71289075 | ||
|
|
fbd3772788 | ||
|
|
334b28cddc | ||
|
|
2fbfc88bc1 | ||
|
|
f781979d38 | ||
|
|
aba37be6eb | ||
|
|
30042bc047 | ||
|
|
49f60f7775 | ||
|
|
5f44c80cd5 | ||
|
|
85d42ce94f | ||
|
|
2f3c3d0ed2 | ||
|
|
7debf96610 | ||
|
|
d8b60c3cd5 | ||
|
|
f8eb548376 | ||
|
|
88fc4f81d4 | ||
|
|
911f2b0bb5 | ||
|
|
2d16eabc6e | ||
|
|
be50be75fe | ||
|
|
70e7987df5 | ||
|
|
8ceeb454ee | ||
|
|
f311bf1dbf | ||
|
|
837d7f77a1 | ||
|
|
d0213ce50b | ||
|
|
622e440366 | ||
|
|
1dc5452f1d | ||
|
|
a0daf2fae2 | ||
|
|
a21bdc9396 | ||
|
|
4cf7f75749 | ||
|
|
bcdac5aad6 | ||
|
|
3d5f851fce | ||
|
|
5487dc41cc | ||
|
|
5a908b9f58 | ||
|
|
61288db11e | ||
|
|
317f7fe9da | ||
|
|
7b5dd2d0ee | ||
|
|
b1302c70fb | ||
|
|
addedb1adf | ||
|
|
62bb42cfab | ||
|
|
f4be9f234a | ||
|
|
947129a62a | ||
|
|
66f0a13145 | ||
|
|
9626379731 | ||
|
|
c2c61cdd5b | ||
|
|
b5ae580d12 | ||
|
|
63939244a4 | ||
|
|
213b693bd3 | ||
|
|
a289ef5d10 | ||
|
|
955eb8f142 | ||
|
|
d396fb5d06 | ||
|
|
b5dd258074 | ||
|
|
c855a292cb | ||
|
|
f2132c62e9 | ||
|
|
94a3807353 | ||
|
|
7cacfc074e | ||
|
|
9e8ac8a087 | ||
|
|
e64a9eeee6 | ||
|
|
a55a1a7102 | ||
|
|
46bc39c160 | ||
|
|
2a182d8b9a | ||
|
|
77241c7fcf | ||
|
|
fd6a85afd9 | ||
|
|
9a89d7bfab | ||
|
|
edd6b22109 | ||
|
|
5468069bef | ||
|
|
0cce35784e | ||
|
|
80c1e58ed5 | ||
|
|
b0871a6ef6 | ||
|
|
288374d5fa | ||
|
|
1f7c79c735 | ||
|
|
251fd608df | ||
|
|
456941323b | ||
|
|
a6a77688dc | ||
|
|
09cd2248dc | ||
|
|
8143379645 | ||
|
|
5bd6baa055 | ||
|
|
41e9290574 | ||
|
|
cf7d50617b | ||
|
|
95e006963c | ||
|
|
65588a4492 | ||
|
|
d39c7e4ae3 | ||
|
|
3bec9ee273 | ||
|
|
7b3628d33b | ||
|
|
ad1aa5b2f9 | ||
|
|
46ef8c503e | ||
|
|
721fec3b5a | ||
|
|
30a5f66f26 | ||
|
|
bb6e6861ca | ||
|
|
4c0e391597 | ||
|
|
43c1fc9aad | ||
|
|
7a48cbb191 | ||
|
|
004d69392b | ||
|
|
fc0882805d | ||
|
|
f553922d53 | ||
|
|
7b2764566c | ||
|
|
55d38dfa48 | ||
|
|
0e266b88f0 | ||
|
|
7bb3e517b2 | ||
|
|
7d0c3b6517 | ||
|
|
67201fc678 | ||
|
|
d137deccfa | ||
|
|
00777e3a25 | ||
|
|
bcb2f125ff | ||
|
|
37ab8f42e9 | ||
|
|
cf1cfbee96 | ||
|
|
d89e03023f | ||
|
|
5ec1559c7b | ||
|
|
cf2b1fd9ec | ||
|
|
ddbc1602ce | ||
|
|
77cee098ad | ||
|
|
89b94c2c90 | ||
|
|
350743fea3 | ||
|
|
8011d0b6c6 | ||
|
|
9e5d7ac1d0 | ||
|
|
e01fb9b605 | ||
|
|
52a468d586 | ||
|
|
c73c71cc83 | ||
|
|
2141d62069 | ||
|
|
f286c9a86a | ||
|
|
b4fd254c71 | ||
|
|
0a8cf7e41b | ||
|
|
abd48551fd | ||
|
|
bb9a10051f | ||
|
|
5c6406ab58 | ||
|
|
a25137f215 | ||
|
|
50c296eb28 | ||
|
|
b1a302de95 | ||
|
|
7b567458ff | ||
|
|
13e3e23f20 | ||
|
|
efecdf5fd0 | ||
|
|
f21904caf2 | ||
|
|
15a97af215 | ||
|
|
20a55c086e | ||
|
|
c727b81772 | ||
|
|
77692e0298 | ||
|
|
5e3e965647 | ||
|
|
88cde18bb2 | ||
|
|
7fd93e25fd | ||
|
|
1c9643e6a3 | ||
|
|
eb83516fa5 | ||
|
|
1b8df3c0a1 | ||
|
|
54d45cc029 | ||
|
|
e35767aff2 | ||
|
|
9bbcb74db6 | ||
|
|
515e7f7fef | ||
|
|
87a5190b7d | ||
|
|
9d47d74a7a | ||
|
|
92f5dfed1c | ||
|
|
867e5ea022 | ||
|
|
a24814104c | ||
|
|
024b65524d | ||
|
|
f22dd6b53d | ||
|
|
735df6bd4e | ||
|
|
0e77547e98 | ||
|
|
747a1e1f60 | ||
|
|
ac31d6d9fb | ||
|
|
83c853ffb6 | ||
|
|
058ab5f901 | ||
|
|
78638a9737 | ||
|
|
7f8f0b0f2d | ||
|
|
7b9e0b946e | ||
|
|
6c087ceb1a | ||
|
|
6602c55f3c | ||
|
|
d405141ad0 | ||
|
|
3ee3432d8f | ||
|
|
b2d70a2a9b | ||
|
|
26f160fb89 | ||
|
|
0a5811adf8 | ||
|
|
733a011b28 | ||
|
|
c8023b7c8d | ||
|
|
bed3cd445d | ||
|
|
c8baf5ceee | ||
|
|
85c715a2f6 | ||
|
|
55c1fe26fb | ||
|
|
8d11f83ac7 | ||
|
|
d349bffcd6 | ||
|
|
5856160c30 | ||
|
|
3cd3693b2c | ||
|
|
bc3003be54 | ||
|
|
e53615d119 | ||
|
|
55232f9033 | ||
|
|
fd40e97008 | ||
|
|
1598ad804d | ||
|
|
c8e666d8ae | ||
|
|
e1533b9418 | ||
|
|
a60a8d8a2f | ||
|
|
73704e38d5 | ||
|
|
1680bb36c3 | ||
|
|
cd483c191a | ||
|
|
7d09b4e840 | ||
|
|
83ffa25d6f | ||
|
|
a53d2b927f | ||
|
|
146dc6ce4a | ||
|
|
e597ea5ab2 | ||
|
|
3e833fca9b | ||
|
|
c96cf85619 | ||
|
|
0a63c75138 | ||
|
|
c6295085fe | ||
|
|
4b3bdebfa5 | ||
|
|
1a603b2501 | ||
|
|
3fa5f834b8 | ||
|
|
ff33539fba | ||
|
|
961636b510 | ||
|
|
88b7d0dc44 | ||
|
|
e164c7e780 | ||
|
|
481d4beabb | ||
|
|
19c991014e | ||
|
|
12ec487241 | ||
|
|
a18cbdcf11 | ||
|
|
4f8de18d1f | ||
|
|
dbac48f05d | ||
|
|
3a02359325 | ||
|
|
9e63b0e2b3 | ||
|
|
07dc8c4803 | ||
|
|
20bfd71cf1 | ||
|
|
6a33a48a9a | ||
|
|
1885a8c0bf | ||
|
|
4ce53920fe | ||
|
|
5100bbba52 | ||
|
|
f93d912644 | ||
|
|
ce8551b8c4 | ||
|
|
d26c21d900 | ||
|
|
f5f9347661 | ||
|
|
a0f5875cb3 | ||
|
|
3055c68615 | ||
|
|
c3ed8051f3 | ||
|
|
d2cdc51c54 | ||
|
|
ee896662f5 | ||
|
|
177bd036a3 | ||
|
|
d03e049320 | ||
|
|
957d9e24fb | ||
|
|
865e47e9a6 | ||
|
|
607c5d3598 | ||
|
|
8879541999 | ||
|
|
0b896d9c31 | ||
|
|
6f4a2809e2 | ||
|
|
103a26edb6 | ||
|
|
7419592626 | ||
|
|
edabfad559 | ||
|
|
2849cc0b2e | ||
|
|
46eb174af1 | ||
|
|
f8878208ca | ||
|
|
e126095949 | ||
|
|
df2f292b68 | ||
|
|
9f5ba0cf93 | ||
|
|
b5c5539501 | ||
|
|
252afe47c0 | ||
|
|
379451135d | ||
|
|
bc06dbab21 | ||
|
|
6a71ea7f5e | ||
|
|
942b9862d8 | ||
|
|
ae55fdc38a | ||
|
|
cc3ff61ae2 | ||
|
|
045717010a | ||
|
|
aae32d9211 | ||
|
|
cd5ad78f56 | ||
|
|
b450aacebd | ||
|
|
640068b279 | ||
|
|
04d85af40e | ||
|
|
fca882ee31 | ||
|
|
2832106bc6 | ||
|
|
bf24838939 | ||
|
|
16e3107d23 | ||
|
|
262e3c0985 | ||
|
|
2b460bac1a | ||
|
|
d1c4eb9b4c | ||
|
|
dfa3d39ab3 | ||
|
|
2b54d0344e | ||
|
|
f817f922fe | ||
|
|
55f7fcd1b3 | ||
|
|
b0f974a94d | ||
|
|
6bebde4105 | ||
|
|
fa4a63c958 | ||
|
|
11def0a753 | ||
|
|
513f87550a | ||
|
|
81838b504c | ||
|
|
4980744793 | ||
|
|
5ff6f4094e | ||
|
|
bbce1eb3f7 | ||
|
|
204f73a692 | ||
|
|
641a7d3e57 | ||
|
|
3f7e819a9b | ||
|
|
834d003ab6 | ||
|
|
c627227893 | ||
|
|
2a8725a7a5 | ||
|
|
ca2d1bb901 | ||
|
|
fa19649286 | ||
|
|
56d75f5293 | ||
|
|
e1132a3f41 | ||
|
|
4d479102ad | ||
|
|
e90c575bfd | ||
|
|
9d02f455cc | ||
|
|
13f67f595c | ||
|
|
25741dcb08 | ||
|
|
a7af62162c | ||
|
|
3e0faecaae | ||
|
|
dc7dbae14a | ||
|
|
dd8c763b21 | ||
|
|
e0e7c102b8 | ||
|
|
f26a7df11b | ||
|
|
72a1b1e3f3 | ||
|
|
dfdb77c491 | ||
|
|
846efe8eb4 | ||
|
|
8b79b5a315 | ||
|
|
c71041a60d | ||
|
|
6865cb108c | ||
|
|
ee2089257a | ||
|
|
9f85ec72a8 | ||
|
|
c4c266205b | ||
|
|
e4afbcea3b | ||
|
|
44cede41fd | ||
|
|
672fd1da19 | ||
|
|
f3fe866af2 | ||
|
|
25e50aa6f1 | ||
|
|
ff3657e15a | ||
|
|
4af626bb4b | ||
|
|
936f2e6ec2 | ||
|
|
3e5984930e | ||
|
|
40a0297499 | ||
|
|
e13015a920 | ||
|
|
ec4755952a | ||
|
|
d8fb83b1f2 | ||
|
|
4fb060d25e | ||
|
|
3eff8022f2 | ||
|
|
19bb28cb26 | ||
|
|
b70ecc12b3 | ||
|
|
1e72a22c96 | ||
|
|
d695ea8192 | ||
|
|
b639a1bbd5 | ||
|
|
4f952963ae | ||
|
|
7f2ef94c7f | ||
|
|
81372de369 | ||
|
|
a3b170d6c4 | ||
|
|
4548755375 | ||
|
|
0c047cf124 | ||
|
|
c6b59f1ee3 | ||
|
|
b40759fe18 | ||
|
|
e6f08517a3 | ||
|
|
293787f867 | ||
|
|
54bf24f662 | ||
|
|
b4727cc5f8 | ||
|
|
b4fc5a7ab8 | ||
|
|
d0e53231fe | ||
|
|
cce4b0a449 | ||
|
|
ff90e2a02e | ||
|
|
c575425292 | ||
|
|
82b2809fcc | ||
|
|
7ceb6a3651 | ||
|
|
7ee9133e9b | ||
|
|
c4162e3eb4 | ||
|
|
591fb0abe3 | ||
|
|
ed424e09df | ||
|
|
2c7b8d1d36 | ||
|
|
de8ba29253 | ||
|
|
c38d75f3d9 | ||
|
|
4e7ad9e6de | ||
|
|
8c40e7281f | ||
|
|
54946e725e | ||
|
|
ab4b10f619 | ||
|
|
674109106d | ||
|
|
6a4302e45d | ||
|
|
7412bf17a9 | ||
|
|
519f2b462e | ||
|
|
11c781cce7 | ||
|
|
b7e10fc7c6 | ||
|
|
bacd0a6b72 | ||
|
|
87f9c7b9f8 | ||
|
|
d74b39b9cf | ||
|
|
5db35c32de | ||
|
|
abb79bd978 | ||
|
|
38c318737b | ||
|
|
c1ed6d8454 | ||
|
|
26a0481d82 | ||
|
|
304510aefc | ||
|
|
3ef04201cc | ||
|
|
de26dbbc96 | ||
|
|
e1da43053d | ||
|
|
3bb90cbf24 | ||
|
|
099f2fc52b | ||
|
|
d8e0c958e7 | ||
|
|
63f71e527c | ||
|
|
9f18d60b9c | ||
|
|
5be9131078 | ||
|
|
0e5de1aec8 | ||
|
|
91ebe7008d | ||
|
|
72f868506d | ||
|
|
56850165a4 | ||
|
|
a784a94806 | ||
|
|
8ba46a99ba | ||
|
|
472694a611 | ||
|
|
7c980343f1 | ||
|
|
2dd203e174 | ||
|
|
7084812515 | ||
|
|
64df14f8d5 | ||
|
|
838d0c2625 | ||
|
|
e51c59995c | ||
|
|
c07b2c73d7 | ||
|
|
c0580bccb5 | ||
|
|
45469e9f64 | ||
|
|
87acb81496 | ||
|
|
16be454f6d | ||
|
|
ef24174a38 | ||
|
|
f2c28822c1 | ||
|
|
48d6362a69 | ||
|
|
3f2adbd70a | ||
|
|
706c39452b | ||
|
|
8b855a7cb5 | ||
|
|
80759c8951 | ||
|
|
e55f3c37fd | ||
|
|
c87c1017d8 | ||
|
|
43aea38641 | ||
|
|
88744d92b3 | ||
|
|
7b38d02ff0 | ||
|
|
3da6c4d7d9 | ||
|
|
606360ae03 | ||
|
|
e2fd84a6ae | ||
|
|
f56dd43999 | ||
|
|
f0f5163a83 | ||
|
|
373628a6a3 | ||
|
|
a790efb18d | ||
|
|
868224ae97 | ||
|
|
60169bd055 | ||
|
|
33db9d0f90 | ||
|
|
27d020709e | ||
|
|
77be5cf7d8 | ||
|
|
c9d768a086 | ||
|
|
9c0718bc44 | ||
|
|
826c7264b5 | ||
|
|
162349f8c8 | ||
|
|
a6dfdcdd31 | ||
|
|
03a6c131f9 | ||
|
|
8dad9a4338 | ||
|
|
f0d4dbf838 | ||
|
|
3152d5f191 | ||
|
|
17f64462d2 | ||
|
|
bbec13c0da | ||
|
|
466ad1605b | ||
|
|
a068c350ee | ||
|
|
0605221628 | ||
|
|
3856c4d0f9 | ||
|
|
557a9d020a | ||
|
|
14d7cb812e | ||
|
|
c49a9e877c | ||
|
|
b08d653e02 | ||
|
|
0928e408c0 | ||
|
|
032ed73c0d | ||
|
|
c5bbee354f | ||
|
|
fe7ce3f74b | ||
|
|
63acd585ba | ||
|
|
9a1cf70451 | ||
|
|
1777f257a8 | ||
|
|
b4997da51c | ||
|
|
4f9aff3043 | ||
|
|
e68317c6bd | ||
|
|
b7f476568b | ||
|
|
40c2f5206b | ||
|
|
b0a544a321 | ||
|
|
12828b6a15 | ||
|
|
8c6c9ace79 | ||
|
|
f881c5347c | ||
|
|
430338f045 | ||
|
|
74b14657cf | ||
|
|
07b35753d4 | ||
|
|
263df2abf5 | ||
|
|
738a771b87 | ||
|
|
025f559460 | ||
|
|
baaa814a51 | ||
|
|
d46e0e6925 | ||
|
|
3cd3cba8c6 | ||
|
|
366463bbb3 | ||
|
|
a0d6f85837 | ||
|
|
f25a7a571e | ||
|
|
87e173b567 | ||
|
|
6fbcd28799 | ||
|
|
b9ffe62d69 | ||
|
|
96786c9418 | ||
|
|
0423d8d919 | ||
|
|
688a68aba9 | ||
|
|
bc56e63737 | ||
|
|
a3e5628961 | ||
|
|
74ff17eeed | ||
|
|
b1dca4dc24 | ||
|
|
1a2b14b212 | ||
|
|
20b30958cb | ||
|
|
d224c207f8 | ||
|
|
f2d7b23e50 | ||
|
|
a28a855eff | ||
|
|
68da556bd2 | ||
|
|
b77001973a | ||
|
|
7d887024df | ||
|
|
8641ec8f13 | ||
|
|
6337efb786 | ||
|
|
4d9852f68d | ||
|
|
750fc54787 | ||
|
|
a2cd6a534f | ||
|
|
529eb3088a | ||
|
|
f0de1e5a62 | ||
|
|
14c5546661 | ||
|
|
e6a52dc2f6 | ||
|
|
28c9e7fe9c | ||
|
|
b37b12754f | ||
|
|
c7ca4c29d5 | ||
|
|
44b74224ab | ||
|
|
7a4523dec1 |
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
XUI_DEBUG=true
|
||||
XUI_DB_FOLDER=x-ui
|
||||
XUI_LOG_FOLDER=x-ui
|
||||
XUI_BIN_FOLDER=x-ui
|
||||
14
.github/FUNDING.yml
vendored
Normal file
14
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: MHSanaei
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: mhsanaei
|
||||
custom: https://nowpayments.io/donation/hsanaei
|
||||
77
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
77
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: "Bug report"
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting a bug! Please fill out the following information.
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: My problem is...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how-repeat-problem
|
||||
attributes:
|
||||
label: How to repeat the problem?
|
||||
description: Sequence of actions that allow you to reproduce the bug
|
||||
placeholder: |
|
||||
1. Open `Inbounds` page
|
||||
2. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-action
|
||||
attributes:
|
||||
label: Expected action
|
||||
description: What's going to happen
|
||||
placeholder: Must be...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: received-action
|
||||
attributes:
|
||||
label: Received action
|
||||
description: What's really happening
|
||||
placeholder: It's actually happening...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: xui-version
|
||||
attributes:
|
||||
label: 3x-ui Version
|
||||
description: Which version of 3x-ui are you using?
|
||||
placeholder: 2.X.X
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: xray-version
|
||||
attributes:
|
||||
label: Xray-core Version
|
||||
description: Which version of Xray-core are you using?
|
||||
placeholder: 2.X.X
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
options:
|
||||
- label: This bug report is written entirely in English.
|
||||
required: true
|
||||
- label: This bug report is new and no one has reported it before me.
|
||||
required: true
|
||||
39
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
title: "Feature request"
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: is-related-problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
options:
|
||||
- label: This feature report is written entirely in English.
|
||||
required: true
|
||||
22
.github/ISSUE_TEMPLATE/question.yaml
vendored
Normal file
22
.github/ISSUE_TEMPLATE/question.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Question
|
||||
description: Describe this issue template's purpose here.
|
||||
title: "Question"
|
||||
labels: ["question"]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
placeholder: I have a question, ..., how can I solve it?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
options:
|
||||
- label: This question is written entirely in English.
|
||||
required: true
|
||||
158
.github/copilot-instructions.md
vendored
Normal file
158
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
# 3X-UI Development Guide
|
||||
|
||||
## Project Overview
|
||||
3X-UI is a web-based control panel for managing Xray-core servers. It's a Go application using Gin web framework with embedded static assets and SQLite database. The panel manages VPN/proxy inbounds, monitors traffic, and provides Telegram bot integration.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
- **main.go**: Entry point that initializes database, web server, and subscription server. Handles graceful shutdown via SIGHUP/SIGTERM signals
|
||||
- **web/**: Primary web server with Gin router, HTML templates, and static assets embedded via `//go:embed`
|
||||
- **xray/**: Xray-core process management and API communication for traffic monitoring
|
||||
- **database/**: GORM-based SQLite database with models in `database/model/`
|
||||
- **sub/**: Subscription server running alongside main web server (separate port)
|
||||
- **web/service/**: Business logic layer containing InboundService, SettingService, TgBot, etc.
|
||||
- **web/controller/**: HTTP handlers using Gin context (`*gin.Context`)
|
||||
- **web/job/**: Cron-based background jobs for traffic monitoring, CPU checks, LDAP sync
|
||||
|
||||
### Key Architectural Patterns
|
||||
1. **Embedded Resources**: All web assets (HTML, CSS, JS, translations) are embedded at compile time using `embed.FS`:
|
||||
- `web/assets` → `assetsFS`
|
||||
- `web/html` → `htmlFS`
|
||||
- `web/translation` → `i18nFS`
|
||||
|
||||
2. **Dual Server Design**: Main web panel + subscription server run concurrently, managed by `web/global` package
|
||||
|
||||
3. **Xray Integration**: Panel generates `config.json` for Xray binary, communicates via gRPC API for real-time traffic stats
|
||||
|
||||
4. **Signal-Based Restart**: SIGHUP triggers graceful restart. **Critical**: Always call `service.StopBot()` before restart to prevent Telegram bot 409 conflicts
|
||||
|
||||
5. **Database Seeders**: Uses `HistoryOfSeeders` model to track one-time migrations (e.g., password bcrypt migration)
|
||||
|
||||
## Development Workflows
|
||||
|
||||
### Building & Running
|
||||
```bash
|
||||
# Build (creates bin/3x-ui.exe)
|
||||
go run tasks.json → "go: build" task
|
||||
|
||||
# Run with debug logging
|
||||
XUI_DEBUG=true go run ./main.go
|
||||
# Or use task: "go: run"
|
||||
|
||||
# Test
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Command-Line Operations
|
||||
The main.go accepts flags for admin tasks:
|
||||
- `-reset` - Reset all panel settings to defaults
|
||||
- `-show` - Display current settings (port, paths)
|
||||
- Use these by running the binary directly, not via web interface
|
||||
|
||||
### Database Management
|
||||
- DB path: Configured via `config.GetDBPath()`, typically `/etc/x-ui/x-ui.db`
|
||||
- Models: Located in `database/model/model.go` - Auto-migrated on startup
|
||||
- Seeders: Use `HistoryOfSeeders` to prevent re-running migrations
|
||||
- Default credentials: admin/admin (hashed with bcrypt)
|
||||
|
||||
### Telegram Bot Development
|
||||
- Bot instance in `web/service/tgbot.go` (3700+ lines)
|
||||
- Uses `telego` library with long polling
|
||||
- **Critical Pattern**: Must call `service.StopBot()` before any server restart to prevent 409 bot conflicts
|
||||
- Bot handlers use `telegohandler.BotHandler` for routing
|
||||
- i18n via embedded `i18nFS` passed to bot startup
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Service Layer Pattern
|
||||
Services inject dependencies (like xray.XrayAPI) and operate on GORM models:
|
||||
```go
|
||||
type InboundService struct {
|
||||
xrayApi xray.XrayAPI
|
||||
}
|
||||
|
||||
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||
// Business logic here
|
||||
}
|
||||
```
|
||||
|
||||
### Controller Pattern
|
||||
Controllers use Gin context and inherit from BaseController:
|
||||
```go
|
||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
// Use I18nWeb(c, "key") for translations
|
||||
// Check auth via checkLogin middleware
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Management
|
||||
- Environment vars: `XUI_DEBUG`, `XUI_LOG_LEVEL`, `XUI_MAIN_FOLDER`
|
||||
- Config embedded files: `config/version`, `config/name`
|
||||
- Use `config.GetLogLevel()`, `config.GetDBPath()` helpers
|
||||
|
||||
### Internationalization
|
||||
- Translation files: `web/translation/<lang>.json` (one nested-namespace file per locale,
|
||||
e.g. `en-US.json`). Vue SPA imports these via `import.meta.glob` from `frontend/src/i18n/`,
|
||||
and the Go binary embeds the same files via `web/web.go`'s `//go:embed translation/*`.
|
||||
- Access from Go via `locale.I18n(locale.Web, "pages.login.loginAgain")` (see
|
||||
`web/locale/locale.go`); access from Vue via `useI18n()` and `t('pages.login.loginAgain')`.
|
||||
- Use `locale.I18nType` enum (Web, Bot).
|
||||
|
||||
## External Dependencies & Integration
|
||||
|
||||
### Xray-core
|
||||
- Binary management: Download platform-specific binary (`xray-{os}-{arch}`) to bin folder
|
||||
- Config generation: Panel creates `config.json` dynamically from inbound/outbound settings
|
||||
- Process control: Start/stop via `xray/process.go`
|
||||
- gRPC API: Real-time stats via `xray/api.go` using `google.golang.org/grpc`
|
||||
|
||||
### Critical External Paths
|
||||
- Xray binary: `{bin_folder}/xray-{os}-{arch}`
|
||||
- Xray config: `{bin_folder}/config.json`
|
||||
- GeoIP/GeoSite: `{bin_folder}/geoip.dat`, `geosite.dat`
|
||||
- Logs: `{log_folder}/3xipl.log`, `3xipl-banned.log`
|
||||
|
||||
### Job Scheduling
|
||||
Uses `robfig/cron/v3` for periodic tasks:
|
||||
- Traffic monitoring: `xray_traffic_job.go`
|
||||
- CPU alerts: `check_cpu_usage.go`
|
||||
- IP tracking: `check_client_ip_job.go`
|
||||
- LDAP sync: `ldap_sync_job.go`
|
||||
|
||||
Jobs registered in `web/web.go` during server initialization
|
||||
|
||||
## Deployment & Scripts
|
||||
|
||||
### Installation Script Pattern
|
||||
Both `install.sh` and `x-ui.sh` follow these patterns:
|
||||
- Multi-distro support via `$release` variable (ubuntu, debian, centos, arch, etc.)
|
||||
- Port detection with `is_port_in_use()` using ss/netstat/lsof
|
||||
- Systemd service management with distro-specific unit files (`.service.debian`, `.service.arch`, `.service.rhel`)
|
||||
|
||||
### Docker Build
|
||||
Multi-stage Dockerfile:
|
||||
1. **Builder**: CGO-enabled build, runs `DockerInit.sh` to download Xray binary
|
||||
2. **Final**: Alpine-based with fail2ban pre-configured
|
||||
|
||||
### Key File Locations (Production)
|
||||
- Binary: `/usr/local/x-ui/`
|
||||
- Database: `/etc/x-ui/x-ui.db`
|
||||
- Logs: `/var/log/x-ui/`
|
||||
- Service: `/etc/systemd/system/x-ui.service.*`
|
||||
|
||||
## Testing & Debugging
|
||||
- Set `XUI_DEBUG=true` for detailed logging
|
||||
- Check Xray process: `x-ui.sh` script provides menu for status/logs
|
||||
- Database inspection: Direct SQLite access to x-ui.db
|
||||
- Traffic debugging: Check `3xipl.log` for IP limit tracking
|
||||
- Telegram bot: Logs show bot initialization and command handling
|
||||
|
||||
## Common Gotchas
|
||||
1. **Bot Restart**: Always stop Telegram bot before server restart to avoid 409 conflict
|
||||
2. **Embedded Assets**: Changes to HTML/CSS require recompilation (not hot-reload)
|
||||
3. **Password Migration**: Seeder system tracks bcrypt migration - check `HistoryOfSeeders` table
|
||||
4. **Port Binding**: Subscription server uses different port from main panel
|
||||
5. **Xray Binary**: Must match OS/arch exactly - managed by installer scripts
|
||||
6. **Session Management**: Uses `gin-contrib/sessions` with cookie store
|
||||
7. **IP Limitation**: Implements "last IP wins" - when client exceeds LimitIP, oldest connections are automatically disconnected via Xray API to allow newest IPs
|
||||
17
.github/dependabot.yml
vendored
17
.github/dependabot.yml
vendored
@@ -1,10 +1,19 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod" # See documentation for possible values
|
||||
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "github-actions"
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
20
.github/pull_request_template.yml
vendored
Normal file
20
.github/pull_request_template.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
## What is the pull request?
|
||||
|
||||
<!-- Briefly describe the changes introduced by this pull request -->
|
||||
|
||||
## Which part of the application is affected by the change?
|
||||
|
||||
- [ ] Frontend
|
||||
- [ ] Backend
|
||||
|
||||
## Type of Changes
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactoring
|
||||
- [ ] Other
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- Add screenshots to illustrate the changes -->
|
||||
<!-- Remove this section if it is not applicable. -->
|
||||
89
.github/workflows/ci.yml
vendored
Normal file
89
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.html"
|
||||
- "**.css"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- ".nvmrc"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.html"
|
||||
- "**.css"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- ".nvmrc"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
go-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Stub web/dist for go:embed
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
- name: Test
|
||||
run: |
|
||||
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||
go test $(cat /tmp/go-packages.txt)
|
||||
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Stub web/dist for go:embed
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
- name: Run govulncheck
|
||||
run: govulncheck ./...
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
- name: Audit
|
||||
run: npm audit --audit-level=high
|
||||
working-directory: frontend
|
||||
31
.github/workflows/cleanup_caches.yml
vendored
Normal file
31
.github/workflows/cleanup_caches.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Cleanup Caches
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # every day
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Delete caches older than 1 day
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/')
|
||||
echo "Deleting caches older than: $CUTOFF_DATE"
|
||||
|
||||
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
|
||||
--jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null)
|
||||
|
||||
if [ -z "$CACHE_IDS" ]; then
|
||||
echo "No old caches found to delete."
|
||||
else
|
||||
echo "$CACHE_IDS" | while read CACHE_ID; do
|
||||
echo "Deleting cache: $CACHE_ID"
|
||||
gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID
|
||||
done
|
||||
echo "Old caches deleted successfully."
|
||||
fi
|
||||
82
.github/workflows/codeql.yml
vendored
Normal file
82
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "frontend/package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "frontend/package-lock.json"
|
||||
schedule:
|
||||
- cron: "18 2 * * 2"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
env:
|
||||
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
if: matrix.language == 'go'
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
60
.github/workflows/docker.yml
vendored
Normal file
60
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Release 3X-UI for Docker
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
hsanaeii/3x-ui
|
||||
ghcr.io/mhsanaei/3x-ui
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
312
.github/workflows/release.yml
vendored
312
.github/workflows/release.yml
vendored
@@ -1,43 +1,289 @@
|
||||
name: Release 3X-ui
|
||||
name: Release 3X-UI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.html"
|
||||
- "**.sh"
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.html"
|
||||
- "**.sh"
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
|
||||
jobs:
|
||||
linuxamd64build:
|
||||
name: build x-ui amd64 version
|
||||
runs-on: ubuntu-20.04
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- amd64
|
||||
- arm64
|
||||
- armv7
|
||||
- armv6
|
||||
- 386
|
||||
- armv5
|
||||
- s390x
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3.3.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3.5.0
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.20.1'
|
||||
- name: build linux amd64 version
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
# Frontend dist must be built BEFORE go build — Go's //go:embed
|
||||
# all:dist directive in web/web.go requires web/dist/ to exist
|
||||
# at compile time. web/dist/ is .gitignored, so on a fresh CI
|
||||
# checkout it doesn't exist until vite emits it.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
run: |
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o xui-release -v main.go
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build 3X-UI
|
||||
run: |
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=linux
|
||||
export GOARCH=${{ matrix.platform }}
|
||||
# Use Bootlin prebuilt cross-toolchains (musl 1.2.5 in stable series)
|
||||
case "${{ matrix.platform }}" in
|
||||
amd64) BOOTLIN_ARCH="x86-64" ;;
|
||||
arm64) BOOTLIN_ARCH="aarch64" ;;
|
||||
armv7) BOOTLIN_ARCH="armv7-eabihf"; export GOARCH=arm GOARM=7 ;;
|
||||
armv6) BOOTLIN_ARCH="armv6-eabihf"; export GOARCH=arm GOARM=6 ;;
|
||||
armv5) BOOTLIN_ARCH="armv5-eabi"; export GOARCH=arm GOARM=5 ;;
|
||||
386) BOOTLIN_ARCH="x86-i686" ;;
|
||||
s390x) BOOTLIN_ARCH="s390x-z13" ;;
|
||||
esac
|
||||
echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
|
||||
TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/"
|
||||
TARBALL_URL=$(curl -fsSL "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
|
||||
[ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
|
||||
echo "Downloading: $TARBALL_URL"
|
||||
cd /tmp
|
||||
curl -fL -sS -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
|
||||
tar -xf "$(basename "$TARBALL_URL")"
|
||||
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
|
||||
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
|
||||
export CC=$(realpath "$(find "$TOOLCHAIN_DIR/bin" -name '*-gcc.br_real' -type f -executable | head -n1)")
|
||||
[ -z "$CC" ] && { echo "No gcc.br_real found in $TOOLCHAIN_DIR/bin" >&2; exit 1; }
|
||||
cd -
|
||||
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
|
||||
file xui-release
|
||||
ldd xui-release || echo "Static binary confirmed"
|
||||
|
||||
mkdir x-ui
|
||||
cp xui-release x-ui/xui-release
|
||||
cp x-ui.service x-ui/x-ui.service
|
||||
cp x-ui.sh x-ui/x-ui.sh
|
||||
cd x-ui
|
||||
mv xui-release x-ui
|
||||
mkdir bin
|
||||
cd bin
|
||||
wget https://github.com/mhsanaei/Xray-core/releases/latest/download/Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
rm -f Xray-linux-64.zip geoip.dat geosite.dat
|
||||
wget https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
mv xray xray-linux-amd64
|
||||
cd ..
|
||||
cd ..
|
||||
- name: package
|
||||
run: tar -zcvf x-ui-linux-amd64.tar.gz x-ui
|
||||
- name: upload
|
||||
uses: svenstaro/upload-release-action@2.5.0
|
||||
cp xui-release x-ui/
|
||||
cp x-ui.service.debian x-ui/
|
||||
cp x-ui.service.arch x-ui/
|
||||
cp x-ui.service.rhel x-ui/
|
||||
cp x-ui.sh x-ui/
|
||||
mv x-ui/xui-release x-ui/x-ui
|
||||
mkdir x-ui/bin
|
||||
cd x-ui/bin
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
rm -f Xray-linux-64.zip
|
||||
elif [ "${{ matrix.platform }}" == "arm64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-arm64-v8a.zip
|
||||
unzip Xray-linux-arm64-v8a.zip
|
||||
rm -f Xray-linux-arm64-v8a.zip
|
||||
elif [ "${{ matrix.platform }}" == "armv7" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-arm32-v7a.zip
|
||||
unzip Xray-linux-arm32-v7a.zip
|
||||
rm -f Xray-linux-arm32-v7a.zip
|
||||
elif [ "${{ matrix.platform }}" == "armv6" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-arm32-v6.zip
|
||||
unzip Xray-linux-arm32-v6.zip
|
||||
rm -f Xray-linux-arm32-v6.zip
|
||||
elif [ "${{ matrix.platform }}" == "386" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-32.zip
|
||||
unzip Xray-linux-32.zip
|
||||
rm -f Xray-linux-32.zip
|
||||
elif [ "${{ matrix.platform }}" == "armv5" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-arm32-v5.zip
|
||||
unzip Xray-linux-arm32-v5.zip
|
||||
rm -f Xray-linux-arm32-v5.zip
|
||||
elif [ "${{ matrix.platform }}" == "s390x" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-s390x.zip
|
||||
unzip Xray-linux-s390x.zip
|
||||
rm -f Xray-linux-s390x.zip
|
||||
fi
|
||||
rm -f geoip.dat geosite.dat
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
mv xray xray-linux-${{ matrix.platform }}
|
||||
cd ../..
|
||||
|
||||
- name: Package
|
||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-linux-${{ matrix.platform }}
|
||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref }}
|
||||
file: x-ui-linux-amd64.tar.gz
|
||||
asset_name: x-ui-linux-amd64.tar.gz
|
||||
prerelease: true
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
|
||||
# =================================
|
||||
# Windows Build
|
||||
# =================================
|
||||
build-windows:
|
||||
name: Build for Windows
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- amd64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
# Frontend dist must be built BEFORE go build — see comment on the
|
||||
# Linux job above. This step is identical except npm runs on the
|
||||
# Windows runner here.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
shell: pwsh
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install MSYS2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-x86_64-gcc
|
||||
mingw-w64-x86_64-sqlite3
|
||||
mingw-w64-x86_64-pkg-config
|
||||
|
||||
- name: Build 3X-UI for Windows (CGO)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
|
||||
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
export CC=x86_64-w64-mingw32-gcc
|
||||
|
||||
which go
|
||||
go version
|
||||
gcc --version
|
||||
|
||||
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
||||
|
||||
- name: Copy and download resources
|
||||
shell: pwsh
|
||||
run: |
|
||||
mkdir x-ui
|
||||
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||
mkdir x-ui\bin
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
|
||||
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
|
||||
Rename-Item xray.exe xray-windows-amd64.exe
|
||||
cd ..
|
||||
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
|
||||
cd ..
|
||||
|
||||
- name: Package to Zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-windows-amd64
|
||||
path: ./x-ui-windows-amd64.zip
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-windows-amd64.zip
|
||||
asset_name: x-ui-windows-amd64.zip
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
|
||||
52
.gitignore
vendored
52
.gitignore
vendored
@@ -1,12 +1,46 @@
|
||||
.idea
|
||||
tmp
|
||||
# Ignore editor and IDE settings
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
.cache/
|
||||
.sync*
|
||||
|
||||
# Ignore log files
|
||||
*.log
|
||||
|
||||
# Ignore temporary files
|
||||
tmp/
|
||||
*.tar.gz
|
||||
|
||||
# Ignore build and distribution directories
|
||||
backup/
|
||||
bin/
|
||||
dist/
|
||||
x-ui-*.tar.gz
|
||||
/x-ui
|
||||
/release.sh
|
||||
.sync*
|
||||
main
|
||||
!web/dist/
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
release/
|
||||
access.log
|
||||
.cache
|
||||
node_modules/
|
||||
|
||||
# Ignore compiled binaries
|
||||
main
|
||||
|
||||
# Ignore script and executable files
|
||||
/release.sh
|
||||
/x-ui
|
||||
|
||||
# Ignore OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Ignore Go build files
|
||||
*.exe
|
||||
x-ui.db
|
||||
x-ui.db-shm
|
||||
x-ui.db-wal
|
||||
|
||||
# Ignore Docker specific files
|
||||
docker-compose.override.yml
|
||||
|
||||
# Ignore .env (Environment Variables) file
|
||||
.env
|
||||
21
.vscode/launch.json
vendored
Normal file
21
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "vscode://schemas/launch",
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run 3x-ui (Debug)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true",
|
||||
"XUI_DB_FOLDER": "x-ui",
|
||||
"XUI_LOG_FOLDER": "x-ui",
|
||||
"XUI_BIN_FOLDER": "x-ui"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
]
|
||||
}
|
||||
89
.vscode/tasks.json
vendored
Normal file
89
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "go: build",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"build",
|
||||
"-o",
|
||||
"bin/3x-ui.exe",
|
||||
"./main.go"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "go: run",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"run",
|
||||
"./main.go"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true"
|
||||
}
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "go: test",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
],
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "go: vet",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"vet",
|
||||
"./..."
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "go: fmt",
|
||||
"type": "shell",
|
||||
"command": "gofmt",
|
||||
"args": [
|
||||
"-l",
|
||||
"-w",
|
||||
"."
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
226
CONTRIBUTING.md
Normal file
226
CONTRIBUTING.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running locally and explains the conventions the project follows so changes land cleanly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.26+** (the version pinned in `go.mod`)
|
||||
- **Node.js 22+** and npm 10+ (for the React frontend)
|
||||
- **Git**
|
||||
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
|
||||
|
||||
### Windows: MinGW-w64
|
||||
|
||||
`go build` on Windows fails with `cgo: C compiler "gcc" not found` until a GCC toolchain is installed. Two options — pick whichever fits.
|
||||
|
||||
**Option A — standalone zip (fastest, no package manager)**
|
||||
|
||||
1. Download the latest build from <https://github.com/niXman/mingw-builds-binaries/releases>. For most setups, pick a release named:
|
||||
```
|
||||
x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
|
||||
```
|
||||
(64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches modern Windows defaults.)
|
||||
2. Extract it somewhere stable, e.g. `C:\mingw64\`.
|
||||
3. Add `C:\mingw64\bin` to the **Windows** `PATH` (System Properties → Environment Variables → Path → New).
|
||||
4. Open a fresh terminal and confirm:
|
||||
```powershell
|
||||
gcc --version
|
||||
```
|
||||
|
||||
**Option B — MSYS2 (when a Unix shell is also useful)**
|
||||
|
||||
1. Install MSYS2 from <https://www.msys2.org/>.
|
||||
2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
|
||||
```bash
|
||||
pacman -Syu
|
||||
```
|
||||
3. Install the UCRT64 toolchain:
|
||||
```bash
|
||||
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
|
||||
```
|
||||
4. Add `C:\msys64\ucrt64\bin` to the Windows `PATH`.
|
||||
5. Verify with `gcc --version` in a fresh terminal.
|
||||
|
||||
After either path, `go build ./...` and `go run .` work normally.
|
||||
|
||||
> **Why MinGW-w64 over MSVC:** `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain does not require a Visual Studio install. If Visual Studio Build Tools are already present that works too — just make sure `CC=cl` is **not** set in the environment.
|
||||
|
||||
Cross-building the Linux SQLite target from Windows (or vice versa) requires a separate cross-compiler and is out of scope here; build natively on the target OS.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MHSanaei/3x-ui.git
|
||||
cd 3x-ui
|
||||
|
||||
cp .env.example .env
|
||||
|
||||
mkdir x-ui
|
||||
|
||||
go mod download
|
||||
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
`.env.example` ships with defaults that keep the database, logs, and xray binary inside the local `x-ui/` folder so nothing escapes the project directory:
|
||||
|
||||
```
|
||||
XUI_DEBUG=true
|
||||
XUI_DB_FOLDER=x-ui
|
||||
XUI_LOG_FOLDER=x-ui
|
||||
XUI_BIN_FOLDER=x-ui
|
||||
```
|
||||
|
||||
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. Credentials must be changed on first login.
|
||||
|
||||
### Inside VS Code
|
||||
|
||||
The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run 3x-ui (Debug)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true",
|
||||
"XUI_DB_FOLDER": "x-ui",
|
||||
"XUI_LOG_FOLDER": "x-ui",
|
||||
"XUI_BIN_FOLDER": "x-ui"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Working on the frontend
|
||||
|
||||
The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`, built with Vite 8. The sections below cover the architecture, the conventions, and the two dev workflows.
|
||||
|
||||
### Architecture
|
||||
|
||||
The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either.
|
||||
|
||||
### State and data flow
|
||||
|
||||
- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive.
|
||||
- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
|
||||
- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer.
|
||||
- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`.
|
||||
|
||||
### i18n
|
||||
|
||||
Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
|
||||
|
||||
### Two dev workflows
|
||||
|
||||
| Goal | Command |
|
||||
|------|---------|
|
||||
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
|
||||
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
|
||||
|
||||
The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there.
|
||||
|
||||
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
|
||||
|
||||
### Adding a new page
|
||||
|
||||
1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`).
|
||||
2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
|
||||
3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
|
||||
4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
|
||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
|
||||
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
|
||||
|
||||
### Conventions
|
||||
|
||||
- **TypeScript strict mode** — all new code in `.ts` / `.tsx`. Run `npm run typecheck` (`tsc --noEmit`) before pushing. The path alias `@/*` resolves to `src/*`.
|
||||
- **Ant Design 6** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back. Small, targeted UX tweaks beat sweeping rewrites; raise broader visual changes for discussion before implementing.
|
||||
- **Function components + hooks** everywhere. No class components.
|
||||
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
|
||||
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
|
||||
- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes.
|
||||
- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode.
|
||||
|
||||
### Project layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── *.html — Vite entry HTML, one per panel route
|
||||
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
|
||||
├── eslint.config.js — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks)
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── entries/ — per-page bootstrap (createRoot + render)
|
||||
├── pages/ — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
|
||||
├── components/ — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
|
||||
├── hooks/ — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
|
||||
├── api/ — Axios setup + CSRF interceptor + WebSocket client
|
||||
├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/)
|
||||
├── models/ — Inbound, DBInbound, Outbound, Status, reality-targets, …
|
||||
├── styles/ — shared CSS (page-cards, …)
|
||||
└── utils/ — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
|
||||
```
|
||||
|
||||
For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
|
||||
|
||||
## Project layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `main.go` | Process entry point, CLI subcommands, signal handling |
|
||||
| `web/` | Gin HTTP server, controllers, services, embedded frontend assets |
|
||||
| `frontend/` | React + Ant Design 6 + TypeScript source for the panel UI |
|
||||
| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
|
||||
| `xray/` | Xray-core process lifecycle and gRPC API client |
|
||||
| `sub/` | Subscription endpoints (raw, JSON, Clash) |
|
||||
| `config/` | Environment-variable helpers, paths, defaults |
|
||||
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
|
||||
|
||||
## Sending a pull request
|
||||
|
||||
1. Branch off `main` (e.g. `feat/short-description`).
|
||||
2. Keep the diff focused — separate refactors from feature work.
|
||||
3. Run the relevant checks before pushing:
|
||||
- `go build ./...`
|
||||
- `go test ./...` (when Go code changed)
|
||||
- `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed)
|
||||
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
|
||||
5. Open the PR against `main` with a brief description of what changed and how to test it.
|
||||
|
||||
## Useful environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `XUI_DEBUG` | `false` | Verbose logs + Gin debug mode + serve `/assets` from disk |
|
||||
| `XUI_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
|
||||
| `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
|
||||
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
|
||||
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
|
||||
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
||||
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
||||
|
||||
## Issues and discussion
|
||||
|
||||
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
|
||||
- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
|
||||
|
||||
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
|
||||
61
DockerEntrypoint.sh
Normal file
61
DockerEntrypoint.sh
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Start fail2ban with the 3x-ipl jail
|
||||
if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
|
||||
LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
|
||||
mkdir -p "$LOG_FOLDER"
|
||||
touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
|
||||
|
||||
mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
|
||||
|
||||
cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
|
||||
[3x-ipl]
|
||||
enabled=true
|
||||
backend=auto
|
||||
filter=3x-ipl
|
||||
action=3x-ipl
|
||||
logpath=$LOG_FOLDER/3xipl.log
|
||||
maxretry=1
|
||||
findtime=32
|
||||
bantime=30m
|
||||
EOF
|
||||
|
||||
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||
[Definition]
|
||||
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
||||
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||
[INCLUDES]
|
||||
before = iptables-allports.conf
|
||||
|
||||
[Definition]
|
||||
actionstart = <iptables> -N f2b-<name>
|
||||
<iptables> -A f2b-<name> -j <returntype>
|
||||
<iptables> -I <chain> -p <protocol> -j f2b-<name>
|
||||
|
||||
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
<actionflush>
|
||||
<iptables> -X f2b-<name>
|
||||
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
protocol = tcp
|
||||
chain = INPUT
|
||||
EOF
|
||||
|
||||
fail2ban-client -x start
|
||||
fi
|
||||
|
||||
# Run x-ui
|
||||
exec /app/x-ui
|
||||
40
DockerInit.sh
Executable file
40
DockerInit.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
case $1 in
|
||||
amd64)
|
||||
ARCH="64"
|
||||
FNAME="amd64"
|
||||
;;
|
||||
i386)
|
||||
ARCH="32"
|
||||
FNAME="i386"
|
||||
;;
|
||||
armv8 | arm64 | aarch64)
|
||||
ARCH="arm64-v8a"
|
||||
FNAME="arm64"
|
||||
;;
|
||||
armv7 | arm | arm32)
|
||||
ARCH="arm32-v7a"
|
||||
FNAME="arm32"
|
||||
;;
|
||||
armv6)
|
||||
ARCH="arm32-v6"
|
||||
FNAME="armv6"
|
||||
;;
|
||||
*)
|
||||
ARCH="64"
|
||||
FNAME="amd64"
|
||||
;;
|
||||
esac
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
cd ../../
|
||||
74
Dockerfile
Normal file
74
Dockerfile
Normal file
@@ -0,0 +1,74 @@
|
||||
# ========================================================
|
||||
# Stage: Frontend (Vite)
|
||||
# ========================================================
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
COPY web/translation /src/web/translation
|
||||
RUN npm run build
|
||||
|
||||
# ========================================================
|
||||
# Stage: Builder
|
||||
# ========================================================
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk --no-cache --update add \
|
||||
build-base \
|
||||
gcc \
|
||||
curl \
|
||||
unzip
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /src/web/dist ./web/dist
|
||||
|
||||
ENV CGO_ENABLED=1
|
||||
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
|
||||
RUN go build -ldflags "-w -s" -o build/x-ui main.go
|
||||
RUN ./DockerInit.sh "$TARGETARCH"
|
||||
|
||||
# ========================================================
|
||||
# Stage: Final Image of 3x-ui
|
||||
# ========================================================
|
||||
FROM alpine
|
||||
ENV TZ=Asia/Tehran
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache --update \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
fail2ban \
|
||||
bash \
|
||||
curl \
|
||||
openssl
|
||||
|
||||
COPY --from=builder /app/build/ /app/
|
||||
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
||||
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
|
||||
COPY --from=builder /app/web/translation /app/web/translation
|
||||
|
||||
|
||||
# Configure fail2ban
|
||||
RUN rm -f /etc/fail2ban/jail.d/alpine-ssh.conf \
|
||||
&& cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local \
|
||||
&& sed -i "s/^\[ssh\]$/&\nenabled = false/" /etc/fail2ban/jail.local \
|
||||
&& sed -i "s/^\[sshd\]$/&\nenabled = false/" /etc/fail2ban/jail.local \
|
||||
&& sed -i "s/#allowipv6 = auto/allowipv6 = auto/g" /etc/fail2ban/fail2ban.conf
|
||||
|
||||
RUN chmod +x \
|
||||
/app/DockerEntrypoint.sh \
|
||||
/app/x-ui \
|
||||
/usr/bin/x-ui
|
||||
|
||||
ENV XUI_ENABLE_FAIL2BAN="true"
|
||||
# Database backend: set XUI_DB_TYPE=postgres and XUI_DB_DSN=postgres://... to use PostgreSQL.
|
||||
# Default (unset) is SQLite stored under /etc/x-ui.
|
||||
ENV XUI_DB_TYPE=""
|
||||
ENV XUI_DB_DSN=""
|
||||
EXPOSE 2053
|
||||
VOLUME [ "/etc/x-ui" ]
|
||||
CMD [ "./x-ui" ]
|
||||
ENTRYPOINT [ "/app/DockerEntrypoint.sh" ]
|
||||
62
README.ar_EG.md
Normal file
62
README.ar_EG.md
Normal file
@@ -0,0 +1,62 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> هذا المشروع مخصص للاستخدام الشخصي والاتصال فقط، يرجى عدم استخدامه لأغراض غير قانونية، يرجى عدم استخدامه في بيئة الإنتاج.
|
||||
|
||||
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
|
||||
|
||||
## البدء السريع
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## شكر خاص إلى
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## الاعتراف
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
|
||||
|
||||
## أدوات المجتمع
|
||||
|
||||
أدوات وتكاملات بناها المجتمع حول 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (الترخيص: **MIT**): _إدارة الاتصالات الواردة والعملاء وإعدادات اللوحة وتكوين Xray كرمز باستخدام Terraform / OpenTofu._
|
||||
|
||||
## دعم المشروع
|
||||
|
||||
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## النجوم عبر الزمن
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
63
README.es_ES.md
Normal file
63
README.es_ES.md
Normal file
@@ -0,0 +1,63 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Este proyecto es solo para uso personal y comunicación, por favor no lo use para fines ilegales, por favor no lo use en un entorno de producción.
|
||||
|
||||
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
|
||||
|
||||
## Inicio Rápido
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
Para documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## Un Agradecimiento Especial a
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## Reconocimientos
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
|
||||
|
||||
## Herramientas de la Comunidad
|
||||
|
||||
Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Licencia: **MIT**): _Gestiona inbounds, clientes, configuración del panel y configuración de Xray como código con Terraform / OpenTofu._
|
||||
|
||||
## Apoyar el Proyecto
|
||||
|
||||
**Si este proyecto te es útil, puedes darle una**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## Estrellas a lo Largo del Tiempo
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
63
README.fa_IR.md
Normal file
63
README.fa_IR.md
Normal file
@@ -0,0 +1,63 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکلهای مختلف VPN و پراکسی ارائه میدهد.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> این پروژه فقط برای استفاده شخصی و ارتباطات است، لطفاً از آن برای اهداف غیرقانونی استفاده نکنید، لطفاً از آن در محیط تولید استفاده نکنید.
|
||||
|
||||
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گستردهتر از پروتکلها و ویژگیهای اضافی را ارائه میدهد.
|
||||
|
||||
## شروع سریع
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
|
||||
|
||||
## تشکر ویژه از
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## قدردانی
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنههای ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray بهروزرسانی شده خودکار بر اساس دادههای دامنهها و آدرسهای مسدود شده در روسیه است._
|
||||
|
||||
## ابزارهای جامعه
|
||||
|
||||
ابزارها و یکپارچهسازیهایی که توسط جامعه پیرامون 3x-ui ساخته شدهاند.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (مجوز: **MIT**): _مدیریت اینباندها، کلاینتها، تنظیمات پنل و پیکربندی Xray بهصورت کد با Terraform / OpenTofu._
|
||||
|
||||
## پشتیبانی از پروژه
|
||||
|
||||
**اگر این پروژه برای شما مفید است، میتوانید به آن یک**:star2: بدهید
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## ستارهها در طول زمان
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
118
README.md
118
README.md
@@ -1,67 +1,95 @@
|
||||
# 3x-ui
|
||||

|
||||

|
||||
[](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui)
|
||||
[](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
> **Disclaimer: This project is only for personal learning and communication, please do not use it for illegal purposes, please do not use it in a production environment**
|
||||
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
|
||||
|
||||
xray panel supporting multi-protocol, **Multi-lang (English,Farsi,Chinese)**
|
||||
> [!IMPORTANT]
|
||||
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
|
||||
|
||||
# Install & Upgrade
|
||||
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
||||
|
||||
```
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
# SSL
|
||||
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## Database Options
|
||||
|
||||
3X-UI supports two backends, chosen during the install:
|
||||
|
||||
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
|
||||
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
|
||||
|
||||
At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
|
||||
|
||||
```
|
||||
apt-get install certbot -y
|
||||
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
|
||||
certbot renew --dry-run
|
||||
XUI_DB_TYPE=postgres
|
||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||
```
|
||||
|
||||
**If you think this project is helpful to you, you may wish to give a** :star2:
|
||||
### Migrating an existing SQLite install to PostgreSQL
|
||||
|
||||
# Default settings
|
||||
```bash
|
||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||
# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
- Port: 2053
|
||||
- username and password will be generated randomly you can see them after you install it (x-ui "7")
|
||||
- database path: /etc/x-ui/x-ui.db
|
||||
The source SQLite file is left untouched; remove it manually once you have verified the new backend.
|
||||
|
||||
before you set ssl on settings
|
||||
- http:// ip or domain:2053/xui
|
||||
### Docker
|
||||
|
||||
After you set ssl on settings
|
||||
- https://yourdomain:2053/xui
|
||||
The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
|
||||
|
||||
# Enable Traffic For Users:
|
||||
```bash
|
||||
docker compose --profile postgres up -d
|
||||
```
|
||||
|
||||
**copy and paste to xray Configuration :** (you don't need to do this if you have a fresh install)
|
||||
- [for enable traffic](https://raw.githubusercontent.com/MHSanaei/3x-ui/main/media/for%20enable%20traffic.txt)
|
||||
- [for enable traffic+block all iran ip address](https://raw.githubusercontent.com/MHSanaei/3x-ui/main/media/for%20enable%20traffic%2Bblock%20all%20iran%20ip.txt)
|
||||
## A Special Thanks to
|
||||
|
||||
# Suggestion System
|
||||
- Ubuntu 20.04+
|
||||
- Debian 10+
|
||||
- CentOS 8+
|
||||
|
||||
# Pictures
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# A Special Thanks To
|
||||
- [vaxilu](https://github.com/vaxilu/)
|
||||
- [HexaSoftwareTech](https://github.com/HexaSoftwareTech/)
|
||||
- [diditra](https://github.com/diditra/)
|
||||
- [FranzKafkaYu](https://github.com/FranzKafkaYu)
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
## Stargazers over time
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
## Community Tools
|
||||
|
||||
Tools and integrations built by the community around 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
|
||||
|
||||
## Support project
|
||||
|
||||
**If this project is helpful to you, you may wish to give it a**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## Stargazers over Time
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
63
README.ru_RU.md
Normal file
63
README.ru_RU.md
Normal file
@@ -0,0 +1,63 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Этот проект предназначен только для личного использования, пожалуйста, не используйте его в незаконных целях и в производственной среде.
|
||||
|
||||
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## Особая благодарность
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## Благодарности
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
|
||||
|
||||
## Инструменты сообщества
|
||||
|
||||
Инструменты и интеграции, созданные сообществом вокруг 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Лицензия: **MIT**): _Управление входящими, клиентами, настройками панели и конфигурацией Xray через код с помощью Terraform / OpenTofu._
|
||||
|
||||
## Поддержка проекта
|
||||
|
||||
**Если этот проект полезен для вас, вы можете поставить ему**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## Звезды с течением времени
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
63
README.zh_CN.md
Normal file
63
README.zh_CN.md
Normal file
@@ -0,0 +1,63 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本项目仅用于个人使用和通信,请勿将其用于非法目的,请勿在生产环境中使用。
|
||||
|
||||
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
|
||||
|
||||
## 特别感谢
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## 致谢
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则,内置伊朗域名,专注于安全性和广告拦截。_
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
|
||||
|
||||
## 社区工具
|
||||
|
||||
社区围绕 3x-ui 构建的工具和集成。
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (许可证: **MIT**): _使用 Terraform / OpenTofu 通过代码管理入站、客户端、面板设置和 Xray 配置。_
|
||||
|
||||
## 支持项目
|
||||
|
||||
**如果这个项目对您有帮助,您可以给它一个**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## 随时间变化的星标数
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
137
config/config.go
137
config/config.go
@@ -1,9 +1,14 @@
|
||||
// Package config provides configuration management utilities for the 3x-ui panel,
|
||||
// including version information, logging levels, database paths, and environment variable handling.
|
||||
package config
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -13,23 +18,29 @@ var version string
|
||||
//go:embed name
|
||||
var name string
|
||||
|
||||
// LogLevel represents the logging level for the application.
|
||||
type LogLevel string
|
||||
|
||||
// Logging level constants
|
||||
const (
|
||||
Debug LogLevel = "debug"
|
||||
Info LogLevel = "info"
|
||||
Warn LogLevel = "warn"
|
||||
Error LogLevel = "error"
|
||||
Debug LogLevel = "debug"
|
||||
Info LogLevel = "info"
|
||||
Notice LogLevel = "notice"
|
||||
Warning LogLevel = "warning"
|
||||
Error LogLevel = "error"
|
||||
)
|
||||
|
||||
// GetVersion returns the version string of the 3x-ui application.
|
||||
func GetVersion() string {
|
||||
return strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
// GetName returns the name of the 3x-ui application.
|
||||
func GetName() string {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
|
||||
func GetLogLevel() LogLevel {
|
||||
if IsDebug() {
|
||||
return Debug
|
||||
@@ -41,10 +52,126 @@ func GetLogLevel() LogLevel {
|
||||
return LogLevel(logLevel)
|
||||
}
|
||||
|
||||
// IsDebug returns true if debug mode is enabled via the XUI_DEBUG environment variable.
|
||||
func IsDebug() bool {
|
||||
return os.Getenv("XUI_DEBUG") == "true"
|
||||
}
|
||||
|
||||
// IsSkipHSTS returns true if skipping HSTS mode is enabled via the XUI_SKIP_HSTS environment variable.
|
||||
func IsSkipHSTS() bool {
|
||||
return os.Getenv("XUI_SKIP_HSTS") == "true"
|
||||
}
|
||||
|
||||
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
|
||||
func GetBinFolderPath() string {
|
||||
binFolderPath := os.Getenv("XUI_BIN_FOLDER")
|
||||
if binFolderPath == "" {
|
||||
binFolderPath = "bin"
|
||||
}
|
||||
return binFolderPath
|
||||
}
|
||||
|
||||
func getBaseDir() string {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
exeDir := filepath.Dir(exePath)
|
||||
exeDirLower := strings.ToLower(filepath.ToSlash(exeDir))
|
||||
if strings.Contains(exeDirLower, "/appdata/local/temp/") || strings.Contains(exeDirLower, "/go-build") {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return wd
|
||||
}
|
||||
return exeDir
|
||||
}
|
||||
|
||||
// GetDBFolderPath returns the path to the database folder based on environment variables or platform defaults.
|
||||
func GetDBFolderPath() string {
|
||||
dbFolderPath := os.Getenv("XUI_DB_FOLDER")
|
||||
if dbFolderPath != "" {
|
||||
return dbFolderPath
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return getBaseDir()
|
||||
}
|
||||
return "/etc/x-ui"
|
||||
}
|
||||
|
||||
// GetDBPath returns the full path to the database file.
|
||||
func GetDBPath() string {
|
||||
return fmt.Sprintf("/etc/%s/%s.db", GetName(), GetName())
|
||||
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
||||
}
|
||||
|
||||
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
|
||||
func GetDBKind() string {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
|
||||
switch v {
|
||||
case "postgres", "postgresql", "pg":
|
||||
return "postgres"
|
||||
default:
|
||||
return "sqlite"
|
||||
}
|
||||
}
|
||||
|
||||
// GetDBDSN returns the PostgreSQL DSN from XUI_DB_DSN. Empty for sqlite.
|
||||
func GetDBDSN() string {
|
||||
return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
|
||||
}
|
||||
|
||||
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
|
||||
func GetLogFolder() string {
|
||||
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
||||
if logFolderPath != "" {
|
||||
return logFolderPath
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(".", "log")
|
||||
}
|
||||
return "/var/log/x-ui"
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func init() {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
if os.Getenv("XUI_DB_FOLDER") != "" {
|
||||
return
|
||||
}
|
||||
oldDBFolder := "/etc/x-ui"
|
||||
oldDBPath := fmt.Sprintf("%s/%s.db", oldDBFolder, GetName())
|
||||
newDBFolder := GetDBFolderPath()
|
||||
newDBPath := fmt.Sprintf("%s/%s.db", newDBFolder, GetName())
|
||||
_, err := os.Stat(newDBPath)
|
||||
if err == nil {
|
||||
return // new exists
|
||||
}
|
||||
_, err = os.Stat(oldDBPath)
|
||||
if os.IsNotExist(err) {
|
||||
return // old does not exist
|
||||
}
|
||||
_ = copyFile(oldDBPath, newDBPath) // ignore error
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.8
|
||||
3.1.0
|
||||
502
database/db.go
502
database/db.go
@@ -1,13 +1,26 @@
|
||||
// Package database provides database initialization, migration, and management utilities
|
||||
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
|
||||
package database
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"x-ui/config"
|
||||
"x-ui/database/model"
|
||||
"x-ui/xray"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -15,91 +28,460 @@ import (
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
const (
|
||||
DialectSQLite = "sqlite"
|
||||
DialectPostgres = "postgres"
|
||||
)
|
||||
|
||||
// IsPostgres reports whether the active connection is a PostgreSQL backend.
|
||||
func IsPostgres() bool {
|
||||
if db == nil {
|
||||
return config.GetDBKind() == "postgres"
|
||||
}
|
||||
return db.Dialector.Name() == "postgres"
|
||||
}
|
||||
|
||||
// Dialect returns the active GORM dialect name, or "" if the DB is not open.
|
||||
func Dialect() string {
|
||||
if db == nil {
|
||||
return ""
|
||||
}
|
||||
return db.Dialector.Name()
|
||||
}
|
||||
|
||||
const (
|
||||
defaultUsername = "admin"
|
||||
defaultPassword = "admin"
|
||||
)
|
||||
|
||||
func initModels() error {
|
||||
models := []any{
|
||||
&model.User{},
|
||||
&model.Inbound{},
|
||||
&model.OutboundTraffics{},
|
||||
&model.Setting{},
|
||||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.InboundFallback{},
|
||||
}
|
||||
for _, mdl := range models {
|
||||
if err := db.AutoMigrate(mdl); err != nil {
|
||||
if isIgnorableDuplicateColumnErr(err, mdl) {
|
||||
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("Error auto migrating model: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
// SQLite: "duplicate column name: foo"
|
||||
// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
|
||||
const sqlitePrefix = "duplicate column name:"
|
||||
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
|
||||
col := strings.TrimSpace(after)
|
||||
col = strings.Trim(col, "`\"[]")
|
||||
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
|
||||
// Best effort: extract the column name between the first pair of double quotes.
|
||||
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
|
||||
rest := after
|
||||
if e := strings.Index(rest, "\""); e > 0 {
|
||||
col := rest[:e]
|
||||
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// initUser creates a default admin user if the users table is empty.
|
||||
func initUser() error {
|
||||
err := db.AutoMigrate(&model.User{})
|
||||
empty, err := isTableEmpty("users")
|
||||
if err != nil {
|
||||
log.Printf("Error checking if users table is empty: %v", err)
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
err = db.Model(&model.User{}).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if empty {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error hashing default password: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: "admin",
|
||||
Password: "admin",
|
||||
Username: defaultUsername,
|
||||
Password: hashedPassword,
|
||||
}
|
||||
return db.Create(user).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initInbound() error {
|
||||
return db.AutoMigrate(&model.Inbound{})
|
||||
}
|
||||
|
||||
func initSetting() error {
|
||||
return db.AutoMigrate(&model.Setting{})
|
||||
}
|
||||
func initInboundClientIps() error {
|
||||
return db.AutoMigrate(&model.InboundClientIps{})
|
||||
}
|
||||
func initClientTraffic() error {
|
||||
return db.AutoMigrate(&xray.ClientTraffic{})
|
||||
}
|
||||
|
||||
func InitDB(dbPath string) error {
|
||||
dir := path.Dir(dbPath)
|
||||
err := os.MkdirAll(dir, fs.ModeDir)
|
||||
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
|
||||
func runSeeders(isUsersEmpty bool) error {
|
||||
empty, err := isTableEmpty("history_of_seeders")
|
||||
if err != nil {
|
||||
log.Printf("Error checking if users table is empty: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var gormLogger logger.Interface
|
||||
|
||||
if config.IsDebug() {
|
||||
gormLogger = logger.Default
|
||||
} else {
|
||||
gormLogger = logger.Discard
|
||||
if empty && isUsersEmpty {
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return seedApiTokens()
|
||||
}
|
||||
|
||||
c := &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
}
|
||||
db, err = gorm.Open(sqlite.Open(dbPath), c)
|
||||
if err != nil {
|
||||
var seedersHistory []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = initUser()
|
||||
if err != nil {
|
||||
return err
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
log.Printf("Error fetching users for password migration: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = initInbound()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
if !slices.Contains(seedersHistory, "ApiTokensTable") {
|
||||
if err := seedApiTokens(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = initSetting()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
if !slices.Contains(seedersHistory, "ClientsTable") {
|
||||
if err := seedClientsFromInboundJSON(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = initInboundClientIps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = initClientTraffic()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
|
||||
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
|
||||
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
|
||||
// drop the key so the field falls back to its zero value.
|
||||
func normalizeClientJSONFields(obj map[string]any) {
|
||||
normalizeInt := func(key string) {
|
||||
raw, exists := obj[key]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
|
||||
if trimmed == "" {
|
||||
delete(obj, key)
|
||||
return
|
||||
}
|
||||
if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
|
||||
obj[key] = n
|
||||
} else {
|
||||
delete(obj, key)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} {
|
||||
normalizeInt(k)
|
||||
}
|
||||
}
|
||||
|
||||
func seedClientsFromInboundJSON() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
byEmail := map[string]*model.ClientRecord{}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
rawList, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, raw := range rawList {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalizeClientJSONFields(obj)
|
||||
blob, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var c model.Client
|
||||
if err := json.Unmarshal(blob, &c); err != nil {
|
||||
log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s",
|
||||
inbound.Id, err, string(blob))
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(c.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
incoming := c.ToRecord()
|
||||
|
||||
row, dup := byEmail[email]
|
||||
if !dup {
|
||||
if err := tx.Create(incoming).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
byEmail[email] = incoming
|
||||
row = incoming
|
||||
} else {
|
||||
conflicts := model.MergeClientRecord(row, incoming)
|
||||
for _, x := range conflicts {
|
||||
log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v",
|
||||
email, x.Field, x.Old, x.New, x.Kept)
|
||||
}
|
||||
if err := tx.Save(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
link := model.ClientInbound{
|
||||
ClientId: row.Id,
|
||||
InboundId: inbound.Id,
|
||||
FlowOverride: c.Flow,
|
||||
}
|
||||
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
|
||||
FirstOrCreate(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// seedApiTokens copies the legacy `apiToken` setting into the new
|
||||
// api_tokens table as a row named "default" so existing central panels
|
||||
// keep working after the upgrade. Idempotent — records itself in
|
||||
// history_of_seeders and only runs when api_tokens is empty.
|
||||
func seedApiTokens() error {
|
||||
empty, err := isTableEmpty("api_tokens")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if empty {
|
||||
var legacy model.Setting
|
||||
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
|
||||
if err == nil && legacy.Value != "" {
|
||||
row := &model.ApiToken{
|
||||
Name: "default",
|
||||
Token: legacy.Value,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
log.Printf("Error migrating legacy apiToken: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
|
||||
}
|
||||
|
||||
// isTableEmpty returns true if the named table contains zero rows.
|
||||
func isTableEmpty(tableName string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Table(tableName).Count(&count).Error
|
||||
return count == 0, err
|
||||
}
|
||||
|
||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||
// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
|
||||
func InitDB(dbPath string) error {
|
||||
var gormLogger logger.Interface
|
||||
if config.IsDebug() {
|
||||
gormLogger = logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
gormLogger = logger.Discard
|
||||
}
|
||||
c := &gorm.Config{Logger: gormLogger}
|
||||
|
||||
var err error
|
||||
switch config.GetDBKind() {
|
||||
case "postgres":
|
||||
dsn := config.GetDBDSN()
|
||||
if dsn == "" {
|
||||
return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
|
||||
}
|
||||
db, err = gorm.Open(postgres.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
dir := path.Dir(dbPath)
|
||||
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(8)
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := initModels(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isUsersEmpty, err := isTableEmpty("users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := initUser(); err != nil {
|
||||
return err
|
||||
}
|
||||
return runSeeders(isUsersEmpty)
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection if it exists.
|
||||
func CloseDB() error {
|
||||
if db != nil {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB returns the global GORM database instance.
|
||||
func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||
signature := []byte("SQLite format 3\x00")
|
||||
buf := make([]byte, len(signature))
|
||||
_, err := file.ReadAt(buf, 0)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return bytes.Equal(buf, signature), nil
|
||||
}
|
||||
|
||||
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||
// No-op on PostgreSQL (WAL there is managed by the server).
|
||||
func Checkpoint() error {
|
||||
if IsPostgres() {
|
||||
return nil
|
||||
}
|
||||
return db.Exec("PRAGMA wal_checkpoint;").Error
|
||||
}
|
||||
|
||||
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
|
||||
// It does not mutate global state or run migrations.
|
||||
func ValidateSQLiteDB(dbPath string) error {
|
||||
if _, err := os.Stat(dbPath); err != nil { // file must exist
|
||||
return err
|
||||
}
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
var res string
|
||||
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if res != "ok" {
|
||||
return errors.New("sqlite integrity check failed: " + res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
26
database/dialect.go
Normal file
26
database/dialect.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package database
|
||||
|
||||
import "fmt"
|
||||
|
||||
// JSONClientsFromInbound returns the FROM clause that yields one row per element
|
||||
// of inbounds.settings -> clients, with a column named `client.value` whose text
|
||||
// fields can be read with JSONFieldText("client.value", "<key>").
|
||||
func JSONClientsFromInbound() string {
|
||||
if IsPostgres() {
|
||||
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
|
||||
}
|
||||
return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
|
||||
}
|
||||
|
||||
// JSONFieldText returns a SQL expression that extracts the textual value of <key>
|
||||
// from a JSON expression. On both backends the result is the raw (unquoted) string,
|
||||
// so callers do NOT need to trim surrounding quotes.
|
||||
func JSONFieldText(expr, key string) string {
|
||||
if IsPostgres() {
|
||||
return fmt.Sprintf("(%s ->> '%s')", expr, key)
|
||||
}
|
||||
// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
|
||||
// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
|
||||
// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
|
||||
return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
|
||||
}
|
||||
143
database/migrate_data.go
Normal file
143
database/migrate_data.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// migrationModels is the FK-aware order in which tables are created and copied.
|
||||
// Parents come before their children so foreign-key constraints stay satisfied
|
||||
// even when checks are not explicitly disabled.
|
||||
func migrationModels() []any {
|
||||
return []any{
|
||||
&model.User{},
|
||||
&model.Setting{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
&model.Inbound{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.OutboundTraffics{},
|
||||
&model.InboundClientIps{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.InboundFallback{},
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateData copies every row from the configured SQLite file at srcPath into
|
||||
// a fresh PostgreSQL database described by dstDSN. The destination tables are
|
||||
// (re)created with AutoMigrate before the copy. Source data is left untouched.
|
||||
func MigrateData(srcPath, dstDSN string) error {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||
}
|
||||
if dstDSN == "" {
|
||||
return errors.New("destination DSN is required")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
|
||||
src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sqlite source: %w", err)
|
||||
}
|
||||
srcSQL, err := src.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcSQL.Close()
|
||||
|
||||
dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open postgres destination: %w", err)
|
||||
}
|
||||
dstSQL, err := dst.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstSQL.Close()
|
||||
dstSQL.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
log.Println("Creating destination schema...")
|
||||
for _, m := range migrationModels() {
|
||||
if err := dst.AutoMigrate(m); err != nil {
|
||||
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
totalRows := 0
|
||||
for _, m := range migrationModels() {
|
||||
n, err := copyTable(src, dst, m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy %T: %w", m, err)
|
||||
}
|
||||
totalRows += n
|
||||
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
|
||||
}
|
||||
|
||||
if err := resetPostgresSequences(dst); err != nil {
|
||||
log.Printf("warning: failed to reset some postgres sequences: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
|
||||
log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyTable streams every row of `mdl` from src to dst in batches.
|
||||
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
|
||||
batchPtr := reflect.New(sliceType)
|
||||
batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
|
||||
|
||||
total := 0
|
||||
err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
|
||||
batch := batchPtr.Elem()
|
||||
if batch.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total += batch.Len()
|
||||
return nil
|
||||
}).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// resetPostgresSequences advances each table's id sequence past MAX(id),
|
||||
// otherwise the next INSERT-without-id would clash with copied rows.
|
||||
func resetPostgresSequences(dst *gorm.DB) error {
|
||||
tables := []string{
|
||||
"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
|
||||
"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
|
||||
"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
|
||||
}
|
||||
for _, t := range tables {
|
||||
// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
|
||||
_ = dst.Exec(fmt.Sprintf(
|
||||
`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
|
||||
WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
|
||||
t, t, t,
|
||||
)).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,63 +1,232 @@
|
||||
// Package model defines the database models and data structures used by the 3x-ui panel.
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"x-ui/util/json_util"
|
||||
"x-ui/xray"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
// Protocol represents the protocol type for Xray inbounds.
|
||||
type Protocol string
|
||||
|
||||
// Protocol constants for different Xray inbound protocols
|
||||
const (
|
||||
VMess Protocol = "vmess"
|
||||
VMESS Protocol = "vmess"
|
||||
VLESS Protocol = "vless"
|
||||
Dokodemo Protocol = "Dokodemo-door"
|
||||
Http Protocol = "http"
|
||||
Tunnel Protocol = "tunnel"
|
||||
HTTP Protocol = "http"
|
||||
Trojan Protocol = "trojan"
|
||||
Shadowsocks Protocol = "shadowsocks"
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
|
||||
// with the literal v2 string would otherwise fall through (#4081).
|
||||
func IsHysteria(p Protocol) bool {
|
||||
return p == Hysteria || p == Hysteria2
|
||||
}
|
||||
|
||||
type Inbound struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UserId int `json:"-"`
|
||||
Up int64 `json:"up" form:"up"`
|
||||
Down int64 `json:"down" form:"down"`
|
||||
Total int64 `json:"total" form:"total"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginEpoch int64 `json:"-" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// config part
|
||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||
type Inbound struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
||||
UserId int `json:"-"` // Associated user ID
|
||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
|
||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||
|
||||
// Xray configuration fields
|
||||
Listen string `json:"listen" form:"listen"`
|
||||
Port int `json:"port" form:"port" gorm:"unique"`
|
||||
Port int `json:"port" form:"port"`
|
||||
Protocol Protocol `json:"protocol" form:"protocol"`
|
||||
Settings string `json:"settings" form:"settings"`
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||
}
|
||||
type InboundClientIps struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
|
||||
Ips string `json:"ips" form:"ips"`
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
|
||||
// FallbackParent is populated by the API layer when this inbound is
|
||||
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
|
||||
// The frontend uses it to rewrite client-share links so they advertise
|
||||
// the master's externally reachable endpoint instead of the child's
|
||||
// loopback listen. Not persisted.
|
||||
FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
// FallbackParentInfo carries everything the frontend needs to rewrite a
|
||||
// child inbound's client link: where to connect (the master's address
|
||||
// and port) and which path matched on the master's fallbacks array.
|
||||
// The frontend already has the master inbound in its dbInbounds list,
|
||||
// so we only ship identifiers + the match path here.
|
||||
type FallbackParentInfo struct {
|
||||
MasterId int `json:"masterId"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||
type OutboundTraffics struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
Up int64 `json:"up" form:"up" gorm:"default:0"`
|
||||
Down int64 `json:"down" form:"down" gorm:"default:0"`
|
||||
Total int64 `json:"total" form:"total" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// InboundClientIps stores IP addresses associated with inbound clients for access control.
|
||||
type InboundClientIps struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
|
||||
Ips string `json:"ips" form:"ips"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits the Ips column as a real JSON array instead of an escaped
|
||||
// JSON-text string. Empty or unparseable storage renders as null so API
|
||||
// consumers don't have to special-case the legacy double-encoded shape.
|
||||
func (ic InboundClientIps) MarshalJSON() ([]byte, error) {
|
||||
type alias InboundClientIps
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Ips json.RawMessage `json:"ips"`
|
||||
}{
|
||||
alias: alias(ic),
|
||||
Ips: jsonStringFieldToRaw(ic.Ips),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts ips as either a JSON array (modern shape) or a
|
||||
// JSON-encoded string (legacy shape), normalising back to the JSON-text the
|
||||
// column stores.
|
||||
func (ic *InboundClientIps) UnmarshalJSON(data []byte) error {
|
||||
type alias InboundClientIps
|
||||
aux := struct {
|
||||
*alias
|
||||
Ips json.RawMessage `json:"ips"`
|
||||
}{
|
||||
alias: (*alias)(ic),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
ic.Ips = jsonStringFieldFromRaw(aux.Ips)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
|
||||
type HistoryOfSeeders struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
SeederName string `json:"seederName"`
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
Token string `json:"token" gorm:"not null"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
|
||||
// objects rather than escaped strings, so API consumers don't need to JSON.parse
|
||||
// a string inside a string. Empty fields render as null; fields whose stored
|
||||
// text isn't valid JSON fall back to a JSON-encoded string so no data is lost.
|
||||
func (i Inbound) MarshalJSON() ([]byte, error) {
|
||||
type alias Inbound
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
StreamSettings json.RawMessage `json:"streamSettings"`
|
||||
Sniffing json.RawMessage `json:"sniffing"`
|
||||
}{
|
||||
alias: alias(i),
|
||||
Settings: jsonStringFieldToRaw(i.Settings),
|
||||
StreamSettings: jsonStringFieldToRaw(i.StreamSettings),
|
||||
Sniffing: jsonStringFieldToRaw(i.Sniffing),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts settings, streamSettings, and sniffing as either a raw
|
||||
// JSON object/array (the modern shape MarshalJSON emits) or a JSON-encoded
|
||||
// string (the legacy shape). Either form is normalised back to the JSON-text
|
||||
// string the DB column stores.
|
||||
func (i *Inbound) UnmarshalJSON(data []byte) error {
|
||||
type alias Inbound
|
||||
aux := struct {
|
||||
*alias
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
StreamSettings json.RawMessage `json:"streamSettings"`
|
||||
Sniffing json.RawMessage `json:"sniffing"`
|
||||
}{
|
||||
alias: (*alias)(i),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
i.Settings = jsonStringFieldFromRaw(aux.Settings)
|
||||
i.StreamSettings = jsonStringFieldFromRaw(aux.StreamSettings)
|
||||
i.Sniffing = jsonStringFieldFromRaw(aux.Sniffing)
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonStringFieldToRaw(s string) json.RawMessage {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return json.RawMessage("null")
|
||||
}
|
||||
if json.Valid([]byte(trimmed)) {
|
||||
return json.RawMessage(trimmed)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
return b
|
||||
}
|
||||
|
||||
func jsonStringFieldFromRaw(r json.RawMessage) string {
|
||||
trimmed := bytes.TrimSpace(r)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return ""
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(trimmed, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return string(trimmed)
|
||||
}
|
||||
|
||||
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
listen := i.Listen
|
||||
if listen != "" {
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
if listen == "" {
|
||||
listen = "0.0.0.0"
|
||||
}
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
protocol := string(i.Protocol)
|
||||
return &xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(listen),
|
||||
Port: i.Port,
|
||||
Protocol: string(i.Protocol),
|
||||
Protocol: protocol,
|
||||
Settings: json_util.RawMessage(i.Settings),
|
||||
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
||||
Tag: i.Tag,
|
||||
@@ -65,18 +234,347 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Setting stores key-value configuration settings for the 3x-ui panel.
|
||||
type Setting struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Key string `json:"key" form:"key"`
|
||||
Value string `json:"value" form:"value"`
|
||||
}
|
||||
|
||||
// Node represents a remote 3x-ui panel registered with the central panel.
|
||||
// The central panel polls each node's existing /panel/api/server/status
|
||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||
// status fields below.
|
||||
type Node struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||
|
||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||
// truthful without us having to read LastHeartbeat separately.
|
||||
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
|
||||
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
||||
LatencyMs int `json:"latencyMs"`
|
||||
XrayVersion string `json:"xrayVersion"`
|
||||
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
|
||||
CpuPct float64 `json:"cpuPct"`
|
||||
MemPct float64 `json:"memPct"`
|
||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||
LastError string `json:"lastError"`
|
||||
|
||||
InboundCount int `json:"inboundCount" gorm:"-"`
|
||||
ClientCount int `json:"clientCount" gorm:"-"`
|
||||
OnlineCount int `json:"onlineCount" gorm:"-"`
|
||||
DepletedCount int `json:"depletedCount" gorm:"-"`
|
||||
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
type CustomGeoResource struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
|
||||
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
|
||||
Url string `json:"url" gorm:"not null"`
|
||||
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
||||
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
||||
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli;column:created_at"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli;column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientReverse struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||
type Client struct {
|
||||
ID string `json:"id"`
|
||||
AlterIds uint16 `json:"alterId"`
|
||||
Email string `json:"email"`
|
||||
LimitIP int `json:"limitIp"`
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
}
|
||||
|
||||
type ClientRecord struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"`
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientRecord) TableName() string { return "clients" }
|
||||
|
||||
// MarshalJSON emits the reverse column as a nested JSON object rather than an
|
||||
// escaped JSON-text string, matching the same convention Inbound uses for its
|
||||
// JSON-text columns. Empty storage renders as null.
|
||||
func (r ClientRecord) MarshalJSON() ([]byte, error) {
|
||||
type alias ClientRecord
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Reverse json.RawMessage `json:"reverse"`
|
||||
}{
|
||||
alias: alias(r),
|
||||
Reverse: jsonStringFieldToRaw(r.Reverse),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts reverse as either a JSON object (modern shape) or a
|
||||
// JSON-encoded string (legacy shape).
|
||||
func (r *ClientRecord) UnmarshalJSON(data []byte) error {
|
||||
type alias ClientRecord
|
||||
aux := struct {
|
||||
*alias
|
||||
Reverse json.RawMessage `json:"reverse"`
|
||||
}{
|
||||
alias: (*alias)(r),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Reverse = jsonStringFieldFromRaw(aux.Reverse)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ClientInbound struct {
|
||||
ClientId int `json:"clientId" gorm:"primaryKey;column:client_id;index"`
|
||||
InboundId int `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
|
||||
FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientInbound) TableName() string { return "client_inbounds" }
|
||||
|
||||
// InboundFallback is one routing rule on a master inbound's
|
||||
// settings.fallbacks array. The master is always a VLESS or Trojan
|
||||
// inbound on TCP transport with TLS or Reality. The child is any other
|
||||
// inbound — its listen+port becomes the fallback dest, with optional
|
||||
// SNI/ALPN/path match criteria pulled from the same row.
|
||||
type InboundFallback struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
|
||||
ChildId int `json:"childId" gorm:"index;not null;column:child_id"`
|
||||
Name string `json:"name"`
|
||||
Alpn string `json:"alpn"`
|
||||
Path string `json:"path"`
|
||||
Xver int `json:"xver"`
|
||||
SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"`
|
||||
}
|
||||
|
||||
func (InboundFallback) TableName() string { return "inbound_fallbacks" }
|
||||
|
||||
func (c *Client) ToRecord() *ClientRecord {
|
||||
rec := &ClientRecord{
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
UUID: c.ID,
|
||||
Password: c.Password,
|
||||
Auth: c.Auth,
|
||||
Flow: c.Flow,
|
||||
Security: c.Security,
|
||||
LimitIP: c.LimitIP,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
Enable: c.Enable,
|
||||
TgID: c.TgID,
|
||||
Comment: c.Comment,
|
||||
Reset: c.Reset,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
if c.Reverse != nil {
|
||||
if b, err := json.Marshal(c.Reverse); err == nil {
|
||||
rec.Reverse = string(b)
|
||||
}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func (r *ClientRecord) ToClient() *Client {
|
||||
c := &Client{
|
||||
ID: r.UUID,
|
||||
Email: r.Email,
|
||||
SubID: r.SubID,
|
||||
Password: r.Password,
|
||||
Auth: r.Auth,
|
||||
Flow: r.Flow,
|
||||
Security: r.Security,
|
||||
LimitIP: r.LimitIP,
|
||||
TotalGB: r.TotalGB,
|
||||
ExpiryTime: r.ExpiryTime,
|
||||
Enable: r.Enable,
|
||||
TgID: r.TgID,
|
||||
Comment: r.Comment,
|
||||
Reset: r.Reset,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
if r.Reverse != "" {
|
||||
var rev ClientReverse
|
||||
if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil {
|
||||
c.Reverse = &rev
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type ClientMergeConflict struct {
|
||||
Field string
|
||||
Old any
|
||||
New any
|
||||
Kept any
|
||||
}
|
||||
|
||||
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
|
||||
var conflicts []ClientMergeConflict
|
||||
keep := func(field string, oldV, newV, kept any) {
|
||||
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept})
|
||||
}
|
||||
const redacted = "<redacted>"
|
||||
keepSecret := func(field string) {
|
||||
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: redacted, New: redacted, Kept: redacted})
|
||||
}
|
||||
|
||||
incomingNewer := incoming.UpdatedAt > existing.UpdatedAt ||
|
||||
(incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt)
|
||||
|
||||
if existing.UUID != incoming.UUID && incoming.UUID != "" {
|
||||
if incomingNewer || existing.UUID == "" {
|
||||
existing.UUID = incoming.UUID
|
||||
}
|
||||
keepSecret("uuid")
|
||||
}
|
||||
if existing.Password != incoming.Password && incoming.Password != "" {
|
||||
if incomingNewer || existing.Password == "" {
|
||||
existing.Password = incoming.Password
|
||||
keepSecret("password")
|
||||
}
|
||||
}
|
||||
if existing.Auth != incoming.Auth && incoming.Auth != "" {
|
||||
if incomingNewer || existing.Auth == "" {
|
||||
existing.Auth = incoming.Auth
|
||||
keepSecret("auth")
|
||||
}
|
||||
}
|
||||
if existing.Flow != incoming.Flow && incoming.Flow != "" {
|
||||
if incomingNewer || existing.Flow == "" {
|
||||
keep("flow", existing.Flow, incoming.Flow, incoming.Flow)
|
||||
existing.Flow = incoming.Flow
|
||||
}
|
||||
}
|
||||
if existing.Security != incoming.Security && incoming.Security != "" {
|
||||
if incomingNewer || existing.Security == "" {
|
||||
keep("security", existing.Security, incoming.Security, incoming.Security)
|
||||
existing.Security = incoming.Security
|
||||
}
|
||||
}
|
||||
if existing.SubID != incoming.SubID && incoming.SubID != "" {
|
||||
if incomingNewer || existing.SubID == "" {
|
||||
existing.SubID = incoming.SubID
|
||||
keepSecret("subId")
|
||||
}
|
||||
}
|
||||
if existing.TotalGB != incoming.TotalGB {
|
||||
picked := existing.TotalGB
|
||||
if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) {
|
||||
picked = incoming.TotalGB
|
||||
}
|
||||
if picked != existing.TotalGB {
|
||||
keep("totalGB", existing.TotalGB, incoming.TotalGB, picked)
|
||||
existing.TotalGB = picked
|
||||
}
|
||||
}
|
||||
if existing.ExpiryTime != incoming.ExpiryTime {
|
||||
picked := existing.ExpiryTime
|
||||
if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) {
|
||||
picked = incoming.ExpiryTime
|
||||
}
|
||||
if picked != existing.ExpiryTime {
|
||||
keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked)
|
||||
existing.ExpiryTime = picked
|
||||
}
|
||||
}
|
||||
if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 {
|
||||
picked := existing.LimitIP
|
||||
if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP {
|
||||
picked = incoming.LimitIP
|
||||
}
|
||||
if picked != existing.LimitIP {
|
||||
keep("limitIp", existing.LimitIP, incoming.LimitIP, picked)
|
||||
existing.LimitIP = picked
|
||||
}
|
||||
}
|
||||
if existing.TgID != incoming.TgID && incoming.TgID != 0 {
|
||||
if incomingNewer || existing.TgID == 0 {
|
||||
keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
|
||||
existing.TgID = incoming.TgID
|
||||
}
|
||||
}
|
||||
if existing.Reset != incoming.Reset && incoming.Reset != 0 {
|
||||
if incomingNewer || existing.Reset == 0 {
|
||||
keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
|
||||
existing.Reset = incoming.Reset
|
||||
}
|
||||
}
|
||||
if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
|
||||
if incomingNewer || existing.Reverse == "" {
|
||||
keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
|
||||
existing.Reverse = incoming.Reverse
|
||||
}
|
||||
}
|
||||
if existing.Comment != incoming.Comment && incoming.Comment != "" {
|
||||
if incomingNewer || existing.Comment == "" {
|
||||
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
|
||||
existing.Comment = incoming.Comment
|
||||
}
|
||||
}
|
||||
if existing.Enable != incoming.Enable {
|
||||
if incoming.Enable {
|
||||
if !existing.Enable {
|
||||
keep("enable", existing.Enable, incoming.Enable, true)
|
||||
existing.Enable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) {
|
||||
existing.CreatedAt = incoming.CreatedAt
|
||||
}
|
||||
if incoming.UpdatedAt > existing.UpdatedAt {
|
||||
existing.UpdatedAt = incoming.UpdatedAt
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
209
database/model/model_test.go
Normal file
209
database/model/model_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
|
||||
in := Inbound{
|
||||
Id: 7,
|
||||
Protocol: VLESS,
|
||||
Port: 443,
|
||||
Settings: `{"clients":[],"decryption":"none"}`,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
Sniffing: `{"enabled":true}`,
|
||||
}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
||||
if _, ok := parsed[field].(map[string]any); !ok {
|
||||
t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(out), `"settings":"`) {
|
||||
t.Errorf("settings should not be emitted as a JSON string: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
|
||||
in := Inbound{Id: 1, Protocol: VLESS}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
||||
if parsed[field] != nil {
|
||||
t.Errorf("expected %s to be null, got %v", field, parsed[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "nested objects (modern)",
|
||||
body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
|
||||
},
|
||||
{
|
||||
name: "JSON-encoded strings (legacy)",
|
||||
body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var in Inbound
|
||||
if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(in.Settings, `"decryption":"none"`) {
|
||||
t.Errorf("Settings not normalised: %q", in.Settings)
|
||||
}
|
||||
if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
|
||||
t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
|
||||
}
|
||||
if !strings.Contains(in.Sniffing, `"enabled":true`) {
|
||||
t.Errorf("Sniffing not normalised: %q", in.Sniffing)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
|
||||
in := Inbound{Id: 1, Settings: "not json at all"}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), `"settings":"not json at all"`) {
|
||||
t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
|
||||
rec := ClientRecord{Id: 1, Email: "alice@example.com", Reverse: `{"tag":"vless-in"}`}
|
||||
out, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
obj, ok := parsed["reverse"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
|
||||
}
|
||||
if obj["tag"] != "vless-in" {
|
||||
t.Errorf("expected tag to be preserved, got %v", obj["tag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
|
||||
rec := ClientRecord{Id: 1, Email: "alice@example.com"}
|
||||
out, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
if parsed["reverse"] != nil {
|
||||
t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
|
||||
{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var rec ClientRecord
|
||||
if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
|
||||
t.Errorf("Reverse not normalised: %q", rec.Reverse)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
|
||||
row := InboundClientIps{Id: 1, ClientEmail: "alice@example.com", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
|
||||
out, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
arr, ok := parsed["ips"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
|
||||
}
|
||||
if len(arr) != 1 {
|
||||
t.Errorf("expected 1 entry, got %d", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
|
||||
{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var row InboundClientIps
|
||||
if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
|
||||
t.Errorf("Ips not normalised: %q", row.Ips)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHysteria(t *testing.T) {
|
||||
cases := []struct {
|
||||
in Protocol
|
||||
want bool
|
||||
}{
|
||||
{Hysteria, true},
|
||||
{Hysteria2, true},
|
||||
{VLESS, false},
|
||||
{Shadowsocks, false},
|
||||
{Protocol(""), false},
|
||||
{Protocol("hysteria3"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsHysteria(c.in); got != c.want {
|
||||
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
3xui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
container_name: 3xui_app
|
||||
# hostname: yourhostname <- optional
|
||||
volumes:
|
||||
- $PWD/db/:/etc/x-ui/
|
||||
- $PWD/cert/:/root/cert/
|
||||
environment:
|
||||
XRAY_VMESS_AEAD_FORCED: "false"
|
||||
XUI_ENABLE_FAIL2BAN: "true"
|
||||
# To use PostgreSQL instead of the default SQLite, run:
|
||||
# docker compose --profile postgres up -d
|
||||
# and uncomment the two lines below.
|
||||
# XUI_DB_TYPE: "postgres"
|
||||
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
|
||||
tty: true
|
||||
ports:
|
||||
- "2053:2053"
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: 3xui_postgres
|
||||
profiles: ["postgres"]
|
||||
environment:
|
||||
POSTGRES_USER: xui
|
||||
POSTGRES_PASSWORD: xui
|
||||
POSTGRES_DB: xui
|
||||
volumes:
|
||||
- $PWD/pgdata/:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.vite/
|
||||
*.log
|
||||
83
frontend/README.md
Normal file
83
frontend/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 3x-ui frontend
|
||||
|
||||
React 19 + Ant Design 6 + TypeScript + Vite 8. Multi-page app — one HTML
|
||||
entry per panel route — built into `../web/dist/` and embedded into the
|
||||
Go binary via `embed.FS`.
|
||||
|
||||
## Dev
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
|
||||
proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
|
||||
first (`go run main.go`) and then Vite.
|
||||
|
||||
The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
|
||||
`/panel/xray` to the matching Vite-served HTML in dev mode (see
|
||||
`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
|
||||
production-style links work without round-tripping through Go.
|
||||
|
||||
## Production build
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
|
||||
`assets/`). The Go binary embeds this directory at compile time and
|
||||
`web/controller/dist.go` serves the per-page HTML.
|
||||
|
||||
## Type check and lint
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
`tsc --noEmit` against `tsconfig.json` (strict mode, `jsx: "react-jsx"`,
|
||||
`@/*` → `src/*` alias). ESLint 10 with `eslint.config.js` (flat config)
|
||||
— `@eslint/js` recommended plus `typescript-eslint` and
|
||||
`eslint-plugin-react-hooks` rules.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── *.html # Vite entry HTML, one per panel route
|
||||
├── tsconfig.json
|
||||
├── eslint.config.js
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── entries/ # Per-page bootstrap (createRoot + render)
|
||||
├── pages/ # One folder per route, each with the page
|
||||
│ ├── index/ # component + helpers + sub-components
|
||||
│ ├── login/
|
||||
│ ├── inbounds/
|
||||
│ ├── clients/
|
||||
│ ├── xray/
|
||||
│ ├── nodes/
|
||||
│ ├── settings/
|
||||
│ ├── api-docs/
|
||||
│ └── sub/
|
||||
├── components/ # Cross-page React components
|
||||
├── hooks/ # Reusable hooks (useTheme, useWebSocket, …)
|
||||
├── api/ # Axios setup, CSRF interceptor, WebSocket
|
||||
├── i18n/ # react-i18next init (locales live in web/translation/)
|
||||
├── models/ # Inbound, Outbound, Status, … domain classes
|
||||
├── styles/ # Shared CSS modules (page-cards, …)
|
||||
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
|
||||
```
|
||||
|
||||
## Adding a new page
|
||||
|
||||
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.tsx`.
|
||||
2. Add `src/entries/<page>.tsx` that imports the page component and
|
||||
mounts it with `createRoot(...).render(...)`.
|
||||
3. Add the page component under `src/pages/<page>/`.
|
||||
4. Register the entry in `rollupOptions.input` in `vite.config.js`.
|
||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add
|
||||
it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
|
||||
6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.
|
||||
13
frontend/api-docs.html
Normal file
13
frontend/api-docs.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/api-docs.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
frontend/clients.html
Normal file
13
frontend/clients.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clients</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/clients.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
71
frontend/eslint.config.js
Normal file
71
frontend/eslint.config.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', '../web/dist/**'] },
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-case-declarations': 'off',
|
||||
},
|
||||
},
|
||||
...tseslint.configs.recommended.map((config) => ({
|
||||
...config,
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
})),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
|
||||
// react-hooks v7 introduces several new rules driven by the React
|
||||
// Compiler. The migration uses several legitimate patterns those
|
||||
// rules flag (initial-fetch in useEffect, dirty-check derived
|
||||
// state, `Date.now()` inside derive helpers, inline arrow event
|
||||
// handlers, in-place mutation of imported Outbound class
|
||||
// instances in the OutboundFormModal). We're not running the
|
||||
// compiler, so the memoization-preservation warnings have no
|
||||
// effect on runtime — turning them off until the codebase
|
||||
// stabilises.
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/react-compiler': 'off',
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
'react-hooks/immutability': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
13
frontend/inbounds.html
Normal file
13
frontend/inbounds.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Inbounds</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/inbounds.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
frontend/login.html
Normal file
14
frontend/login.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>Sign in</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/login.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
frontend/nodes.html
Normal file
13
frontend/nodes.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nodes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/nodes.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4413
frontend/package-lock.json
generated
Normal file
4413
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.16.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^26.2.0",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vite": "8.0.13"
|
||||
}
|
||||
}
|
||||
13
frontend/settings.html
Normal file
13
frontend/settings.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/settings.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
128
frontend/src/api/axios-init.js
Normal file
128
frontend/src/api/axios-init.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import axios from 'axios';
|
||||
import qs from 'qs';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||
|
||||
let csrfToken = null;
|
||||
let csrfFetchPromise = null;
|
||||
let sessionExpired = false;
|
||||
|
||||
function readMetaToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||
: CSRF_TOKEN_PATH);
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCsrfToken() {
|
||||
if (csrfToken) return csrfToken;
|
||||
const meta = readMetaToken();
|
||||
if (meta) {
|
||||
csrfToken = meta;
|
||||
return csrfToken;
|
||||
}
|
||||
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
|
||||
const fetched = await csrfFetchPromise;
|
||||
csrfFetchPromise = null;
|
||||
if (fetched) csrfToken = fetched;
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
// Apply the panel's axios defaults + interceptors. Call once at app
|
||||
// startup before any HTTP call goes out.
|
||||
export function setupAxios() {
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
// Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
|
||||
let basePath = window.X_UI_BASE_PATH;
|
||||
if (!basePath) {
|
||||
const metaTag = document.querySelector('meta[name="base-path"]');
|
||||
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
||||
}
|
||||
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||
axios.defaults.baseURL = basePath;
|
||||
}
|
||||
|
||||
// Seed the cache from the meta tag if a server-rendered page injected
|
||||
// one — saves a round trip on legacy templates that still embed it.
|
||||
csrfToken = readMetaToken();
|
||||
|
||||
axios.interceptors.request.use(
|
||||
async (config) => {
|
||||
config.headers = config.headers || {};
|
||||
const method = (config.method || 'get').toUpperCase();
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) config.headers['X-CSRF-Token'] = token;
|
||||
}
|
||||
if (config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = 'multipart/form-data';
|
||||
} else {
|
||||
const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||
config.data = JSON.stringify(config.data);
|
||||
}
|
||||
} else {
|
||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
if (!sessionExpired) {
|
||||
sessionExpired = true;
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
return new Promise(() => { });
|
||||
}
|
||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||
const cfg = error.config;
|
||||
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
||||
csrfToken = null;
|
||||
cfg.__csrfRetried = true;
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
cfg.headers['X-CSRF-Token'] = token;
|
||||
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
|
||||
if (typeof cfg.data === 'string') {
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
|
||||
} else {
|
||||
cfg.data = qs.parse(cfg.data);
|
||||
}
|
||||
}
|
||||
return axios(cfg);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
231
frontend/src/api/websocket.js
Normal file
231
frontend/src/api/websocket.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* WebSocket client for real-time panel updates.
|
||||
*
|
||||
* Public API (kept stable for index.html / inbounds.html / xray.html):
|
||||
* - connect() — open the connection (idempotent)
|
||||
* - disconnect() — close and stop reconnecting
|
||||
* - on(event, callback) — subscribe to event
|
||||
* - off(event, callback) — unsubscribe
|
||||
* - send(data) — send JSON to the server
|
||||
* - isConnected — boolean, current state
|
||||
* - reconnectAttempts — number, attempts since last success
|
||||
* - maxReconnectAttempts — number, give-up threshold
|
||||
*
|
||||
* Built-in events:
|
||||
* 'connected', 'disconnected', 'error', 'message',
|
||||
* plus any server-emitted message type (status, traffic, client_stats, ...).
|
||||
*/
|
||||
export class WebSocketClient {
|
||||
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
|
||||
static #BASE_RECONNECT_MS = 1000;
|
||||
static #MAX_RECONNECT_MS = 30_000;
|
||||
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
|
||||
// cadence rather than giving up forever — a panel that recovers an hour
|
||||
// later should reconnect without a manual page reload.
|
||||
static #SLOW_RETRY_MS = 60_000;
|
||||
|
||||
constructor(basePath = '') {
|
||||
this.basePath = basePath;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnected = false;
|
||||
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectTimer = null;
|
||||
this.listeners = new Map(); // event → Set<callback>
|
||||
}
|
||||
|
||||
// Open the connection. Safe to call repeatedly — no-op if already
|
||||
// open/connecting. Re-enables reconnects if previously disabled. Cancels
|
||||
// any pending reconnect timer so an external connect() can't race a
|
||||
// delayed retry into spawning a second socket.
|
||||
connect() {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
this.shouldReconnect = true;
|
||||
this.#cancelReconnect();
|
||||
this.#openSocket();
|
||||
}
|
||||
|
||||
// Close the connection and stop any pending reconnect attempt. Resets the
|
||||
// attempt counter so a future connect() starts fresh from the small backoff.
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this.#cancelReconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.ws) {
|
||||
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
// Subscribe to an event. Re-subscribing the same callback is a no-op.
|
||||
on(event, callback) {
|
||||
if (typeof callback !== 'function') return;
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
}
|
||||
|
||||
// Unsubscribe from an event.
|
||||
off(event, callback) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
}
|
||||
|
||||
// Send JSON to the server. Drops silently if not connected — callers
|
||||
// should rely on connect()/server pushes rather than client-initiated sends.
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// ───── internals ─────
|
||||
|
||||
#openSocket() {
|
||||
const url = this.#buildUrl();
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: failed to construct connection', err);
|
||||
this.#emit('error', err);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = socket;
|
||||
|
||||
// Every handler must check `this.ws !== socket` first. A previous socket
|
||||
// can still fire events (especially `close`) after we've moved on to a
|
||||
// new one — e.g. connect() called while the old socket is in CLOSING
|
||||
// state. Without the guard, a stale close would null out the freshly
|
||||
// opened socket and silently break send().
|
||||
socket.addEventListener('open', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.#emit('connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
this.#onMessage(event);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
// Browsers fire 'error' before 'close' on failure. We surface it for
|
||||
// consumers (so polling fallbacks can engage) but don't log every blip
|
||||
// — bad networks would flood the console otherwise.
|
||||
this.#emit('error', event);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = false;
|
||||
this.ws = null;
|
||||
this.#emit('disconnected');
|
||||
if (this.shouldReconnect) this.#scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
#buildUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// basePath comes from window.X_UI_BASE_PATH which is only injected
|
||||
// by the Go binary in production. In dev (Vite serves directly) the
|
||||
// global is missing and basePath would be '' — without the fallback to
|
||||
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
|
||||
// constructor throws a SyntaxError.
|
||||
let basePath = this.basePath || '/';
|
||||
if (!basePath.startsWith('/')) basePath = '/' + basePath;
|
||||
if (!basePath.endsWith('/')) basePath += '/';
|
||||
return `${protocol}//${window.location.host}${basePath}ws`;
|
||||
}
|
||||
|
||||
#onMessage(event) {
|
||||
const data = event.data;
|
||||
// Reject oversized payloads up front. We compare actual UTF-8 byte
|
||||
// length (via Blob.size) against the limit — string.length counts
|
||||
// UTF-16 code units, which can undercount real bytes by up to 4× for
|
||||
// payloads with non-ASCII characters and bypass the cap.
|
||||
if (typeof data === 'string') {
|
||||
const byteLen = new Blob([data]).size;
|
||||
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
||||
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: invalid JSON message', err);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
console.error('WebSocket: malformed message envelope');
|
||||
return;
|
||||
}
|
||||
this.#emit(message.type, message.payload, message.time);
|
||||
this.#emit('message', message);
|
||||
}
|
||||
|
||||
#emit(event, ...args) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const callback of set) {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (err) {
|
||||
console.error(`WebSocket: handler for "${event}" threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleReconnect() {
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#cancelReconnect();
|
||||
|
||||
let base;
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts += 1;
|
||||
// Exponential backoff inside the active window.
|
||||
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
||||
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
||||
} else {
|
||||
// Active window exhausted — keep trying once a minute. The page-level
|
||||
// polling fallback runs in parallel; this just brings WS back when the
|
||||
// network recovers.
|
||||
base = WebSocketClient.#SLOW_RETRY_MS;
|
||||
}
|
||||
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
|
||||
const delay = base * (0.75 + Math.random() * 0.5);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
// clearTimeout doesn't cancel a callback that has already fired but
|
||||
// whose macrotask hasn't run yet — re-check shouldReconnect here so
|
||||
// disconnect() called in that window can't be overridden.
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#openSocket();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#cancelReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
287
frontend/src/components/AppSidebar.css
Normal file
287
frontend/src/components/AppSidebar.css
Normal file
@@ -0,0 +1,287 @@
|
||||
.ant-sidebar > .ant-layout-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-block {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-donate {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-donate:hover,
|
||||
.sidebar-donate:focus-visible {
|
||||
background-color: rgba(236, 72, 153, 0.12);
|
||||
color: #ec4899;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-donate .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle:hover,
|
||||
.sidebar-theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.drawer-menu .ant-menu-item {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.drawer-menu .ant-menu-item .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider {
|
||||
flex: 0 0 0 !important;
|
||||
max-width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
290
frontend/src/components/AppSidebar.tsx
Normal file
290
frontend/src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer, Layout, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ClusterOutlined,
|
||||
CloseOutlined,
|
||||
DashboardOutlined,
|
||||
HeartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
|
||||
interface AppSidebarProps {
|
||||
basePath?: string;
|
||||
requestUri?: string;
|
||||
}
|
||||
|
||||
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
|
||||
|
||||
const iconByName: Record<IconName, ComponentType> = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
team: TeamOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
logout: LogoutOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
};
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-donate"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<HeartOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
id: string;
|
||||
isDark: boolean;
|
||||
isUltra: boolean;
|
||||
onCycle: () => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className="sidebar-theme-cycle"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
onClick={onCycle}
|
||||
>
|
||||
{!isDark ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
) : !isUltra ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
|
||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||
const panelVersion = window.X_UI_CUR_VER || '';
|
||||
|
||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: 'logout', icon: 'logout', title: t('logout') },
|
||||
], [prefix, t]);
|
||||
|
||||
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
|
||||
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
|
||||
|
||||
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
|
||||
items.map((tab) => {
|
||||
const Icon = iconByName[tab.icon];
|
||||
return {
|
||||
key: tab.key,
|
||||
icon: <Icon />,
|
||||
label: tab.title,
|
||||
};
|
||||
}),
|
||||
[]);
|
||||
|
||||
const openLink = useCallback(async (key: string) => {
|
||||
if (key === 'logout') {
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = basePath || '/';
|
||||
return;
|
||||
}
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}, [basePath]);
|
||||
|
||||
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
|
||||
openLink(String(key));
|
||||
}, [openLink]);
|
||||
|
||||
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
|
||||
setCollapsed(isCollapsed);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cycleTheme = useCallback((id: string) => {
|
||||
pauseAnimationsUntilLeave(id);
|
||||
if (!isDark) {
|
||||
toggleTheme();
|
||||
if (isUltra) toggleUltra();
|
||||
} else if (!isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||
|
||||
return (
|
||||
<div className="ant-sidebar">
|
||||
<Layout.Sider
|
||||
theme={currentTheme}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
breakpoint="md"
|
||||
onCollapse={onSiderCollapse}
|
||||
>
|
||||
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
|
||||
<div className="brand-block">
|
||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
||||
{!collapsed && panelVersion && (
|
||||
<span className="brand-version">v{panelVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="brand-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle"
|
||||
isDark={isDark}
|
||||
isUltra={isUltra}
|
||||
onCycle={() => cycleTheme('theme-cycle')}
|
||||
ariaLabel={t('menu.theme')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="sider-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="sider-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
|
||||
<Drawer
|
||||
placement="left"
|
||||
closable={false}
|
||||
open={drawerOpen}
|
||||
rootClassName={currentTheme}
|
||||
size="min(82vw, 320px)"
|
||||
styles={{
|
||||
wrapper: { padding: 0 },
|
||||
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<div className="brand-block">
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
{panelVersion && <span className="brand-version">v{panelVersion}</span>}
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle-drawer"
|
||||
isDark={isDark}
|
||||
isUltra={isUltra}
|
||||
onCycle={() => cycleTheme('theme-cycle-drawer')}
|
||||
ariaLabel={t('menu.theme')}
|
||||
/>
|
||||
<button
|
||||
className="drawer-close"
|
||||
type="button"
|
||||
aria-label={t('close')}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="drawer-menu drawer-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
/>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="drawer-menu drawer-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{!drawerOpen && (
|
||||
<button
|
||||
className="drawer-handle"
|
||||
type="button"
|
||||
aria-label={t('menu.dashboard')}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
frontend/src/components/CustomStatistic.css
Normal file
52
frontend/src/components/CustomStatistic.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.ant-statistic-content {
|
||||
font-size: 17px !important;
|
||||
line-height: 1.4 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-statistic-content-value,
|
||||
.ant-statistic-content-prefix,
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix {
|
||||
margin-inline-end: 8px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix .anticon {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 12px !important;
|
||||
opacity: 0.55;
|
||||
margin-inline-start: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-statistic-title {
|
||||
font-size: 11px !important;
|
||||
margin-bottom: 6px !important;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
}
|
||||
14
frontend/src/components/CustomStatistic.tsx
Normal file
14
frontend/src/components/CustomStatistic.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Statistic } from 'antd';
|
||||
import './CustomStatistic.css';
|
||||
|
||||
interface CustomStatisticProps {
|
||||
title?: string;
|
||||
value?: string | number;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
}
|
||||
|
||||
export default function CustomStatistic({ title = '', value = '', prefix, suffix }: CustomStatisticProps) {
|
||||
return <Statistic title={title} value={value} prefix={prefix} suffix={suffix} />;
|
||||
}
|
||||
35
frontend/src/components/DateTimePicker.css
Normal file
35
frontend/src/components/DateTimePicker.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.jdp-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jdp-wrap > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jdp-wrap input {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.jdp-dark .jdp-wrap input,
|
||||
.jdp-dark input {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
background-color: #23252b !important;
|
||||
}
|
||||
|
||||
.jdp-ultra .jdp-wrap input,
|
||||
.jdp-ultra input {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
background-color: #101013 !important;
|
||||
}
|
||||
|
||||
.jdp-dark input::placeholder,
|
||||
.jdp-ultra input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.30) !important;
|
||||
}
|
||||
|
||||
.jdp-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
98
frontend/src/components/DateTimePicker.tsx
Normal file
98
frontend/src/components/DateTimePicker.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { PersianDateTimePicker } from 'persian-calendar-suite';
|
||||
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import './DateTimePicker.css';
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value: Dayjs | null;
|
||||
onChange: (next: Dayjs | null) => void;
|
||||
showTime?: boolean;
|
||||
format?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LIGHT_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#d9d9d9',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.10)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(0, 0, 0, 0.88)',
|
||||
};
|
||||
|
||||
const DARK_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#23252b',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.18)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
||||
};
|
||||
|
||||
const ULTRA_DARK_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#101013',
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.16)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
||||
};
|
||||
|
||||
export default function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
showTime = true,
|
||||
format = 'YYYY-MM-DD HH:mm:ss',
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
}: DateTimePickerProps) {
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isDark, isUltra } = useTheme();
|
||||
|
||||
const persianTheme = useMemo(() => {
|
||||
if (isUltra) return ULTRA_DARK_THEME;
|
||||
if (isDark) return DARK_THEME;
|
||||
return LIGHT_THEME;
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
if (datepicker === 'jalalian') {
|
||||
return (
|
||||
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
|
||||
<PersianDateTimePicker
|
||||
value={value ? value.valueOf() : null}
|
||||
onChange={(next: number | string | null) => {
|
||||
if (next == null || next === '') {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
const ms = typeof next === 'number' ? next : Number(next);
|
||||
if (Number.isFinite(ms)) onChange(dayjs(ms));
|
||||
}}
|
||||
showTime={showTime}
|
||||
outputFormat="timestamp"
|
||||
persianNumbers
|
||||
rtlCalendar
|
||||
theme={persianTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
value={value}
|
||||
onChange={(next) => onChange(next || null)}
|
||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||
format={format}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
738
frontend/src/components/FinalMaskForm.tsx
Normal file
738
frontend/src/components/FinalMaskForm.tsx
Normal file
@@ -0,0 +1,738 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { Protocols } from '@/models/outbound.js';
|
||||
|
||||
interface StreamShape {
|
||||
network?: string;
|
||||
kcp?: { mtu?: number };
|
||||
finalmask: {
|
||||
tcp?: MaskRow[];
|
||||
udp?: MaskRow[];
|
||||
enableQuicParams?: boolean;
|
||||
quicParams?: QuicParams;
|
||||
};
|
||||
addTcpMask: (type?: string) => void;
|
||||
delTcpMask: (index: number) => void;
|
||||
addUdpMask: (type?: string) => void;
|
||||
delUdpMask: (index: number) => void;
|
||||
}
|
||||
|
||||
interface MaskRow {
|
||||
type: string;
|
||||
settings: Record<string, unknown>;
|
||||
_getDefaultSettings: (type: string, settings: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ItemRow {
|
||||
type: string;
|
||||
packet: string | unknown[];
|
||||
delay?: number | string;
|
||||
rand?: number | string;
|
||||
randRange?: string;
|
||||
}
|
||||
|
||||
interface QuicParams {
|
||||
congestion: string;
|
||||
debug?: boolean;
|
||||
brutalUp?: number | string;
|
||||
brutalDown?: number | string;
|
||||
hasUdpHop?: boolean;
|
||||
udpHop?: { ports: string; interval: string | number };
|
||||
maxIdleTimeout?: number;
|
||||
keepAlivePeriod?: number;
|
||||
disablePathMTUDiscovery?: boolean;
|
||||
maxIncomingStreams?: number;
|
||||
initStreamReceiveWindow?: number;
|
||||
maxStreamReceiveWindow?: number;
|
||||
initConnectionReceiveWindow?: number;
|
||||
maxConnectionReceiveWindow?: number;
|
||||
}
|
||||
|
||||
interface FinalMaskFormProps {
|
||||
stream: StreamShape;
|
||||
protocol: string;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
function changeMaskType(mask: MaskRow, type: string) {
|
||||
mask.type = type;
|
||||
mask.settings = mask._getDefaultSettings(type, {});
|
||||
}
|
||||
|
||||
function changeItemType(item: ItemRow, type: string) {
|
||||
item.type = type;
|
||||
if (type === 'base64') item.packet = RandomUtil.randomBase64();
|
||||
else if (type === 'array') {
|
||||
item.rand = 0;
|
||||
item.packet = [];
|
||||
} else item.packet = '';
|
||||
}
|
||||
|
||||
function newClientServerItem(): ItemRow {
|
||||
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newUdpClientServerItem(): ItemRow {
|
||||
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newNoiseItem(): ItemRow {
|
||||
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
|
||||
}
|
||||
|
||||
export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskFormProps) {
|
||||
const isHysteria = protocol === Protocols.Hysteria || protocol === 'hysteria';
|
||||
const network = stream?.network || '';
|
||||
|
||||
const showTcp = useMemo(
|
||||
() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network),
|
||||
[network],
|
||||
);
|
||||
const showUdp = isHysteria || network === 'kcp';
|
||||
const showQuic = isHysteria || network === 'xhttp';
|
||||
|
||||
function notify() {
|
||||
onChange();
|
||||
}
|
||||
|
||||
function changeUdpMaskType(mask: MaskRow, type: string) {
|
||||
changeMaskType(mask, type);
|
||||
if (network === 'kcp' && stream.kcp) {
|
||||
stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function addUdpMaskWithDefault() {
|
||||
const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm';
|
||||
stream.addUdpMask(def);
|
||||
notify();
|
||||
}
|
||||
|
||||
const tcpMasks = stream.finalmask.tcp || [];
|
||||
const udpMasks = stream.finalmask.udp || [];
|
||||
|
||||
if (!showTcp && !showUdp && !showQuic) return null;
|
||||
|
||||
return (
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
{showTcp && (
|
||||
<>
|
||||
<Form.Item label="TCP Masks">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
stream.addTcpMask('fragment');
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{tcpMasks.map((mask, mIdx) => (
|
||||
<div key={`tcp-${mIdx}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
TCP Mask {mIdx + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
stream.delTcpMask(mIdx);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={mask.type}
|
||||
onChange={(v) => {
|
||||
changeMaskType(mask, v);
|
||||
notify();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'fragment', label: 'Fragment' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'sudoku', label: 'Sudoku' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{mask.type === 'fragment' && (
|
||||
<>
|
||||
<Form.Item label="Packets">
|
||||
<Select
|
||||
value={mask.settings.packets as string}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).packets = v;
|
||||
notify();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'tlshello', label: 'tlshello' },
|
||||
{ value: '1-3', label: '1-3' },
|
||||
{ value: '1-5', label: '1-5' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(['length', 'delay', 'maxSplit'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'maxSplit' ? 'Max Split' : field.charAt(0).toUpperCase() + field.slice(1)}>
|
||||
<Input
|
||||
value={(mask.settings[field] as string) || ''}
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mask.type === 'sudoku' && (
|
||||
<>
|
||||
{(['password', 'ascii', 'customTable', 'customTables'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'customTable' ? 'Custom Table' : field === 'customTables' ? 'Custom Tables' : field.charAt(0).toUpperCase() + field.slice(1)}>
|
||||
<Input
|
||||
value={(mask.settings[field] as string) || ''}
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
{(['paddingMin', 'paddingMax'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'paddingMin' ? 'Padding Min' : 'Padding Max'}>
|
||||
<InputNumber
|
||||
value={(mask.settings[field] as number) || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = Number(v) || 0;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mask.type === 'header-custom' && (
|
||||
<HeaderCustomGroups mask={mask} kind="tcp" onChange={notify} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showUdp && (
|
||||
<>
|
||||
<Form.Item label="UDP Masks">
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={addUdpMaskWithDefault} />
|
||||
</Form.Item>
|
||||
|
||||
{udpMasks.map((mask, mIdx) => (
|
||||
<div key={`udp-${mIdx}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
UDP Mask {mIdx + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
stream.delUdpMask(mIdx);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={mask.type}
|
||||
onChange={(v) => changeUdpMaskType(mask, v)}
|
||||
options={
|
||||
isHysteria
|
||||
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
|
||||
: [
|
||||
{ value: 'mkcp-aes128gcm', label: 'mKCP AES-128-GCM' },
|
||||
{ value: 'header-dns', label: 'Header DNS' },
|
||||
{ value: 'header-dtls', label: 'Header DTLS 1.2' },
|
||||
{ value: 'header-srtp', label: 'Header SRTP' },
|
||||
{ value: 'header-utp', label: 'Header uTP' },
|
||||
{ value: 'header-wechat', label: 'Header WeChat Video' },
|
||||
{ value: 'header-wireguard', label: 'Header WireGuard' },
|
||||
{ value: 'mkcp-original', label: 'mKCP Original' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{['mkcp-aes128gcm', 'salamander'].includes(mask.type) && (
|
||||
<Form.Item label="Password">
|
||||
<Input
|
||||
value={(mask.settings.password as string) || ''}
|
||||
placeholder="Obfuscation password"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).password = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'header-dns' && (
|
||||
<Form.Item label="Domain">
|
||||
<Input
|
||||
value={(mask.settings.domain as string) || ''}
|
||||
placeholder="e.g., www.example.com"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).domain = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'xdns' && (
|
||||
<Form.Item label="Domains">
|
||||
<Select
|
||||
mode="tags"
|
||||
value={(mask.settings.domains as string[]) || []}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="e.g., www.example.com"
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).domains = v;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'noise' && (
|
||||
<NoiseItems mask={mask} onChange={notify} />
|
||||
)}
|
||||
|
||||
{mask.type === 'header-custom' && (
|
||||
<UdpHeaderCustom mask={mask} onChange={notify} />
|
||||
)}
|
||||
|
||||
{mask.type === 'xicmp' && (
|
||||
<>
|
||||
<Form.Item label="IP">
|
||||
<Input
|
||||
value={(mask.settings.ip as string) || ''}
|
||||
placeholder="0.0.0.0"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).ip = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="ID">
|
||||
<InputNumber
|
||||
value={(mask.settings.id as number) || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).id = Number(v) || 0;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showQuic && (
|
||||
<>
|
||||
<Form.Item label="QUIC Params">
|
||||
<Switch
|
||||
checked={!!stream.finalmask.enableQuicParams}
|
||||
onChange={(v) => {
|
||||
stream.finalmask.enableQuicParams = v;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{stream.finalmask.enableQuicParams && stream.finalmask.quicParams && (
|
||||
<QuicParamsForm params={stream.finalmask.quicParams} onChange={notify} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCustomGroups({
|
||||
mask,
|
||||
kind: _kind,
|
||||
onChange,
|
||||
}: {
|
||||
mask: MaskRow;
|
||||
kind: 'tcp';
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const settings = mask.settings as { clients?: ItemRow[][]; servers?: ItemRow[][] };
|
||||
if (!settings.clients) settings.clients = [];
|
||||
if (!settings.servers) settings.servers = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(['clients', 'servers'] as const).map((groupKey) => (
|
||||
<div key={groupKey}>
|
||||
<Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[][]).push([newClientServerItem()]);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings[groupKey] as ItemRow[][]).map((group, gi) => (
|
||||
<div key={`${groupKey}-${gi}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[][]).splice(gi, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
{group.map((item, _ii) => (
|
||||
<ItemEditor key={_ii} item={item} onChange={onChange} delayAsNumber />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UdpHeaderCustom({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
|
||||
const settings = mask.settings as { client?: ItemRow[]; server?: ItemRow[] };
|
||||
if (!settings.client) settings.client = [];
|
||||
if (!settings.server) settings.server = [];
|
||||
return (
|
||||
<>
|
||||
{(['client', 'server'] as const).map((groupKey) => (
|
||||
<div key={groupKey}>
|
||||
<Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[]).push(newUdpClientServerItem());
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings[groupKey] as ItemRow[]).map((item, ci) => (
|
||||
<div key={ci}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[]).splice(ci, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
<ItemEditor item={item} onChange={onChange} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoiseItems({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
|
||||
const settings = mask.settings as { reset?: number; noise?: ItemRow[] };
|
||||
if (!settings.noise) settings.noise = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Reset">
|
||||
<InputNumber
|
||||
value={settings.reset || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
settings.reset = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Noise">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings.noise as ItemRow[]).push(newNoiseItem());
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings.noise as ItemRow[]).map((n, ni) => (
|
||||
<div key={ni}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
Noise {ni + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings.noise as ItemRow[]).splice(ni, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
<ItemEditor item={n} onChange={onChange} delayAsString />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemEditor({
|
||||
item,
|
||||
onChange,
|
||||
delayAsNumber,
|
||||
delayAsString,
|
||||
}: {
|
||||
item: ItemRow;
|
||||
onChange: () => void;
|
||||
delayAsNumber?: boolean;
|
||||
delayAsString?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={item.type}
|
||||
onChange={(v) => {
|
||||
changeItemType(item, v);
|
||||
onChange();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'array', label: 'Array' },
|
||||
{ value: 'str', label: 'String' },
|
||||
{ value: 'hex', label: 'Hex' },
|
||||
{ value: 'base64', label: 'Base64' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{delayAsNumber && (
|
||||
<Form.Item label="Delay (ms)">
|
||||
<InputNumber
|
||||
value={typeof item.delay === 'number' ? item.delay : 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
item.delay = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{item.type === 'array' ? (
|
||||
<>
|
||||
<Form.Item label="Rand">
|
||||
{delayAsString ? (
|
||||
<Input
|
||||
value={String(item.rand ?? '')}
|
||||
onChange={(e) => {
|
||||
item.rand = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
placeholder="0 or 1-8192"
|
||||
/>
|
||||
) : (
|
||||
<InputNumber
|
||||
value={typeof item.rand === 'number' ? item.rand : 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
item.rand = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item label="Rand Range">
|
||||
<Input
|
||||
value={item.randRange || ''}
|
||||
placeholder="0-255"
|
||||
onChange={(e) => {
|
||||
item.randRange = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item label="Packet">
|
||||
{item.type === 'base64' ? (
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
value={String(item.packet ?? '')}
|
||||
placeholder="binary data"
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
onChange={(e) => {
|
||||
item.packet = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
item.packet = RandomUtil.randomBase64();
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Input.Group>
|
||||
) : (
|
||||
<Input
|
||||
value={String(item.packet ?? '')}
|
||||
placeholder="binary data"
|
||||
onChange={(e) => {
|
||||
item.packet = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
{delayAsString && (
|
||||
<Form.Item label="Delay">
|
||||
<Input
|
||||
value={typeof item.delay === 'string' ? item.delay : ''}
|
||||
placeholder="10-20"
|
||||
onChange={(e) => {
|
||||
item.delay = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function QuicParamsForm({ params, onChange }: { params: QuicParams; onChange: () => void }) {
|
||||
function update<K extends keyof QuicParams>(key: K, value: QuicParams[K]) {
|
||||
params[key] = value;
|
||||
onChange();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Congestion">
|
||||
<Select
|
||||
value={params.congestion}
|
||||
onChange={(v) => update('congestion', v)}
|
||||
options={[
|
||||
{ value: 'reno', label: 'Reno' },
|
||||
{ value: 'bbr', label: 'BBR' },
|
||||
{ value: 'brutal', label: 'Brutal' },
|
||||
{ value: 'force-brutal', label: 'Force Brutal' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Debug">
|
||||
<Switch checked={!!params.debug} onChange={(v) => update('debug', v)} />
|
||||
</Form.Item>
|
||||
{['brutal', 'force-brutal'].includes(params.congestion) && (
|
||||
<>
|
||||
<Form.Item label="Brutal Up">
|
||||
<Input
|
||||
value={String(params.brutalUp ?? '')}
|
||||
placeholder="65537"
|
||||
onChange={(e) => update('brutalUp', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Brutal Down">
|
||||
<Input
|
||||
value={String(params.brutalDown ?? '')}
|
||||
placeholder="65537"
|
||||
onChange={(e) => update('brutalDown', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item label="UDP Hop">
|
||||
<Switch checked={!!params.hasUdpHop} onChange={(v) => update('hasUdpHop', v)} />
|
||||
</Form.Item>
|
||||
{params.hasUdpHop && params.udpHop && (
|
||||
<>
|
||||
<Form.Item label="Hop Ports">
|
||||
<Input
|
||||
value={params.udpHop.ports || ''}
|
||||
placeholder="e.g. 20000-50000"
|
||||
onChange={(e) => {
|
||||
params.udpHop!.ports = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Hop Interval (s)">
|
||||
<InputNumber
|
||||
value={Number(params.udpHop.interval) || 5}
|
||||
min={5}
|
||||
onChange={(v) => {
|
||||
params.udpHop!.interval = Number(v) || 5;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{(
|
||||
[
|
||||
['maxIdleTimeout', 'Max Idle Timeout (s)', 4, 120],
|
||||
['keepAlivePeriod', 'Keep Alive Period (s)', 2, 60],
|
||||
] as const
|
||||
).map(([key, label, min, max]) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<InputNumber
|
||||
value={params[key] as number}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(v) => update(key, Number(v) || min)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item label="Disable Path MTU Dis">
|
||||
<Switch checked={!!params.disablePathMTUDiscovery} onChange={(v) => update('disablePathMTUDiscovery', v)} />
|
||||
</Form.Item>
|
||||
{(
|
||||
[
|
||||
['maxIncomingStreams', 'Max Incoming Streams', 8, '1024 = default'],
|
||||
['initStreamReceiveWindow', 'Init Stream Window', 16384, '8388608 = default'],
|
||||
['maxStreamReceiveWindow', 'Max Stream Window', 16384, '8388608 = default'],
|
||||
['initConnectionReceiveWindow', 'Init Conn Window', 16384, '20971520 = default'],
|
||||
['maxConnectionReceiveWindow', 'Max Conn Window', 16384, '20971520 = default'],
|
||||
] as const
|
||||
).map(([key, label, min, placeholder]) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<InputNumber
|
||||
value={params[key] as number}
|
||||
min={min}
|
||||
placeholder={placeholder}
|
||||
onChange={(v) => update(key, Number(v) || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/InfinityIcon.tsx
Normal file
19
frontend/src/components/InfinityIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface InfinityIconProps {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
export default function InfinityIcon({ width = 14, height = 10 }: InfinityIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 640 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
style={{ verticalAlign: '-1px', display: 'inline-block' }}
|
||||
>
|
||||
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/InputAddon.css
Normal file
40
frontend/src/components/InputAddon.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.input-addon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 11px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.dark .input-addon,
|
||||
html[data-theme='ultra-dark'] .input-addon {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:not(:first-child) {
|
||||
margin-inline-start: -1px;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:first-child {
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:last-child {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
}
|
||||
21
frontend/src/components/InputAddon.tsx
Normal file
21
frontend/src/components/InputAddon.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import './InputAddon.css';
|
||||
|
||||
interface InputAddonProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
|
||||
return (
|
||||
<span
|
||||
className={`input-addon ${className}`.trim()}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/JsonEditor.css
Normal file
26
frontend/src/components/JsonEditor.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.json-editor-host {
|
||||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.json-editor-host .cm-editor,
|
||||
.json-editor-host .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.json-editor-host:focus-within {
|
||||
border-color: var(--ant-color-primary, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .json-editor-host {
|
||||
border-color: #3a3a3c;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] .json-editor-host {
|
||||
border-color: #1f1f1f;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
179
frontend/src/components/JsonEditor.tsx
Normal file
179
frontend/src/components/JsonEditor.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||
import { lintGutter, linter } from '@codemirror/lint';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { syntaxHighlighting } from '@codemirror/language';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import './JsonEditor.css';
|
||||
|
||||
export interface JsonEditorProps {
|
||||
value: string;
|
||||
onChange?: (next: string) => void;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface JsonEditorHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface DarkPalette {
|
||||
bg: string;
|
||||
panelBg: string;
|
||||
activeBg: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
}
|
||||
|
||||
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) {
|
||||
return EditorView.theme(
|
||||
{
|
||||
'&': { color: '#dcdcdc', backgroundColor: bg },
|
||||
'.cm-content': { caretColor: '#dcdcdc' },
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
|
||||
'.cm-gutters': {
|
||||
backgroundColor: bg,
|
||||
borderRight: `1px solid ${border}`,
|
||||
color: '#6a6a6a',
|
||||
},
|
||||
'.cm-activeLine': { backgroundColor: activeBg },
|
||||
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{ backgroundColor: selection },
|
||||
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: panelBg,
|
||||
border: `1px solid ${border}`,
|
||||
color: '#dcdcdc',
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
}
|
||||
|
||||
const darkTheme = buildDarkTheme({
|
||||
bg: '#1e1e1e',
|
||||
panelBg: '#2d2d30',
|
||||
activeBg: '#252526',
|
||||
border: '#3a3a3c',
|
||||
selection: '#3a3a3c',
|
||||
});
|
||||
|
||||
const ultraDarkTheme = buildDarkTheme({
|
||||
bg: '#0a0a0a',
|
||||
panelBg: '#141414',
|
||||
activeBg: '#141414',
|
||||
border: '#1f1f1f',
|
||||
selection: '#2a2a2a',
|
||||
});
|
||||
|
||||
function themeExtension(isDark: boolean, isUltra: boolean) {
|
||||
if (!isDark) return [];
|
||||
const chrome = isUltra ? ultraDarkTheme : darkTheme;
|
||||
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
|
||||
}
|
||||
|
||||
const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
|
||||
{ value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
|
||||
ref,
|
||||
) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const themeCompartmentRef = useRef<Compartment>(new Compartment());
|
||||
const readonlyCompartmentRef = useRef<Compartment>(new Compartment());
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
const { isDark, isUltra } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => viewRef.current?.focus(),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
|
||||
const updateListener = EditorView.updateListener.of((u) => {
|
||||
if (!u.docChanged) return;
|
||||
const next = u.state.doc.toString();
|
||||
if (next === valueRef.current) return;
|
||||
valueRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
json(),
|
||||
linter(jsonParseLinter()),
|
||||
lintGutter(),
|
||||
EditorView.lineWrapping,
|
||||
updateListener,
|
||||
themeCompartmentRef.current.of(themeExtension(isDark, isUltra)),
|
||||
readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
|
||||
EditorView.theme({
|
||||
'&': { height: '100%' },
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '12px',
|
||||
minHeight,
|
||||
maxHeight,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (value === current) return;
|
||||
valueRef.current = value;
|
||||
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)),
|
||||
});
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
|
||||
});
|
||||
}, [readOnly]);
|
||||
|
||||
return <div ref={hostRef} className="json-editor-host" />;
|
||||
});
|
||||
|
||||
export default JsonEditor;
|
||||
20
frontend/src/components/LazyMount.tsx
Normal file
20
frontend/src/components/LazyMount.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
interface LazyMountProps {
|
||||
when: boolean;
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Mounts children only after `when` first becomes true and keeps them mounted
|
||||
// thereafter, so React.lazy modals get loaded on demand but their close
|
||||
// animations still play out. Pair with `lazy(() => import(...))` modal imports
|
||||
// on heavy list pages to keep the initial bundle small.
|
||||
export default function LazyMount({ when, fallback = null, children }: LazyMountProps) {
|
||||
const [mounted, setMounted] = useState(when);
|
||||
useEffect(() => {
|
||||
if (when && !mounted) setMounted(true);
|
||||
}, [when, mounted]);
|
||||
if (!mounted) return null;
|
||||
return <Suspense fallback={fallback}>{children}</Suspense>;
|
||||
}
|
||||
82
frontend/src/components/PromptModal.tsx
Normal file
82
frontend/src/components/PromptModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Input, Modal } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
|
||||
interface PromptModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
okText?: string;
|
||||
type?: 'input' | 'textarea';
|
||||
initialValue?: string;
|
||||
loading?: boolean;
|
||||
onConfirm: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function PromptModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
okText = 'OK',
|
||||
type = 'input',
|
||||
initialValue = '',
|
||||
loading = false,
|
||||
onConfirm,
|
||||
}: PromptModalProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(initialValue);
|
||||
setTimeout(() => {
|
||||
if (type === 'textarea') textareaRef.current?.focus();
|
||||
else inputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [open, initialValue, type]);
|
||||
|
||||
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
|
||||
if (type !== 'textarea' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onConfirm(value);
|
||||
return;
|
||||
}
|
||||
if (type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
onConfirm(value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText="Cancel"
|
||||
mask={{ closable: false }}
|
||||
confirmLoading={loading}
|
||||
onOk={() => onConfirm(value)}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
>
|
||||
{type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
onKeyDown={onKeydown}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeydown}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/SettingListItem.css
Normal file
43
frontend/src/components/SettingListItem.css
Normal file
@@ -0,0 +1,43 @@
|
||||
.setting-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
}
|
||||
|
||||
.setting-list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .setting-list-item,
|
||||
html[data-theme='ultra-dark'] .setting-list-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.setting-list-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.setting-list-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-list-description {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5715;
|
||||
}
|
||||
|
||||
body.dark .setting-list-title,
|
||||
html[data-theme='ultra-dark'] .setting-list-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .setting-list-description,
|
||||
html[data-theme='ultra-dark'] .setting-list-description {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
36
frontend/src/components/SettingListItem.tsx
Normal file
36
frontend/src/components/SettingListItem.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Col, Row } from 'antd';
|
||||
import './SettingListItem.css';
|
||||
|
||||
interface SettingListItemProps {
|
||||
paddings?: 'small' | 'default';
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
control?: ReactNode;
|
||||
}
|
||||
|
||||
export default function SettingListItem({
|
||||
paddings = 'default',
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
control,
|
||||
}: SettingListItemProps) {
|
||||
const padding = paddings === 'small' ? '10px 20px' : '20px';
|
||||
return (
|
||||
<div className="setting-list-item" style={{ padding }}>
|
||||
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<div className="setting-list-meta">
|
||||
{title && <div className="setting-list-title">{title}</div>}
|
||||
{description && <div className="setting-list-description">{description}</div>}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
{control ?? children}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/Sparkline.css
Normal file
44
frontend/src/components/Sparkline.css
Normal file
@@ -0,0 +1,44 @@
|
||||
.sparkline-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.55);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-text {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
368
frontend/src/components/Sparkline.tsx
Normal file
368
frontend/src/components/Sparkline.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import './Sparkline.css';
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
labels?: (string | number)[];
|
||||
vbWidth?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
maxPoints?: number;
|
||||
showGrid?: boolean;
|
||||
gridColor?: string;
|
||||
fillOpacity?: number;
|
||||
showMarker?: boolean;
|
||||
markerRadius?: number;
|
||||
showAxes?: boolean;
|
||||
yTickStep?: number;
|
||||
tickCountX?: number;
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
showTooltip?: boolean;
|
||||
valueMin?: number;
|
||||
valueMax?: number | null;
|
||||
yFormatter?: (v: number) => string;
|
||||
tooltipFormatter?: ((v: number) => string) | null;
|
||||
}
|
||||
|
||||
export default function Sparkline({
|
||||
data,
|
||||
labels = [],
|
||||
vbWidth = 320,
|
||||
height = 80,
|
||||
stroke = '#008771',
|
||||
strokeWidth = 2,
|
||||
maxPoints = 120,
|
||||
showGrid = true,
|
||||
gridColor = 'rgba(0,0,0,0.08)',
|
||||
fillOpacity = 0.22,
|
||||
showMarker = true,
|
||||
markerRadius = 3,
|
||||
showAxes = false,
|
||||
yTickStep = 25,
|
||||
tickCountX = 4,
|
||||
paddingLeft = 56,
|
||||
paddingRight = 6,
|
||||
paddingTop = 6,
|
||||
paddingBottom = 20,
|
||||
showTooltip = false,
|
||||
valueMin = 0,
|
||||
valueMax = 100,
|
||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||
tooltipFormatter = null,
|
||||
}: SparklineProps) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [measuredWidth, setMeasuredWidth] = useState(0);
|
||||
const [hoverIdx, setHoverIdx] = useState(-1);
|
||||
|
||||
const reactId = useId();
|
||||
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
|
||||
const gradId = `spkGrad-${safeId}`;
|
||||
const shadowId = `spkShadow-${safeId}`;
|
||||
const glowId = `spkGlow-${safeId}`;
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) setMeasuredWidth(Math.round(w));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, []);
|
||||
|
||||
const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth;
|
||||
const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight);
|
||||
const drawHeight = Math.max(1, height - paddingTop - paddingBottom);
|
||||
const nPoints = Math.min(data.length, maxPoints);
|
||||
|
||||
const dataSlice = useMemo(
|
||||
() => (nPoints === 0 ? [] : data.slice(data.length - nPoints)),
|
||||
[data, nPoints],
|
||||
);
|
||||
|
||||
const labelsSlice = useMemo(() => {
|
||||
if (!labels?.length || nPoints === 0) return [] as (string | number)[];
|
||||
const start = Math.max(0, labels.length - nPoints);
|
||||
return labels.slice(start);
|
||||
}, [labels, nPoints]);
|
||||
|
||||
const yDomain = useMemo(() => {
|
||||
const min = valueMin;
|
||||
if (valueMax != null) return { min, max: valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
}, [dataSlice, valueMin, valueMax]);
|
||||
|
||||
const project = useCallback(
|
||||
(v: number) => {
|
||||
const { min, max } = yDomain;
|
||||
const span = max - min;
|
||||
if (span <= 0) return paddingTop + drawHeight;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(paddingTop + (drawHeight - ratio * drawHeight));
|
||||
},
|
||||
[yDomain, paddingTop, drawHeight],
|
||||
);
|
||||
|
||||
const pointsArr = useMemo<[number, number][]>(() => {
|
||||
if (nPoints === 0) return [];
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
return dataSlice.map((v, i) => {
|
||||
const x = Math.round(paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
}, [dataSlice, nPoints, drawWidth, paddingLeft, project]);
|
||||
|
||||
const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]);
|
||||
|
||||
const areaPath = useMemo(() => {
|
||||
if (pointsArr.length === 0) return '';
|
||||
const first = pointsArr[0];
|
||||
const last = pointsArr[pointsArr.length - 1];
|
||||
const baseY = paddingTop + drawHeight;
|
||||
const line = pointsStr.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
}, [pointsArr, pointsStr, paddingTop, drawHeight]);
|
||||
|
||||
const gridLines = useMemo(() => {
|
||||
if (!showGrid) return [];
|
||||
const h = drawHeight;
|
||||
const w = drawWidth;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(paddingTop + h * r);
|
||||
return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y };
|
||||
});
|
||||
}, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]);
|
||||
|
||||
const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1];
|
||||
|
||||
const yTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
const { min, max } = yDomain;
|
||||
const out: { y: number; label: string }[] = [];
|
||||
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += yTickStep) {
|
||||
out.push({ y: project(p), label: yFormatter(p) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]);
|
||||
|
||||
const xTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
if (nPoints === 0) return [];
|
||||
const m = Math.max(2, tickCountX);
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
const out: { x: number; label: string }[] = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (nPoints - 1)) / (m - 1));
|
||||
const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx);
|
||||
const x = Math.round(paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(evt: MouseEvent<SVGSVGElement>) => {
|
||||
if (!showTooltip || pointsArr.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth;
|
||||
const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1))));
|
||||
setHoverIdx(idx);
|
||||
},
|
||||
[showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft],
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => setHoverIdx(-1), []);
|
||||
|
||||
const hoverText = useMemo(() => {
|
||||
const idx = hoverIdx;
|
||||
if (idx < 0 || idx >= dataSlice.length) return '';
|
||||
const raw = Number(dataSlice[idx] || 0);
|
||||
const fmt = tooltipFormatter || yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice[idx] != null ? labelsSlice[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]);
|
||||
|
||||
const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14);
|
||||
const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null;
|
||||
const tooltipX = hoverPoint
|
||||
? Math.max(
|
||||
paddingLeft + 2,
|
||||
Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${effectiveVbWidth} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="sparkline-svg"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={Math.min(1, fillOpacity * 1.8)} />
|
||||
<stop offset="50%" stopColor={stroke} stopOpacity={fillOpacity * 0.7} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<filter id={shadowId} x="-10%" y="-50%" width="120%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2.4" />
|
||||
<feOffset dx="0" dy="2" result="offsetBlur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.45" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<radialGradient id={glowId}>
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{showGrid && (
|
||||
<g>
|
||||
{gridLines.map((g, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={g.x1}
|
||||
y1={g.y1}
|
||||
x2={g.x2}
|
||||
y2={g.y2}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 5"
|
||||
className="cpu-grid-line"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{showAxes && (
|
||||
<g>
|
||||
{yTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`y${i}`}
|
||||
className="cpu-grid-y-text"
|
||||
x={Math.max(0, paddingLeft - 6)}
|
||||
y={tk.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
{xTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`x${i}`}
|
||||
className="cpu-grid-x-text"
|
||||
x={tk.x}
|
||||
y={paddingTop + drawHeight + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradId})`} stroke="none" />}
|
||||
<polyline
|
||||
points={pointsStr}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter={`url(#${shadowId})`}
|
||||
/>
|
||||
{showMarker && lastPoint && (
|
||||
<>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius * 3} fill={`url(#${glowId})`}>
|
||||
<animate attributeName="r" values={`${markerRadius * 2.4};${markerRadius * 3.4};${markerRadius * 2.4}`} dur="2.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius + 1.5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && (
|
||||
<g>
|
||||
<line
|
||||
className="cpu-grid-h-line"
|
||||
x1={pointsArr[hoverIdx][0]}
|
||||
x2={pointsArr[hoverIdx][0]}
|
||||
y1={paddingTop}
|
||||
y2={paddingTop + drawHeight}
|
||||
stroke={stroke}
|
||||
strokeOpacity={0.45}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={3.5} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
<rect
|
||||
x={tooltipX}
|
||||
y={paddingTop + 2}
|
||||
width={tooltipPillWidth}
|
||||
height={18}
|
||||
rx={9}
|
||||
ry={9}
|
||||
className="cpu-tooltip-pill"
|
||||
fill={stroke}
|
||||
fillOpacity={0.92}
|
||||
/>
|
||||
<text
|
||||
className="cpu-tooltip-text"
|
||||
x={tooltipX + tooltipPillWidth / 2}
|
||||
y={paddingTop + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill="#fff"
|
||||
>
|
||||
{hoverText}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/TextModal.tsx
Normal file
59
frontend/src/components/TextModal.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Button, Input, Modal, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
interface TextModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(content || '');
|
||||
if (ok) {
|
||||
messageApi.success('Copied');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!fileName) return;
|
||||
FileManager.downloadTextFile(content, fileName);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<>
|
||||
{fileName && (
|
||||
<Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
|
||||
)}
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>Copy</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={content}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
frontend/src/entries/api-docs.tsx
Normal file
28
frontend/src/entries/api-docs.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<ApiDocsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
28
frontend/src/entries/clients.tsx
Normal file
28
frontend/src/entries/clients.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ClientsPage from '@/pages/clients/ClientsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<ClientsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
28
frontend/src/entries/inbounds.tsx
Normal file
28
frontend/src/entries/inbounds.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<InboundsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
28
frontend/src/entries/index.tsx
Normal file
28
frontend/src/entries/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import IndexPage from '@/pages/index/IndexPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<IndexPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
28
frontend/src/entries/login.tsx
Normal file
28
frontend/src/entries/login.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import LoginPage from '@/pages/login/LoginPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<LoginPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
28
frontend/src/entries/nodes.tsx
Normal file
28
frontend/src/entries/nodes.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import NodesPage from '@/pages/nodes/NodesPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<NodesPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
28
frontend/src/entries/settings.tsx
Normal file
28
frontend/src/entries/settings.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<SettingsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
23
frontend/src/entries/subpage.tsx
Normal file
23
frontend/src/entries/subpage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import SubPage from '@/pages/sub/SubPage';
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<SubPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
28
frontend/src/entries/xray.tsx
Normal file
28
frontend/src/entries/xray.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import XrayPage from '@/pages/xray/XrayPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<XrayPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
65
frontend/src/env.d.ts
vendored
Normal file
65
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface SubPageData {
|
||||
sId?: string;
|
||||
enabled?: boolean;
|
||||
download?: string;
|
||||
upload?: string;
|
||||
total?: string;
|
||||
used?: string;
|
||||
remained?: string;
|
||||
totalByte?: string | number;
|
||||
expire?: string | number;
|
||||
lastOnline?: string | number;
|
||||
subUrl?: string;
|
||||
subJsonUrl?: string;
|
||||
subClashUrl?: string;
|
||||
subTitle?: string;
|
||||
links?: string[];
|
||||
datepicker?: 'gregorian' | 'jalalian';
|
||||
downloadByte?: string | number;
|
||||
uploadByte?: string | number;
|
||||
usedByte?: string | number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
X_UI_BASE_PATH?: string;
|
||||
X_UI_CUR_VER?: string;
|
||||
__SUB_PAGE_DATA__?: SubPageData;
|
||||
}
|
||||
|
||||
declare module 'persian-calendar-suite' {
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
type DateInput = string | number | null;
|
||||
type OutputFormat = 'iso' | 'shamsi' | 'gregorian' | 'hijri' | 'timestamp';
|
||||
|
||||
interface PersianDateTimePickerProps {
|
||||
value?: DateInput;
|
||||
onChange?: (value: number | string | null) => void;
|
||||
defaultValue?: string | number | 'now' | null;
|
||||
showTime?: boolean;
|
||||
minuteStep?: number;
|
||||
outputFormat?: OutputFormat;
|
||||
showFooter?: boolean;
|
||||
theme?: Record<string, unknown>;
|
||||
disabledHours?: number[];
|
||||
minDate?: string | Date | null;
|
||||
maxDate?: string | Date | null;
|
||||
enabledDates?: string[] | null;
|
||||
disabledDates?: string[] | null;
|
||||
disabledWeekDays?: number[];
|
||||
persianNumbers?: boolean;
|
||||
rtlCalendar?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const PersianDateTimePicker: ComponentType<PersianDateTimePickerProps>;
|
||||
export const PersianCalendar: ComponentType<Record<string, unknown>>;
|
||||
export const PersianDateRangePicker: ComponentType<Record<string, unknown>>;
|
||||
export const PersianTimePicker: ComponentType<Record<string, unknown>>;
|
||||
export const PersianTimeline: ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
69
frontend/src/hooks/useAllSetting.ts
Normal file
69
frontend/src/hooks/useAllSetting.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
export function useAllSetting() {
|
||||
const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
|
||||
const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const applyServerState = useCallback((obj: unknown) => {
|
||||
setAllSetting(new AllSetting(obj));
|
||||
setOldAllSetting(new AllSetting(obj));
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
|
||||
if (msg?.success) {
|
||||
applyServerState(msg.obj);
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}, [applyServerState]);
|
||||
|
||||
const saveAll = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
|
||||
if (msg?.success) await fetchAll();
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [allSetting, fetchAll]);
|
||||
|
||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||
setAllSetting((prev) => {
|
||||
const next = new AllSetting(prev);
|
||||
Object.assign(next, patch);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveDisabled = useMemo(
|
||||
() => allSetting.equals(oldAllSetting),
|
||||
[allSetting, oldAllSetting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
return {
|
||||
allSetting,
|
||||
updateSetting,
|
||||
fetched,
|
||||
spinning,
|
||||
setSpinning,
|
||||
saveDisabled,
|
||||
fetchAll,
|
||||
saveAll,
|
||||
};
|
||||
}
|
||||
391
frontend/src/hooks/useClients.ts
Normal file
391
frontend/src/hooks/useClients.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||
|
||||
export interface ClientTraffic {
|
||||
up?: number;
|
||||
down?: number;
|
||||
total?: number;
|
||||
expiryTime?: number;
|
||||
enable?: boolean;
|
||||
lastOnline?: number;
|
||||
}
|
||||
|
||||
export interface ClientRecord {
|
||||
email: string;
|
||||
subId?: string;
|
||||
uuid?: string;
|
||||
password?: string;
|
||||
auth?: string;
|
||||
flow?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
limitIp?: number;
|
||||
tgId?: number | string;
|
||||
comment?: string;
|
||||
enable?: boolean;
|
||||
inboundIds?: number[];
|
||||
traffic?: ClientTraffic;
|
||||
reverse?: { tag?: string };
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface InboundOption {
|
||||
id: number;
|
||||
remark?: string;
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
tlsFlowCapable?: boolean;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
}
|
||||
|
||||
export interface ClientQueryParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
filter?: string;
|
||||
protocol?: string;
|
||||
inbound?: number;
|
||||
sort?: string;
|
||||
order?: 'ascend' | 'descend';
|
||||
}
|
||||
|
||||
export interface ClientsSummary {
|
||||
total: number;
|
||||
active: number;
|
||||
online: string[];
|
||||
depleted: string[];
|
||||
expiring: string[];
|
||||
deactive: string[];
|
||||
}
|
||||
|
||||
interface ClientPageResponse {
|
||||
items: ClientRecord[];
|
||||
total: number;
|
||||
filtered: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary?: ClientsSummary;
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
||||
|
||||
export function useClients() {
|
||||
const [clients, setClients] = useState<ClientRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filtered, setFiltered] = useState(0);
|
||||
const [summary, setSummary] = useState<ClientsSummary>({
|
||||
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
|
||||
});
|
||||
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
|
||||
const [onlines, setOnlines] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
|
||||
// Shallow-compare against the previous query so callers can pass a fresh
|
||||
// object on every render (the common React pattern) without triggering a
|
||||
// re-fetch when nothing actually changed.
|
||||
const setQuery = useCallback((next: ClientQueryParams) => {
|
||||
setQueryState((prev) => {
|
||||
if (
|
||||
prev.page === next.page
|
||||
&& prev.pageSize === next.pageSize
|
||||
&& (prev.search ?? '') === (next.search ?? '')
|
||||
&& (prev.filter ?? '') === (next.filter ?? '')
|
||||
&& (prev.protocol ?? '') === (next.protocol ?? '')
|
||||
&& (prev.inbound ?? 0) === (next.inbound ?? 0)
|
||||
&& (prev.sort ?? '') === (next.sort ?? '')
|
||||
&& (prev.order ?? '') === (next.order ?? '')
|
||||
) return prev;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const [subSettings, setSubSettings] = useState<SubSettings>({
|
||||
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
|
||||
});
|
||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
||||
const [tgBotEnable, setTgBotEnable] = useState(false);
|
||||
const [expireDiff, setExpireDiff] = useState(0);
|
||||
const [trafficDiff, setTrafficDiff] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(0);
|
||||
|
||||
const clientsRef = useRef<ClientRecord[]>([]);
|
||||
const queryRef = useRef<ClientQueryParams>(query);
|
||||
const invalidateTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => { clientsRef.current = clients; }, [clients]);
|
||||
useEffect(() => { queryRef.current = query; }, [query]);
|
||||
|
||||
const buildQS = (p: ClientQueryParams) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('page', String(p.page || 1));
|
||||
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
|
||||
if (p.search) sp.set('search', p.search);
|
||||
if (p.filter) sp.set('filter', p.filter);
|
||||
if (p.protocol) sp.set('protocol', p.protocol);
|
||||
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
|
||||
if (p.sort) sp.set('sort', p.sort);
|
||||
if (p.order) sp.set('order', p.order);
|
||||
return sp.toString();
|
||||
};
|
||||
|
||||
const refresh = useCallback(async (override?: ClientQueryParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = override ?? queryRef.current;
|
||||
const qs = buildQS(params);
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>;
|
||||
if (msg?.success && msg.obj) {
|
||||
setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
|
||||
setTotal(msg.obj.total ?? 0);
|
||||
setFiltered(msg.obj.filtered ?? 0);
|
||||
if (msg.obj.summary) setSummary(msg.obj.summary);
|
||||
}
|
||||
setFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Inbound options are picker-shaped and don't depend on the clients query —
|
||||
// fetch them once on mount instead of every refresh.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<InboundOption[]>;
|
||||
if (cancelled) return;
|
||||
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const fetchSubSettings = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
|
||||
if (!msg?.success) return;
|
||||
const s = msg.obj || {};
|
||||
setSubSettings({
|
||||
enable: !!s.subEnable,
|
||||
subURI: (s.subURI as string) || '',
|
||||
subJsonURI: (s.subJsonURI as string) || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
});
|
||||
setIpLimitEnable(!!s.ipLimitEnable);
|
||||
setTgBotEnable(!!s.tgBotEnable);
|
||||
setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000);
|
||||
setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
|
||||
setPageSize((s.pageSize as number) ?? 0);
|
||||
}, []);
|
||||
|
||||
// hydrate fetches the full client record (uuid, password, flow, ...) for a
|
||||
// single email. The paged list endpoint omits these to keep the row payload
|
||||
// tiny; edit / info / qr / link modals call this to get a complete record
|
||||
// before opening.
|
||||
const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => {
|
||||
if (!email) return null;
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>;
|
||||
if (!msg?.success || !msg.obj) return null;
|
||||
return msg.obj;
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (payload: unknown) => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const update = useCallback(async (email: string, client: unknown) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const remove = useCallback(async (email: string, keepTraffic = false) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encoded}`;
|
||||
const msg = await HttpUtil.post(url) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const removeMany = useCallback(async (emails: string[], keepTraffic = false) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return [];
|
||||
const suffix = keepTraffic ? '?keepTraffic=1' : '';
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
|
||||
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
|
||||
}));
|
||||
await refresh();
|
||||
return results;
|
||||
}, [refresh]);
|
||||
|
||||
const bulkAdjust = useCallback(async (emails: string[], addDays: number, addBytes: number) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return null;
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/clients/bulkAdjust',
|
||||
{ emails, addDays, addBytes },
|
||||
JSON_HEADERS,
|
||||
) as ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const attach = useCallback(async (email: string, inboundIds: number[]) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const detach = useCallback(async (email: string, inboundIds: number[]) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const resetTraffic = useCallback(async (client: ClientRecord) => {
|
||||
if (!client?.email) return null;
|
||||
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
|
||||
const msg = await HttpUtil.post(url) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const resetAllTraffics = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const delDepleted = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
|
||||
if (!client?.email) return null;
|
||||
const payload = {
|
||||
email: client.email,
|
||||
subId: client.subId,
|
||||
id: client.uuid,
|
||||
password: client.password,
|
||||
auth: client.auth,
|
||||
totalGB: client.totalGB || 0,
|
||||
expiryTime: client.expiryTime || 0,
|
||||
limitIp: client.limitIp || 0,
|
||||
comment: client.comment || '',
|
||||
enable: !!enable,
|
||||
};
|
||||
return update(client.email, payload);
|
||||
}, [update]);
|
||||
|
||||
const applyTrafficEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { onlineClients?: string[] };
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
setOnlines(p.onlineClients);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyClientStatsEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { clients?: ClientTraffic[] & { email?: string }[] };
|
||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||
const byEmail = new Map<string, ClientTraffic>();
|
||||
for (const row of p.clients as (ClientTraffic & { email?: string })[]) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
const cur = clientsRef.current || [];
|
||||
let touched = false;
|
||||
const next = cur.slice();
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged: ClientTraffic = { ...(row.traffic || {}) };
|
||||
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||
next[i] = { ...row, traffic: merged };
|
||||
touched = true;
|
||||
}
|
||||
if (touched) setClients(next);
|
||||
}, []);
|
||||
|
||||
const applyInvalidate = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { type?: string };
|
||||
if (p.type !== 'inbounds' && p.type !== 'clients') return;
|
||||
if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
|
||||
invalidateTimerRef.current = window.setTimeout(() => {
|
||||
invalidateTimerRef.current = null;
|
||||
refresh();
|
||||
}, 200);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([refresh(query), fetchSubSettings()]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, fetchSubSettings]);
|
||||
|
||||
return {
|
||||
clients,
|
||||
total,
|
||||
filtered,
|
||||
summary,
|
||||
hydrate,
|
||||
query,
|
||||
setQuery,
|
||||
inbounds,
|
||||
onlines,
|
||||
loading,
|
||||
fetched,
|
||||
subSettings,
|
||||
ipLimitEnable,
|
||||
tgBotEnable,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
removeMany,
|
||||
bulkAdjust,
|
||||
attach,
|
||||
detach,
|
||||
resetTraffic,
|
||||
resetAllTraffics,
|
||||
delDepleted,
|
||||
setEnable,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
};
|
||||
}
|
||||
57
frontend/src/hooks/useDatepicker.ts
Normal file
57
frontend/src/hooks/useDatepicker.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
type Calendar = 'gregorian' | 'jalalian';
|
||||
|
||||
let cachedValue: Calendar = 'gregorian';
|
||||
let fetched = false;
|
||||
let pending: Promise<void> | null = null;
|
||||
const listeners = new Set<(value: Calendar) => void>();
|
||||
|
||||
function notify(value: Calendar) {
|
||||
listeners.forEach((fn) => fn(value));
|
||||
}
|
||||
|
||||
async function loadOnce(): Promise<void> {
|
||||
if (fetched) return;
|
||||
if (pending) {
|
||||
await pending;
|
||||
return;
|
||||
}
|
||||
pending = (async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
|
||||
success?: boolean;
|
||||
obj?: { datepicker?: Calendar };
|
||||
};
|
||||
if (msg?.success) {
|
||||
cachedValue = msg.obj?.datepicker || 'gregorian';
|
||||
notify(cachedValue);
|
||||
}
|
||||
} finally {
|
||||
fetched = true;
|
||||
pending = null;
|
||||
}
|
||||
})();
|
||||
await pending;
|
||||
}
|
||||
|
||||
export function setDatepicker(value: Calendar) {
|
||||
fetched = true;
|
||||
cachedValue = value || 'gregorian';
|
||||
notify(cachedValue);
|
||||
}
|
||||
|
||||
export function useDatepicker() {
|
||||
const [datepicker, setLocal] = useState<Calendar>(cachedValue);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(setLocal);
|
||||
loadOnce();
|
||||
return () => {
|
||||
listeners.delete(setLocal);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { datepicker };
|
||||
}
|
||||
15
frontend/src/hooks/useMediaQuery.ts
Normal file
15
frontend/src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= breakpoint);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [breakpoint]);
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
177
frontend/src/hooks/useNodes.ts
Normal file
177
frontend/src/hooks/useNodes.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export interface NodeRecord {
|
||||
id: number;
|
||||
name?: string;
|
||||
remark?: string;
|
||||
scheme?: string;
|
||||
address?: string;
|
||||
port?: number;
|
||||
basePath?: string;
|
||||
apiToken?: string;
|
||||
enable?: boolean;
|
||||
status?: 'online' | 'offline' | string;
|
||||
latencyMs?: number;
|
||||
cpuPct?: number;
|
||||
memPct?: number;
|
||||
xrayVersion?: string;
|
||||
panelVersion?: string;
|
||||
uptimeSecs?: number;
|
||||
inboundCount?: number;
|
||||
clientCount?: number;
|
||||
onlineCount?: number;
|
||||
depletedCount?: number;
|
||||
lastHeartbeat?: number;
|
||||
lastError?: string;
|
||||
allowPrivateAddress?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface NodeTotals {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
avgLatency: number;
|
||||
inbounds: number;
|
||||
clients: number;
|
||||
onlineClients: number;
|
||||
depleted: number;
|
||||
}
|
||||
|
||||
export function useNodes() {
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list') as ApiMsg<NodeRecord[]>;
|
||||
if (msg?.success) {
|
||||
setNodes(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
}
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyNodesEvent = useCallback((payload: unknown) => {
|
||||
if (Array.isArray(payload)) {
|
||||
setNodes(payload as NodeRecord[]);
|
||||
if (!fetchedRef.current) {
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/add', payload) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const update = useCallback(async (id: number, payload: Partial<NodeRecord>) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const remove = useCallback(async (id: number) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const setEnable = useCallback(async (id: number, enable: boolean) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const testConnection = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
return await HttpUtil.post('/panel/api/nodes/test', payload) as ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
xrayVersion?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}, []);
|
||||
|
||||
const probe = useCallback(async (id: number) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`) as ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const totals = useMemo<NodeTotals>(() => {
|
||||
let online = 0;
|
||||
let offline = 0;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
let inbounds = 0;
|
||||
let clients = 0;
|
||||
let onlineClients = 0;
|
||||
let depleted = 0;
|
||||
for (const n of nodes) {
|
||||
inbounds += n.inboundCount || 0;
|
||||
clients += n.clientCount || 0;
|
||||
onlineClients += n.onlineCount || 0;
|
||||
depleted += n.depletedCount || 0;
|
||||
if (!n.enable) continue;
|
||||
if (n.status === 'online') {
|
||||
online += 1;
|
||||
if (n.latencyMs && n.latencyMs > 0) {
|
||||
latencySum += n.latencyMs;
|
||||
latencyCount += 1;
|
||||
}
|
||||
} else if (n.status === 'offline') {
|
||||
offline += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: nodes.length,
|
||||
online,
|
||||
offline,
|
||||
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||
inbounds,
|
||||
clients,
|
||||
onlineClients,
|
||||
depleted,
|
||||
};
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
refresh,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
};
|
||||
}
|
||||
35
frontend/src/hooks/useStatus.ts
Normal file
35
frontend/src/hooks/useStatus.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { Status } from '@/models/status';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export function useStatus() {
|
||||
const [status, setStatus] = useState<Status>(() => new Status());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
if (msg?.success) {
|
||||
setStatus(new Status(msg.obj));
|
||||
if (!fetchedRef.current) {
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get status:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
return { status, fetched, refresh };
|
||||
}
|
||||
136
frontend/src/hooks/useTheme.tsx
Normal file
136
frontend/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { theme as antdTheme } from 'antd';
|
||||
import type { ThemeConfig } from 'antd';
|
||||
|
||||
const STORAGE_DARK = 'dark-mode';
|
||||
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
|
||||
|
||||
function readBool(key: string, fallback: boolean): boolean {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
return raw === 'true';
|
||||
}
|
||||
|
||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||
document.body.setAttribute('class', isDark ? 'dark' : 'light');
|
||||
if (isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
const msg = document.getElementById('message');
|
||||
if (msg) msg.className = isDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// module load so the document is in the right theme before React mounts.
|
||||
const initialDark = readBool(STORAGE_DARK, true);
|
||||
const initialUltra = readBool(STORAGE_ULTRA, false);
|
||||
applyDom(initialDark, initialUltra);
|
||||
|
||||
const DARK_TOKENS = {
|
||||
colorBgBase: '#1a1b1f',
|
||||
colorBgLayout: '#1a1b1f',
|
||||
colorBgContainer: '#23252b',
|
||||
colorBgElevated: '#2d2f37',
|
||||
};
|
||||
const ULTRA_DARK_TOKENS = {
|
||||
colorBgBase: '#000',
|
||||
colorBgLayout: '#000',
|
||||
colorBgContainer: '#101013',
|
||||
colorBgElevated: '#1a1a1e',
|
||||
};
|
||||
const DARK_LAYOUT_TOKENS = {
|
||||
bodyBg: '#1a1b1f',
|
||||
headerBg: '#15161a',
|
||||
headerColor: '#ffffff',
|
||||
footerBg: '#1a1b1f',
|
||||
siderBg: '#15161a',
|
||||
triggerBg: '#23252b',
|
||||
triggerColor: '#ffffff',
|
||||
};
|
||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
bodyBg: '#000',
|
||||
headerBg: '#050507',
|
||||
headerColor: '#ffffff',
|
||||
footerBg: '#000',
|
||||
siderBg: '#050507',
|
||||
triggerBg: '#1a1a1e',
|
||||
triggerColor: '#ffffff',
|
||||
};
|
||||
const DARK_MENU_TOKENS = {
|
||||
darkItemBg: '#15161a',
|
||||
darkSubMenuItemBg: '#1a1b1f',
|
||||
darkPopupBg: '#23252b',
|
||||
};
|
||||
const ULTRA_DARK_MENU_TOKENS = {
|
||||
darkItemBg: '#050507',
|
||||
darkSubMenuItemBg: '#000',
|
||||
darkPopupBg: '#101013',
|
||||
};
|
||||
|
||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||
if (!isDark) {
|
||||
return { algorithm: antdTheme.defaultAlgorithm };
|
||||
}
|
||||
return {
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||
components: {
|
||||
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pauseAnimationsUntilLeave(elementId: string): void {
|
||||
document.documentElement.setAttribute('data-theme-animations', 'off');
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
const restore = () => {
|
||||
document.documentElement.removeAttribute('data-theme-animations');
|
||||
el.removeEventListener('mouseleave', restore);
|
||||
el.removeEventListener('touchend', restore);
|
||||
};
|
||||
el.addEventListener('mouseleave', restore);
|
||||
el.addEventListener('touchend', restore);
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: boolean;
|
||||
isUltra: boolean;
|
||||
toggleTheme: () => void;
|
||||
toggleUltra: () => void;
|
||||
antdThemeConfig: ThemeConfig;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [isDark, setIsDark] = useState<boolean>(initialDark);
|
||||
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
|
||||
|
||||
useEffect(() => {
|
||||
applyDom(isDark, isUltra);
|
||||
localStorage.setItem(STORAGE_DARK, String(isDark));
|
||||
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const toggleTheme = useCallback(() => setIsDark((v) => !v), []);
|
||||
const toggleUltra = useCallback(() => setIsUltra((v) => !v), []);
|
||||
|
||||
const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }),
|
||||
[isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
|
||||
return ctx;
|
||||
}
|
||||
32
frontend/src/hooks/useWebSocket.ts
Normal file
32
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WebSocketClient } from '@/api/websocket.js';
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
interface SharedClient {
|
||||
connect(): void;
|
||||
on(event: string, fn: Handler): void;
|
||||
off(event: string, fn: Handler): void;
|
||||
}
|
||||
|
||||
let sharedClient: SharedClient | null = null;
|
||||
|
||||
function getSharedClient(): SharedClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath) as SharedClient;
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
export function useWebSocket(handlers: Record<string, Handler>) {
|
||||
useEffect(() => {
|
||||
const client = getSharedClient();
|
||||
const entries = Object.entries(handlers);
|
||||
for (const [event, fn] of entries) client.on(event, fn);
|
||||
client.connect();
|
||||
return () => {
|
||||
for (const [event, fn] of entries) client.off(event, fn);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
370
frontend/src/hooks/useXraySetting.ts
Normal file
370
frontend/src/hooks/useXraySetting.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
|
||||
const DIRTY_POLL_MS = 1000;
|
||||
|
||||
export interface OutboundTrafficRow {
|
||||
tag: string;
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export interface OutboundTestResult {
|
||||
success: boolean;
|
||||
delay?: number;
|
||||
error?: string;
|
||||
mode?: string;
|
||||
ttfbMs?: number;
|
||||
tlsMs?: number;
|
||||
connectMs?: number;
|
||||
dnsMs?: number;
|
||||
statusCode?: number;
|
||||
endpoints?: { address: string; delay?: number; success: boolean; error?: string }[];
|
||||
}
|
||||
|
||||
export interface OutboundTestState {
|
||||
testing?: boolean;
|
||||
result?: OutboundTestResult | null;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface XraySettingsValue {
|
||||
inbounds?: unknown[];
|
||||
outbounds?: { tag?: string; protocol?: string; settings?: unknown; streamSettings?: unknown }[];
|
||||
routing?: {
|
||||
rules?: { type?: string; outboundTag?: string; balancerTag?: string; [key: string]: unknown }[];
|
||||
balancers?: unknown[];
|
||||
domainStrategy?: string;
|
||||
};
|
||||
dns?: { tag?: string; servers?: unknown[] };
|
||||
log?: Record<string, unknown>;
|
||||
policy?: { system?: Record<string, boolean> };
|
||||
observatory?: unknown;
|
||||
burstObservatory?: unknown;
|
||||
fakedns?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SetTemplate = (
|
||||
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
|
||||
) => void;
|
||||
|
||||
export interface UseXraySettingResult {
|
||||
fetched: boolean;
|
||||
spinning: boolean;
|
||||
saveDisabled: boolean;
|
||||
fetchError: string;
|
||||
xraySetting: string;
|
||||
setXraySetting: (next: string) => void;
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
outboundTestUrl: string;
|
||||
setOutboundTestUrl: (v: string) => void;
|
||||
inboundTags: string[];
|
||||
clientReverseTags: string[];
|
||||
restartResult: string;
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
testingAll: boolean;
|
||||
fetchAll: () => Promise<void>;
|
||||
fetchOutboundsTraffic: () => Promise<void>;
|
||||
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
||||
applyOutboundsEvent: (payload: unknown) => void;
|
||||
testOutbound: (
|
||||
index: number,
|
||||
outbound: unknown,
|
||||
mode?: string,
|
||||
) => Promise<OutboundTestResult | null>;
|
||||
testAllOutbounds: (mode?: string) => Promise<void>;
|
||||
saveAll: () => Promise<void>;
|
||||
resetToDefault: () => Promise<void>;
|
||||
restartXray: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useXraySetting(): UseXraySettingResult {
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [saveDisabled, setSaveDisabled] = useState(true);
|
||||
const [fetchError, setFetchError] = useState('');
|
||||
const [xraySetting, setXraySettingState] = useState('');
|
||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState('https://www.google.com/generate_204');
|
||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||
const [restartResult, setRestartResult] = useState('');
|
||||
const [outboundsTraffic, setOutboundsTraffic] = useState<OutboundTrafficRow[]>([]);
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
const oldXraySettingRef = useRef('');
|
||||
const oldOutboundTestUrlRef = useRef('');
|
||||
const syncingRef = useRef(false);
|
||||
const xraySettingRef = useRef('');
|
||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||
|
||||
xraySettingRef.current = xraySetting;
|
||||
outboundTestUrlRef.current = outboundTestUrl;
|
||||
templateSettingsRef.current = templateSettings;
|
||||
|
||||
const setXraySetting = useCallback((next: string) => {
|
||||
setXraySettingState(next);
|
||||
if (syncingRef.current) return;
|
||||
try {
|
||||
const parsed = JSON.parse(next);
|
||||
syncingRef.current = true;
|
||||
setTemplateSettingsState(parsed);
|
||||
syncingRef.current = false;
|
||||
} catch {
|
||||
/* ignore — wait for user to finish */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
|
||||
setTemplateSettingsState((prev) => {
|
||||
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
|
||||
if (next == null) return next;
|
||||
if (!syncingRef.current) {
|
||||
try {
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(JSON.stringify(next, null, 2));
|
||||
} finally {
|
||||
syncingRef.current = false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOutboundTestUrl = useCallback((v: string) => {
|
||||
setOutboundTestUrlState(v);
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setFetchError('');
|
||||
const msg = await HttpUtil.post('/panel/xray/');
|
||||
if (!msg?.success) {
|
||||
setFetchError(msg?.msg || 'Failed to load xray config');
|
||||
setFetched(true);
|
||||
return;
|
||||
}
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(msg.obj);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
setFetchError(`Malformed xray config response: ${err?.message || String(err)}`);
|
||||
setFetched(true);
|
||||
return;
|
||||
}
|
||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(obj.xraySetting);
|
||||
oldXraySettingRef.current = pretty;
|
||||
syncingRef.current = false;
|
||||
setInboundTags(obj.inboundTags || []);
|
||||
setClientReverseTags(obj.clientReverseTags || []);
|
||||
const nextUrl = obj.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
oldOutboundTestUrlRef.current = nextUrl;
|
||||
setFetched(true);
|
||||
setSaveDisabled(true);
|
||||
}, []);
|
||||
|
||||
const saveAll = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/update', {
|
||||
xraySetting: xraySettingRef.current,
|
||||
outboundTestUrl: outboundTestUrlRef.current || 'https://www.google.com/generate_204',
|
||||
});
|
||||
if (msg?.success) await fetchAll();
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [fetchAll]);
|
||||
|
||||
const fetchOutboundsTraffic = useCallback(async () => {
|
||||
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
|
||||
if (msg?.success) setOutboundsTraffic(msg.obj || []);
|
||||
}, []);
|
||||
|
||||
const resetOutboundsTraffic = useCallback(async (tag: string) => {
|
||||
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
|
||||
if (msg?.success) await fetchOutboundsTraffic();
|
||||
}, [fetchOutboundsTraffic]);
|
||||
|
||||
const applyOutboundsEvent = useCallback((payload: unknown) => {
|
||||
if (Array.isArray(payload)) setOutboundsTraffic(payload as OutboundTrafficRow[]);
|
||||
}, []);
|
||||
|
||||
const testOutbound = useCallback(
|
||||
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
if (!outbound) return null;
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: true, result: null, mode },
|
||||
}));
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
|
||||
outbound: JSON.stringify(outbound),
|
||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||
mode,
|
||||
});
|
||||
if (msg?.success) {
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: false, result: msg.obj },
|
||||
}));
|
||||
return msg.obj;
|
||||
}
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
testing: false,
|
||||
result: { success: false, error: msg?.msg || 'Unknown error', mode },
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
testing: false,
|
||||
result: { success: false, error: String(e), mode },
|
||||
},
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||
const list = templateSettingsRef.current?.outbounds || [];
|
||||
if (list.length === 0 || testingAll) return;
|
||||
setTestingAll(true);
|
||||
try {
|
||||
const concurrency = mode === 'tcp' ? 8 : 1;
|
||||
const queue = list
|
||||
.map((ob, i) => ({ index: i, outbound: ob }))
|
||||
.filter(({ outbound }) => {
|
||||
const tag = outbound?.tag;
|
||||
const proto = outbound?.protocol;
|
||||
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
|
||||
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
|
||||
return true;
|
||||
});
|
||||
async function worker() {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
await testOutbound(item.index, item.outbound, mode);
|
||||
}
|
||||
}
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, queue.length) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
}, [testingAll, testOutbound]);
|
||||
|
||||
const resetToDefault = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
||||
if (msg?.success) {
|
||||
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
||||
setTemplateSettings(cloned);
|
||||
}
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [setTemplateSettings]);
|
||||
|
||||
const restartXray = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
if (msg?.success) {
|
||||
await PromiseUtil.sleep(500);
|
||||
const r = await HttpUtil.get('/panel/xray/getXrayResult');
|
||||
if (r?.success) setRestartResult(r.obj || '');
|
||||
}
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
fetchOutboundsTraffic();
|
||||
const timer = window.setInterval(() => {
|
||||
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
|
||||
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
|
||||
setSaveDisabled(!(dirtyXray || dirtyUrl));
|
||||
}, DIRTY_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [fetchAll, fetchOutboundsTraffic]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
fetched,
|
||||
spinning,
|
||||
saveDisabled,
|
||||
fetchError,
|
||||
xraySetting,
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
}),
|
||||
[
|
||||
fetched,
|
||||
spinning,
|
||||
saveDisabled,
|
||||
fetchError,
|
||||
xraySetting,
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
],
|
||||
);
|
||||
}
|
||||
43
frontend/src/i18n/react.ts
Normal file
43
frontend/src/i18n/react.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import i18next from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
import enUS from '../../../web/translation/en-US.json';
|
||||
|
||||
const FALLBACK = 'en-US';
|
||||
|
||||
const lazyModules = import.meta.glob([
|
||||
'../../../web/translation/*.json',
|
||||
'!../../../web/translation/en-US.json',
|
||||
]);
|
||||
|
||||
function moduleKeyFor(code: string): string {
|
||||
return `../../../web/translation/${code}.json`;
|
||||
}
|
||||
|
||||
let active: string = LanguageManager.getLanguage();
|
||||
if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
|
||||
active = FALLBACK;
|
||||
}
|
||||
|
||||
export async function readyI18n() {
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: active,
|
||||
fallbackLng: FALLBACK,
|
||||
resources: { [FALLBACK]: { translation: enUS } },
|
||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
||||
returnNull: false,
|
||||
});
|
||||
if (active !== FALLBACK) {
|
||||
const loader = lazyModules[moduleKeyFor(active)] as (() => Promise<{ default: Record<string, unknown> }>) | undefined;
|
||||
if (loader) {
|
||||
const mod = await loader();
|
||||
const messages = (mod.default ?? mod) as Record<string, unknown>;
|
||||
i18next.addResourceBundle(active, 'translation', messages, true, true);
|
||||
await i18next.changeLanguage(active);
|
||||
}
|
||||
}
|
||||
return i18next;
|
||||
}
|
||||
|
||||
export { i18next as i18n };
|
||||
191
frontend/src/models/dbinbound.js
Normal file
191
frontend/src/models/dbinbound.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||
import { Inbound, Protocols } from './inbound.js';
|
||||
|
||||
export function coerceInboundJsonField(value) {
|
||||
if (value == null) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
if (typeof value !== 'string') return {};
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export class DBInbound {
|
||||
|
||||
constructor(data) {
|
||||
this.id = 0;
|
||||
this.userId = 0;
|
||||
this.up = 0;
|
||||
this.down = 0;
|
||||
this.total = 0;
|
||||
this.remark = "";
|
||||
this.enable = true;
|
||||
this.expiryTime = 0;
|
||||
this.trafficReset = "never";
|
||||
this.lastTrafficResetTime = 0;
|
||||
|
||||
this.listen = "";
|
||||
this.port = 0;
|
||||
this.protocol = "";
|
||||
this.settings = "";
|
||||
this.streamSettings = "";
|
||||
this.tag = "";
|
||||
this.sniffing = "";
|
||||
this.clientStats = ""
|
||||
// Optional FK to web/runtime registered Node. null/undefined =
|
||||
// local panel; otherwise the inbound lives on the named node.
|
||||
this.nodeId = null;
|
||||
// Populated by the API when this inbound is a fallback child of
|
||||
// a VLESS/Trojan TCP-TLS master. Shape: { masterId, path }.
|
||||
this.fallbackParent = null;
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
|
||||
get totalGB() {
|
||||
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set totalGB(gb) {
|
||||
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
|
||||
get isVMess() {
|
||||
return this.protocol === Protocols.VMESS;
|
||||
}
|
||||
|
||||
get isVLess() {
|
||||
return this.protocol === Protocols.VLESS;
|
||||
}
|
||||
|
||||
get isTrojan() {
|
||||
return this.protocol === Protocols.TROJAN;
|
||||
}
|
||||
|
||||
get isSS() {
|
||||
return this.protocol === Protocols.SHADOWSOCKS;
|
||||
}
|
||||
|
||||
get isMixed() {
|
||||
return this.protocol === Protocols.MIXED;
|
||||
}
|
||||
|
||||
get isHTTP() {
|
||||
return this.protocol === Protocols.HTTP;
|
||||
}
|
||||
|
||||
get isWireguard() {
|
||||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
get address() {
|
||||
let address = location.hostname;
|
||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||
address = this.listen;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
get _expiryTime() {
|
||||
if (this.expiryTime === 0) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
if (t == null) {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
get isExpiry() {
|
||||
return this.expiryTime < new Date().getTime();
|
||||
}
|
||||
|
||||
invalidateCache() {
|
||||
this._cachedInbound = null;
|
||||
this._clientStatsMap = null;
|
||||
}
|
||||
|
||||
toInbound() {
|
||||
if (this._cachedInbound) {
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
const settings = coerceInboundJsonField(this.settings);
|
||||
const streamSettings = coerceInboundJsonField(this.streamSettings);
|
||||
const sniffing = coerceInboundJsonField(this.sniffing);
|
||||
|
||||
const config = {
|
||||
port: this.port,
|
||||
listen: this.listen,
|
||||
protocol: this.protocol,
|
||||
settings: settings,
|
||||
streamSettings: streamSettings,
|
||||
tag: this.tag,
|
||||
sniffing: sniffing,
|
||||
clientStats: this.clientStats,
|
||||
};
|
||||
|
||||
this._cachedInbound = Inbound.fromJson(config);
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
getClientStats(email) {
|
||||
if (!this._clientStatsMap) {
|
||||
this._clientStatsMap = new Map();
|
||||
if (this.clientStats && Array.isArray(this.clientStats)) {
|
||||
for (const stats of this.clientStats) {
|
||||
this._clientStatsMap.set(stats.email, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._clientStatsMap.get(email);
|
||||
}
|
||||
|
||||
isMultiUser() {
|
||||
switch (this.protocol) {
|
||||
case Protocols.VMESS:
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return this.toInbound().isSSMultiUser;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hasLink() {
|
||||
switch (this.protocol) {
|
||||
case Protocols.VMESS:
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.SHADOWSOCKS:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
genInboundLinks(remarkModel, hostOverride = '') {
|
||||
const inbound = this.toInbound();
|
||||
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
|
||||
}
|
||||
}
|
||||
3267
frontend/src/models/inbound.js
Normal file
3267
frontend/src/models/inbound.js
Normal file
File diff suppressed because it is too large
Load Diff
2357
frontend/src/models/outbound.js
Normal file
2357
frontend/src/models/outbound.js
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/src/models/reality-targets.js
Normal file
24
frontend/src/models/reality-targets.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// List of popular services for VLESS Reality Target/SNI randomization
|
||||
export const REALITY_TARGETS = [
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random Reality target configuration from the predefined list
|
||||
* @returns {Object} Object with target and sni properties
|
||||
*/
|
||||
export function getRandomRealityTarget() {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
// Return a copy to avoid reference issues
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni
|
||||
};
|
||||
}
|
||||
100
frontend/src/models/setting.ts
Normal file
100
frontend/src/models/setting.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ObjectUtil } from '@/utils';
|
||||
|
||||
export class AllSetting {
|
||||
webListen = '';
|
||||
webDomain = '';
|
||||
webPort = 2053;
|
||||
webCertFile = '';
|
||||
webKeyFile = '';
|
||||
webBasePath = '/';
|
||||
sessionMaxAge = 360;
|
||||
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
|
||||
pageSize = 25;
|
||||
expireDiff = 0;
|
||||
trafficDiff = 0;
|
||||
remarkModel = '-ieo';
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
tgBotProxy = '';
|
||||
tgBotAPIServer = '';
|
||||
tgBotChatId = '';
|
||||
tgRunTime = '@daily';
|
||||
tgBotBackup = false;
|
||||
tgBotLoginNotify = true;
|
||||
tgCpu = 80;
|
||||
tgLang = 'en-US';
|
||||
twoFactorEnable = false;
|
||||
twoFactorToken = '';
|
||||
xrayTemplateConfig = '';
|
||||
subEnable = true;
|
||||
subJsonEnable = false;
|
||||
subTitle = '';
|
||||
subSupportUrl = '';
|
||||
subProfileUrl = '';
|
||||
subAnnounce = '';
|
||||
subEnableRouting = true;
|
||||
subRoutingRules = '';
|
||||
subListen = '';
|
||||
subPort = 2096;
|
||||
subPath = '/sub/';
|
||||
subJsonPath = '/json/';
|
||||
subClashEnable = true;
|
||||
subClashPath = '/clash/';
|
||||
subDomain = '';
|
||||
externalTrafficInformEnable = false;
|
||||
externalTrafficInformURI = '';
|
||||
restartXrayOnClientDisable = true;
|
||||
subCertFile = '';
|
||||
subKeyFile = '';
|
||||
subUpdates = 12;
|
||||
subEncrypt = true;
|
||||
subShowInfo = true;
|
||||
subEmailInRemark = true;
|
||||
subURI = '';
|
||||
subJsonURI = '';
|
||||
subClashURI = '';
|
||||
subJsonFragment = '';
|
||||
subJsonNoises = '';
|
||||
subJsonMux = '';
|
||||
subJsonRules = '';
|
||||
|
||||
timeLocation = 'Local';
|
||||
|
||||
ldapEnable = false;
|
||||
ldapHost = '';
|
||||
ldapPort = 389;
|
||||
ldapUseTLS = false;
|
||||
ldapBindDN = '';
|
||||
ldapPassword = '';
|
||||
ldapBaseDN = '';
|
||||
ldapUserFilter = '(objectClass=person)';
|
||||
ldapUserAttr = 'mail';
|
||||
ldapVlessField = 'vless_enabled';
|
||||
ldapSyncCron = '@every 1m';
|
||||
ldapFlagField = '';
|
||||
ldapTruthyValues = 'true,1,yes,on';
|
||||
ldapInvertFlag = false;
|
||||
ldapInboundTags = '';
|
||||
ldapAutoCreate = false;
|
||||
ldapAutoDelete = false;
|
||||
ldapDefaultTotalGB = 0;
|
||||
ldapDefaultExpiryDays = 0;
|
||||
ldapDefaultLimitIP = 0;
|
||||
hasTgBotToken = false;
|
||||
hasTwoFactorToken = false;
|
||||
hasLdapPassword = false;
|
||||
hasApiToken = false;
|
||||
hasWarpSecret = false;
|
||||
hasNordSecret = false;
|
||||
|
||||
constructor(data?: unknown) {
|
||||
if (data != null) {
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
equals(other: AllSetting): boolean {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
120
frontend/src/models/status.ts
Normal file
120
frontend/src/models/status.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NumberFormatter } from '@/utils';
|
||||
|
||||
export class CurTotal {
|
||||
current: number;
|
||||
total: number;
|
||||
|
||||
constructor(current: number, total: number) {
|
||||
this.current = current;
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
get percent(): number {
|
||||
if (this.total === 0) return 0;
|
||||
return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
|
||||
}
|
||||
|
||||
get color(): string {
|
||||
const p = this.percent;
|
||||
if (p < 80) return '#1677ff';
|
||||
if (p < 90) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
}
|
||||
}
|
||||
|
||||
const XRAY_STATE_COLORS: Record<string, string> = {
|
||||
running: 'green',
|
||||
stop: 'orange',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
export interface NetIO {
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export interface NetTraffic {
|
||||
sent: number;
|
||||
recv: number;
|
||||
}
|
||||
|
||||
export interface PublicIP {
|
||||
ipv4: string | number;
|
||||
ipv6: string | number;
|
||||
}
|
||||
|
||||
export interface AppStats {
|
||||
threads: number;
|
||||
mem: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface XrayInfo {
|
||||
state: 'running' | 'stop' | 'error' | string;
|
||||
errorMsg: string;
|
||||
version: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StatusInput {
|
||||
cpu?: number;
|
||||
cpuCores?: number;
|
||||
logicalPro?: number;
|
||||
cpuSpeedMhz?: number;
|
||||
disk?: { current?: number; total?: number };
|
||||
loads?: number[];
|
||||
mem?: { current?: number; total?: number };
|
||||
netIO?: NetIO;
|
||||
netTraffic?: NetTraffic;
|
||||
publicIP?: PublicIP;
|
||||
swap?: { current?: number; total?: number };
|
||||
tcpCount?: number;
|
||||
udpCount?: number;
|
||||
uptime?: number;
|
||||
appUptime?: number;
|
||||
appStats?: AppStats;
|
||||
xray?: Partial<XrayInfo>;
|
||||
}
|
||||
|
||||
export class Status {
|
||||
cpu: CurTotal = new CurTotal(0, 0);
|
||||
cpuCores = 0;
|
||||
logicalPro = 0;
|
||||
cpuSpeedMhz = 0;
|
||||
disk: CurTotal = new CurTotal(0, 0);
|
||||
loads: number[] = [0, 0, 0];
|
||||
mem: CurTotal = new CurTotal(0, 0);
|
||||
netIO: NetIO = { up: 0, down: 0 };
|
||||
netTraffic: NetTraffic = { sent: 0, recv: 0 };
|
||||
publicIP: PublicIP = { ipv4: 0, ipv6: 0 };
|
||||
swap: CurTotal = new CurTotal(0, 0);
|
||||
tcpCount = 0;
|
||||
udpCount = 0;
|
||||
uptime = 0;
|
||||
appUptime = 0;
|
||||
appStats: AppStats = { threads: 0, mem: 0, uptime: 0 };
|
||||
xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' };
|
||||
|
||||
constructor(data?: StatusInput | null) {
|
||||
if (data == null) return;
|
||||
|
||||
this.cpu = new CurTotal(data.cpu ?? 0, 100);
|
||||
this.cpuCores = data.cpuCores ?? 0;
|
||||
this.logicalPro = data.logicalPro ?? 0;
|
||||
this.cpuSpeedMhz = data.cpuSpeedMhz ?? 0;
|
||||
this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
|
||||
this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
|
||||
this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
|
||||
this.netIO = data.netIO ?? this.netIO;
|
||||
this.netTraffic = data.netTraffic ?? this.netTraffic;
|
||||
this.publicIP = data.publicIP ?? this.publicIP;
|
||||
this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
|
||||
this.tcpCount = data.tcpCount ?? 0;
|
||||
this.udpCount = data.udpCount ?? 0;
|
||||
this.uptime = data.uptime ?? 0;
|
||||
this.appUptime = data.appUptime ?? 0;
|
||||
this.appStats = data.appStats ?? this.appStats;
|
||||
this.xray = { ...this.xray, ...(data.xray || {}) };
|
||||
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
|
||||
}
|
||||
}
|
||||
292
frontend/src/pages/api-docs/ApiDocsPage.css
Normal file
292
frontend/src/pages/api-docs/ApiDocsPage.css
Normal file
@@ -0,0 +1,292 @@
|
||||
.api-docs-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.api-docs-page .content-shell {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page .content-area {
|
||||
padding: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-docs-page .content-area {
|
||||
padding: 16px 12px 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.65;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-lead code,
|
||||
.token-hint code {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.curl-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toc-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12.5px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.toc-link.active {
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toc-icon {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toc-text {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.toc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toc-link.active .toc-badge {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.dark .docs-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-title {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .docs-header {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-header {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .docs-lead,
|
||||
body.dark .token-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead,
|
||||
html[data-theme='ultra-dark'] .token-hint {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .docs-lead code,
|
||||
body.dark .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead code,
|
||||
html[data-theme='ultra-dark'] .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .toc-nav {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-nav {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .toc-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
body.dark .toc-link {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-link {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark .toc-link:hover {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
body.dark .toc-link.active {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
}
|
||||
|
||||
body.dark .toc-badge {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
body.dark .toc-link.active .toc-badge {
|
||||
background: #58a6ff;
|
||||
color: #0d1117;
|
||||
}
|
||||
247
frontend/src/pages/api-docs/ApiDocsPage.tsx
Normal file
247
frontend/src/pages/api-docs/ApiDocsPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentType, MouseEvent } from 'react';
|
||||
import { Button, Card, ConfigProvider, Input, Layout, Space } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
CompressOutlined,
|
||||
ExpandOutlined,
|
||||
GlobalOutlined,
|
||||
KeyOutlined,
|
||||
LinkOutlined,
|
||||
NodeIndexOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
WifiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import { sections as allSections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection';
|
||||
import type { Section } from './EndpointSection';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import '@/styles/page-cards.css';
|
||||
import './ApiDocsPage.css';
|
||||
|
||||
const sectionIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||
authentication: SafetyCertificateOutlined,
|
||||
inbounds: NodeIndexOutlined,
|
||||
server: CloudServerOutlined,
|
||||
nodes: ClusterOutlined,
|
||||
'custom-geo': GlobalOutlined,
|
||||
backup: SaveOutlined,
|
||||
settings: SettingOutlined,
|
||||
'api-tokens': KeyOutlined,
|
||||
'xray-settings': WifiOutlined,
|
||||
subscription: LinkOutlined,
|
||||
websocket: ApiOutlined,
|
||||
};
|
||||
|
||||
const curlExample = `curl -X GET \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
const settingsHref = `${basePath}panel/settings#security`;
|
||||
|
||||
const endpointCount = (allSections as Section[]).reduce(
|
||||
(sum, s) => sum + s.endpoints.length,
|
||||
0,
|
||||
);
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => new Set());
|
||||
const [activeSection, setActiveSection] = useState('');
|
||||
|
||||
const sections = useMemo<Section[]>(() => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (!q) return allSections as Section[];
|
||||
return (allSections as Section[])
|
||||
.map((s) => ({
|
||||
...s,
|
||||
endpoints: s.endpoints.filter((e) =>
|
||||
e.path.toLowerCase().includes(q)
|
||||
|| e.summary?.toLowerCase().includes(q)
|
||||
|| e.method.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter((s) => s.endpoints.length > 0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const visibleEndpoints = useMemo(
|
||||
() => sections.reduce((sum, s) => sum + s.endpoints.length, 0),
|
||||
[sections],
|
||||
);
|
||||
|
||||
const toggleSection = useCallback((id: string) => {
|
||||
setCollapsedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAll = useCallback(() => setCollapsedSections(new Set()), []);
|
||||
const collapseAll = useCallback(
|
||||
() => setCollapsedSections(new Set((allSections as Section[]).map((s) => s.id))),
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollToSection = useCallback((id: string) => (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (window.location.hash !== `#${id}`) {
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const id = window.location.hash.slice(1);
|
||||
if (!id) return;
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
};
|
||||
requestAnimationFrame(onHashChange);
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const toc = document.querySelector('.toc-nav');
|
||||
const tocHeight = toc instanceof HTMLElement ? toc.offsetHeight : 56;
|
||||
let current = '';
|
||||
for (const s of sections) {
|
||||
const el = document.getElementById(s.id);
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= tocHeight + 20) {
|
||||
current = s.id;
|
||||
}
|
||||
}
|
||||
setActiveSection(current);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
requestAnimationFrame(onScroll);
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, [sections]);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['api-docs-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<div className="docs-wrapper">
|
||||
<header className="docs-header">
|
||||
<h1 className="docs-title">API Documentation</h1>
|
||||
<p className="docs-lead">
|
||||
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{'{ success, msg, obj }'}</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card className="token-card" size="small">
|
||||
<div className="token-card-head">
|
||||
<div className="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Tokens</span>
|
||||
</div>
|
||||
<Button type="primary" size="small" href={settingsHref}>
|
||||
Manage tokens
|
||||
</Button>
|
||||
</div>
|
||||
<p className="token-hint">
|
||||
Create, enable, or revoke named Bearer tokens in{' '}
|
||||
<a href={settingsHref}>Settings → Security</a>. Send each request as{' '}
|
||||
<code>Authorization: Bearer <token></code>. Token-authenticated callers skip CSRF and don't
|
||||
need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="curl-card" size="small" title="Quick example">
|
||||
<CodeBlock code={curlExample} lang="text" />
|
||||
</Card>
|
||||
|
||||
<div className="toolbar">
|
||||
<Input
|
||||
className="search-bar"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search endpoints by path, method, or description…"
|
||||
allowClear
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<span className="match-count">
|
||||
{visibleEndpoints} / {endpointCount} endpoints
|
||||
</span>
|
||||
)}
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<ExpandOutlined />} onClick={expandAll}>
|
||||
Expand all
|
||||
</Button>
|
||||
<Button size="small" icon={<CompressOutlined />} onClick={collapseAll}>
|
||||
Collapse all
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<nav className="toc-nav">
|
||||
<span className="toc-label">On this page:</span>
|
||||
<div className="toc-links">
|
||||
{sections.map((s) => {
|
||||
const Icon = sectionIcons[s.id];
|
||||
return (
|
||||
<a
|
||||
key={s.id}
|
||||
className={`toc-link${activeSection === s.id ? ' active' : ''}`}
|
||||
href={`#${s.id}`}
|
||||
onClick={scrollToSection(s.id)}
|
||||
>
|
||||
{Icon && <Icon />}
|
||||
<span className="toc-text">{s.title}</span>
|
||||
<span className="toc-badge">{s.endpoints.length}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{sections.map((s) => (
|
||||
<EndpointSection
|
||||
key={s.id}
|
||||
section={s}
|
||||
icon={sectionIcons[s.id]}
|
||||
collapsed={collapsedSections.has(s.id)}
|
||||
onToggle={() => toggleSection(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
107
frontend/src/pages/api-docs/CodeBlock.css
Normal file
107
frontend/src/pages/api-docs/CodeBlock.css
Normal file
@@ -0,0 +1,107 @@
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.code-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.lang-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
text-transform: uppercase;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #fff;
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
padding: 10px 12px;
|
||||
margin: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.json-key { color: #0550ae; }
|
||||
.json-string { color: #116329; }
|
||||
.json-number { color: #9a6700; }
|
||||
.json-boolean { color: #cf222e; }
|
||||
.json-null { color: #8250df; }
|
||||
|
||||
body.dark .code-block-wrapper {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .code-toolbar {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .lang-badge {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .json-key { color: #79c0ff; }
|
||||
body.dark .json-string { color: #7ee787; }
|
||||
body.dark .json-number { color: #d29922; }
|
||||
body.dark .json-boolean { color: #ff7b72; }
|
||||
body.dark .json-null { color: #d2a8ff; }
|
||||
|
||||
body.dark .copy-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #58a6ff;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
69
frontend/src/pages/api-docs/CodeBlock.tsx
Normal file
69
frontend/src/pages/api-docs/CodeBlock.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { ClipboardManager } from '@/utils';
|
||||
import './CodeBlock.css';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function highlightJson(str: string): string {
|
||||
const escaped = escapeHtml(str);
|
||||
return escaped.replace(
|
||||
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
|
||||
(_m, key, colon, string, number, bool, nil) => {
|
||||
if (colon) return `<span class="json-key">${key}</span>${colon}`;
|
||||
if (string) return `<span class="json-string">${string}</span>`;
|
||||
if (number) return `<span class="json-number">${number}</span>`;
|
||||
if (bool) return `<span class="json-boolean">${bool}</span>`;
|
||||
if (nil) return `<span class="json-null">${nil}</span>`;
|
||||
return _m;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const highlighted = useMemo(
|
||||
() => (lang === 'json' ? highlightJson(code) : escapeHtml(code)),
|
||||
[code, lang],
|
||||
);
|
||||
|
||||
async function copyCode() {
|
||||
const ok = await ClipboardManager.copyText(code);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
messageApi.success('Copied');
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
messageApi.error('Copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-block-wrapper">
|
||||
{messageContextHolder}
|
||||
<div className="code-toolbar">
|
||||
<span className="lang-badge">{lang.toUpperCase()}</span>
|
||||
<button
|
||||
className={`copy-btn${copied ? ' copied' : ''}`}
|
||||
onClick={copyCode}
|
||||
title={copied ? 'Copied' : 'Copy'}
|
||||
>
|
||||
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className={`code-block lang-${lang}`}>
|
||||
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/api-docs/EndpointRow.css
Normal file
93
frontend/src/pages/api-docs/EndpointRow.css
Normal file
@@ -0,0 +1,93 @@
|
||||
.endpoint-row {
|
||||
padding: 14px 8px;
|
||||
margin: 0 -8px;
|
||||
transition: background 0.15s;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.endpoint-row:hover {
|
||||
background: rgba(128, 128, 128, 0.03);
|
||||
}
|
||||
|
||||
.endpoint-row + .endpoint-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13.5px;
|
||||
word-break: break-all;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.endpoint-summary {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.6;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
body.dark .endpoint-row:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body.dark .endpoint-row + .endpoint-row {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .endpoint-path {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark .endpoint-summary {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
body.dark .error-label {
|
||||
color: #ff7b72;
|
||||
}
|
||||
84
frontend/src/pages/api-docs/EndpointRow.tsx
Normal file
84
frontend/src/pages/api-docs/EndpointRow.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import './EndpointRow.css';
|
||||
|
||||
interface Param {
|
||||
name: string;
|
||||
in?: string;
|
||||
type?: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
summary?: string;
|
||||
params?: Param[];
|
||||
body?: string;
|
||||
response?: string;
|
||||
errorResponse?: string;
|
||||
}
|
||||
|
||||
const paramColumns: ColumnsType<Param> = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
|
||||
];
|
||||
|
||||
export default function EndpointRow({ endpoint }: { endpoint: Endpoint }) {
|
||||
const tagColor = (methodColors as Record<string, string>)[endpoint.method] || 'default';
|
||||
const hasParams = Array.isArray(endpoint.params) && endpoint.params.length > 0;
|
||||
|
||||
return (
|
||||
<div className="endpoint-row">
|
||||
<div className="endpoint-header">
|
||||
<Tag color={tagColor} className="method-tag">{endpoint.method}</Tag>
|
||||
<code className="endpoint-path">{endpoint.path}</code>
|
||||
</div>
|
||||
|
||||
{endpoint.summary && (
|
||||
<p
|
||||
className="endpoint-summary"
|
||||
dangerouslySetInnerHTML={{ __html: safeInlineHtml(endpoint.summary) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasParams && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Parameters</div>
|
||||
<Table
|
||||
columns={paramColumns}
|
||||
dataSource={endpoint.params}
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey="name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.body && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Request body</div>
|
||||
<CodeBlock code={endpoint.body} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.response && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Response</div>
|
||||
<CodeBlock code={endpoint.response} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.errorResponse && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label error-label">Error response</div>
|
||||
<CodeBlock code={endpoint.errorResponse} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user