Compare commits
592 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
e03e6232fb | ||
|
484266666d | ||
|
fb1a74ec09 | ||
|
812397235c | ||
|
3411783401 | ||
|
1ab1559c20 | ||
|
0837393903 | ||
|
55cec2e055 | ||
|
14d7f1e416 | ||
|
c267b489e4 | ||
|
8a1ce73564 | ||
|
6b273622df | ||
|
80c4500822 | ||
|
aa14f6045f | ||
|
4719c2210e | ||
|
079ff0be32 | ||
|
38ebfcee44 | ||
|
365ba91bff | ||
|
865a5ba8ee | ||
|
37adab9208 | ||
|
e5a4c2f410 | ||
|
ce21c9b774 | ||
|
fb1b412015 | ||
|
24a079624c | ||
|
b630505ca3 | ||
|
d2ab62efdc | ||
|
b5ac8251d9 | ||
|
0b7a5be3c0 | ||
|
0f435ee7c1 | ||
|
ac15be6e71 | ||
|
b1bcd7227d | ||
|
29b545706e | ||
|
8bb5ea0cf7 | ||
|
28d1399b2f | ||
|
b456cad3d5 | ||
|
2302a50484 | ||
|
6c3c18fa1e | ||
|
fca3a9ab52 | ||
|
5b8be03765 | ||
|
1dae136edb | ||
|
f49ca034cb | ||
|
e8530956fa | ||
|
4171829a15 | ||
|
8fbe50fd8d | ||
|
e2a9357f18 | ||
|
69ff5365d5 | ||
|
084f71e4a6 | ||
|
cc810dd41c | ||
|
c14f371bc2 | ||
|
23a3261251 | ||
|
a6aa91d35a | ||
|
e55a60c12e | ||
|
2164321e72 | ||
|
b5658d81ab | ||
|
79ba408d9f | ||
|
98eb515144 | ||
|
e8302c7667 | ||
|
4bbda14bf0 | ||
|
e90914b320 | ||
|
fc86e904aa | ||
|
7277e44f85 | ||
|
649a3546f1 | ||
|
5e2cad71db | ||
|
07afc44eb7 | ||
|
1713189333 | ||
|
3ce131419f | ||
|
010cfce1c4 | ||
|
bf959fbb9a | ||
|
d9b83b9c34 | ||
|
9fc050c2a1 | ||
|
c520d415e8 | ||
|
9731eeb530 | ||
|
77ac6f2f44 | ||
|
d715d050d2 | ||
|
e7f947a692 | ||
|
6f36d49e00 | ||
|
56ddf847a0 | ||
|
4da2eca1c1 | ||
|
d775cde3ca | ||
|
c59b381a35 | ||
|
5cd8d1d92e | ||
|
b74e7827d3 | ||
|
43fb66552e | ||
|
25bd7c41b4 | ||
|
f4a400522a | ||
|
bbee98b19e | ||
|
361a0ecffd | ||
|
ac23b6a77e | ||
|
c4196eec83 | ||
|
f7b081fad4 | ||
|
527bf45f43 | ||
|
9f6fa51c5a | ||
|
4a3c88c681 | ||
|
1b00cd7bf0 | ||
|
d7f9e378fe | ||
|
ea7b8969ee | ||
|
3ca6a8e310 | ||
|
f69ec0a010 | ||
|
a4c0ab7e07 | ||
|
fe18404f4e | ||
|
f9f57e4d19 | ||
|
646354e515 | ||
|
a8f4c8564f | ||
|
b4c3bccace | ||
|
b76ab55cb2 | ||
|
bc0886e8c0 | ||
|
957c679a90 | ||
|
0dd8bf7bb7 | ||
|
fbc4eda27d | ||
|
b265b68fa3 | ||
|
0e1f59bb30 | ||
|
95525f0adb | ||
|
aa9ea7b763 | ||
|
f38d3a192b | ||
|
6ff8b275e7 | ||
|
36c7316a7f | ||
|
a5ed28c0f1 | ||
|
51689d482e | ||
|
d8b55564cb | ||
|
35457f8a17 | ||
|
8228f15125 | ||
|
cc97147270 | ||
|
6bc7e0de0e | ||
|
6ba7747dd6 | ||
|
15086d054a | ||
|
6979139c98 | ||
|
68b7034db6 | ||
|
2bc608e332 | ||
|
c5b8695a82 | ||
|
0de53d8888 | ||
|
297dd53dd2 | ||
|
70b328b8d7 | ||
|
a07a70e965 | ||
|
39c1eca1b0 | ||
|
83122ad867 | ||
|
93a90f4a33 | ||
|
8a93bda5b6 | ||
|
8575aee27e | ||
|
4c7ffb509c | ||
|
2a44cc7afb | ||
|
72472f7cc1 | ||
|
a9a7a367c0 | ||
|
6b52a8dedb | ||
|
0c3479bb3b | ||
|
114a6f95eb | ||
|
682e4ccfe0 | ||
|
14b12bf8e0 | ||
|
80a58d8cfc | ||
|
9ff5913ffb | ||
|
4c5a012bdc | ||
|
c212c6688c | ||
|
b7ed4b8a4b | ||
|
eebda376ca | ||
|
474ed06445 | ||
|
83942483cb | ||
|
f72ea3079b | ||
|
1ec186faf7 | ||
|
2f058d8777 | ||
|
24d038b914 | ||
|
5f7892c837 | ||
|
0ac6235422 | ||
|
a19da63fce | ||
|
b21f4288a7 | ||
|
6e6a3315b8 | ||
|
4de49cb3ab | ||
|
95530243a4 | ||
|
6f16d4b4eb | ||
|
583a52cced | ||
|
406b7ef37f | ||
|
f6e0654bec | ||
|
01ac9403e9 | ||
|
74418c6ae5 | ||
|
ecd4688ec9 | ||
|
2b918ef915 | ||
|
533d73c9c8 | ||
|
6529ed1b8d | ||
|
4cc4cf98d6 | ||
|
d26490d644 | ||
|
124966c27c | ||
|
408cd9ce4b | ||
|
55cc033cf5 | ||
|
17946d5f0b | ||
|
3ddf688229 | ||
|
c083a1bfa8 | ||
|
315b3ee16f | ||
|
231a66931b | ||
|
d075ab5e0e | ||
|
1e881f5d9f | ||
|
2b426c8d18 | ||
|
aafc19872b | ||
|
e0e9faaa24 | ||
|
33d22fa8b3 | ||
|
d72cd29446 | ||
|
4036c157c9 | ||
|
d64683a617 | ||
|
dfef790884 | ||
|
8f38cf94d8 | ||
|
b573b62452 | ||
|
f28f559a0b | ||
|
7a31071c3d | ||
|
7200c3ecd1 | ||
|
452b045e14 | ||
|
50a98911ce | ||
|
4bf4b62ed0 | ||
|
b86bfa20f4 | ||
|
0806fc09d8 | ||
|
d582027fcf | ||
|
ea385ee699 | ||
|
b4e4c7908e | ||
|
8c70cce555 | ||
|
841650ba7a | ||
|
2fc4686d51 | ||
|
eddacdd781 | ||
|
39145adeb4 | ||
|
08b6c54a68 | ||
|
ba6186aaba | ||
|
4135a4fd77 | ||
|
6559774b1a | ||
|
c0fcbe15e6 | ||
|
59ad0c805b | ||
|
2afe8b5e75 | ||
|
b2e5c80c07 | ||
|
017a828591 | ||
|
0b93cf7af8 | ||
|
f8bfbde9b5 | ||
|
07f02f5da8 | ||
|
ec1c72e4cb | ||
|
ace9c2ee8c | ||
|
e51ec0866a | ||
|
310e7d4338 | ||
|
8042edaeff | ||
|
b21e9d12d5 | ||
|
89adcd98da | ||
|
da3e1c7086 | ||
|
63b9c952a0 | ||
|
99654855cf | ||
|
6c99ed6983 | ||
|
41ccd7eeca | ||
|
ac5c7e801d | ||
|
950a20f60c | ||
|
1f4c58457d | ||
|
c40e3706cd | ||
|
3671da354b | ||
|
ccc875fb04 | ||
|
34f29420df | ||
|
c771162e0b | ||
|
0cd78f230f | ||
|
d258f71beb | ||
|
a32c485cc5 | ||
|
5e7a345fd9 | ||
|
3ce9e6cdfc | ||
|
6ab257afdf | ||
|
a387773697 | ||
|
848cf32b73 | ||
|
367ed9baa9 | ||
|
fea435cd47 | ||
|
6844bea7be | ||
|
3a88c9a837 | ||
|
b4e68180ef | ||
|
14abc76fbc | ||
|
9a621e3e17 | ||
|
9e7ecc4fa4 | ||
|
e74e881a65 | ||
|
42b60e6c5b | ||
|
91f3cf8e2c | ||
|
70038f9044 | ||
|
6980b6e6e8 | ||
|
2c4cf3aebe | ||
|
7f4ae91204 | ||
|
391e356f7c | ||
|
671e31c148 | ||
|
7970582f8c | ||
|
61e90d253c | ||
|
b3ef5e1f95 | ||
|
4ffe7a5056 | ||
|
ff52d52f11 | ||
|
c4d801a847 | ||
|
a7a3228ac1 | ||
|
4eb9cfe218 | ||
|
548c17802e | ||
|
4b813073b0 | ||
|
7107e22ddd | ||
|
05c8b17abc | ||
|
2d4b32491a | ||
|
1b13bb665a | ||
|
d443021d27 | ||
|
6788aab22e | ||
|
db3338c159 | ||
|
16d9210c5c | ||
|
a57f19fe2a | ||
|
02f7f7b53a | ||
|
e3e1d3176c | ||
|
711ed79dc5 | ||
|
e758ae137b | ||
|
1e7e4c6678 | ||
|
a75aa209f2 | ||
|
1b1f565e2c | ||
|
56052d35b8 | ||
|
504a725864 | ||
|
3d5dd6d077 | ||
|
480c4a42d0 | ||
|
fc5761b50c | ||
|
4d0d371ab3 | ||
|
80fd6aff9f | ||
|
cabf63a99f | ||
|
f1eecd04c6 | ||
|
09b1a4b052 | ||
|
f7dd9c8965 | ||
|
b3d2e09fa1 | ||
|
2cd1a91ab8 | ||
|
86c6de0cb5 | ||
|
24f19bfed9 | ||
|
252c64009c | ||
|
dba1528116 | ||
|
1306dbde80 | ||
|
2e0c05f474 | ||
|
e90b9cf4d4 | ||
|
e7851e1d13 | ||
|
14f7df00c4 | ||
|
e6cd4daa57 | ||
|
f043f5e3a9 | ||
|
32df026c80 | ||
|
962a5cd4be | ||
|
22a0b03a98 | ||
|
0b061d805e | ||
|
cb278ec742 | ||
|
5878b272be | ||
|
cbdbf6227c | ||
|
88a7a2bba7 | ||
|
8f673497ba | ||
|
9738ba97c1 | ||
|
87b8651adb | ||
|
f4645d956e | ||
|
97cb19f820 | ||
|
8f5fc7ef71 | ||
|
f66758bc03 | ||
|
ce82c67f88 | ||
|
70e748f249 | ||
|
585770f33f | ||
|
d06e1017d6 | ||
|
d80a01caf8 | ||
|
7eee205f51 | ||
|
1b125ce0f8 | ||
|
6c7967fc9c | ||
|
407f0e5ccc | ||
|
d63f5923ea | ||
|
5e51fb3d02 | ||
|
01b3b11bc5 | ||
|
60f6df50af | ||
|
a4f641a3dd | ||
|
16da9c3f0f | ||
|
005b80360c | ||
|
fc6c367f97 | ||
|
e5e2d2b02d | ||
|
b8d6e83169 | ||
|
62cdb7e18c | ||
|
7e1a818dc2 | ||
|
6a0367c192 | ||
|
3551bc90cd | ||
|
4133a7f5c7 | ||
|
753c7dec10 | ||
|
722ec67091 | ||
|
d8312642e4 | ||
|
5eddce9624 | ||
|
e49a4b5b2a | ||
|
1c345d16a9 | ||
|
930f0416e7 | ||
|
cfb9d0b49f | ||
|
8d6bcbcccd | ||
|
ffe36efccc | ||
|
9e3945efff | ||
|
635cc47176 | ||
|
4a2971e309 | ||
|
20983bb692 | ||
|
30656c192e | ||
|
687b75d6fe | ||
|
461dcd0b52 | ||
|
7a207d559a | ||
|
955244ee7f | ||
|
cc12787382 | ||
|
44938a920b | ||
|
55aff8563d | ||
|
6ed640e9e4 | ||
|
00d9a86bf6 | ||
|
7a5f2e6f6d | ||
|
f03d145320 | ||
|
271e0c5b60 | ||
|
2b10bd4510 | ||
|
d83691adb3 | ||
|
5f710c311f | ||
|
01a9be99bb | ||
|
39ed9f4a86 | ||
|
348519ed5b | ||
|
926d45b4be | ||
|
7e1a74c6f0 | ||
|
f931a9c487 | ||
|
e6d2916402 | ||
|
34becfe979 | ||
|
b5d0bc0b65 | ||
|
9202985fdc | ||
|
2ab1fc7710 | ||
|
6a8e14e9bc | ||
|
4116e12237 | ||
|
b9e6cd750d | ||
|
d6be65e321 | ||
|
adaea14bc9 | ||
|
9854df5199 | ||
|
b498b67ea3 | ||
|
56d28c1ec1 | ||
|
bc6566a363 | ||
|
d3936ff307 | ||
|
913a905100 | ||
|
2462659026 | ||
|
adaed58fa5 | ||
|
b123ad9f3e | ||
|
a8cbf9589a | ||
|
64814fcf40 | ||
|
e485eeda0c | ||
|
7d837b6dcc | ||
|
2924240d81 | ||
|
732bd660dd | ||
|
ffbd3a5aff | ||
|
b284f1e1d2 | ||
|
b85b4b8762 | ||
|
2c09f8a59e | ||
|
1876d87c78 | ||
|
667ac0f165 | ||
|
d8bb86ddd9 | ||
|
5ba82625b2 | ||
|
d92d6c997e | ||
|
80c7f6aebe | ||
|
0fffc45f19 | ||
|
8930cea0c4 | ||
|
42c7acabb3 | ||
|
795ced7f38 | ||
|
f42c1d1166 | ||
|
e35f37b87d | ||
|
510ddc228c | ||
|
c813c3f6df | ||
|
6c2edce5af | ||
|
92c3894772 | ||
|
d9f7a32503 | ||
|
9cd68fd0a1 | ||
|
1e156bb5cb | ||
|
30e8a6349c | ||
|
1110b23dbf | ||
|
ccecf219af | ||
|
32e617fd56 | ||
|
ea51c7d5f3 | ||
|
d41db008f5 | ||
|
7f4f8d142a | ||
|
260bbb5081 | ||
|
23cb4c8cce | ||
|
b48e467c74 | ||
|
3cb64a6e76 | ||
|
ae844d1031 | ||
|
a9c6cd607e | ||
|
606237d57e | ||
|
3007992af5 | ||
|
ff02c025fb | ||
|
50221b451c | ||
|
472cdce8ba | ||
|
9ab1cfff9d | ||
|
1a3bc4650e | ||
|
fac484bc45 | ||
|
3e9a6e1eef | ||
|
7656bbe319 | ||
|
f2211a418c | ||
|
0cfebd9773 | ||
|
e1a2a2dfe1 | ||
|
ffbd9c5201 | ||
|
c5e33b08b6 | ||
|
7e182ed6eb | ||
|
59363fcf0c | ||
|
2c6bf553f7 | ||
|
be6df3cbed | ||
|
84a10263f0 | ||
|
6fbba1ec2d | ||
|
11abe9255a | ||
|
1b868a90b5 | ||
|
6e8691f9de | ||
|
0729aeffed | ||
|
b3168750b3 | ||
|
4d8ea21feb | ||
|
7d23a20043 | ||
|
f65a7a8724 | ||
|
d964d668dc | ||
|
4b4a5e33a9 | ||
|
3de1b73ff5 | ||
|
71a77f8308 | ||
|
ba4ea6ec8f | ||
|
b6980c9daa | ||
|
c94a846c0b | ||
|
117ea33382 | ||
|
dd05154be2 | ||
|
a7b12bbf64 | ||
|
8e5ddd6de8 | ||
|
17a9b20cda | ||
|
667ae590bb | ||
|
b7085f3d41 | ||
|
ca0f12774b | ||
|
9a0b65a3ab | ||
|
9ad5c1f289 | ||
|
f42cc026ac | ||
|
f2b47d5cbf | ||
|
1a46cec662 | ||
|
6bb1a5a5a0 | ||
|
1c2aec7187 | ||
|
1e14031509 | ||
|
a70a6a5645 | ||
|
f1369b0a51 | ||
|
5533ef5fb9 | ||
|
27d61f6174 | ||
|
aff62d55dc | ||
|
b7e403756a | ||
|
20ed4a819f | ||
|
62e0adb97a | ||
|
adfea0be8e | ||
|
7bed9cf4ba | ||
|
f8032070db | ||
|
5dba593bf0 | ||
|
dff839e0da | ||
|
a8358e04e4 | ||
|
46958194a4 | ||
|
57c7a60860 | ||
|
3413c73889 | ||
|
250d0baff8 | ||
|
1c71d37683 | ||
|
3ac6124653 | ||
|
293bc0d58e | ||
|
9e5a9e2e2d | ||
|
4040c6d83a | ||
|
5f1d84992c | ||
|
6be6e30fd7 | ||
|
3e6e1f1e8a | ||
|
aa07d49166 | ||
|
b83219bbf2 | ||
|
dbe67ab205 | ||
|
d7f36ce144 | ||
|
7ce2415d75 | ||
|
9d04dc5889 | ||
|
fe20952e7b | ||
|
b4dcb94685 | ||
|
78042da151 | ||
|
45c12e3399 | ||
|
78da53fbfa | ||
|
5de6d7f63a | ||
|
36b929a9d2 | ||
|
f54fb11041 | ||
|
a4078442f5 | ||
|
190a4b1894 | ||
|
5c3194f71e | ||
|
ca8a1b4a73 | ||
|
05cbeacbdb | ||
|
94d2d56a16 | ||
|
294ca29584 | ||
|
1024c597cf | ||
|
b14efedd18 | ||
|
791ec6097d | ||
|
5ef444f818 | ||
|
a627fa6fb5 | ||
|
f9eae5eb38 | ||
|
bc7eb0d3e6 | ||
|
e14963cfda | ||
|
431dd61c71 | ||
|
8c7baae37e | ||
|
182829b10b | ||
|
a962b994c2 | ||
|
fc2b8b6e77 | ||
|
fe80d0b8d3 | ||
|
bfd88f18ab | ||
|
0fc2963c72 | ||
|
dd15612d15 | ||
|
f8f7b5190a | ||
|
e4cb2fb6b4 | ||
|
129ffa9853 | ||
|
8a197e88b6 | ||
|
73bb7b97f1 | ||
|
50a141583e | ||
|
67badd2b7d | ||
|
bd14f46deb | ||
|
fc503be954 | ||
|
2c9d544f58 | ||
|
6b3d49fdfd | ||
|
d409703f58 | ||
|
a07113a0bf | ||
|
3361fa1dff | ||
|
7430617097 | ||
|
c29632aae2 | ||
|
e3ce8ad574 | ||
|
419ae81204 | ||
|
668bcb7a25 |
15
.env.example
@ -1,7 +1,18 @@
|
||||
NezhaBaseUrl=http://124.XX.XX.XX:8008
|
||||
NezhaAuth=your-nezha-api-token
|
||||
DefaultLocale=zh
|
||||
ForceShowAllServers=false
|
||||
NEXT_PUBLIC_NezhaFetchInterval=5000
|
||||
NEXT_PUBLIC_ShowFlag=true
|
||||
NEXT_PUBLIC_DisableCartoon=true
|
||||
NEXT_PUBLIC_ShowTag=true
|
||||
NEXT_PUBLIC_DisableCartoon=false
|
||||
NEXT_PUBLIC_ShowTag=true
|
||||
NEXT_PUBLIC_ShowNetTransfer=false
|
||||
NEXT_PUBLIC_ForceUseSvgFlag=false
|
||||
NEXT_PUBLIC_FixedTopServerName=false
|
||||
NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png
|
||||
NEXT_PUBLIC_CustomTitle=NezhaDash
|
||||
NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha.
|
||||
NEXT_PUBLIC_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕️"}]'
|
||||
NEXT_PUBLIC_DisableIndex=false
|
||||
NEXT_PUBLIC_ShowTagCount=false
|
||||
NEXT_PUBLIC_ShowIpInfo=false
|
||||
|
19
.github/ISSUE_TEMPLATE/bug-提交.md
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Bug 提交
|
||||
about: 提交 bug,让面板变得更好。
|
||||
title: "[BUG]"
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**面板版本(二选一)**
|
||||
V0 | V1
|
||||
|
||||
**描述 bug**
|
||||
在这里描述 bug 的相关信息
|
||||
|
||||
**屏幕截图**
|
||||
有屏幕截图可以帮助更快定位到问题
|
||||
|
||||
**额外信息**
|
||||
可附上其他需要的额外信息
|
14
.github/ISSUE_TEMPLATE/功能申请.md
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
name: 功能申请
|
||||
about: 描述需求
|
||||
title: "[FEAT]"
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**面板版本(二选一)**
|
||||
V0 | V1
|
||||
|
||||
**需要什么?**
|
||||
|
||||
**额外信息**
|
14
.github/ISSUE_TEMPLATE/改善建议.md
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
name: 改善建议
|
||||
about: 交流面板需要改进的地方
|
||||
title: "[SUGGEST]"
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**面板版本(二选一)**
|
||||
V0 | V1
|
||||
|
||||
**需要改进的?**
|
||||
|
||||
-
|
BIN
.github/get-token.png
vendored
Before Width: | Height: | Size: 40 KiB |
BIN
.github/shotOne.png
vendored
Before Width: | Height: | Size: 597 KiB |
BIN
.github/shotTwo.png
vendored
Before Width: | Height: | Size: 596 KiB |
BIN
.github/v2-1.webp
vendored
Normal file
After Width: | Height: | Size: 185 KiB |
BIN
.github/v2-2.webp
vendored
Normal file
After Width: | Height: | Size: 141 KiB |
BIN
.github/v2-3.webp
vendored
Normal file
After Width: | Height: | Size: 126 KiB |
BIN
.github/v2-4.webp
vendored
Normal file
After Width: | Height: | Size: 142 KiB |
BIN
.github/v2-dark.webp
vendored
Normal file
After Width: | Height: | Size: 183 KiB |
48
.github/workflows/Deploy.yml
vendored
@ -1,5 +1,7 @@
|
||||
name: Build and push Docker image
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@ -10,33 +12,6 @@ env:
|
||||
ALIYUN_REGISTRY_IMAGE: registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
name: Generate Changelog
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_body: ${{ steps.git-cliff.outputs.content }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Generate a changelog
|
||||
uses: orhun/git-cliff-action@v4
|
||||
id: git-cliff
|
||||
with:
|
||||
config: git-cliff-config/cliff.toml
|
||||
args: -vv --latest --strip 'footer'
|
||||
env:
|
||||
OUTPUT: CHANGES.md
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
body: ${{ steps.git-cliff.outputs.content }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
build-and-push:
|
||||
name: Build and push Docker image
|
||||
runs-on: ubuntu-latest
|
||||
@ -44,12 +19,13 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
@ -83,3 +59,13 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: "latest"
|
||||
|
||||
- name: Changelog
|
||||
run: bun x changelogithub
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
56
.github/workflows/auto-fix-lint-format-commit.yml
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
name: Auto Fix Lint and Format
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
auto-fix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Run linter & formatter and fix issues
|
||||
run: bun run check:fix
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "chore: auto-fix linting and formatting issues"
|
||||
commit_options: "--no-verify"
|
||||
file_pattern: "."
|
||||
|
||||
- name: Add PR comment
|
||||
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: 'Linting and formatting issues were automatically fixed. Please review the changes.'
|
||||
});
|
@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1 AS base
|
||||
FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM base AS deps
|
||||
@ -14,7 +14,7 @@ COPY . .
|
||||
RUN bun run build
|
||||
|
||||
# Stage 3: Production image
|
||||
FROM oven/bun:1-alpine AS runner
|
||||
FROM node:23-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/public ./public
|
||||
@ -22,4 +22,4 @@ COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "run", "server.js"]
|
||||
CMD ["node", "server.js"]
|
||||
|
201
LICENSE
Normal file
@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
55
README.md
@ -1,35 +1,38 @@
|
||||
<h1 align="center">NezhaDash</h1>
|
||||
|
||||
<strong>NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘</strong>
|
||||
<div align="center"><img width="600" alt="nezhadash" src="https://github.com/user-attachments/assets/0a5768e1-96f2-4f8a-b77f-01488ed3b237"></div>
|
||||
<h3 align="center">NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘</h3>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
|
||||
| 一键部署到 Vercel-推荐 | Docker部署 | Cloudflare部署 |
|
||||
| ----------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [部署简易教程](https://buycoffee.top/blog/tech/nezha) | [Docker 部署教程](https://buycoffee.top/blog/tech/nezha-docker) | [Cloudflare 部署教程](https://buycoffee.top/blog/tech/nezha-cloudflare) |
|
||||
| [Vercel-demo](https://nezha-dash-ruddy.vercel.app) | [Docker-demo](https://nezha-docker.buycoffee.tech) | [Cloudflare-demo](https://nezha-cloudflare.buycoffee.tech) |
|
||||
> [!CAUTION]
|
||||
> 此为 V0 兼容版本,与 V1 内置版本功能上可能有所不同
|
||||
>
|
||||
> V0 | V1 版本 issue 请在当前仓库发起
|
||||
|
||||
#### 环境变量
|
||||
> [!TIP]
|
||||
> 有关 V1 版本 pr 可移步 https://github.com/hamster1963/nezha-dash-v1
|
||||
|
||||
| 变量名 | 含义 | 示例 |
|
||||
| ------------------------------ | -------------------------------- | -------------------------------- |
|
||||
| NezhaBaseUrl | nezha 面板地址 | http://120.x.x.x:8008 |
|
||||
| NezhaAuth | nezha 面板 API Token | 5hAY3QX6Nl9B3Uxxxx26KMvOMyXS1Udi |
|
||||
| DefaultLocale | 面板默认显示语言(代码参考下表) | **默认**:en |
|
||||
| NEXT_PUBLIC_NezhaFetchInterval | 获取数据间隔(毫秒) | **默认**:2000 |
|
||||
| NEXT_PUBLIC_ShowFlag | 是否显示旗帜 | **默认**:false |
|
||||
| NEXT_PUBLIC_DisableCartoon | 是否禁用卡通人物 | **默认**:false |
|
||||
| NEXT_PUBLIC_ShowTag | 是否显示标签 | **默认**:false |
|
||||
### 部署
|
||||
|
||||
#### 多语言支持
|
||||
支持部署环境:
|
||||
|
||||
| 语言 | 代码 | 是否完成翻译 |
|
||||
| -------- | ---- | ------------ |
|
||||
| 简体中文 | zh | 是 |
|
||||
| 繁体中文 | zh-t | 是 |
|
||||
| 英语 | en | 是 |
|
||||
| 日语 | ja | 是 |
|
||||
- Vercel
|
||||
- Cloudflare
|
||||
- Docker
|
||||
|
||||

|
||||

|
||||
[演示站点](https://nezha-vercel.vercel.app)
|
||||
[说明文档](https://nezhadash-docs.vercel.app)
|
||||
|
||||
### 如何更新
|
||||
|
||||
[更新教程](https://buycoffee.top/blog/tech/nezha-upgrade)
|
||||
|
||||
### 环境变量
|
||||
|
||||
[环境变量介绍](https://nezhadash-docs.vercel.app/environment)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
398
app/(main)/ClientComponents/detail/NetworkChart.tsx
Normal file
@ -0,0 +1,398 @@
|
||||
"use client"
|
||||
|
||||
import type { NezhaAPIMonitor, ServerMonitorChart } from "@/app/types/nezha-api"
|
||||
import NetworkChartLoading from "@/components/loading/NetworkChartLoading"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { formatTime, nezhaFetcher } from "@/lib/utils"
|
||||
import { useTranslations } from "next-intl"
|
||||
import * as React from "react"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
import useSWR from "swr"
|
||||
|
||||
interface ResultItem {
|
||||
created_at: number
|
||||
[key: string]: number
|
||||
}
|
||||
|
||||
export function NetworkChartClient({
|
||||
server_id,
|
||||
show,
|
||||
}: {
|
||||
server_id: number
|
||||
show: boolean
|
||||
}) {
|
||||
const t = useTranslations("NetworkChartClient")
|
||||
const { data, error } = useSWR<NezhaAPIMonitor[]>(
|
||||
`/api/monitor?server_id=${server_id}`,
|
||||
nezhaFetcher,
|
||||
{
|
||||
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 15000,
|
||||
isVisible: () => show,
|
||||
},
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="font-medium text-sm opacity-40">{error.message}</p>
|
||||
<p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
|
||||
</div>
|
||||
<NetworkChartLoading />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return <NetworkChartLoading />
|
||||
|
||||
const transformedData = transformData(data)
|
||||
|
||||
const formattedData = formatData(data)
|
||||
|
||||
const initChartConfig = {
|
||||
avg_delay: {
|
||||
label: t("avg_delay"),
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const chartDataKey = Object.keys(transformedData)
|
||||
|
||||
return (
|
||||
<NetworkChart
|
||||
chartDataKey={chartDataKey}
|
||||
chartConfig={initChartConfig}
|
||||
chartData={transformedData}
|
||||
serverName={data[0].server_name}
|
||||
formattedData={formattedData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const NetworkChart = React.memo(function NetworkChart({
|
||||
chartDataKey,
|
||||
chartConfig,
|
||||
chartData,
|
||||
serverName,
|
||||
formattedData,
|
||||
}: {
|
||||
chartDataKey: string[]
|
||||
chartConfig: ChartConfig
|
||||
chartData: ServerMonitorChart
|
||||
serverName: string
|
||||
formattedData: ResultItem[]
|
||||
}) {
|
||||
const t = useTranslations("NetworkChart")
|
||||
|
||||
const defaultChart = "All"
|
||||
|
||||
const [activeChart, setActiveChart] = React.useState(defaultChart)
|
||||
const [isPeakEnabled, setIsPeakEnabled] = React.useState(false)
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(chart: string) => {
|
||||
setActiveChart((prev) => (prev === chart ? defaultChart : chart))
|
||||
},
|
||||
[defaultChart],
|
||||
)
|
||||
|
||||
const getColorByIndex = useCallback(
|
||||
(chart: string) => {
|
||||
const index = chartDataKey.indexOf(chart)
|
||||
return `hsl(var(--chart-${(index % 10) + 1}))`
|
||||
},
|
||||
[chartDataKey],
|
||||
)
|
||||
|
||||
const chartButtons = useMemo(
|
||||
() =>
|
||||
chartDataKey.map((key) => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
data-active={activeChart === key}
|
||||
className={
|
||||
"relative z-30 flex grow basis-0 cursor-pointer flex-col justify-center gap-1 border-neutral-200 border-b px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-t-0 sm:border-l sm:px-6 dark:border-neutral-800"
|
||||
}
|
||||
onClick={() => handleButtonClick(key)}
|
||||
>
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">{key}</span>
|
||||
<span className="font-bold text-md leading-none sm:text-lg">
|
||||
{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms
|
||||
</span>
|
||||
</button>
|
||||
)),
|
||||
[chartDataKey, activeChart, chartData, handleButtonClick],
|
||||
)
|
||||
|
||||
const chartLines = useMemo(() => {
|
||||
if (activeChart !== defaultChart) {
|
||||
return (
|
||||
<Line
|
||||
isAnimationActive={false}
|
||||
strokeWidth={1}
|
||||
type="linear"
|
||||
dot={false}
|
||||
dataKey="avg_delay"
|
||||
stroke={getColorByIndex(activeChart)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return chartDataKey.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={1}
|
||||
type="linear"
|
||||
dot={false}
|
||||
dataKey={key}
|
||||
stroke={getColorByIndex(key)}
|
||||
connectNulls={true}
|
||||
/>
|
||||
))
|
||||
}, [activeChart, defaultChart, chartDataKey, getColorByIndex])
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
if (!isPeakEnabled) {
|
||||
return activeChart === defaultChart ? formattedData : chartData[activeChart]
|
||||
}
|
||||
|
||||
const data = (
|
||||
activeChart === defaultChart ? formattedData : chartData[activeChart]
|
||||
) as ResultItem[]
|
||||
|
||||
const windowSize = 11 // 增加窗口大小以获取更好的统计效果
|
||||
const alpha = 0.3 // EWMA平滑因子
|
||||
|
||||
// 辅助函数:计算中位数
|
||||
const getMedian = (arr: number[]) => {
|
||||
const sorted = [...arr].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
// 辅助函数:异常值处理
|
||||
const processValues = (values: number[]) => {
|
||||
if (values.length === 0) return null
|
||||
|
||||
const median = getMedian(values)
|
||||
const deviations = values.map((v) => Math.abs(v - median))
|
||||
const medianDeviation = getMedian(deviations) * 1.4826 // MAD估计器
|
||||
|
||||
// 使用中位数绝对偏差(MAD)进行异常值检测
|
||||
const validValues = values.filter(
|
||||
(v) =>
|
||||
Math.abs(v - median) <= 3 * medianDeviation && // 更严格的异常值判定
|
||||
v <= median * 3, // 限制最大值不超过中位数的3倍
|
||||
)
|
||||
|
||||
if (validValues.length === 0) return median // 如果没有有效值,返回中位数
|
||||
|
||||
// 计算EWMA
|
||||
let ewma = validValues[0]
|
||||
for (let i = 1; i < validValues.length; i++) {
|
||||
ewma = alpha * validValues[i] + (1 - alpha) * ewma
|
||||
}
|
||||
|
||||
return ewma
|
||||
}
|
||||
|
||||
// 初始化EWMA历史值
|
||||
const ewmaHistory: { [key: string]: number } = {}
|
||||
|
||||
return data.map((point, index) => {
|
||||
if (index < windowSize - 1) return point
|
||||
|
||||
const window = data.slice(index - windowSize + 1, index + 1)
|
||||
const smoothed = { ...point } as ResultItem
|
||||
|
||||
if (activeChart === defaultChart) {
|
||||
for (const key of chartDataKey) {
|
||||
const values = window
|
||||
.map((w) => w[key])
|
||||
.filter((v) => v !== undefined && v !== null) as number[]
|
||||
|
||||
if (values.length > 0) {
|
||||
const processed = processValues(values)
|
||||
if (processed !== null) {
|
||||
// 应用EWMA平滑
|
||||
if (ewmaHistory[key] === undefined) {
|
||||
ewmaHistory[key] = processed
|
||||
} else {
|
||||
ewmaHistory[key] = alpha * processed + (1 - alpha) * ewmaHistory[key]
|
||||
}
|
||||
smoothed[key] = ewmaHistory[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const values = window
|
||||
.map((w) => w.avg_delay)
|
||||
.filter((v) => v !== undefined && v !== null) as number[]
|
||||
|
||||
if (values.length > 0) {
|
||||
const processed = processValues(values)
|
||||
if (processed !== null) {
|
||||
// 应用EWMA平滑
|
||||
if (ewmaHistory.current === undefined) {
|
||||
ewmaHistory.current = processed
|
||||
} else {
|
||||
ewmaHistory.current = alpha * processed + (1 - alpha) * ewmaHistory.current
|
||||
}
|
||||
smoothed.avg_delay = ewmaHistory.current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return smoothed
|
||||
})
|
||||
}, [isPeakEnabled, activeChart, formattedData, chartData, chartDataKey, defaultChart])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 p-0 sm:flex-row">
|
||||
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
|
||||
<CardTitle className="flex flex-none items-center gap-0.5 text-md">
|
||||
{serverName}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{chartDataKey.length} {t("ServerMonitorCount")}
|
||||
</CardDescription>
|
||||
<div className="mt-0.5 flex items-center space-x-2">
|
||||
<Switch id="Peak" checked={isPeakEnabled} onCheckedChange={setIsPeakEnabled} />
|
||||
<Label className="text-xs" htmlFor="Peak">
|
||||
Peak cut
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap">{chartButtons}</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-4 pr-2 pl-0 sm:pt-6 sm:pr-6 sm:pb-6 sm:pl-2">
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||||
<LineChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="created_at"
|
||||
tickLine={true}
|
||||
tickSize={3}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={80}
|
||||
ticks={processedData
|
||||
.filter((item, index, array) => {
|
||||
if (array.length < 6) {
|
||||
return index === 0 || index === array.length - 1
|
||||
}
|
||||
|
||||
// 计算数据的总时间跨度(毫秒)
|
||||
const timeSpan = array[array.length - 1].created_at - array[0].created_at
|
||||
const hours = timeSpan / (1000 * 60 * 60)
|
||||
|
||||
// 根据时间跨度调整显示间隔
|
||||
if (hours <= 12) {
|
||||
// 12小时内,每60分钟显示一个刻度
|
||||
return (
|
||||
index === 0 ||
|
||||
index === array.length - 1 ||
|
||||
new Date(item.created_at).getMinutes() % 60 === 0
|
||||
)
|
||||
}
|
||||
// 超过12小时,每2小时显示一个刻度
|
||||
const date = new Date(item.created_at)
|
||||
return date.getMinutes() === 0 && date.getHours() % 2 === 0
|
||||
})
|
||||
.map((item) => item.created_at)}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
const minutes = date.getMinutes()
|
||||
return minutes === 0 ? `${date.getHours()}:00` : `${date.getHours()}:${minutes}`
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={15}
|
||||
minTickGap={20}
|
||||
tickFormatter={(value) => `${value}ms`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
isAnimationActive={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator={"line"}
|
||||
labelKey="created_at"
|
||||
labelFormatter={(_, payload) => {
|
||||
return formatTime(payload[0].payload.created_at)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{activeChart === defaultChart && <ChartLegend content={<ChartLegendContent />} />}
|
||||
{chartLines}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
const transformData = (data: NezhaAPIMonitor[]) => {
|
||||
const monitorData: ServerMonitorChart = {}
|
||||
|
||||
for (const item of data) {
|
||||
const monitorName = item.monitor_name
|
||||
|
||||
if (!monitorData[monitorName]) {
|
||||
monitorData[monitorName] = []
|
||||
}
|
||||
|
||||
for (let i = 0; i < item.created_at.length; i++) {
|
||||
monitorData[monitorName].push({
|
||||
created_at: item.created_at[i],
|
||||
avg_delay: item.avg_delay[i],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return monitorData
|
||||
}
|
||||
|
||||
const formatData = (rawData: NezhaAPIMonitor[]) => {
|
||||
const result: { [time: number]: ResultItem } = {}
|
||||
|
||||
const allTimes = new Set<number>()
|
||||
for (const item of rawData) {
|
||||
for (const time of item.created_at) {
|
||||
allTimes.add(time)
|
||||
}
|
||||
}
|
||||
|
||||
const allTimeArray = Array.from(allTimes).sort((a, b) => a - b)
|
||||
|
||||
for (const item of rawData) {
|
||||
const { monitor_name, created_at, avg_delay } = item
|
||||
|
||||
for (const time of allTimeArray) {
|
||||
if (!result[time]) {
|
||||
result[time] = { created_at: time }
|
||||
}
|
||||
|
||||
const timeIndex = created_at.indexOf(time)
|
||||
// @ts-expect-error - avg_delay is an array
|
||||
result[time][monitor_name] = timeIndex !== -1 ? avg_delay[timeIndex] : null
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(result).sort((a, b) => a.created_at - b.created_at)
|
||||
}
|
892
app/(main)/ClientComponents/detail/ServerDetailChartClient.tsx
Normal file
@ -0,0 +1,892 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
MAX_HISTORY_LENGTH,
|
||||
type ServerDataWithTimestamp,
|
||||
useServerData,
|
||||
} from "@/app/context/server-data-context"
|
||||
import type { NezhaAPISafe } from "@/app/types/nezha-api"
|
||||
import { ServerDetailChartLoading } from "@/components/loading/ServerDetailLoading"
|
||||
import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"
|
||||
import { formatBytes, formatNezhaInfo, formatRelativeTime } from "@/lib/utils"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
|
||||
type cpuChartData = {
|
||||
timeStamp: string
|
||||
cpu: number
|
||||
}
|
||||
|
||||
type processChartData = {
|
||||
timeStamp: string
|
||||
process: number
|
||||
}
|
||||
|
||||
type diskChartData = {
|
||||
timeStamp: string
|
||||
disk: number
|
||||
}
|
||||
|
||||
type memChartData = {
|
||||
timeStamp: string
|
||||
mem: number
|
||||
swap: number
|
||||
}
|
||||
|
||||
type networkChartData = {
|
||||
timeStamp: string
|
||||
upload: number
|
||||
download: number
|
||||
}
|
||||
|
||||
type connectChartData = {
|
||||
timeStamp: string
|
||||
tcp: number
|
||||
udp: number
|
||||
}
|
||||
|
||||
export default function ServerDetailChartClient({
|
||||
server_id,
|
||||
}: {
|
||||
server_id: number
|
||||
show: boolean
|
||||
}) {
|
||||
const t = useTranslations("ServerDetailChartClient")
|
||||
|
||||
const { data: serverList, error, history } = useServerData()
|
||||
|
||||
const data = serverList?.result?.find((item) => item.id === server_id)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="font-medium text-sm opacity-40">{error.message}</p>
|
||||
<p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (!data) return <ServerDetailChartLoading />
|
||||
|
||||
return (
|
||||
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<CpuChart data={data} history={history} />
|
||||
<ProcessChart data={data} history={history} />
|
||||
<DiskChart data={data} history={history} />
|
||||
<MemChart data={data} history={history} />
|
||||
<NetworkChart data={data} history={history} />
|
||||
<ConnectChart data={data} history={history} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CpuChart({
|
||||
history,
|
||||
data,
|
||||
}: {
|
||||
history: ServerDataWithTimestamp[]
|
||||
data: NezhaAPISafe
|
||||
}) {
|
||||
const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[])
|
||||
const hasInitialized = useRef(false)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current && history.length > 0) {
|
||||
const historyData = history
|
||||
.map((msg) => {
|
||||
const server = msg.data?.result?.find((item) => item.id === data.id)
|
||||
if (!server) return null
|
||||
const { cpu } = formatNezhaInfo(server)
|
||||
return {
|
||||
timeStamp: msg.timestamp.toString(),
|
||||
cpu: cpu,
|
||||
}
|
||||
})
|
||||
.filter((item): item is cpuChartData => item !== null)
|
||||
.reverse() // 保持时间顺序
|
||||
|
||||
setCpuChartData(historyData)
|
||||
hasInitialized.current = true
|
||||
setHistoryLoaded(true)
|
||||
} else if (history.length === 0) {
|
||||
setHistoryLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { cpu } = formatNezhaInfo(data)
|
||||
|
||||
useEffect(() => {
|
||||
if (data && historyLoaded) {
|
||||
const timestamp = Date.now().toString()
|
||||
let newData = [] as cpuChartData[]
|
||||
if (cpuChartData.length === 0) {
|
||||
newData = [
|
||||
{ timeStamp: timestamp, cpu: cpu },
|
||||
{ timeStamp: timestamp, cpu: cpu },
|
||||
]
|
||||
} else {
|
||||
newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }]
|
||||
}
|
||||
if (newData.length > MAX_HISTORY_LENGTH) {
|
||||
newData.shift()
|
||||
}
|
||||
setCpuChartData(newData)
|
||||
}
|
||||
}, [data, historyLoaded])
|
||||
|
||||
const chartConfig = {
|
||||
cpu: {
|
||||
label: "CPU",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6 py-3">
|
||||
<section className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-md">CPU</p>
|
||||
<section className="flex items-center gap-2">
|
||||
<p className="w-10 text-end font-medium text-xs">{cpu.toFixed(0)}%</p>
|
||||
<AnimatedCircularProgressBar
|
||||
className="size-3 text-[0px]"
|
||||
max={100}
|
||||
min={0}
|
||||
value={cpu}
|
||||
primaryColor="hsl(var(--chart-1))"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={cpuChartData}
|
||||
margin={{
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timeStamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={200}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={(value) => formatRelativeTime(value)}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
mirror={true}
|
||||
tickMargin={-15}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
dataKey="cpu"
|
||||
type="step"
|
||||
fill="hsl(var(--chart-1))"
|
||||
fillOpacity={0.3}
|
||||
stroke="hsl(var(--chart-1))"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ProcessChart({
|
||||
data,
|
||||
history,
|
||||
}: {
|
||||
data: NezhaAPISafe
|
||||
history: ServerDataWithTimestamp[]
|
||||
}) {
|
||||
const t = useTranslations("ServerDetailChartClient")
|
||||
const [processChartData, setProcessChartData] = useState([] as processChartData[])
|
||||
const hasInitialized = useRef(false)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current && history.length > 0) {
|
||||
const historyData = history
|
||||
.map((msg) => {
|
||||
const server = msg.data?.result?.find((item) => item.id === data.id)
|
||||
if (!server) return null
|
||||
const { process } = formatNezhaInfo(server)
|
||||
return {
|
||||
timeStamp: msg.timestamp.toString(),
|
||||
process: process,
|
||||
}
|
||||
})
|
||||
.filter((item): item is processChartData => item !== null)
|
||||
.reverse()
|
||||
|
||||
setProcessChartData(historyData)
|
||||
hasInitialized.current = true
|
||||
setHistoryLoaded(true)
|
||||
} else if (history.length === 0) {
|
||||
setHistoryLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { process } = formatNezhaInfo(data)
|
||||
|
||||
useEffect(() => {
|
||||
if (data && historyLoaded) {
|
||||
const timestamp = Date.now().toString()
|
||||
let newData = [] as processChartData[]
|
||||
if (processChartData.length === 0) {
|
||||
newData = [
|
||||
{ timeStamp: timestamp, process: process },
|
||||
{ timeStamp: timestamp, process: process },
|
||||
]
|
||||
} else {
|
||||
newData = [...processChartData, { timeStamp: timestamp, process: process }]
|
||||
}
|
||||
if (newData.length > MAX_HISTORY_LENGTH) {
|
||||
newData.shift()
|
||||
}
|
||||
setProcessChartData(newData)
|
||||
}
|
||||
}, [data, historyLoaded])
|
||||
|
||||
const chartConfig = {
|
||||
process: {
|
||||
label: "Process",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6 py-3">
|
||||
<section className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-md">{t("Process")}</p>
|
||||
<section className="flex items-center gap-2">
|
||||
<p className="w-10 text-end font-medium text-xs">{process}</p>
|
||||
</section>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={processChartData}
|
||||
margin={{
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timeStamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={200}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={(value) => formatRelativeTime(value)}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} mirror={true} tickMargin={-15} />
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
dataKey="process"
|
||||
type="step"
|
||||
fill="hsl(var(--chart-2))"
|
||||
fillOpacity={0.3}
|
||||
stroke="hsl(var(--chart-2))"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MemChart({
|
||||
data,
|
||||
history,
|
||||
}: {
|
||||
data: NezhaAPISafe
|
||||
history: ServerDataWithTimestamp[]
|
||||
}) {
|
||||
const t = useTranslations("ServerDetailChartClient")
|
||||
const [memChartData, setMemChartData] = useState([] as memChartData[])
|
||||
const hasInitialized = useRef(false)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current && history.length > 0) {
|
||||
const historyData = history
|
||||
.map((msg) => {
|
||||
const server = msg.data?.result?.find((item) => item.id === data.id)
|
||||
if (!server) return null
|
||||
const { mem, swap } = formatNezhaInfo(server)
|
||||
return {
|
||||
timeStamp: msg.timestamp.toString(),
|
||||
mem: mem,
|
||||
swap: swap,
|
||||
}
|
||||
})
|
||||
.filter((item): item is memChartData => item !== null)
|
||||
.reverse()
|
||||
|
||||
setMemChartData(historyData)
|
||||
hasInitialized.current = true
|
||||
setHistoryLoaded(true)
|
||||
} else if (history.length === 0) {
|
||||
setHistoryLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { mem, swap } = formatNezhaInfo(data)
|
||||
|
||||
useEffect(() => {
|
||||
if (data && historyLoaded) {
|
||||
const timestamp = Date.now().toString()
|
||||
let newData = [] as memChartData[]
|
||||
if (memChartData.length === 0) {
|
||||
newData = [
|
||||
{ timeStamp: timestamp, mem: mem, swap: swap },
|
||||
{ timeStamp: timestamp, mem: mem, swap: swap },
|
||||
]
|
||||
} else {
|
||||
newData = [...memChartData, { timeStamp: timestamp, mem: mem, swap: swap }]
|
||||
}
|
||||
if (newData.length > MAX_HISTORY_LENGTH) {
|
||||
newData.shift()
|
||||
}
|
||||
setMemChartData(newData)
|
||||
}
|
||||
}, [data, historyLoaded])
|
||||
|
||||
const chartConfig = {
|
||||
mem: {
|
||||
label: "Mem",
|
||||
},
|
||||
swap: {
|
||||
label: "Swap",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6 py-3">
|
||||
<section className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<section className="flex items-center gap-4">
|
||||
<div className="flex flex-col">
|
||||
<p className=" text-muted-foreground text-xs">{t("Mem")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AnimatedCircularProgressBar
|
||||
className="size-3 text-[0px]"
|
||||
max={100}
|
||||
min={0}
|
||||
value={mem}
|
||||
primaryColor="hsl(var(--chart-8))"
|
||||
/>
|
||||
<p className="font-medium text-xs">{mem.toFixed(0)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className=" text-muted-foreground text-xs">{t("Swap")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AnimatedCircularProgressBar
|
||||
className="size-3 text-[0px]"
|
||||
max={100}
|
||||
min={0}
|
||||
value={swap}
|
||||
primaryColor="hsl(var(--chart-10))"
|
||||
/>
|
||||
<p className="font-medium text-xs">{swap.toFixed(0)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col items-end gap-0.5">
|
||||
<div className="flex items-center gap-2 font-medium text-[11px]">
|
||||
{formatBytes(data.status.MemUsed)} / {formatBytes(data.host.MemTotal)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-medium text-[11px]">
|
||||
swap: {formatBytes(data.status.SwapUsed)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={memChartData}
|
||||
margin={{
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timeStamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={200}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={(value) => formatRelativeTime(value)}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
mirror={true}
|
||||
tickMargin={-15}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
dataKey="mem"
|
||||
type="step"
|
||||
fill="hsl(var(--chart-8))"
|
||||
fillOpacity={0.3}
|
||||
stroke="hsl(var(--chart-8))"
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
dataKey="swap"
|
||||
type="step"
|
||||
fill="hsl(var(--chart-10))"
|
||||
fillOpacity={0.3}
|
||||
stroke="hsl(var(--chart-10))"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DiskChart({
|
||||
data,
|
||||
history,
|
||||
}: {
|
||||
data: NezhaAPISafe
|
||||
history: ServerDataWithTimestamp[]
|
||||
}) {
|
||||
const t = useTranslations("ServerDetailChartClient")
|
||||
const [diskChartData, setDiskChartData] = useState([] as diskChartData[])
|
||||
const hasInitialized = useRef(false)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current && history.length > 0) {
|
||||
const historyData = history
|
||||
.map((msg) => {
|
||||
const server = msg.data?.result?.find((item) => item.id === data.id)
|
||||
if (!server) return null
|
||||
const { disk } = formatNezhaInfo(server)
|
||||
return {
|
||||
timeStamp: msg.timestamp.toString(),
|
||||
disk: disk,
|
||||
}
|
||||
})
|
||||
.filter((item): item is diskChartData => item !== null)
|
||||
.reverse()
|
||||
|
||||
setDiskChartData(historyData)
|
||||
hasInitialized.current = true
|
||||
setHistoryLoaded(true)
|
||||
} else if (history.length === 0) {
|
||||
setHistoryLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { disk } = formatNezhaInfo(data)
|
||||
|
||||
useEffect(() => {
|
||||
if (data && historyLoaded) {
|
||||
const timestamp = Date.now().toString()
|
||||
let newData = [] as diskChartData[]
|
||||
if (diskChartData.length === 0) {
|
||||
newData = [
|
||||
{ timeStamp: timestamp, disk: disk },
|
||||
{ timeStamp: timestamp, disk: disk },
|
||||
]
|
||||
} else {
|
||||
newData = [...diskChartData, { timeStamp: timestamp, disk: disk }]
|
||||
}
|
||||
if (newData.length > MAX_HISTORY_LENGTH) {
|
||||
newData.shift()
|
||||
}
|
||||
setDiskChartData(newData)
|
||||
}
|
||||
}, [data, historyLoaded])
|
||||
|
||||
const chartConfig = {
|
||||
disk: {
|
||||
label: "Disk",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6 py-3">
|
||||
<section className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-md">{t("Disk")}</p>
|
||||
<section className="flex flex-col items-end gap-0.5">
|
||||
<section className="flex items-center gap-2">
|
||||
<p className="w-10 text-end font-medium text-xs">{disk.toFixed(0)}%</p>
|
||||
<AnimatedCircularProgressBar
|
||||
className="size-3 text-[0px]"
|
||||
max={100}
|
||||
min={0}
|
||||
value={disk}
|
||||
primaryColor="hsl(var(--chart-5))"
|
||||
/>
|
||||
</section>
|
||||
<div className="flex items-center gap-2 font-medium text-[11px]">
|
||||
{formatBytes(data.status.DiskUsed)} / {formatBytes(data.host.DiskTotal)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={diskChartData}
|
||||
margin={{
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timeStamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={200}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={(value) => formatRelativeTime(value)}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
mirror={true}
|
||||
tickMargin={-15}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
dataKey="disk"
|
||||
type="step"
|
||||
fill="hsl(var(--chart-5))"
|
||||
fillOpacity={0.3}
|
||||
stroke="hsl(var(--chart-5))"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkChart({
|
||||
data,
|
||||
history,
|
||||
}: {
|
||||
data: NezhaAPISafe
|
||||
history: ServerDataWithTimestamp[]
|
||||
}) {
|
||||
const t = useTranslations("ServerDetailChartClient")
|
||||
const [networkChartData, setNetworkChartData] = useState([] as networkChartData[])
|
||||
const hasInitialized = useRef(false)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current && history.length > 0) {
|
||||
const historyData = history
|
||||
.map((msg) => {
|
||||
const server = msg.data?.result?.find((item) => item.id === data.id)
|
||||
if (!server) return null
|
||||
const { up, down } = formatNezhaInfo(server)
|
||||
return {
|
||||
timeStamp: msg.timestamp.toString(),
|
||||
upload: up,
|
||||
download: down,
|
||||
}
|
||||
})
|
||||
.filter((item): item is networkChartData => item !== null)
|
||||
.reverse()
|
||||
|
||||
setNetworkChartData(historyData)
|
||||
hasInitialized.current = true
|
||||
setHistoryLoaded(true)
|
||||
} else if (history.length === 0) {
|
||||
setHistoryLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { up, down } = formatNezhaInfo(data)
|
||||
|
||||
useEffect(() => {
|
||||
if (data && historyLoaded) {
|
||||
const timestamp = Date.now().toString()
|
||||
let newData = [] as networkChartData[]
|
||||
if (networkChartData.length === 0) {
|
||||
newData = [
|
||||
{ timeStamp: timestamp, upload: up, download: down },
|
||||
{ timeStamp: timestamp, upload: up, download: down },
|
||||
]
|
||||
} else {
|
||||
newData = [...networkChartData, { timeStamp: timestamp, upload: up, download: down }]
|
||||
}
|
||||
if (newData.length > MAX_HISTORY_LENGTH) {
|
||||
newData.shift()
|
||||
}
|
||||
setNetworkChartData(newData)
|
||||
}
|
||||
}, [data, historyLoaded])
|
||||
|
||||
let maxDownload = Math.max(...networkChartData.map((item) => item.download))
|
||||
maxDownload = Math.ceil(maxDownload)
|
||||
if (maxDownload < 1) {
|
||||
maxDownload = 1
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
upload: {
|
||||
label: "Upload",
|
||||
},
|
||||
download: {
|
||||
label: "Download",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6 py-3">
|
||||
<section className="flex flex-col gap-1">
|
||||
<div className="flex items-center">
|
||||
<section className="flex items-center gap-4">
|
||||
<div className="flex w-20 flex-col">
|
||||
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
|
||||
<p className="font-medium text-xs">{up.toFixed(2)} M/s</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-20 flex-col">
|
||||
<p className=" text-muted-foreground text-xs">{t("Download")}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
|
||||
<p className="font-medium text-xs">{down.toFixed(2)} M/s</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={networkChartData}
|
||||
margin={{
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timeStamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={200}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={(value) => formatRelativeTime(value)}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
mirror={true}
|
||||
tickMargin={-15}
|
||||
type="number"
|
||||
minTickGap={50}
|
||||
interval="preserveStartEnd"
|
||||
domain={[1, maxDownload]}
|
||||
tickFormatter={(value) => `${value.toFixed(0)}M/s`}
|
||||
/>
|
||||
<Line
|
||||
isAnimationActive={false}
|
||||
dataKey="upload"
|
||||
type="linear"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
isAnimationActive={false}
|
||||
dataKey="download"
|
||||
type="linear"
|
||||
stroke="hsl(var(--chart-4))"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectChart({
|
||||
data,
|
||||
history,
|
||||
}: {
|
||||
data: NezhaAPISafe
|
||||
history: ServerDataWithTimestamp[]
|
||||
}) {
|
||||
const [connectChartData, setConnectChartData] = useState([] as connectChartData[])
|
||||
const hasInitialized = useRef(false)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current && history.length > 0) {
|
||||
const historyData = history
|
||||
.map((msg) => {
|
||||
const server = msg.data?.result?.find((item) => item.id === data.id)
|
||||
if (!server) return null
|
||||
const { tcp, udp } = formatNezhaInfo(server)
|
||||
return {
|
||||
timeStamp: msg.timestamp.toString(),
|
||||
tcp: tcp,
|
||||
udp: udp,
|
||||
}
|
||||
})
|
||||
.filter((item): item is connectChartData => item !== null)
|
||||
.reverse()
|
||||
|
||||
setConnectChartData(historyData)
|
||||
hasInitialized.current = true
|
||||
setHistoryLoaded(true)
|
||||
} else if (history.length === 0) {
|
||||
setHistoryLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { tcp, udp } = formatNezhaInfo(data)
|
||||
|
||||
useEffect(() => {
|
||||
if (data && historyLoaded) {
|
||||
const timestamp = Date.now().toString()
|
||||
let newData = [] as connectChartData[]
|
||||
if (connectChartData.length === 0) {
|
||||
newData = [
|
||||
{ timeStamp: timestamp, tcp: tcp, udp: udp },
|
||||
{ timeStamp: timestamp, tcp: tcp, udp: udp },
|
||||
]
|
||||
} else {
|
||||
newData = [...connectChartData, { timeStamp: timestamp, tcp: tcp, udp: udp }]
|
||||
}
|
||||
if (newData.length > MAX_HISTORY_LENGTH) {
|
||||
newData.shift()
|
||||
}
|
||||
setConnectChartData(newData)
|
||||
}
|
||||
}, [data, historyLoaded])
|
||||
|
||||
const chartConfig = {
|
||||
tcp: {
|
||||
label: "TCP",
|
||||
},
|
||||
udp: {
|
||||
label: "UDP",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6 py-3">
|
||||
<section className="flex flex-col gap-1">
|
||||
<div className="flex items-center">
|
||||
<section className="flex items-center gap-4">
|
||||
<div className="flex w-12 flex-col">
|
||||
<p className="text-muted-foreground text-xs">TCP</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
|
||||
<p className="font-medium text-xs">{tcp}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-12 flex-col">
|
||||
<p className=" text-muted-foreground text-xs">UDP</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
|
||||
<p className="font-medium text-xs">{udp}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={connectChartData}
|
||||
margin={{
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timeStamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={200}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={(value) => formatRelativeTime(value)}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
mirror={true}
|
||||
tickMargin={-15}
|
||||
type="number"
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<Line
|
||||
isAnimationActive={false}
|
||||
dataKey="tcp"
|
||||
type="linear"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
isAnimationActive={false}
|
||||
dataKey="udp"
|
||||
type="linear"
|
||||
stroke="hsl(var(--chart-4))"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
276
app/(main)/ClientComponents/detail/ServerDetailClient.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
"use client"
|
||||
|
||||
import { useServerData } from "@/app/context/server-data-context"
|
||||
import { BackIcon } from "@/components/Icon"
|
||||
import ServerFlag from "@/components/ServerFlag"
|
||||
import { ServerDetailLoading } from "@/components/loading/ServerDetailLoading"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
|
||||
import countries from "i18n-iso-countries"
|
||||
import enLocale from "i18n-iso-countries/langs/en.json"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { notFound, useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
countries.registerLocale(enLocale)
|
||||
|
||||
export default function ServerDetailClient({
|
||||
server_id,
|
||||
}: {
|
||||
server_id: number
|
||||
}) {
|
||||
const t = useTranslations("ServerDetailClient")
|
||||
const router = useRouter()
|
||||
|
||||
const [hasHistory, setHasHistory] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const previousPath = sessionStorage.getItem("fromMainPage")
|
||||
if (previousPath) {
|
||||
setHasHistory(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const linkClick = () => {
|
||||
if (hasHistory) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push("/")
|
||||
}
|
||||
}
|
||||
|
||||
const { data: serverList, error, isLoading } = useServerData()
|
||||
const serverData = serverList?.result?.find((item) => item.id === server_id)
|
||||
|
||||
if (!serverData && !isLoading) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="font-medium text-sm opacity-40">{error.message}</p>
|
||||
<p className="font-medium text-sm opacity-40">{t("detail_fetch_error_message")}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!serverData) return <ServerDetailLoading />
|
||||
|
||||
const {
|
||||
name,
|
||||
online,
|
||||
uptime,
|
||||
version,
|
||||
arch,
|
||||
mem_total,
|
||||
disk_total,
|
||||
country_code,
|
||||
platform,
|
||||
platform_version,
|
||||
cpu_info,
|
||||
gpu_info,
|
||||
load_1,
|
||||
load_5,
|
||||
load_15,
|
||||
net_out_transfer,
|
||||
net_in_transfer,
|
||||
last_active_time_string,
|
||||
boot_time_string,
|
||||
} = formatNezhaInfo(serverData)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={linkClick}
|
||||
className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight transition-opacity duration-300 hover:opacity-50"
|
||||
>
|
||||
<BackIcon />
|
||||
{name}
|
||||
</div>
|
||||
<section className="mt-3 flex flex-wrap gap-2">
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("status")}</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
"-mt-[0.3px] w-fit rounded-[6px] px-1 py-0 text-[9px] dark:text-white",
|
||||
{
|
||||
" bg-green-800": online,
|
||||
" bg-red-600": !online,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{online ? t("Online") : t("Offline")}
|
||||
</Badge>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Uptime")}</p>
|
||||
<div className="text-xs">
|
||||
{" "}
|
||||
{uptime / 86400 >= 1
|
||||
? `${Math.floor(uptime / 86400)} ${t("Days")} ${Math.floor((uptime % 86400) / 3600)} ${t("Hours")}`
|
||||
: `${Math.floor(uptime / 3600)} ${t("Hours")}`}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{version && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Version")}</p>
|
||||
<div className="text-xs">{version} </div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{arch && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Arch")}</p>
|
||||
<div className="text-xs">{arch} </div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
|
||||
<div className="text-xs">{formatBytes(mem_total)}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Disk")}</p>
|
||||
<div className="text-xs">{formatBytes(disk_total)}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{country_code && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Region")}</p>
|
||||
<section className="flex items-start gap-1">
|
||||
<div className="text-start text-xs">{countries.getName(country_code, "en")}</div>
|
||||
<ServerFlag className="-mt-[1px] text-[11px]" country_code={country_code} />
|
||||
</section>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
<section className="mt-1 flex flex-wrap gap-2">
|
||||
{platform && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("System")}</p>
|
||||
|
||||
<div className="text-xs">
|
||||
{" "}
|
||||
{platform} - {platform_version}{" "}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{cpu_info && cpu_info.length > 0 && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
|
||||
|
||||
<div className="text-xs"> {cpu_info.join(", ")}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{gpu_info && gpu_info.length > 0 && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{"GPU"}</p>
|
||||
<div className="text-xs"> {gpu_info.join(", ")}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
<section className="mt-1 flex flex-wrap gap-2">
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Load")}</p>
|
||||
<div className="text-xs">
|
||||
{load_1 || "0.00"} / {load_5 || "0.00"} / {load_15 || "0.00"}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
|
||||
{net_out_transfer ? (
|
||||
<div className="text-xs"> {formatBytes(net_out_transfer)} </div>
|
||||
) : (
|
||||
<div className="text-xs">Unknown</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("Download")}</p>
|
||||
{net_in_transfer ? (
|
||||
<div className="text-xs"> {formatBytes(net_in_transfer)} </div>
|
||||
) : (
|
||||
<div className="text-xs">Unknown</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<section className="mt-1 flex flex-wrap gap-2">
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("BootTime")}</p>
|
||||
<div className="text-xs">{boot_time_string ? boot_time_string : "N/A"}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("LastActive")}</p>
|
||||
<div className="text-xs">
|
||||
{last_active_time_string ? last_active_time_string : "N/A"}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
119
app/(main)/ClientComponents/detail/ServerIPInfo.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import type { IPInfo } from "@/app/api/server-ip/route"
|
||||
import { Loader } from "@/components/loading/Loader"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { nezhaFetcher } from "@/lib/utils"
|
||||
import { useTranslations } from "next-intl"
|
||||
import useSWRImmutable from "swr/immutable"
|
||||
|
||||
export default function ServerIPInfo({ server_id }: { server_id: number }) {
|
||||
const t = useTranslations("IPInfo")
|
||||
|
||||
const { data } = useSWRImmutable<IPInfo>(`/api/server-ip?server_id=${server_id}`, nezhaFetcher)
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="mb-11">
|
||||
<Loader visible />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mb-4 flex flex-wrap gap-2">
|
||||
{data.asn?.autonomous_system_organization && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{"ASN"}</p>
|
||||
<div className="text-xs">{data.asn.autonomous_system_organization}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data.asn?.autonomous_system_number && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("asn_number")}</p>
|
||||
<div className="text-xs">AS{data.asn.autonomous_system_number}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data.city?.registered_country?.names.en && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("registered_country")}</p>
|
||||
<div className="text-xs">{data.city.registered_country?.names.en}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data.city?.country?.iso_code && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{"ISO"}</p>
|
||||
<div className="text-xs">{data.city.country?.iso_code}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data.city?.city?.names.en && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("city")}</p>
|
||||
<div className="text-xs">{data.city.city?.names.en}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data.city?.location?.longitude && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("longitude")}</p>
|
||||
<div className="text-xs">{data.city.location?.longitude}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data.city?.location?.latitude && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("latitude")}</p>
|
||||
<div className="text-xs">{data.city.location?.latitude}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data.city?.location?.time_zone && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("time_zone")}</p>
|
||||
<div className="text-xs">{data.city.location?.time_zone}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data.city?.postal && (
|
||||
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
|
||||
<CardContent className="px-1.5 py-1">
|
||||
<section className="flex flex-col items-start gap-0.5">
|
||||
<p className="text-muted-foreground text-xs">{t("postal_code")}</p>
|
||||
<div className="text-xs">{data.city.postal?.code}</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
62
app/(main)/ClientComponents/main/Global.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import GlobalInfo from "@/app/(main)/ClientComponents/main/GlobalInfo"
|
||||
import { InteractiveMap } from "@/app/(main)/ClientComponents/main/InteractiveMap"
|
||||
import { useServerData } from "@/app/context/server-data-context"
|
||||
import { TooltipProvider } from "@/app/context/tooltip-context"
|
||||
import GlobalLoading from "@/components/loading/GlobalLoading"
|
||||
import { geoJsonString } from "@/lib/geo/geo-json-string"
|
||||
|
||||
export default function ServerGlobal() {
|
||||
const { data: nezhaServerList, error } = useServerData()
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="font-medium text-sm opacity-40">{error.message}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!nezhaServerList) {
|
||||
return <GlobalLoading />
|
||||
}
|
||||
|
||||
const countryList: string[] = []
|
||||
const serverCounts: { [key: string]: number } = {}
|
||||
|
||||
for (const server of nezhaServerList.result) {
|
||||
if (server.host.CountryCode) {
|
||||
const countryCode = server.host.CountryCode.toUpperCase()
|
||||
if (!countryList.includes(countryCode)) {
|
||||
countryList.push(countryCode)
|
||||
}
|
||||
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
const width = 900
|
||||
const height = 500
|
||||
|
||||
const geoJson = JSON.parse(geoJsonString)
|
||||
const filteredFeatures = geoJson.features.filter(
|
||||
(feature: any) => feature.properties.iso_a3_eh !== "",
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="mt-[3.2px] flex flex-col gap-4">
|
||||
<GlobalInfo countries={countryList} />
|
||||
<div className="w-full overflow-x-auto">
|
||||
<TooltipProvider>
|
||||
<InteractiveMap
|
||||
countries={countryList}
|
||||
serverCounts={serverCounts}
|
||||
width={width}
|
||||
height={height}
|
||||
filteredFeatures={filteredFeatures}
|
||||
nezhaServerList={nezhaServerList}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
18
app/(main)/ClientComponents/main/GlobalInfo.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
type GlobalInfoProps = {
|
||||
countries: string[]
|
||||
}
|
||||
|
||||
export default function GlobalInfo({ countries }: GlobalInfoProps) {
|
||||
const t = useTranslations("Global")
|
||||
return (
|
||||
<section className="flex items-center justify-between">
|
||||
<p className="font-medium text-sm opacity-40">
|
||||
{t("Distributions")} {countries.length} {t("Regions")}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
153
app/(main)/ClientComponents/main/InteractiveMap.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import MapTooltip from "@/app/(main)/ClientComponents/main/MapTooltip"
|
||||
import { useTooltip } from "@/app/context/tooltip-context"
|
||||
import { countryCoordinates } from "@/lib/geo/geo-limit"
|
||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
||||
|
||||
interface InteractiveMapProps {
|
||||
countries: string[]
|
||||
serverCounts: { [key: string]: number }
|
||||
width: number
|
||||
height: number
|
||||
filteredFeatures: any[]
|
||||
nezhaServerList: any
|
||||
}
|
||||
|
||||
export function InteractiveMap({
|
||||
countries,
|
||||
serverCounts,
|
||||
width,
|
||||
height,
|
||||
filteredFeatures,
|
||||
nezhaServerList,
|
||||
}: InteractiveMapProps) {
|
||||
const { setTooltipData } = useTooltip()
|
||||
|
||||
const projection = geoEquirectangular()
|
||||
.scale(140)
|
||||
.translate([width / 2, height / 2])
|
||||
.rotate([-12, 0, 0])
|
||||
|
||||
const path = geoPath().projection(projection)
|
||||
|
||||
return (
|
||||
<div className="relative aspect-[2/1] w-full" onMouseLeave={() => setTooltipData(null)}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-auto w-full"
|
||||
>
|
||||
<title>Interactive Map</title>
|
||||
<defs>
|
||||
<pattern id="dots" width="2" height="2" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1" cy="1" r="0.5" fill="currentColor" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<g>
|
||||
{/* Background rect to handle mouse events in empty areas */}
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={width}
|
||||
height={height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setTooltipData(null)}
|
||||
/>
|
||||
{filteredFeatures.map((feature, index) => {
|
||||
const isHighlighted = countries.includes(feature.properties.iso_a2_eh)
|
||||
|
||||
const serverCount = serverCounts[feature.properties.iso_a2_eh] || 0
|
||||
|
||||
return (
|
||||
<path
|
||||
key={feature.properties.iso_a2_eh + String(index)}
|
||||
d={path(feature) || ""}
|
||||
className={
|
||||
isHighlighted
|
||||
? "cursor-pointer fill-green-700 transition-all hover:fill-green-600 dark:fill-green-900 dark:hover:fill-green-700"
|
||||
: "fill-neutral-200/50 stroke-[0.5] stroke-neutral-300/40 dark:fill-neutral-800 dark:stroke-neutral-700"
|
||||
}
|
||||
onMouseEnter={() => {
|
||||
if (!isHighlighted) {
|
||||
setTooltipData(null)
|
||||
return
|
||||
}
|
||||
if (path.centroid(feature)) {
|
||||
const countryCode = feature.properties.iso_a2_eh
|
||||
const countryServers = nezhaServerList.result
|
||||
.filter(
|
||||
(server: any) => server.host.CountryCode?.toUpperCase() === countryCode,
|
||||
)
|
||||
.map((server: any) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
status: server.online_status,
|
||||
}))
|
||||
setTooltipData({
|
||||
centroid: path.centroid(feature),
|
||||
country: feature.properties.name,
|
||||
count: serverCount,
|
||||
servers: countryServers,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 渲染不在 filteredFeatures 中的国家标记点 */}
|
||||
{countries.map((countryCode) => {
|
||||
// 检查该国家是否已经在 filteredFeatures 中
|
||||
const isInFilteredFeatures = filteredFeatures.some(
|
||||
(feature) => feature.properties.iso_a2_eh === countryCode,
|
||||
)
|
||||
|
||||
// 如果已经在 filteredFeatures 中,跳过
|
||||
if (isInFilteredFeatures) return null
|
||||
|
||||
// 获取国家的经纬度
|
||||
const coords = countryCoordinates[countryCode]
|
||||
if (!coords) return null
|
||||
|
||||
// 使用投影函数将经纬度转换为 SVG 坐标
|
||||
const [x, y] = projection([coords.lng, coords.lat]) || [0, 0]
|
||||
const serverCount = serverCounts[countryCode] || 0
|
||||
|
||||
return (
|
||||
<g
|
||||
key={countryCode}
|
||||
onMouseEnter={() => {
|
||||
const countryServers = nezhaServerList.result
|
||||
.filter((server: any) => server.host.CountryCode?.toUpperCase() === countryCode)
|
||||
.map((server: any) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
status: server.online_status,
|
||||
}))
|
||||
setTooltipData({
|
||||
centroid: [x, y],
|
||||
country: coords.name,
|
||||
count: serverCount,
|
||||
servers: countryServers,
|
||||
})
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={4}
|
||||
className="fill-sky-700 stroke-white transition-all hover:fill-sky-600 dark:fill-sky-900 dark:hover:fill-sky-700"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
<MapTooltip />
|
||||
</div>
|
||||
)
|
||||
}
|
70
app/(main)/ClientComponents/main/MapTooltip.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { useTooltip } from "@/app/context/tooltip-context"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
import { memo } from "react"
|
||||
|
||||
const MapTooltip = memo(function MapTooltip() {
|
||||
const { tooltipData } = useTooltip()
|
||||
const t = useTranslations("Global")
|
||||
|
||||
if (!tooltipData) return null
|
||||
|
||||
const sortedServers = tooltipData.servers.sort((a, b) => {
|
||||
return a.status === b.status ? 0 : a.status ? 1 : -1
|
||||
})
|
||||
|
||||
const saveSession = () => {
|
||||
sessionStorage.setItem("fromMainPage", "true")
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tooltip-animate absolute z-50 hidden rounded bg-white px-2 py-1 text-sm shadow-lg lg:block dark:border dark:border-neutral-700 dark:bg-neutral-800"
|
||||
key={tooltipData.country}
|
||||
style={{
|
||||
left: tooltipData.centroid[0],
|
||||
top: tooltipData.centroid[1],
|
||||
transform: "translate(10%, -50%)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{tooltipData.country === "China" ? "Mainland China" : tooltipData.country}
|
||||
</p>
|
||||
<p className="mb-1 font-light text-neutral-600 text-xs dark:text-neutral-400">
|
||||
{tooltipData.count} {t("Servers")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="border-t pt-1 dark:border-neutral-700"
|
||||
style={{
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{sortedServers.map((server) => (
|
||||
<Link
|
||||
onClick={saveSession}
|
||||
href={`/server/${server.id}`}
|
||||
key={server.name}
|
||||
className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
server.status ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs">{server.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default MapTooltip
|
222
app/(main)/ClientComponents/main/ServerListClient.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import { useFilter } from "@/app/context/network-filter-context"
|
||||
import { useServerData } from "@/app/context/server-data-context"
|
||||
import { useStatus } from "@/app/context/status-context"
|
||||
import ServerCard from "@/components/ServerCard"
|
||||
import ServerCardInline from "@/components/ServerCardInline"
|
||||
import Switch from "@/components/Switch"
|
||||
import GlobalLoading from "@/components/loading/GlobalLoading"
|
||||
import { Loader } from "@/components/loading/Loader"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MapIcon, ViewColumnsIcon } from "@heroicons/react/20/solid"
|
||||
import { useTranslations } from "next-intl"
|
||||
import dynamic from "next/dynamic"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
const ServerGlobal = dynamic(() => import("./Global"), {
|
||||
ssr: false,
|
||||
loading: () => <GlobalLoading />,
|
||||
})
|
||||
|
||||
const sortServersByDisplayIndex = (servers: any[]) => {
|
||||
return servers.sort((a, b) => {
|
||||
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
|
||||
return displayIndexDiff !== 0 ? displayIndexDiff : a.id - b.id
|
||||
})
|
||||
}
|
||||
|
||||
const filterServersByStatus = (servers: any[], status: string) => {
|
||||
return status === "all"
|
||||
? servers
|
||||
: servers.filter((server) => [status].includes(server.online_status ? "online" : "offline"))
|
||||
}
|
||||
|
||||
const filterServersByTag = (servers: any[], tag: string, defaultTag: string) => {
|
||||
return tag === defaultTag ? servers : servers.filter((server) => server.tag === tag)
|
||||
}
|
||||
|
||||
const sortServersByNetwork = (servers: any[]) => {
|
||||
return [...servers].sort((a, b) => {
|
||||
if (!a.online_status && b.online_status) return 1
|
||||
if (a.online_status && !b.online_status) return -1
|
||||
if (!a.online_status && !b.online_status) return 0
|
||||
return b.status.NetInSpeed + b.status.NetOutSpeed - (a.status.NetInSpeed + a.status.NetOutSpeed)
|
||||
})
|
||||
}
|
||||
|
||||
const getTagCounts = (servers: any[]) => {
|
||||
return servers.reduce((acc: Record<string, number>, server) => {
|
||||
if (server.tag) {
|
||||
acc[server.tag] = (acc[server.tag] || 0) + 1
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const LoadingState = ({ t }: { t: any }) => (
|
||||
<div className="flex min-h-96 flex-col items-center justify-center ">
|
||||
<div className="flex items-center gap-2 font-semibold text-sm">
|
||||
<Loader visible={true} />
|
||||
{t("connecting")}...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ErrorState = ({ error, t }: { error: Error; t: any }) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="font-medium text-sm opacity-40">{error.message}</p>
|
||||
<p className="font-medium text-sm opacity-40">{t("error_message")}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ServerList = ({
|
||||
servers,
|
||||
inline,
|
||||
containerRef,
|
||||
}: { servers: any[]; inline: string; containerRef: any }) => {
|
||||
if (inline === "1") {
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className="scrollbar-hidden flex flex-col gap-2 overflow-x-scroll"
|
||||
>
|
||||
{servers.map((serverInfo) => (
|
||||
<ServerCardInline key={serverInfo.id} serverInfo={serverInfo} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{servers.map((serverInfo) => (
|
||||
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ServerListClient() {
|
||||
const { status } = useStatus()
|
||||
const { filter } = useFilter()
|
||||
const t = useTranslations("ServerListClient")
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const defaultTag = "defaultTag"
|
||||
|
||||
const [tag, setTag] = useState<string>(defaultTag)
|
||||
const [showMap, setShowMap] = useState<boolean>(false)
|
||||
const [inline, setInline] = useState<string>("0")
|
||||
|
||||
useEffect(() => {
|
||||
const inlineState = localStorage.getItem("inline")
|
||||
if (inlineState !== null) {
|
||||
setInline(inlineState)
|
||||
}
|
||||
|
||||
const showMapState = localStorage.getItem("showMap")
|
||||
if (showMapState !== null) {
|
||||
setShowMap(showMapState === "true")
|
||||
}
|
||||
|
||||
const savedTag = sessionStorage.getItem("selectedTag") || defaultTag
|
||||
setTag(savedTag)
|
||||
restoreScrollPosition()
|
||||
}, [])
|
||||
|
||||
const handleTagChange = (newTag: string) => {
|
||||
setTag(newTag)
|
||||
sessionStorage.setItem("selectedTag", newTag)
|
||||
sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0))
|
||||
}
|
||||
|
||||
const restoreScrollPosition = () => {
|
||||
const savedPosition = sessionStorage.getItem("scrollPosition")
|
||||
if (savedPosition && containerRef.current) {
|
||||
containerRef.current.scrollTop = Number(savedPosition)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = () => {
|
||||
restoreScrollPosition()
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", handleRouteChange)
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handleRouteChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { data, error } = useServerData()
|
||||
|
||||
if (error) return <ErrorState error={error} t={t} />
|
||||
if (!data?.result) return <LoadingState t={t} />
|
||||
|
||||
const { result } = data
|
||||
const sortedServers = sortServersByDisplayIndex(result)
|
||||
const filteredServersByStatus = filterServersByStatus(sortedServers, status)
|
||||
const allTag = filteredServersByStatus.map((server) => server.tag).filter(Boolean)
|
||||
const uniqueTags = [...new Set(allTag)]
|
||||
uniqueTags.unshift(defaultTag)
|
||||
|
||||
let filteredServers = filterServersByTag(filteredServersByStatus, tag, defaultTag)
|
||||
|
||||
if (filter) {
|
||||
filteredServers = sortServersByNetwork(filteredServers)
|
||||
}
|
||||
|
||||
const tagCountMap = getTagCounts(filteredServersByStatus)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="flex w-full items-center gap-2 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newShowMap = !showMap
|
||||
setShowMap(newShowMap)
|
||||
localStorage.setItem("showMap", String(newShowMap))
|
||||
}}
|
||||
className={cn(
|
||||
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-[10px] text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100 ",
|
||||
{
|
||||
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
|
||||
showMap,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<MapIcon className="size-[13px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newInline = inline === "0" ? "1" : "0"
|
||||
setInline(newInline)
|
||||
localStorage.setItem("inline", newInline)
|
||||
}}
|
||||
className={cn(
|
||||
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-[10px] text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100 ",
|
||||
{
|
||||
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
|
||||
inline === "1",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ViewColumnsIcon className="size-[13px]" />
|
||||
</button>
|
||||
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && (
|
||||
<Switch
|
||||
allTag={uniqueTags}
|
||||
nowTag={tag}
|
||||
tagCountMap={tagCountMap}
|
||||
onTagChange={handleTagChange}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{showMap && <ServerGlobal />}
|
||||
<ServerList servers={filteredServers} inline={inline} containerRef={containerRef} />
|
||||
</>
|
||||
)
|
||||
}
|
194
app/(main)/ClientComponents/main/ServerOverviewClient.tsx
Normal file
58
app/(main)/footer.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import pack from "@/package.json"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useEffect, useState } from "react"
|
||||
const GITHUB_URL = "https://github.com/hamster1963/nezha-dash"
|
||||
const PERSONAL_URL = "https://buycoffee.top"
|
||||
|
||||
type LinkProps = {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const FooterLink = ({ href, children }: LinkProps) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
className="cursor-pointer font-normal underline decoration-2 decoration-yellow-500 underline-offset-2 transition-colors hover:decoration-yellow-600 dark:decoration-yellow-500/60 dark:hover:decoration-yellow-500/80"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
||||
const baseTextStyles =
|
||||
"text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50"
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations("Footer")
|
||||
const version = pack.version
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [isMac, setIsMac] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(/macintosh|mac os x/i.test(navigator.userAgent))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<footer className="mx-auto flex w-full max-w-5xl items-center justify-between">
|
||||
<section className="flex flex-col">
|
||||
<p className={`mt-3 flex gap-1 ${baseTextStyles}`}>
|
||||
{t("p_146-598_Findthecodeon")}{" "}
|
||||
<FooterLink href={GITHUB_URL}>{t("a_303-585_GitHub")}</FooterLink>
|
||||
<FooterLink href={`${GITHUB_URL}/releases/tag/v${version}`}>v{version}</FooterLink>
|
||||
</p>
|
||||
<section className={`mt-1 flex items-center gap-2 ${baseTextStyles}`}>
|
||||
{t("section_607-869_2020")}
|
||||
{currentYear} <FooterLink href={PERSONAL_URL}>{t("a_800-850_Hamster1963")}</FooterLink>
|
||||
</section>
|
||||
</section>
|
||||
<p className={`mt-1 ${baseTextStyles}`}>
|
||||
<kbd className="pointer-events-none mx-1 inline-flex h-4 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium font-mono text-[10px] text-muted-foreground opacity-100">
|
||||
{isMac ? <span className="text-xs">⌘</span> : "Ctrl "}K
|
||||
</kbd>
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
162
app/(main)/header.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import AnimateCountClient from "@/components/AnimatedCount"
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher"
|
||||
import { ModeToggle } from "@/components/ThemeSwitcher"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { DateTime } from "luxon"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { memo, useCallback, useEffect, useState } from "react"
|
||||
|
||||
interface TimeState {
|
||||
hh: number
|
||||
mm: number
|
||||
ss: number
|
||||
}
|
||||
|
||||
interface CustomLink {
|
||||
link: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const useCurrentTime = () => {
|
||||
const [time, setTime] = useState<TimeState>({
|
||||
hh: DateTime.now().setLocale("en-US").hour,
|
||||
mm: DateTime.now().setLocale("en-US").minute,
|
||||
ss: DateTime.now().setLocale("en-US").second,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
const now = DateTime.now().setLocale("en-US")
|
||||
setTime({
|
||||
hh: now.hour,
|
||||
mm: now.minute,
|
||||
ss: now.second,
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(intervalId)
|
||||
}, [])
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
const Links = memo(function Links() {
|
||||
const linksEnv = getEnv("NEXT_PUBLIC_Links")
|
||||
const links: CustomLink[] | null = linksEnv ? JSON.parse(linksEnv) : null
|
||||
|
||||
if (!links) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.link}
|
||||
href={link.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 font-medium text-sm opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const Overview = memo(function Overview() {
|
||||
const t = useTranslations("Overview")
|
||||
const time = useCurrentTime()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className={"mt-10 flex flex-col md:mt-16"}>
|
||||
<p className="font-semibold text-base">{t("p_2277-2331_Overview")}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-medium text-sm opacity-50">{t("p_2390-2457_wherethetimeis")}</p>
|
||||
{mounted ? (
|
||||
<div className="flex items-center font-medium text-sm">
|
||||
<AnimateCountClient count={time.hh} minDigits={2} />
|
||||
<span className="mb-[1px] font-medium text-sm opacity-50">:</span>
|
||||
<AnimateCountClient count={time.mm} minDigits={2} />
|
||||
<span className="mb-[1px] font-medium text-sm opacity-50">:</span>
|
||||
<span className="font-medium text-sm">
|
||||
<AnimateCountClient count={time.ss} minDigits={2} />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-[21px] w-16 animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
|
||||
function Header() {
|
||||
const t = useTranslations("Header")
|
||||
const customLogo = getEnv("NEXT_PUBLIC_CustomLogo")
|
||||
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
|
||||
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
sessionStorage.removeItem("selectedTag")
|
||||
router.push("/")
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
<section className="flex items-center justify-between">
|
||||
<section
|
||||
onClick={handleLogoClick}
|
||||
className="flex cursor-pointer items-center font-medium text-base transition-opacity duration-300 hover:opacity-50"
|
||||
>
|
||||
<div className="mr-1 flex flex-row items-center justify-start">
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
alt="apple-touch-icon"
|
||||
src={customLogo ? customLogo : "/apple-touch-icon.png"}
|
||||
className="relative m-0! h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:hidden"
|
||||
/>
|
||||
<img
|
||||
width={40}
|
||||
height={40}
|
||||
alt="apple-touch-icon"
|
||||
src={customLogo ? customLogo : "/apple-touch-icon-dark.png"}
|
||||
className="relative m-0! hidden h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:block"
|
||||
/>
|
||||
</div>
|
||||
{customTitle ? customTitle : "NezhaDash"}
|
||||
<Separator orientation="vertical" className="mx-2 hidden h-4 w-[1px] md:block" />
|
||||
<p className="hidden font-medium text-sm opacity-40 md:block">
|
||||
{customDescription ? customDescription : t("p_1079-1199_Simpleandbeautifuldashbo")}
|
||||
</p>
|
||||
</section>
|
||||
<section className="flex items-center gap-2">
|
||||
<div className="hidden sm:block">
|
||||
<Links />
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
<ModeToggle />
|
||||
</section>
|
||||
</section>
|
||||
<div className="mt-1 flex w-full justify-end sm:hidden">
|
||||
<Links />
|
||||
</div>
|
||||
<Overview />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
38
app/(main)/layout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import Footer from "@/app/(main)/footer"
|
||||
import Header from "@/app/(main)/header"
|
||||
import { ServerDataProvider } from "@/app/context/server-data-context"
|
||||
import { auth } from "@/auth"
|
||||
import { DashCommand } from "@/components/DashCommand"
|
||||
import { SignIn } from "@/components/SignIn"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import type React from "react"
|
||||
|
||||
type DashboardProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
export default function MainLayout({ children }: DashboardProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col">
|
||||
<main className="flex min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
|
||||
<Header />
|
||||
<AuthProtected>
|
||||
<ServerDataProvider>
|
||||
{children}
|
||||
<DashCommand />
|
||||
</ServerDataProvider>
|
||||
</AuthProtected>
|
||||
<Footer />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function AuthProtected({ children }: DashboardProps) {
|
||||
if (getEnv("SitePassword")) {
|
||||
const session = await auth()
|
||||
if (!session) {
|
||||
return <SignIn />
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
11
app/(main)/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import ServerListClient from "@/app/(main)/ClientComponents/main/ServerListClient"
|
||||
import ServerOverviewClient from "@/app/(main)/ClientComponents/main/ServerOverviewClient"
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
|
||||
<ServerOverviewClient />
|
||||
<ServerListClient />
|
||||
</div>
|
||||
)
|
||||
}
|
53
app/(main)/server/[id]/page.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { NetworkChartClient } from "@/app/(main)/ClientComponents/detail/NetworkChart"
|
||||
import ServerDetailChartClient from "@/app/(main)/ClientComponents/detail/ServerDetailChartClient"
|
||||
import ServerDetailClient from "@/app/(main)/ClientComponents/detail/ServerDetailClient"
|
||||
import ServerIPInfo from "@/app/(main)/ClientComponents/detail/ServerIPInfo"
|
||||
import TabSwitch from "@/components/TabSwitch"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { use, useState } from "react"
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
type TabType = "Detail" | "Network"
|
||||
|
||||
export default function Page({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
const serverId = Number(id)
|
||||
const tabs: TabType[] = ["Detail", "Network"]
|
||||
const [currentTab, setCurrentTab] = useState<TabType>(tabs[0])
|
||||
|
||||
const tabContent = {
|
||||
Detail: <ServerDetailChartClient server_id={serverId} show={currentTab === "Detail"} />,
|
||||
Network: (
|
||||
<>
|
||||
{getEnv("NEXT_PUBLIC_ShowIpInfo") && <ServerIPInfo server_id={serverId} />}
|
||||
<NetworkChartClient server_id={serverId} show={currentTab === "Network"} />
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto grid w-full max-w-5xl gap-2">
|
||||
<ServerDetailClient server_id={serverId} />
|
||||
|
||||
<nav className="my-2 flex w-full items-center">
|
||||
<Separator className="flex-1" />
|
||||
<div className="flex w-full max-w-[200px] justify-center">
|
||||
<TabSwitch
|
||||
tabs={tabs}
|
||||
currentTab={currentTab}
|
||||
setCurrentTab={(tab: string) => setCurrentTab(tab as TabType)}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
</nav>
|
||||
|
||||
{tabContent[currentTab]}
|
||||
</main>
|
||||
)
|
||||
}
|
@ -1,188 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import useSWR from "swr";
|
||||
import { ServerMonitorChart } from "../../types/nezha-api";
|
||||
import { formatTime, nezhaFetcher } from "@/lib/utils";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { BackIcon } from "@/components/Icon";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useTranslations } from "next-intl";
|
||||
import NetworkChartLoading from "./NetworkChartLoading";
|
||||
|
||||
export function NetworkChartClient({ server_id }: { server_id: number }) {
|
||||
const t = useTranslations("NetworkChartClient");
|
||||
const { data, error } = useSWR<ServerMonitorChart>(
|
||||
`/api/monitor?server_id=${server_id}`,
|
||||
nezhaFetcher,
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="text-sm font-medium opacity-40">{error.message}</p>
|
||||
</div>
|
||||
);
|
||||
if (!data) return <NetworkChartLoading />;
|
||||
|
||||
const initChartConfig = {
|
||||
avg_delay: {
|
||||
label: t("avg_delay"),
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const chartDataKey = Object.keys(data);
|
||||
|
||||
const generateChartConfig = chartDataKey.reduce((config, key, index) => {
|
||||
return {
|
||||
...config,
|
||||
[key]: {
|
||||
label: key,
|
||||
color: `hsl(var(--chart-${(index % 5) + 1}))`,
|
||||
},
|
||||
};
|
||||
}, {} as ChartConfig);
|
||||
|
||||
const chartConfig = { ...initChartConfig, ...generateChartConfig };
|
||||
|
||||
return (
|
||||
<NetworkChart
|
||||
chartDataKey={chartDataKey}
|
||||
chartConfig={chartConfig}
|
||||
chartData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NetworkChart({
|
||||
chartDataKey,
|
||||
chartConfig,
|
||||
chartData,
|
||||
}: {
|
||||
chartDataKey: string[];
|
||||
chartConfig: ChartConfig;
|
||||
chartData: ServerMonitorChart;
|
||||
}) {
|
||||
const t = useTranslations("NetworkChart");
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
const [activeChart, setActiveChart] = React.useState<
|
||||
keyof typeof chartConfig
|
||||
>(chartDataKey[0]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 p-0 sm:flex-row">
|
||||
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
|
||||
<CardTitle
|
||||
onClick={() => {
|
||||
router.push(`/${locale}/`);
|
||||
}}
|
||||
className="flex flex-none cursor-pointer items-center gap-0.5 text-xl"
|
||||
>
|
||||
<BackIcon />
|
||||
{chartData[chartDataKey[0]][0].server_name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{chartDataKey.length} {t("ServerMonitorCount")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap">
|
||||
{chartDataKey.map((key, index) => {
|
||||
const chart = key as keyof typeof chartConfig;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
data-active={activeChart === key}
|
||||
className={`relative z-30 flex flex-1 flex-col justify-center gap-1 border-b px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-6`}
|
||||
onClick={() => setActiveChart(key as keyof typeof chartConfig)}
|
||||
>
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{chart}
|
||||
</span>
|
||||
<span className="text-md font-bold leading-none sm:text-lg">
|
||||
{chartData[key][chartData[key].length - 1].avg_delay.toFixed(
|
||||
2,
|
||||
)}
|
||||
ms
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={chartData[activeChart]}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="created_at"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => formatRelativeTime(value)}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="avg_delay"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
mirror={true}
|
||||
tickMargin={-15}
|
||||
minTickGap={20}
|
||||
tickFormatter={(value) => `${value}ms`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator={"dot"}
|
||||
className="w-fit"
|
||||
nameKey="avg_delay"
|
||||
labelKey="created_at"
|
||||
labelClassName="text-muted-foreground"
|
||||
labelFormatter={(_, payload) => {
|
||||
return formatTime(payload[0].payload.created_at);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
type="linear"
|
||||
dot={false}
|
||||
dataKey="avg_delay"
|
||||
stroke={`var(--color-${activeChart})`}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ServerApi } from "../../types/nezha-api";
|
||||
import ServerCard from "../../../../components/ServerCard";
|
||||
import { nezhaFetcher } from "../../../../lib/utils";
|
||||
import useSWR from "swr";
|
||||
import getEnv from "../../../../lib/env-entry";
|
||||
import Switch from "@/components/Switch";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ServerListClient() {
|
||||
const t = useTranslations("ServerListClient");
|
||||
|
||||
const [tag, setTag] = useState<string>(t("defaultTag"));
|
||||
|
||||
const { data, error } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
|
||||
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
|
||||
});
|
||||
if (error)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="text-sm font-medium opacity-40">{error.message}</p>
|
||||
<p className="text-sm font-medium opacity-40">{t("error_message")}</p>
|
||||
</div>
|
||||
);
|
||||
if (!data) return null;
|
||||
|
||||
const { result } = data;
|
||||
|
||||
const sortedServers = result.sort((a, b) => {
|
||||
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0);
|
||||
if (displayIndexDiff !== 0) return displayIndexDiff;
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
const allTag = sortedServers.map((server) => server.tag).filter((tag) => tag);
|
||||
const uniqueTags = [...new Set(allTag)];
|
||||
|
||||
uniqueTags.unshift(t("defaultTag"));
|
||||
|
||||
const filteredServers =
|
||||
tag === t("defaultTag")
|
||||
? sortedServers
|
||||
: sortedServers.filter((server) => server.tag === tag);
|
||||
|
||||
return (
|
||||
<>
|
||||
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && uniqueTags.length > 1 && (
|
||||
<Switch allTag={uniqueTags} nowTag={tag} setTag={setTag} />
|
||||
)}
|
||||
<section className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{filteredServers.map((serverInfo) => (
|
||||
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { NetworkChartClient } from "../ClientComponents/NetworkChart";
|
||||
|
||||
export default function Page({ params }: { params: { id: string } }) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
|
||||
<NetworkChartClient server_id={Number(params.id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
export default function Footer() {
|
||||
const t = useTranslations("Footer");
|
||||
return (
|
||||
<footer className="mx-auto w-full max-w-5xl">
|
||||
<section className="flex flex-col">
|
||||
<p className="mt-3 flex gap-1 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
|
||||
{t("p_146-598_Findthecodeon")}{" "}
|
||||
<a
|
||||
href="https://github.com/hamster1963/nezha-dash"
|
||||
target="_blank"
|
||||
className="cursor-pointer font-normal underline decoration-yellow-500 decoration-2 underline-offset-2 dark:decoration-yellow-500/50"
|
||||
>
|
||||
{t("a_303-585_GitHub")}
|
||||
</a>
|
||||
</p>
|
||||
<section className="mt-1 flex items-center gap-2 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
|
||||
{t("section_607-869_2020")}
|
||||
{new Date().getFullYear()}{" "}
|
||||
<a href={"https://buycoffee.top"}>{t("a_800-850_Hamster1963")}</a>
|
||||
</section>
|
||||
</section>
|
||||
</footer>
|
||||
);
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Separator } from "../../../components/ui/separator";
|
||||
import { DateTime } from "luxon";
|
||||
import { ModeToggle } from "../../../components/ThemeSwitcher";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
function Header() {
|
||||
const t = useTranslations("Header");
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
<section className="flex items-center justify-between">
|
||||
<section className="flex items-center text-base font-medium">
|
||||
<div className="mr-1 flex flex-row items-center justify-start">
|
||||
<Image
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
alt="apple-touch-icon"
|
||||
src={"/apple-touch-icon.png"}
|
||||
className="relative !m-0 h-6 w-6 border-2 border-white object-cover object-top !p-0 transition duration-500 group-hover:z-30 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
NezhaDash
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 hidden h-4 w-[1px] md:block"
|
||||
/>
|
||||
<p className="hidden text-sm font-medium opacity-40 md:block">
|
||||
{t("p_1079-1199_Simpleandbeautifuldashbo")}
|
||||
</p>
|
||||
</section>
|
||||
<section className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ModeToggle />
|
||||
</section>
|
||||
</section>
|
||||
<Overview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// https://github.com/streamich/react-use/blob/master/src/useInterval.ts
|
||||
const useInterval = (callback: Function, delay?: number | null) => {
|
||||
const savedCallback = useRef<Function>(() => {});
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (delay !== null) {
|
||||
const interval = setInterval(() => savedCallback.current(), delay || 0);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
return undefined;
|
||||
}, [delay]);
|
||||
};
|
||||
function Overview() {
|
||||
const t = useTranslations("Overview");
|
||||
const [mouted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
const timeOption = DateTime.TIME_SIMPLE;
|
||||
timeOption.hour12 = true;
|
||||
const [timeString, setTimeString] = useState(
|
||||
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
|
||||
);
|
||||
useInterval(() => {
|
||||
setTimeString(DateTime.now().setLocale("en-US").toLocaleString(timeOption));
|
||||
}, 1000);
|
||||
return (
|
||||
<section className={"mt-10 flex flex-col md:mt-16"}>
|
||||
<p className="text-base font-semibold">{t("p_2277-2331_Overview")}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium opacity-50">
|
||||
{t("p_2390-2457_wherethetimeis")}
|
||||
</p>
|
||||
{mouted && (
|
||||
<p className="opacity-1 text-sm font-medium">{timeString}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export default Header;
|
@ -1,18 +0,0 @@
|
||||
import React from "react";
|
||||
import Header from "@/app/[locale]/(main)/header";
|
||||
import Footer from "./footer";
|
||||
|
||||
type DashboardProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export default function MainLayout({ children }: DashboardProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col">
|
||||
<main className="flex min-h-[calc(100vh_-_theme(spacing.16))] flex-1 flex-col gap-4 bg-muted/40 p-4 md:p-10 md:pt-8">
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
import { unstable_setRequestLocale } from "next-intl/server";
|
||||
import ServerList from "../../../components/ServerList";
|
||||
import ServerOverview from "../../../components/ServerOverview";
|
||||
|
||||
export default function Home({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
unstable_setRequestLocale(locale);
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
|
||||
<ServerOverview />
|
||||
<ServerList />
|
||||
</div>
|
||||
);
|
||||
}
|
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 263 B |
Before Width: | Height: | Size: 324 B |
@ -1,78 +0,0 @@
|
||||
// @auto-i18n-check. Please do not delete the line.
|
||||
|
||||
import "@/styles/globals.css";
|
||||
import "/node_modules/flag-icons/css/flag-icons.min.css";
|
||||
|
||||
import React from "react";
|
||||
import { NextIntlClientProvider, useMessages } from "next-intl";
|
||||
import { PublicEnvScript } from "next-runtime-env";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter as FontSans } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Viewport } from "next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { locales } from "@/i18n-metadata";
|
||||
import { unstable_setRequestLocale } from "next-intl/server";
|
||||
|
||||
const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
manifest: "/manifest.json",
|
||||
title: "NezhaDash",
|
||||
description: "A dashboard for nezha",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: "NezhaDash",
|
||||
statusBarStyle: "black-translucent",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default function LocaleLayout({
|
||||
children,
|
||||
params: { locale },
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { locale: string };
|
||||
}) {
|
||||
unstable_setRequestLocale(locale);
|
||||
|
||||
const messages = useMessages();
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-screen bg-background font-sans antialiased",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
export default function NotFoundPage() {
|
||||
const t = useTranslations("NotFoundPage");
|
||||
return (
|
||||
<main className="relative h-screen w-full">
|
||||
<div className="absolute inset-0 m-4 flex items-center justify-center">
|
||||
<Image
|
||||
priority
|
||||
className="rounded-3xl object-cover"
|
||||
src="/tardis.jpg"
|
||||
fill={true}
|
||||
alt="TARDIS"
|
||||
/>
|
||||
<div className="text-container absolute right-4 p-4 md:right-20">
|
||||
<h1 className="text-2xl font-bold opacity-80 md:text-5xl">
|
||||
{t("h1_490-590_404NotFound")}
|
||||
</h1>
|
||||
<p className="text-lg opacity-60 md:text-base">
|
||||
{t("p_601-665_TARDISERROR")}
|
||||
</p>
|
||||
<Link href={"/"} className="text-2xl opacity-80 md:text-3xl">
|
||||
{t("Link_676-775_Doctor")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
export type ServerApi = {
|
||||
live_servers: number;
|
||||
offline_servers: number;
|
||||
total_bandwidth: number;
|
||||
result: NezhaAPISafe[];
|
||||
};
|
||||
|
||||
export type NezhaAPISafe = Omit<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">;
|
||||
|
||||
export interface NezhaAPI {
|
||||
id: number;
|
||||
name: string;
|
||||
tag: string;
|
||||
last_active: number;
|
||||
online_status: boolean;
|
||||
ipv4: string;
|
||||
ipv6: string;
|
||||
valid_ip: string;
|
||||
display_index: number;
|
||||
host: NezhaAPIHost;
|
||||
status: NezhaAPIStatus;
|
||||
}
|
||||
|
||||
export interface NezhaAPIHost {
|
||||
Platform: string;
|
||||
PlatformVersion: string;
|
||||
CPU: string[];
|
||||
MemTotal: number;
|
||||
DiskTotal: number;
|
||||
SwapTotal: number;
|
||||
Arch: string;
|
||||
Virtualization: string;
|
||||
BootTime: number;
|
||||
CountryCode: string;
|
||||
Version: string;
|
||||
GPU: null;
|
||||
}
|
||||
|
||||
export interface NezhaAPIStatus {
|
||||
CPU: number;
|
||||
MemUsed: number;
|
||||
SwapUsed: number;
|
||||
DiskUsed: number;
|
||||
NetInTransfer: number;
|
||||
NetOutTransfer: number;
|
||||
NetInSpeed: number;
|
||||
NetOutSpeed: number;
|
||||
Uptime: number;
|
||||
Load1: number;
|
||||
Load5: number;
|
||||
Load15: number;
|
||||
TcpConnCount: number;
|
||||
UdpConnCount: number;
|
||||
ProcessCount: number;
|
||||
Temperatures: number;
|
||||
GPU: number;
|
||||
}
|
||||
|
||||
export type ServerMonitorChart = {
|
||||
[key: string]: {
|
||||
server_name: string;
|
||||
created_at: number;
|
||||
avg_delay: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export interface NezhaAPIMonitor {
|
||||
monitor_id: number;
|
||||
monitor_name: string;
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
created_at: number[];
|
||||
avg_delay: number[];
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export type MakeOptional<T, K extends keyof T> = Omit<T, K> &
|
||||
Partial<Pick<T, K>>;
|
BIN
app/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
app/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 16 KiB |
3
app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
44
app/api/detail/route.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { auth } from "@/auth"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { GetServerDetail } from "@/lib/serverFetch"
|
||||
import { redirect } from "next/navigation"
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
interface ResError extends Error {
|
||||
statusCode: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (getEnv("SitePassword")) {
|
||||
const session = await auth()
|
||||
if (!session) {
|
||||
redirect("/")
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const server_id = searchParams.get("server_id")
|
||||
|
||||
if (!server_id) {
|
||||
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const serverIdNum = Number.parseInt(server_id, 10)
|
||||
if (Number.isNaN(serverIdNum)) {
|
||||
return NextResponse.json({ error: "server_id must be a valid number" }, { status: 400 })
|
||||
}
|
||||
|
||||
const detailData = await GetServerDetail({ server_id: serverIdNum })
|
||||
return NextResponse.json(detailData, { status: 200 })
|
||||
} catch (error) {
|
||||
const err = error as ResError
|
||||
console.error("Error in GET handler:", err)
|
||||
const statusCode = err.statusCode || 500
|
||||
const message = err.message || "Internal Server Error"
|
||||
return NextResponse.json({ error: message }, { status: statusCode })
|
||||
}
|
||||
}
|
@ -1,27 +1,46 @@
|
||||
import { ServerMonitorChart } from "@/app/[locale]/types/nezha-api";
|
||||
import { GetServerMonitor } from "@/lib/serverFetch";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { GetServerMonitor } from "@/lib/serverFetch"
|
||||
import { redirect } from "next/navigation"
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
|
||||
interface NezhaDataResponse {
|
||||
error?: string;
|
||||
data?: ServerMonitorChart;
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
interface ResError extends Error {
|
||||
statusCode: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const server_id = searchParams.get("server_id");
|
||||
export async function GET(req: NextRequest) {
|
||||
if (getEnv("SitePassword")) {
|
||||
const session = await auth()
|
||||
if (!session) {
|
||||
redirect("/")
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const server_id = searchParams.get("server_id")
|
||||
|
||||
if (!server_id) {
|
||||
return NextResponse.json(
|
||||
{ error: "server_id is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
|
||||
}
|
||||
const response = (await GetServerMonitor({
|
||||
server_id: parseInt(server_id),
|
||||
})) as NezhaDataResponse;
|
||||
if (response.error) {
|
||||
console.log(response.error);
|
||||
return NextResponse.json({ error: response.error }, { status: 400 });
|
||||
|
||||
try {
|
||||
const serverIdNum = Number.parseInt(server_id, 10)
|
||||
if (Number.isNaN(serverIdNum)) {
|
||||
return NextResponse.json({ error: "server_id must be a number" }, { status: 400 })
|
||||
}
|
||||
|
||||
const monitorData = await GetServerMonitor({
|
||||
server_id: serverIdNum,
|
||||
})
|
||||
return NextResponse.json(monitorData, { status: 200 })
|
||||
} catch (error) {
|
||||
const err = error as ResError
|
||||
console.error("Error in GET handler:", err)
|
||||
const statusCode = err.statusCode || 500
|
||||
const message = err.message || "Internal Server Error"
|
||||
return NextResponse.json({ error: message }, { status: statusCode })
|
||||
}
|
||||
return NextResponse.json(response, { status: 200 });
|
||||
}
|
||||
|
66
app/api/server-ip/route.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { auth } from "@/auth"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { GetServerIP } from "@/lib/serverFetch"
|
||||
import { type AsnResponse, type CityResponse, Reader } from "maxmind"
|
||||
import { redirect } from "next/navigation"
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
interface ResError extends Error {
|
||||
statusCode: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export type IPInfo = {
|
||||
city: CityResponse
|
||||
asn: AsnResponse
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (getEnv("SitePassword")) {
|
||||
const session = await auth()
|
||||
if (!session) {
|
||||
redirect("/")
|
||||
}
|
||||
}
|
||||
|
||||
if (!getEnv("NEXT_PUBLIC_ShowIpInfo")) {
|
||||
return NextResponse.json({ error: "ip info is disabled" }, { status: 400 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const server_id = searchParams.get("server_id")
|
||||
|
||||
if (!server_id) {
|
||||
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const ip = await GetServerIP({ server_id: Number(server_id) })
|
||||
|
||||
const cityDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-City.mmdb")
|
||||
|
||||
const asnDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-ASN.mmdb")
|
||||
|
||||
const cityDbBuffer = fs.readFileSync(cityDbPath)
|
||||
const asnDbBuffer = fs.readFileSync(asnDbPath)
|
||||
|
||||
const cityLookup = new Reader<CityResponse>(cityDbBuffer)
|
||||
const asnLookup = new Reader<AsnResponse>(asnDbBuffer)
|
||||
|
||||
const data: IPInfo = {
|
||||
city: cityLookup.get(ip) as CityResponse,
|
||||
asn: asnLookup.get(ip) as AsnResponse,
|
||||
}
|
||||
return NextResponse.json(data, { status: 200 })
|
||||
} catch (error) {
|
||||
const err = error as ResError
|
||||
console.error("Error in GET handler:", err)
|
||||
const statusCode = err.statusCode || 500
|
||||
const message = err.message || "Internal Server Error"
|
||||
return NextResponse.json({ error: message }, { status: statusCode })
|
||||
}
|
||||
}
|
@ -1,19 +1,32 @@
|
||||
import { ServerApi } from "@/app/[locale]/types/nezha-api";
|
||||
import { GetNezhaData } from "@/lib/serverFetch";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { GetNezhaData } from "@/lib/serverFetch"
|
||||
import { redirect } from "next/navigation"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
interface NezhaDataResponse {
|
||||
error?: string;
|
||||
data?: ServerApi;
|
||||
interface ResError extends Error {
|
||||
statusCode: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function GET(_: Request) {
|
||||
const response = (await GetNezhaData()) as NezhaDataResponse;
|
||||
if (response.error) {
|
||||
console.log(response.error);
|
||||
return NextResponse.json({ error: response.error }, { status: 400 });
|
||||
export async function GET() {
|
||||
if (getEnv("SitePassword")) {
|
||||
const session = await auth()
|
||||
if (!session) {
|
||||
redirect("/")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await GetNezhaData()
|
||||
return NextResponse.json(data, { status: 200 })
|
||||
} catch (error) {
|
||||
const err = error as ResError
|
||||
console.error("Error in GET handler:", err)
|
||||
const statusCode = err.statusCode || 500
|
||||
const message = err.message || "Internal Server Error"
|
||||
return NextResponse.json({ error: message }, { status: statusCode })
|
||||
}
|
||||
return NextResponse.json(response, { status: 200 });
|
||||
}
|
||||
|
BIN
app/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
24
app/context/network-filter-context.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode, createContext, useContext, useState } from "react"
|
||||
|
||||
interface FilterContextType {
|
||||
filter: boolean
|
||||
setFilter: (filter: boolean) => void
|
||||
}
|
||||
|
||||
const FilterContext = createContext<FilterContextType | undefined>(undefined)
|
||||
|
||||
export function FilterProvider({ children }: { children: ReactNode }) {
|
||||
const [filter, setFilter] = useState<boolean>(false)
|
||||
|
||||
return <FilterContext.Provider value={{ filter, setFilter }}>{children}</FilterContext.Provider>
|
||||
}
|
||||
|
||||
export function useFilter() {
|
||||
const context = useContext(FilterContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useFilter must be used within a FilterProvider")
|
||||
}
|
||||
return context
|
||||
}
|
62
app/context/server-data-context.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import type { ServerApi } from "@/app/types/nezha-api"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { nezhaFetcher } from "@/lib/utils"
|
||||
import { type ReactNode, createContext, useContext, useEffect, useState } from "react"
|
||||
import useSWR from "swr"
|
||||
|
||||
export interface ServerDataWithTimestamp {
|
||||
timestamp: number
|
||||
data: ServerApi
|
||||
}
|
||||
|
||||
interface ServerDataContextType {
|
||||
data: ServerApi | undefined
|
||||
error: Error | undefined
|
||||
isLoading: boolean
|
||||
history: ServerDataWithTimestamp[]
|
||||
}
|
||||
|
||||
const ServerDataContext = createContext<ServerDataContextType | undefined>(undefined)
|
||||
|
||||
export const MAX_HISTORY_LENGTH = 30
|
||||
|
||||
export function ServerDataProvider({ children }: { children: ReactNode }) {
|
||||
const [history, setHistory] = useState<ServerDataWithTimestamp[]>([])
|
||||
|
||||
const { data, error, isLoading } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
|
||||
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
|
||||
dedupingInterval: 1000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setHistory((prev) => {
|
||||
const newHistory = [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
data: data,
|
||||
},
|
||||
...prev,
|
||||
].slice(0, MAX_HISTORY_LENGTH)
|
||||
|
||||
return newHistory
|
||||
})
|
||||
}
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<ServerDataContext.Provider value={{ data, error, isLoading, history }}>
|
||||
{children}
|
||||
</ServerDataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useServerData() {
|
||||
const context = useContext(ServerDataContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useServerData must be used within a ServerDataProvider")
|
||||
}
|
||||
return context
|
||||
}
|
26
app/context/status-context.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode, createContext, useContext, useState } from "react"
|
||||
|
||||
type Status = "all" | "online" | "offline"
|
||||
|
||||
interface StatusContextType {
|
||||
status: Status
|
||||
setStatus: (status: Status) => void
|
||||
}
|
||||
|
||||
const StatusContext = createContext<StatusContextType | undefined>(undefined)
|
||||
|
||||
export function StatusProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<Status>("all")
|
||||
|
||||
return <StatusContext.Provider value={{ status, setStatus }}>{children}</StatusContext.Provider>
|
||||
}
|
||||
|
||||
export function useStatus() {
|
||||
const context = useContext(StatusContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useStatus must be used within a StatusProvider")
|
||||
}
|
||||
return context
|
||||
}
|
39
app/context/tooltip-context.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode, createContext, useContext, useState } from "react"
|
||||
|
||||
export interface TooltipData {
|
||||
centroid: [number, number]
|
||||
country: string
|
||||
count: number
|
||||
servers: Array<{
|
||||
id: string
|
||||
name: string
|
||||
status: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
interface TooltipContextType {
|
||||
tooltipData: TooltipData | null
|
||||
setTooltipData: (data: TooltipData | null) => void
|
||||
}
|
||||
|
||||
const TooltipContext = createContext<TooltipContextType | undefined>(undefined)
|
||||
|
||||
export function TooltipProvider({ children }: { children: ReactNode }) {
|
||||
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null)
|
||||
|
||||
return (
|
||||
<TooltipContext.Provider value={{ tooltipData, setTooltipData }}>
|
||||
{children}
|
||||
</TooltipContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTooltip() {
|
||||
const context = useContext(TooltipContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useTooltip must be used within a TooltipProvider")
|
||||
}
|
||||
return context
|
||||
}
|
BIN
app/favicon-16x16.png
Normal file
After Width: | Height: | Size: 476 B |
BIN
app/favicon-32x32.png
Normal file
After Width: | Height: | Size: 769 B |
BIN
app/favicon.ico
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
87
app/layout.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { FilterProvider } from "@/app/context/network-filter-context"
|
||||
import { StatusProvider } from "@/app/context/status-context"
|
||||
import { ThemeColorManager } from "@/components/ThemeColorManager"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { cn } from "@/lib/utils"
|
||||
import "@/styles/globals.css"
|
||||
import type { Metadata } from "next"
|
||||
import type { Viewport } from "next"
|
||||
import { NextIntlClientProvider } from "next-intl"
|
||||
import { getLocale, getMessages } from "next-intl/server"
|
||||
import { PublicEnvScript } from "next-runtime-env"
|
||||
import { ThemeProvider } from "next-themes"
|
||||
import { Inter as FontSans } from "next/font/google"
|
||||
import type React from "react"
|
||||
|
||||
const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
})
|
||||
|
||||
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
|
||||
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
|
||||
const disableIndex = getEnv("NEXT_PUBLIC_DisableIndex")
|
||||
|
||||
export const metadata: Metadata = {
|
||||
manifest: "/manifest.json",
|
||||
title: customTitle || "NezhaDash",
|
||||
description: customDescription || "A dashboard for nezha",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: customTitle || "NezhaDash",
|
||||
statusBarStyle: "default",
|
||||
},
|
||||
robots: {
|
||||
index: !disableIndex,
|
||||
follow: !disableIndex,
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const locale = await getLocale()
|
||||
const messages = await getMessages()
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<PublicEnvScript />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fastly.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fastly.jsdelivr.net/npm/font-logos@1/assets/font-logos.css"
|
||||
/>
|
||||
</head>
|
||||
<body className={cn("min-h-screen bg-background font-sans antialiased", fontSans.variable)}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<FilterProvider>
|
||||
<StatusProvider>
|
||||
<ThemeColorManager />
|
||||
{children}
|
||||
</StatusProvider>
|
||||
</FilterProvider>
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
22
app/not-found.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import Footer from "@/app/(main)/footer"
|
||||
import Header from "@/app/(main)/header"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const t = useTranslations("NotFoundPage")
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col">
|
||||
<main className="flex min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
|
||||
<Header />
|
||||
<section className="flex flex-1 flex-col items-center justify-center gap-2">
|
||||
<p className="font-semibold text-sm">{t("h1_490-590_404NotFound")}</p>
|
||||
<Link href="/" className="flex items-center gap-1">
|
||||
<p className="font-medium text-sm opacity-40">{t("h1_490-590_404NotFoundBack")}</p>
|
||||
</Link>
|
||||
</section>
|
||||
<Footer />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
77
app/types/nezha-api.ts
Normal file
@ -0,0 +1,77 @@
|
||||
export type ServerApi = {
|
||||
live_servers: number
|
||||
offline_servers: number
|
||||
total_out_bandwidth: number
|
||||
total_in_bandwidth: number
|
||||
total_out_speed: number
|
||||
total_in_speed: number
|
||||
result: NezhaAPISafe[]
|
||||
}
|
||||
|
||||
export type NezhaAPISafe = Omit<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">
|
||||
|
||||
export interface NezhaAPI {
|
||||
id: number
|
||||
name: string
|
||||
tag: string
|
||||
last_active: number
|
||||
online_status: boolean
|
||||
ipv4: string
|
||||
ipv6: string
|
||||
valid_ip: string
|
||||
display_index: number
|
||||
hide_for_guest: boolean
|
||||
host: NezhaAPIHost
|
||||
status: NezhaAPIStatus
|
||||
}
|
||||
|
||||
export interface NezhaAPIHost {
|
||||
Platform: string
|
||||
PlatformVersion: string
|
||||
CPU: string[]
|
||||
MemTotal: number
|
||||
DiskTotal: number
|
||||
SwapTotal: number
|
||||
Arch: string
|
||||
Virtualization: string
|
||||
BootTime: number
|
||||
CountryCode: string
|
||||
Version: string
|
||||
GPU: string[]
|
||||
}
|
||||
|
||||
export interface NezhaAPIStatus {
|
||||
CPU: number
|
||||
MemUsed: number
|
||||
SwapUsed: number
|
||||
DiskUsed: number
|
||||
NetInTransfer: number
|
||||
NetOutTransfer: number
|
||||
NetInSpeed: number
|
||||
NetOutSpeed: number
|
||||
Uptime: number
|
||||
Load1: number
|
||||
Load5: number
|
||||
Load15: number
|
||||
TcpConnCount: number
|
||||
UdpConnCount: number
|
||||
ProcessCount: number
|
||||
Temperatures: number
|
||||
GPU: number
|
||||
}
|
||||
|
||||
export type ServerMonitorChart = {
|
||||
[key: string]: {
|
||||
created_at: number
|
||||
avg_delay: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface NezhaAPIMonitor {
|
||||
monitor_id: number
|
||||
monitor_name: string
|
||||
server_id: number
|
||||
server_name: string
|
||||
created_at: number[]
|
||||
avg_delay: number[]
|
||||
}
|
1
app/types/utils.ts
Normal file
@ -0,0 +1 @@
|
||||
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
|
34
auth.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import CryptoJS from "crypto-js"
|
||||
import NextAuth from "next-auth"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
secret:
|
||||
process.env.AUTH_SECRET ??
|
||||
CryptoJS.MD5(`this_is_nezha_dash_web_secret_${getEnv("SitePassword")}`).toString(),
|
||||
trustHost: (process.env.AUTH_TRUST_HOST as boolean | undefined) ?? true,
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
type: "credentials",
|
||||
credentials: { password: { label: "Password", type: "password" } },
|
||||
// authorization function
|
||||
async authorize(credentials) {
|
||||
const { password } = credentials
|
||||
if (password === getEnv("SitePassword")) {
|
||||
return { id: "nezha-dash-auth" }
|
||||
}
|
||||
return { error: "Invalid password" }
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async signIn({ user }) {
|
||||
// @ts-expect-error user is not null
|
||||
if (user.error) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"defaultLang": "en",
|
||||
"translatorServerName": "azure",
|
||||
"needLangs": ["en", "zh", "zh-t", "ja"],
|
||||
"brandWords": [],
|
||||
"unMoveToLocaleDirFiles": [],
|
||||
"enableStaticRendering": false,
|
||||
"enableSubPageRedirectToLocale": false,
|
||||
"disableDefaultLangRedirect": true
|
||||
}
|
86
biome.json
Normal file
@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
|
||||
"files": { "ignoreUnknown": false, "ignore": [".next", "public", "styles/globals.css"] },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"useEditorconfig": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100,
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
},
|
||||
"organizeImports": { "enabled": true },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"nursery": {
|
||||
"useSortedClasses": "error"
|
||||
},
|
||||
"a11y": {
|
||||
"useKeyWithClickEvents": "off",
|
||||
"noLabelWithoutControl": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
},
|
||||
"complexity": { "noUselessTypeConstraint": "error" },
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"useArrayLiterals": "off",
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"style": { "noNamespace": "error", "useAsConstAssertion": "error" },
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noExtraNonNullAssertion": "error",
|
||||
"noMisleadingInstantiator": "error",
|
||||
"noUnsafeDeclarationMerging": "error",
|
||||
"useNamespaceKeyword": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double",
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"include": ["*.ts", "*.tsx", "*.mts", "*.cts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"style": {
|
||||
"noArguments": "error",
|
||||
"noVar": "error",
|
||||
"useConst": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noClassAssign": "off",
|
||||
"noDuplicateClassMembers": "off",
|
||||
"noDuplicateObjectKeys": "off",
|
||||
"noDuplicateParameters": "off",
|
||||
"noFunctionAssign": "off",
|
||||
"noRedeclare": "off",
|
||||
"noUnsafeNegation": "off",
|
||||
"useGetterReturn": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
15
changelogithub.config.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"types": {
|
||||
"feat": { "title": "🚀 Features" },
|
||||
"fix": { "title": "🔧 Bug Fixes" },
|
||||
"docs": { "title": "📚 Documentation" },
|
||||
"style": { "title": "💄 Styles" },
|
||||
"refactor": { "title": "🔨 Refactor" },
|
||||
"perf": { "title": "🏎 Performance" },
|
||||
"test": { "title": "🚨 Tests" },
|
||||
"build": { "title": "🛠 Build" },
|
||||
"ci": { "title": "👷 CI" },
|
||||
"chore": { "title": "🛗 Chore" },
|
||||
"revert": { "title": "⏪ Revert" }
|
||||
}
|
||||
}
|
96
components/AnimatedCount.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export default function AnimateCountClient({
|
||||
count,
|
||||
className,
|
||||
minDigits,
|
||||
}: {
|
||||
count: number
|
||||
className?: string
|
||||
minDigits?: number
|
||||
}) {
|
||||
const [previousCount, setPreviousCount] = useState(count)
|
||||
|
||||
useEffect(() => {
|
||||
if (count !== previousCount) {
|
||||
setTimeout(() => {
|
||||
setPreviousCount(count)
|
||||
}, 300)
|
||||
}
|
||||
}, [count])
|
||||
return (
|
||||
<AnimateCount
|
||||
key={count}
|
||||
preCount={previousCount}
|
||||
className={cn("inline-flex items-center leading-none", className)}
|
||||
minDigits={minDigits}
|
||||
data-issues-count-animation
|
||||
>
|
||||
{count}
|
||||
</AnimateCount>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimateCount({
|
||||
children: count,
|
||||
className,
|
||||
preCount,
|
||||
minDigits = 1,
|
||||
...props
|
||||
}: {
|
||||
children: number
|
||||
className?: string
|
||||
preCount?: number
|
||||
minDigits?: number
|
||||
}) {
|
||||
const currentDigits = count.toString().split("")
|
||||
const previousDigits = (
|
||||
preCount !== undefined ? preCount.toString() : count - 1 >= 0 ? (count - 1).toString() : "0"
|
||||
).split("")
|
||||
|
||||
// Ensure both numbers meet the minimum length requirement and maintain the same length for animation
|
||||
const maxLength = Math.max(previousDigits.length, currentDigits.length, minDigits)
|
||||
while (previousDigits.length < maxLength) {
|
||||
previousDigits.unshift("0")
|
||||
}
|
||||
while (currentDigits.length < maxLength) {
|
||||
currentDigits.unshift("0")
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...props} className={cn("flex h-[1em] items-center", className)}>
|
||||
{currentDigits.map((digit, index) => {
|
||||
const hasChanged = digit !== previousDigits[index]
|
||||
return (
|
||||
<div
|
||||
key={`${index}-${digit}`}
|
||||
className={cn("relative flex h-full min-w-[0.6em] items-center text-center", {
|
||||
"min-w-[0.2em]": digit === ".",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
data-issues-count-exit
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
hasChanged ? "animate" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{previousDigits[index]}
|
||||
</div>
|
||||
<div
|
||||
data-issues-count-enter
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
hasChanged && "animate",
|
||||
)}
|
||||
>
|
||||
{digit}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
import React from "react";
|
||||
const BlurLayers = () => {
|
||||
const computeLayerStyle = (index: number) => {
|
||||
const blurAmount = index * 3.7037;
|
||||
const maskStart = index * 10;
|
||||
let maskEnd = maskStart + 20;
|
||||
if (maskEnd > 100) {
|
||||
maskEnd = 100;
|
||||
}
|
||||
return {
|
||||
backdropFilter: `blur(${blurAmount}px)`,
|
||||
WebkitBackdropFilter: `blur(${blurAmount}px)`,
|
||||
zIndex: index + 1,
|
||||
maskImage: `linear-gradient(rgba(0, 0, 0, 0) ${maskStart}%, rgb(0, 0, 0) ${maskEnd}%)`,
|
||||
};
|
||||
};
|
||||
|
||||
// 根据层数动态生成层
|
||||
const layers = Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={"absolute inset-0 h-full w-full"}
|
||||
style={computeLayerStyle(index)}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className={"fixed bottom-0 left-0 right-0 z-50 h-[140px]"}>
|
||||
<div className={"relative h-full"}>{layers}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlurLayers;
|
135
components/DashCommand.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import { Home, Languages, Moon, Sun, SunMoon } from "lucide-react"
|
||||
|
||||
import { useServerData } from "@/app/context/server-data-context"
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command"
|
||||
import { localeItems } from "@/i18n-metadata"
|
||||
import { setUserLocale } from "@/i18n/locale"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function DashCommand() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const { data } = useServerData()
|
||||
const router = useRouter()
|
||||
const { setTheme } = useTheme()
|
||||
const t = useTranslations("DashCommand")
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
setOpen((open) => !open)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => document.removeEventListener("keydown", down)
|
||||
}, [])
|
||||
|
||||
if (!data?.result) return null
|
||||
|
||||
const sortedServers = data.result.sort((a, b) => {
|
||||
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
|
||||
if (displayIndexDiff !== 0) return displayIndexDiff
|
||||
return a.id - b.id
|
||||
})
|
||||
|
||||
const languageShortcuts = localeItems.map((item) => ({
|
||||
keywords: ["language", "locale", item.code.toLowerCase()],
|
||||
icon: <Languages />,
|
||||
label: item.name,
|
||||
action: () => setUserLocale(item.code),
|
||||
value: `language ${item.name.toLowerCase()} ${item.code}`,
|
||||
}))
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
keywords: ["home", "homepage"],
|
||||
icon: <Home />,
|
||||
label: t("Home"),
|
||||
action: () => router.push("/"),
|
||||
},
|
||||
{
|
||||
keywords: ["light", "theme", "lightmode"],
|
||||
icon: <Sun />,
|
||||
label: t("ToggleLightMode"),
|
||||
action: () => setTheme("light"),
|
||||
},
|
||||
{
|
||||
keywords: ["dark", "theme", "darkmode"],
|
||||
icon: <Moon />,
|
||||
label: t("ToggleDarkMode"),
|
||||
action: () => setTheme("dark"),
|
||||
},
|
||||
{
|
||||
keywords: ["system", "theme", "systemmode"],
|
||||
icon: <SunMoon />,
|
||||
label: t("ToggleSystemMode"),
|
||||
action: () => setTheme("system"),
|
||||
},
|
||||
...languageShortcuts,
|
||||
].map((item) => ({
|
||||
...item,
|
||||
value: `${item.keywords.join(" ")} ${item.label}`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t("TypeCommand")} value={search} onValueChange={setSearch} />
|
||||
<CommandList className="border-t">
|
||||
<CommandEmpty>{t("NoResults")}</CommandEmpty>
|
||||
<CommandGroup heading={t("Servers")}>
|
||||
{sortedServers.map((server) => (
|
||||
<CommandItem
|
||||
key={server.id}
|
||||
value={server.name}
|
||||
onSelect={() => {
|
||||
router.push(`/server/${server.id}`)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{server.online_status ? (
|
||||
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
|
||||
) : (
|
||||
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
|
||||
)}
|
||||
<span>{server.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading={t("Shortcuts")}>
|
||||
{shortcuts.map((item) => (
|
||||
<CommandItem
|
||||
key={item.label}
|
||||
value={item.value}
|
||||
onSelect={() => {
|
||||
item.action()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,70 +1,58 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
import { localeItems } from "../i18n-metadata";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { localeItems } from "@/i18n-metadata"
|
||||
import { setUserLocale } from "@/i18n/locale"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckCircleIcon, LanguageIcon } from "@heroicons/react/20/solid"
|
||||
import { useLocale } from "next-intl"
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale()
|
||||
|
||||
const handleChange = (code: string) => {
|
||||
const newLocale = code;
|
||||
|
||||
const rootPath = "/";
|
||||
const currentLocalePath = `/${locale}`;
|
||||
const newLocalePath = `/${newLocale}`;
|
||||
|
||||
// Function to construct new path with locale prefix
|
||||
const constructLocalePath = (path: string, newLocale: string) => {
|
||||
if (path.startsWith(currentLocalePath)) {
|
||||
return path.replace(currentLocalePath, `/${newLocale}`);
|
||||
} else {
|
||||
return `/${newLocale}${path}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (pathname === rootPath || !pathname) {
|
||||
router.push(newLocalePath);
|
||||
} else if (
|
||||
pathname === currentLocalePath ||
|
||||
pathname === `${currentLocalePath}/`
|
||||
) {
|
||||
router.push(newLocalePath);
|
||||
} else {
|
||||
const newPath = constructLocalePath(pathname, newLocale);
|
||||
router.push(newPath);
|
||||
}
|
||||
};
|
||||
const handleSelect = (e: Event, newLocale: string) => {
|
||||
e.preventDefault() // 阻止默认的关闭行为
|
||||
setUserLocale(newLocale)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="rounded-full px-[9px]">
|
||||
{localeItems.find((item) => item.code === locale)?.name}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
|
||||
>
|
||||
<LanguageIcon className="size-4" />
|
||||
<span className="sr-only">Change language</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{localeItems.map((item) => (
|
||||
<DropdownMenuContent className="flex flex-col gap-0.5" align="end">
|
||||
{localeItems.map((item, index) => (
|
||||
<DropdownMenuItem
|
||||
key={item.code}
|
||||
onClick={() => handleChange(item.code)}
|
||||
onSelect={(e) => handleSelect(e, item.code)}
|
||||
className={cn(
|
||||
{
|
||||
"gap-3 bg-muted font-semibold": locale === item.code,
|
||||
},
|
||||
{
|
||||
"rounded-t-[5px]": index === localeItems.length - 1,
|
||||
"rounded-[5px]": index !== 0 && index !== localeItems.length - 1,
|
||||
"rounded-b-[5px]": index === 0,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{item.name} {locale === item.code && <CheckCircleIcon className="size-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,124 +1,174 @@
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { NezhaAPISafe } from "../app/[locale]/types/nezha-api";
|
||||
import ServerUsageBar from "@/components/ServerUsageBar";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn, formatNezhaInfo } from "@/lib/utils";
|
||||
import ServerCardPopover from "./ServerCardPopover";
|
||||
|
||||
import { env } from "next-runtime-env";
|
||||
import ServerFlag from "./ServerFlag";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { NezhaAPISafe } from "@/app/types/nezha-api"
|
||||
import ServerFlag from "@/components/ServerFlag"
|
||||
import ServerUsageBar from "@/components/ServerUsageBar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
|
||||
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function ServerCard({
|
||||
serverInfo,
|
||||
}: {
|
||||
serverInfo: NezhaAPISafe;
|
||||
serverInfo: NezhaAPISafe
|
||||
}) {
|
||||
const t = useTranslations("ServerCard");
|
||||
const router = useRouter();
|
||||
const { id, name, country_code, online, cpu, up, down, mem, stg, ...props } =
|
||||
formatNezhaInfo(serverInfo);
|
||||
const t = useTranslations("ServerCard")
|
||||
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
|
||||
formatNezhaInfo(serverInfo)
|
||||
|
||||
const showFlag = env("NEXT_PUBLIC_ShowFlag") === "true";
|
||||
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
|
||||
const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"
|
||||
const fixedTopServerName = getEnv("NEXT_PUBLIC_FixedTopServerName") === "true"
|
||||
|
||||
const locale = useLocale();
|
||||
const saveSession = () => {
|
||||
sessionStorage.setItem("fromMainPage", "true")
|
||||
}
|
||||
|
||||
return online ? (
|
||||
<Card
|
||||
className={
|
||||
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row"
|
||||
}
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<section className={"flex items-center justify-start gap-2 lg:w-28"}>
|
||||
{showFlag ? <ServerFlag country_code={country_code} /> : null}
|
||||
<p
|
||||
className={cn(
|
||||
"break-all font-bold tracking-tight",
|
||||
showFlag ? "text-xs" : "text-sm",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500"></span>
|
||||
</section>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top">
|
||||
<ServerCardPopover status={props.status} host={props.host} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<section
|
||||
onClick={() => {
|
||||
router.push(`/${locale}/${id}`);
|
||||
}}
|
||||
className={"grid cursor-pointer grid-cols-5 items-center gap-3"}
|
||||
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
|
||||
<Card
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
|
||||
{
|
||||
"flex-col": fixedTopServerName,
|
||||
"lg:flex-row": !fixedTopServerName,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-xs text-muted-foreground">{t("CPU")}</p>
|
||||
<div className="flex items-center text-xs font-semibold">
|
||||
{cpu.toFixed(2)}%
|
||||
</div>
|
||||
<ServerUsageBar value={cpu} />
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-xs text-muted-foreground">{t("Mem")}</p>
|
||||
<div className="flex items-center text-xs font-semibold">
|
||||
{mem.toFixed(2)}%
|
||||
</div>
|
||||
<ServerUsageBar value={mem} />
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-xs text-muted-foreground">{t("STG")}</p>
|
||||
<div className="flex items-center text-xs font-semibold">
|
||||
{stg.toFixed(2)}%
|
||||
</div>
|
||||
<ServerUsageBar value={stg} />
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-xs text-muted-foreground">{t("Upload")}</p>
|
||||
<div className="flex items-center text-xs font-semibold">
|
||||
{up.toFixed(2)}M/s
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-xs text-muted-foreground">{t("Download")}</p>
|
||||
<div className="flex items-center text-xs font-semibold">
|
||||
{down.toFixed(2)}M/s
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
className={
|
||||
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row"
|
||||
}
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<section className={"flex items-center justify-start gap-2 lg:w-28"}>
|
||||
<section
|
||||
className={cn("grid items-center gap-2", {
|
||||
"lg:w-40": !fixedTopServerName,
|
||||
})}
|
||||
style={{ gridTemplateColumns: "auto auto 1fr" }}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
showFlag ? "min-w-[17px]" : "min-w-0",
|
||||
)}
|
||||
>
|
||||
{showFlag ? <ServerFlag country_code={country_code} /> : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<p
|
||||
className={cn(
|
||||
"break-all font-bold tracking-tight",
|
||||
showFlag ? "text-xs" : "text-sm",
|
||||
showFlag ? "text-xs " : "text-sm",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-500"></span>
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex flex-col gap-2">
|
||||
<section
|
||||
className={cn("grid grid-cols-5 items-center gap-3", {
|
||||
"lg:grid-cols-6 lg:gap-4": fixedTopServerName,
|
||||
})}
|
||||
>
|
||||
{fixedTopServerName && (
|
||||
<div className={"col-span-1 hidden items-center gap-2 lg:flex lg:flex-row"}>
|
||||
<div className="font-semibold text-xs">
|
||||
{host.Platform.includes("Windows") ? (
|
||||
<MageMicrosoftWindows className="size-[10px]" />
|
||||
) : (
|
||||
<p className={`fl-${GetFontLogoClass(host.Platform)}`} />
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("System")}</p>
|
||||
<div className="flex items-center font-semibold text-[10.5px]">
|
||||
{host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
|
||||
<ServerUsageBar value={cpu} />
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
|
||||
<ServerUsageBar value={mem} />
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("STG")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
|
||||
<ServerUsageBar value={stg} />
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">
|
||||
{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("Download")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">
|
||||
{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit p-2" side="top">
|
||||
<p className="text-sm text-muted-foreground">{t("Offline")}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Card>
|
||||
);
|
||||
{showNetTransfer && (
|
||||
<section className={"flex items-center justify-between gap-1"}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-1 items-center justify-center text-nowrap rounded-[8px] border-muted-50 text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
|
||||
>
|
||||
{t("Upload")}:{formatBytes(serverInfo.status.NetOutTransfer)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex-1 items-center justify-center text-nowrap rounded-[8px] text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
|
||||
>
|
||||
{t("Download")}:{formatBytes(serverInfo.status.NetInTransfer)}
|
||||
</Badge>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
) : (
|
||||
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
|
||||
<Card
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
|
||||
showNetTransfer ? "min-h-[123px] lg:min-h-[91px]" : "min-h-[93px] lg:min-h-[61px]",
|
||||
{
|
||||
"flex-col": fixedTopServerName,
|
||||
"lg:flex-row": !fixedTopServerName,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<section
|
||||
className={cn("grid items-center gap-2", {
|
||||
"lg:w-40": !fixedTopServerName,
|
||||
})}
|
||||
style={{ gridTemplateColumns: "auto auto 1fr" }}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
showFlag ? "min-w-[17px]" : "min-w-0",
|
||||
)}
|
||||
>
|
||||
{showFlag ? <ServerFlag country_code={country_code} /> : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<p
|
||||
className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
157
components/ServerCardInline.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import type { NezhaAPISafe } from "@/app/types/nezha-api"
|
||||
import ServerFlag from "@/components/ServerFlag"
|
||||
import ServerUsageBar from "@/components/ServerUsageBar"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
|
||||
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Separator } from "./ui/separator"
|
||||
|
||||
export default function ServerCardInline({
|
||||
serverInfo,
|
||||
}: {
|
||||
serverInfo: NezhaAPISafe
|
||||
}) {
|
||||
const t = useTranslations("ServerCard")
|
||||
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
|
||||
formatNezhaInfo(serverInfo)
|
||||
|
||||
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
|
||||
|
||||
const saveSession = () => {
|
||||
sessionStorage.setItem("fromMainPage", "true")
|
||||
}
|
||||
|
||||
return online ? (
|
||||
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
|
||||
<Card
|
||||
className={cn(
|
||||
"flex w-full min-w-[900px] cursor-pointer items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 lg:flex-row dark:hover:border-stone-700",
|
||||
)}
|
||||
>
|
||||
<section
|
||||
className={cn("grid items-center gap-2 lg:w-36")}
|
||||
style={{ gridTemplateColumns: "auto auto 1fr" }}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
showFlag ? "min-w-[17px]" : "min-w-0",
|
||||
)}
|
||||
>
|
||||
{showFlag ? <ServerFlag country_code={country_code} /> : null}
|
||||
</div>
|
||||
<div className="relative w-28">
|
||||
<p
|
||||
className={cn(
|
||||
"break-all font-bold tracking-tight",
|
||||
showFlag ? "text-xs " : "text-sm",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<Separator orientation="vertical" className="mx-0 ml-2 h-8" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<section className={cn("grid flex-1 grid-cols-9 items-center gap-3")}>
|
||||
<div className={"flex flex-row items-center gap-2 whitespace-nowrap"}>
|
||||
<div className="font-semibold text-xs">
|
||||
{host.Platform.includes("Windows") ? (
|
||||
<MageMicrosoftWindows className="size-[10px]" />
|
||||
) : (
|
||||
<p className={`fl-${GetFontLogoClass(host.Platform)}`} />
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("System")}</p>
|
||||
<div className="flex items-center font-semibold text-[10.5px]">
|
||||
{host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex w-20 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("Uptime")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">
|
||||
{(serverInfo?.status.Uptime / 86400).toFixed(0)} {"Days"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
|
||||
<ServerUsageBar value={cpu} />
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
|
||||
<ServerUsageBar value={mem} />
|
||||
</div>
|
||||
<div className={"flex w-14 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("STG")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
|
||||
<ServerUsageBar value={stg} />
|
||||
</div>
|
||||
<div className={"flex w-16 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">
|
||||
{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex w-16 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("Download")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">
|
||||
{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex w-20 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("TotalUpload")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">
|
||||
{formatBytes(serverInfo.status.NetOutTransfer)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex w-20 flex-col"}>
|
||||
<p className="text-muted-foreground text-xs">{t("TotalDownload")}</p>
|
||||
<div className="flex items-center font-semibold text-xs">
|
||||
{formatBytes(serverInfo.status.NetInTransfer)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
) : (
|
||||
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
|
||||
<Card
|
||||
className={cn(
|
||||
"flex min-h-[61px] min-w-[900px] flex-row items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
|
||||
)}
|
||||
>
|
||||
<section
|
||||
className={cn("grid items-center gap-2 lg:w-40")}
|
||||
style={{ gridTemplateColumns: "auto auto 1fr" }}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
showFlag ? "min-w-[17px]" : "min-w-0",
|
||||
)}
|
||||
>
|
||||
{showFlag ? <ServerFlag country_code={country_code} /> : null}
|
||||
</div>
|
||||
<div className="relative w-28">
|
||||
<p
|
||||
className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { NezhaAPISafe } from "../app/[locale]/types/nezha-api";
|
||||
import { cn, formatBytes } from "@/lib/utils";
|
||||
|
||||
export function ServerCardPopoverCard({
|
||||
className,
|
||||
title,
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mb-[6px] flex w-full flex-col", className)}>
|
||||
<div className="text-sm font-semibold">{title}</div>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="break-all text-xs font-medium">{content}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerCardPopover({
|
||||
host,
|
||||
status,
|
||||
}: {
|
||||
host: NezhaAPISafe["host"];
|
||||
status: NezhaAPISafe["status"];
|
||||
}) {
|
||||
const t = useTranslations("ServerCardPopover");
|
||||
return (
|
||||
<section className="max-w-[300px]">
|
||||
<ServerCardPopoverCard
|
||||
title={t("System")}
|
||||
content={`${host.Platform}-${host.PlatformVersion} [${host.Virtualization}: ${host.Arch}]`}
|
||||
/>
|
||||
<ServerCardPopoverCard
|
||||
title={t("CPU")}
|
||||
content={`${host.CPU.map((item) => item).join(", ")}`}
|
||||
/>
|
||||
<ServerCardPopoverCard
|
||||
title={t("Mem")}
|
||||
content={`${formatBytes(status.MemUsed)} / ${formatBytes(host.MemTotal)}`}
|
||||
/>
|
||||
<ServerCardPopoverCard
|
||||
title={t("STG")}
|
||||
content={`${formatBytes(status.DiskUsed)} / ${formatBytes(host.DiskTotal)}`}
|
||||
/>
|
||||
<ServerCardPopoverCard
|
||||
title={t("Swap")}
|
||||
content={`${formatBytes(status.SwapUsed)} / ${formatBytes(host.SwapTotal)}`}
|
||||
/>
|
||||
<ServerCardPopoverCard
|
||||
title={t("Network")}
|
||||
content={`${formatBytes(status.NetOutTransfer)} / ${formatBytes(status.NetInTransfer)}`}
|
||||
/>
|
||||
<ServerCardPopoverCard
|
||||
title={t("Load")}
|
||||
content={`${status.Load1.toFixed(2)} / ${status.Load5.toFixed(2)} / ${status.Load15.toFixed(2)}`}
|
||||
/>
|
||||
<ServerCardPopoverCard
|
||||
className="mb-0"
|
||||
title={t("Online")}
|
||||
content={`${(status.Uptime / 86400).toFixed(0)} Days`}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,36 +1,52 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import getUnicodeFlagIcon from "country-flag-icons/unicode";
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { cn } from "@/lib/utils"
|
||||
import getUnicodeFlagIcon from "country-flag-icons/unicode"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export default function ServerFlag({ country_code }: { country_code: string }) {
|
||||
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(false);
|
||||
export default function ServerFlag({
|
||||
country_code,
|
||||
className,
|
||||
}: {
|
||||
country_code: string
|
||||
className?: string
|
||||
}) {
|
||||
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(true)
|
||||
|
||||
const useSvgFlag = getEnv("NEXT_PUBLIC_ForceUseSvgFlag") === "true"
|
||||
|
||||
useEffect(() => {
|
||||
if (useSvgFlag) {
|
||||
// 如果环境变量要求直接使用 SVG,则无需检查 Emoji 支持
|
||||
setSupportsEmojiFlags(false)
|
||||
return
|
||||
}
|
||||
|
||||
const checkEmojiSupport = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const emojiFlag = "🇺🇸"; // 使用美国国旗作为测试
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.font = "32px Arial";
|
||||
ctx.fillText(emojiFlag, 0, 0);
|
||||
const canvas = document.createElement("canvas")
|
||||
const ctx = canvas.getContext("2d")
|
||||
const emojiFlag = "🇺🇸" // 使用美国国旗作为测试
|
||||
if (!ctx) return
|
||||
ctx.fillStyle = "#000"
|
||||
ctx.textBaseline = "top"
|
||||
ctx.font = "32px Arial"
|
||||
ctx.fillText(emojiFlag, 0, 0)
|
||||
|
||||
const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0;
|
||||
setSupportsEmojiFlags(support);
|
||||
};
|
||||
const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0
|
||||
setSupportsEmojiFlags(support)
|
||||
}
|
||||
|
||||
checkEmojiSupport();
|
||||
}, []);
|
||||
checkEmojiSupport()
|
||||
}, [useSvgFlag]) // 将 `useSvgFlag` 作为依赖,当其变化时重新触发
|
||||
|
||||
if (!country_code) return null;
|
||||
if (!country_code) return null
|
||||
|
||||
return (
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{!supportsEmojiFlags ? (
|
||||
<span className={`fi fi-${country_code}`}></span>
|
||||
<span className={cn("text-[12px] text-muted-foreground", className)}>
|
||||
{useSvgFlag || !supportsEmojiFlags ? (
|
||||
<span className={`fi fi-${country_code}`} />
|
||||
) : (
|
||||
getUnicodeFlagIcon(country_code)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import ServerListClient from "../app/[locale]/(main)/ClientComponents/ServerListClient";
|
||||
|
||||
export default async function ServerList() {
|
||||
return <ServerListClient />;
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import ServerOverviewClient from "../app/[locale]/(main)/ClientComponents/ServerOverviewClient";
|
||||
|
||||
export default async function ServerOverview() {
|
||||
return <ServerOverviewClient />;
|
||||
}
|
@ -1,10 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
type ServerUsageBarProps = {
|
||||
value: number;
|
||||
};
|
||||
value: number
|
||||
}
|
||||
|
||||
export default function ServerUsageBar({ value }: ServerUsageBarProps) {
|
||||
return (
|
||||
@ -12,14 +10,8 @@ export default function ServerUsageBar({ value }: ServerUsageBarProps) {
|
||||
aria-label={"Server Usage Bar"}
|
||||
aria-labelledby={"Server Usage Bar"}
|
||||
value={value}
|
||||
indicatorClassName={
|
||||
value > 90
|
||||
? "bg-red-500"
|
||||
: value > 70
|
||||
? "bg-orange-400"
|
||||
: "bg-green-500"
|
||||
}
|
||||
indicatorClassName={value > 90 ? "bg-red-500" : value > 70 ? "bg-orange-400" : "bg-green-500"}
|
||||
className={"h-[3px] rounded-sm"}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
80
components/SignIn.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { getCsrfToken, signIn } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Loader } from "./loading/Loader"
|
||||
|
||||
export function SignIn() {
|
||||
const t = useTranslations("SignIn")
|
||||
|
||||
const [csrfToken, setCsrfToken] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorState, setErrorState] = useState(false)
|
||||
const [successState, setSuccessState] = useState(false)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProviders() {
|
||||
const csrf = await getCsrfToken()
|
||||
setCsrfToken(csrf)
|
||||
}
|
||||
loadProviders()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const password = formData.get("password") as string
|
||||
const res = await signIn("credentials", {
|
||||
password: password,
|
||||
redirect: false,
|
||||
})
|
||||
if (res?.error) {
|
||||
console.log("login error")
|
||||
setErrorState(true)
|
||||
setSuccessState(false)
|
||||
} else {
|
||||
console.log("login success")
|
||||
setErrorState(false)
|
||||
setSuccessState(true)
|
||||
router.push("/")
|
||||
router.refresh()
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
return (
|
||||
<form
|
||||
className="flex flex-1 flex-col items-center justify-center gap-4 p-4 "
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<section className="flex flex-col items-start gap-2">
|
||||
<label className="flex flex-col items-start gap-1 ">
|
||||
{errorState && <p className="font-semibold text-red-500 text-sm">{t("ErrorMessage")}</p>}
|
||||
{successState && (
|
||||
<p className="font-semibold text-green-500 text-sm">{t("SuccessMessage")}</p>
|
||||
)}
|
||||
<p className="font-semibold text-base">{t("SignInMessage")}</p>
|
||||
<input
|
||||
className="rounded-[5px] border-[1px] border-stone-300 px-1 dark:border-stone-800"
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-fit items-center gap-1 rounded-[8px] border border-stone-300 bg-card px-1.5 py-0.5 font-semibold text-card-foreground text-sm shadow-lg shadow-neutral-200/40 transition-all hover:brightness-95 dark:border-stone-800 dark:shadow-none"
|
||||
disabled={loading}
|
||||
>
|
||||
{t("Submit")}
|
||||
{loading && <Loader visible={true} />}
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
)
|
||||
}
|
@ -1,48 +1,131 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import getEnv from "@/lib/env-entry"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { createRef, useEffect, useRef, useState } from "react"
|
||||
|
||||
export default function Switch({
|
||||
allTag,
|
||||
nowTag,
|
||||
setTag,
|
||||
tagCountMap,
|
||||
onTagChange,
|
||||
}: {
|
||||
allTag: string[];
|
||||
nowTag: string;
|
||||
setTag: (tag: string) => void;
|
||||
allTag: string[]
|
||||
nowTag: string
|
||||
tagCountMap: Record<string, number>
|
||||
onTagChange: (tag: string) => void
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const tagRefs = useRef(allTag.map(() => createRef<HTMLDivElement>()))
|
||||
const t = useTranslations("ServerListClient")
|
||||
const locale = useLocale()
|
||||
const [indicator, setIndicator] = useState<{ x: number; w: number } | null>(null)
|
||||
const [isFirstRender, setIsFirstRender] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const savedTag = sessionStorage.getItem("selectedTag")
|
||||
if (savedTag && allTag.includes(savedTag)) {
|
||||
onTagChange(savedTag)
|
||||
}
|
||||
}, [allTag, onTagChange])
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current
|
||||
if (!container) return
|
||||
|
||||
const isOverflowing = container.scrollWidth > container.clientWidth
|
||||
if (!isOverflowing) return
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
container.scrollLeft += e.deltaY
|
||||
}
|
||||
|
||||
container.addEventListener("wheel", onWheel, { passive: false })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("wheel", onWheel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
|
||||
if (currentTagElement) {
|
||||
setIndicator({
|
||||
x: currentTagElement.offsetLeft,
|
||||
w: currentTagElement.offsetWidth,
|
||||
})
|
||||
}
|
||||
|
||||
if (isFirstRender) {
|
||||
setTimeout(() => {
|
||||
setIsFirstRender(false)
|
||||
}, 50)
|
||||
}
|
||||
}, [nowTag, locale, allTag, isFirstRender])
|
||||
|
||||
useEffect(() => {
|
||||
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
|
||||
const container = scrollRef.current
|
||||
|
||||
if (currentTagElement && container) {
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const tagRect = currentTagElement.getBoundingClientRect()
|
||||
|
||||
const scrollLeft = currentTagElement.offsetLeft - (containerRect.width - tagRect.width) / 2
|
||||
|
||||
container.scrollTo({
|
||||
left: Math.max(0, scrollLeft),
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [nowTag, locale])
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]">
|
||||
<div className="flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
|
||||
{allTag.map((tag) => (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"
|
||||
>
|
||||
<div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
|
||||
{indicator && (
|
||||
<div
|
||||
className="absolute top-[3px] left-0 z-10 h-[35px] bg-white shadow-black/5 shadow-lg dark:bg-stone-700 dark:shadow-white/5"
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
width: `${indicator.w}px`,
|
||||
transform: `translateX(${indicator.x}px)`,
|
||||
transition: isFirstRender ? "none" : "all 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{allTag.map((tag, index) => (
|
||||
<div
|
||||
key={tag}
|
||||
onClick={() => setTag(tag)}
|
||||
ref={tagRefs.current[index]}
|
||||
onClick={() => {
|
||||
onTagChange(tag)
|
||||
sessionStorage.setItem("selectedTag", tag)
|
||||
}}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500",
|
||||
nowTag === tag
|
||||
? "text-black dark:text-white"
|
||||
: "text-stone-400 dark:text-stone-500",
|
||||
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
|
||||
"text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
|
||||
{
|
||||
"text-stone-950 dark:text-stone-50": nowTag === tag,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{nowTag === tag && (
|
||||
<motion.div
|
||||
layoutId="nav-item"
|
||||
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
|
||||
style={{
|
||||
originY: "0px",
|
||||
borderRadius: 46,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-20 flex items-center gap-1">
|
||||
<p className="whitespace-nowrap">{tag}</p>
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
{tag === "defaultTag" ? t("defaultTag") : tag}{" "}
|
||||
{getEnv("NEXT_PUBLIC_ShowTagCount") === "true" && tag !== "defaultTag" && (
|
||||
<div className="w-fit rounded-full bg-muted px-1.5">{tagCountMap[tag]}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
75
components/TabSwitch.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
export default function TabSwitch({
|
||||
tabs,
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
}: {
|
||||
tabs: string[]
|
||||
currentTab: string
|
||||
setCurrentTab: (tab: string) => void
|
||||
}) {
|
||||
const t = useTranslations("TabSwitch")
|
||||
const [indicator, setIndicator] = useState<{ x: number; w: number }>({
|
||||
x: 0,
|
||||
w: 0,
|
||||
})
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const locale = useLocale()
|
||||
|
||||
useEffect(() => {
|
||||
const currentTabElement = tabRefs.current[tabs.indexOf(currentTab)]
|
||||
if (currentTabElement) {
|
||||
const parentPadding = 1
|
||||
setIndicator({
|
||||
x:
|
||||
tabs.indexOf(currentTab) !== 0
|
||||
? currentTabElement.offsetLeft - parentPadding
|
||||
: currentTabElement.offsetLeft,
|
||||
w: currentTabElement.offsetWidth,
|
||||
})
|
||||
}
|
||||
}, [currentTab, tabs, locale])
|
||||
|
||||
return (
|
||||
<div className="z-50 flex flex-col items-start rounded-[50px]">
|
||||
<div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
|
||||
{indicator.w > 0 && (
|
||||
<div
|
||||
className="absolute top-[3px] left-0 z-10 h-[35px] bg-white shadow-black/5 shadow-lg dark:bg-stone-700 dark:shadow-white/5"
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
width: `${indicator.w}px`,
|
||||
transform: `translateX(${indicator.x}px)`,
|
||||
transition: "all 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tabs.map((tab: string, index) => (
|
||||
<div
|
||||
key={tab}
|
||||
ref={(el) => {
|
||||
tabRefs.current[index] = el
|
||||
}}
|
||||
onClick={() => setCurrentTab(tab)}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
|
||||
"text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
|
||||
{
|
||||
"text-stone-950 dark:text-stone-50": currentTab === tab,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="relative z-20 flex items-center gap-1">
|
||||
<p className="whitespace-nowrap">{t(tab)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
39
components/ThemeColorManager.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export function ThemeColorManager() {
|
||||
const { theme, systemTheme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
const updateThemeColor = () => {
|
||||
const currentTheme = theme === "system" ? systemTheme : theme
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
|
||||
if (!meta) {
|
||||
const newMeta = document.createElement("meta")
|
||||
newMeta.name = "theme-color"
|
||||
document.head.appendChild(newMeta)
|
||||
}
|
||||
|
||||
const themeColor =
|
||||
currentTheme === "dark"
|
||||
? "hsl(30 15% 8%)" // 深色模式背景色
|
||||
: "hsl(0 0% 98%)" // 浅色模式背景色
|
||||
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
|
||||
}
|
||||
|
||||
// Update on mount and theme change
|
||||
updateThemeColor()
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
mediaQuery.addEventListener("change", updateThemeColor)
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", updateThemeColor)
|
||||
}, [theme, systemTheme])
|
||||
|
||||
return null
|
||||
}
|
@ -1,41 +1,80 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { CheckIcon, MinusIcon, Moon, Sun } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useId } from "react"
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"
|
||||
|
||||
const items = [
|
||||
{ value: "light", label: "Light", image: "/ui-light.png" },
|
||||
{ value: "dark", label: "Dark", image: "/ui-dark.png" },
|
||||
{ value: "system", label: "System", image: "/ui-system.png" },
|
||||
]
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
const t = useTranslations("ThemeSwitcher");
|
||||
const { setTheme, theme } = useTheme()
|
||||
const t = useTranslations("ThemeSwitcher")
|
||||
|
||||
const handleSelect = (newTheme: string) => {
|
||||
setTheme(newTheme)
|
||||
}
|
||||
const id = useId()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="rounded-full px-[9px]">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
|
||||
>
|
||||
<Sun className="dark:-rotate-90 h-4 w-4 rotate-0 scale-100 transition-all dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
{t("Light")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
{t("Dark")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
{t("System")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuContent className="px-2 pt-2 pb-1.5" align="end">
|
||||
<fieldset className="space-y-4">
|
||||
<RadioGroup className="flex gap-2" defaultValue={theme} onValueChange={handleSelect}>
|
||||
{items.map((item) => (
|
||||
<label key={`${id}-${item.value}`}>
|
||||
<RadioGroupItem
|
||||
id={`${id}-${item.value}`}
|
||||
value={item.value}
|
||||
className="peer sr-only after:absolute after:inset-0"
|
||||
/>
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.label}
|
||||
width={88}
|
||||
height={70}
|
||||
className="relative cursor-pointer overflow-hidden rounded-[8px] border border-neutral-300 shadow-xs outline-none transition-[color,box-shadow] peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-data-disabled:cursor-not-allowed peer-data-[state=checked]:bg-accent peer-data-disabled:opacity-50 dark:border-neutral-700"
|
||||
/>
|
||||
<span className="group mt-2 flex items-center gap-1 peer-data-[state=unchecked]:text-muted-foreground/70">
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className="group-peer-data-[state=unchecked]:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MinusIcon
|
||||
size={16}
|
||||
className="group-peer-data-[state=checked]:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-medium text-xs">{t(item.label)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
16
components/loading/GlobalLoading.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { Loader } from "@/components/loading/Loader"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
export default function GlobalLoading() {
|
||||
const t = useTranslations("Global")
|
||||
return (
|
||||
<section className="mt-[3.2px] flex flex-col gap-4">
|
||||
<div className="flex min-h-40 flex-col items-center justify-center font-medium text-sm">
|
||||
{t("Loading")}
|
||||
<Loader visible={true} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
@ -1,13 +1,13 @@
|
||||
const bars = Array(8).fill(0);
|
||||
const bars = Array(8).fill(0)
|
||||
|
||||
export const Loader = ({ visible }: { visible: boolean }) => {
|
||||
return (
|
||||
<div className="hamster-loading-wrapper" data-visible={visible}>
|
||||
<div className="hamster-spinner">
|
||||
{bars.map((_, i) => (
|
||||
<div className="hamster-loading-bar" key={`hamster-bar-${i}`} />
|
||||
<div className="hamster-loading-bar" key={`hamster-bar-${i + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { BackIcon } from "@/components/Icon";
|
||||
import { Loader } from "@/components/loading/Loader";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Loader } from "@/components/loading/Loader"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export default function NetworkChartLoading() {
|
||||
return (
|
||||
@ -8,18 +7,17 @@ export default function NetworkChartLoading() {
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5">
|
||||
<CardTitle className="flex items-center gap-0.5 text-xl">
|
||||
<BackIcon />
|
||||
<div className="aspect-auto h-[20px] w-24 bg-muted"></div>
|
||||
<div className="aspect-auto h-[20px] w-24 bg-muted" />
|
||||
</CardTitle>
|
||||
<div className="mt-[2px] aspect-auto h-[14px] w-32 bg-muted"></div>
|
||||
<div className="mt-[2px] aspect-auto h-[14px] w-32 bg-muted" />
|
||||
</div>
|
||||
<div className="hidden pr-4 pt-4 sm:block">
|
||||
<div className="hidden pt-4 pr-4 sm:block">
|
||||
<Loader visible={true} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<div className="aspect-auto h-[250px] w-full"></div>
|
||||
<div className="aspect-auto h-[250px] w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
37
components/loading/ServerDetailLoading.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { BackIcon } from "@/components/Icon"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export function ServerDetailChartLoading() {
|
||||
return (
|
||||
<div>
|
||||
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerDetailLoading() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => {
|
||||
router.push("/")
|
||||
}}
|
||||
className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight"
|
||||
>
|
||||
<BackIcon />
|
||||
<Skeleton className="h-[20px] w-24 animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="mt-3 flex h-[81px] w-1/2 animate-none flex-wrap gap-2 rounded-[5px] bg-muted-foreground/10" />
|
||||
</>
|
||||
)
|
||||
}
|
101
components/ui/animated-circular-progress-bar.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Props {
|
||||
max: number
|
||||
value: number
|
||||
min: number
|
||||
className?: string
|
||||
primaryColor?: string
|
||||
}
|
||||
|
||||
export default function AnimatedCircularProgressBar({
|
||||
max = 100,
|
||||
min = 0,
|
||||
value = 0,
|
||||
primaryColor,
|
||||
className,
|
||||
}: Props) {
|
||||
const circumference = 2 * Math.PI * 45
|
||||
const percentPx = circumference / 100
|
||||
const currentPercent = ((value - min) / (max - min)) * 100
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative size-40 font-semibold text-2xl", className)}
|
||||
style={
|
||||
{
|
||||
"--circle-size": "100px",
|
||||
"--circumference": circumference,
|
||||
"--percent-to-px": `${percentPx}px`,
|
||||
"--gap-percent": "5",
|
||||
"--offset-factor": "0",
|
||||
"--transition-length": "1s",
|
||||
"--transition-step": "200ms",
|
||||
"--delay": "0s",
|
||||
"--percent-to-deg": "3.6deg",
|
||||
transform: "translateZ(0)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<svg fill="none" className="size-full" strokeWidth="2" viewBox="0 0 100 100">
|
||||
<title>Circular Progress Bar</title>
|
||||
{currentPercent <= 90 && currentPercent >= 0 && (
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
strokeWidth="10"
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="stroke-muted opacity-100"
|
||||
style={
|
||||
{
|
||||
"--stroke-percent": 90 - currentPercent,
|
||||
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
|
||||
strokeDasharray:
|
||||
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
|
||||
transform:
|
||||
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
|
||||
transition: "all var(--transition-length) ease var(--delay)",
|
||||
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
strokeWidth="10"
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn("stroke-current opacity-100", {
|
||||
"stroke-[var(--stroke-primary-color)]": primaryColor,
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--stroke-primary-color": primaryColor,
|
||||
"--stroke-percent": currentPercent,
|
||||
strokeDasharray:
|
||||
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
|
||||
transition:
|
||||
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
|
||||
transitionProperty: "stroke-dasharray,transform",
|
||||
transform:
|
||||
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
|
||||
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
data-current-value={currentPercent}
|
||||
className="fade-in absolute inset-0 m-auto size-fit animate-in delay-[var(--delay)] duration-[var(--transition-length)] ease-linear"
|
||||
>
|
||||
{currentPercent}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,21 +1,20 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
|
||||
export const AnimatedTooltip = ({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
id: number;
|
||||
name: string;
|
||||
designation: string;
|
||||
image: string;
|
||||
}[];
|
||||
id: number
|
||||
name: string
|
||||
designation: string
|
||||
image: string
|
||||
}[]
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<div className="group relative -mr-4" key={item.name}>
|
||||
<div className="group -mr-4 relative" key={item.name}>
|
||||
<Link href="https://buycoffee.top" target="_blank">
|
||||
<Image
|
||||
width={40}
|
||||
@ -23,11 +22,11 @@ export const AnimatedTooltip = ({
|
||||
unoptimized
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="relative !m-0 h-6 w-6 rounded-full border-2 border-white object-cover object-top !p-0 transition duration-500 group-hover:z-30 group-hover:scale-105"
|
||||
className="relative m-0! h-6 w-6 rounded-full border-2 border-white object-cover object-top p-0! transition duration-500 group-hover:z-30 group-hover:scale-105"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type VariantProps, cva } from "class-variance-authority"
|
||||
import type * as React from "react"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center text-nowarp rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors pointer-events-none focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
@ -21,16 +19,14 @@ const badgeVariants = cva(
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge, badgeVariants }
|
||||
|
@ -1,21 +1,17 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { type VariantProps, cva } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
@ -31,26 +27,22 @@ const buttonVariants = cva(
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
)
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button, buttonVariants }
|
||||
|
@ -1,86 +1,58 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Card.displayName = "Card"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold text-2xl leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
<p ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
@ -1,50 +1,47 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
[k: string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context;
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
@ -52,28 +49,24 @@ const ChartContainer = React.forwardRef<
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([_, config]) => config.theme || config.color,
|
||||
);
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@ -85,10 +78,8 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
@ -97,20 +88,20 @@ ${colorConfig
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
@ -131,64 +122,65 @@ const ChartTooltipContent = React.forwardRef<
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
payload.sort((a, b) => {
|
||||
return Number(b.value) - Number(a.value)
|
||||
})
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
"grid min-w-[8rem] items-start gap-1.5 overflow-hidden rounded-sm border border-border/50 bg-stone-100 text-xs dark:bg-stone-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{!nestLabel && (
|
||||
<div className="-mb-1 mx-auto px-2.5 pt-1">{!nestLabel ? tooltipLabel : null}</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn("grid gap-1.5 bg-white px-2.5 py-1.5 dark:bg-black", {
|
||||
"border-t-[1px]": !nestLabel,
|
||||
})}
|
||||
>
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -208,7 +200,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
@ -239,120 +231,110 @@ const ChartTooltipContent = React.forwardRef<
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 font-medium text-foreground tabular-nums",
|
||||
payload.length === 1 && "-ml-9",
|
||||
)}
|
||||
>
|
||||
{typeof item.value === "number"
|
||||
? item.value.toFixed(2).toLocaleString()
|
||||
: item.value}{" "}
|
||||
ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{key}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key;
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
@ -362,4 +344,4 @@ export {
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
}
|
||||
|