Compare commits
2872 Commits
v1.1.5
...
v1.2.1.1-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11aa77e3a | ||
|
|
1bbcf60e58 | ||
|
|
c90ee58bc0 | ||
|
|
2983e20695 | ||
|
|
4e7570cb26 | ||
|
|
7987b61345 | ||
|
|
6fbd47ed8a | ||
|
|
bada4ef524 | ||
|
|
8918d37bb6 | ||
|
|
b0d94a3594 | ||
|
|
1291204d2b | ||
|
|
804f201589 | ||
|
|
65779b1eb6 | ||
|
|
7eac978950 | ||
|
|
b1d0cfa2e7 | ||
|
|
282b1c2b0a | ||
|
|
b4b0d4cd7e | ||
|
|
6a66ee75ac | ||
|
|
45dcdcccbb | ||
|
|
5d47f1dfca | ||
|
|
1e07e5f17b | ||
|
|
8adb1b9730 | ||
|
|
9d3720b6a1 | ||
|
|
6496601e4e | ||
|
|
b96df3b501 | ||
|
|
489460f509 | ||
|
|
192870d658 | ||
|
|
bed4b5ee49 | ||
|
|
f0eb402100 | ||
|
|
e73486904b | ||
|
|
1119a45e93 | ||
|
|
85860cdddd | ||
|
|
da2e89bc94 | ||
|
|
01ed2da10e | ||
|
|
5fea839e34 | ||
|
|
d725910e7a | ||
|
|
99f73ad745 | ||
|
|
b599a990f6 | ||
|
|
c742393efc | ||
|
|
c403300cd2 | ||
|
|
c3a5d6201e | ||
|
|
9e7350c3bb | ||
|
|
5bee471884 | ||
|
|
77eb8c7b78 | ||
|
|
acf2302755 | ||
|
|
8fedd3defe | ||
|
|
adda8181a6 | ||
|
|
789494cc89 | ||
|
|
561086e940 | ||
|
|
20c1140676 | ||
|
|
9bf99c0fdd | ||
|
|
899eb61dcf | ||
|
|
a5e6e112a5 | ||
|
|
834795d6d9 | ||
|
|
8442cbca77 | ||
|
|
bcca760403 | ||
|
|
102a58a068 | ||
|
|
4e849d5309 | ||
|
|
44e92c8bf0 | ||
|
|
873f5ae51e | ||
|
|
3e0b907138 | ||
|
|
7ee6b6a96b | ||
|
|
14adb673f6 | ||
|
|
91381f0850 | ||
|
|
d3e91b5d06 | ||
|
|
74fcd7d569 | ||
|
|
0843cd8363 | ||
|
|
8c3d022506 | ||
|
|
a5a55f3c7d | ||
|
|
2fb9e74a13 | ||
|
|
f950882ffd | ||
|
|
18b3b572f0 | ||
|
|
023e3ff59b | ||
|
|
7318c81fe0 | ||
|
|
f516a1cf4c | ||
|
|
43959fc758 | ||
|
|
ff7b1e10a4 | ||
|
|
b049712cd6 | ||
|
|
2cdfc60fe1 | ||
|
|
2739cb0894 | ||
|
|
3e5ef4fa08 | ||
|
|
a7a010d660 | ||
|
|
67000f5ff1 | ||
|
|
efa111e2dd | ||
|
|
813798ec2b | ||
|
|
c8b1cd0fab | ||
|
|
9220dfb7a3 | ||
|
|
f6e9497f1e | ||
|
|
45e7713638 | ||
|
|
802dc491f8 | ||
|
|
3ca5a36240 | ||
|
|
3046299414 | ||
|
|
46edd4e3e4 | ||
|
|
800a18ac60 | ||
|
|
37c60cb82a | ||
|
|
9446112081 | ||
|
|
07384e4d7c | ||
|
|
1f7bf74970 | ||
|
|
4b72490486 | ||
|
|
9e4e0bc24a | ||
|
|
18687666a6 | ||
|
|
ccb924f8f6 | ||
|
|
58ac4ee743 | ||
|
|
d515448113 | ||
|
|
ec3de30b14 | ||
|
|
09ff203662 | ||
|
|
4837b66089 | ||
|
|
c312cf3d38 | ||
|
|
46f5af1966 | ||
|
|
85f98dff9a | ||
|
|
bd56964860 | ||
|
|
481a653b75 | ||
|
|
f94b59954f | ||
|
|
4f7977b5ca | ||
|
|
512cc11894 | ||
|
|
51be0bd3bd | ||
|
|
0f6095f8c3 | ||
|
|
831bf67ee4 | ||
|
|
e7cca5e532 | ||
|
|
75b08de934 | ||
|
|
987b665115 | ||
|
|
68ca68c6f1 | ||
|
|
5c4ca290fb | ||
|
|
1d61442a49 | ||
|
|
0e2ede5e66 | ||
|
|
0db74814be | ||
|
|
75c6f74fc4 | ||
|
|
e6faec24fa | ||
|
|
35b7d01d7e | ||
|
|
415bc439bb | ||
|
|
fe47522275 | ||
|
|
24b97831a4 | ||
|
|
ac95a5afba | ||
|
|
03850d2958 | ||
|
|
c7b49cfc4a | ||
|
|
5398211ab5 | ||
|
|
dc8ebb651a | ||
|
|
039e35f3c5 | ||
|
|
ffadb2c508 | ||
|
|
019e98e6b6 | ||
|
|
c998e39038 | ||
|
|
baa2ff4fa9 | ||
|
|
4b6a91e74c | ||
|
|
37f56c8a16 | ||
|
|
09bb47f408 | ||
|
|
5db6762690 | ||
|
|
b1cc880253 | ||
|
|
7d4ea806a2 | ||
|
|
2b306c9033 | ||
|
|
a776d6b746 | ||
|
|
07f87de742 | ||
|
|
cf871da880 | ||
|
|
774d42d5be | ||
|
|
6660122e69 | ||
|
|
1ef4bc4fed | ||
|
|
ee1204c566 | ||
|
|
7f2b0c5de1 | ||
|
|
9737ffd996 | ||
|
|
be60b7e17c | ||
|
|
97368a6f44 | ||
|
|
c3d7f01b40 | ||
|
|
cbebd5147c | ||
|
|
4611be734f | ||
|
|
528b57664f | ||
|
|
cc86d68507 | ||
|
|
7c8da462db | ||
|
|
b7963c3b70 | ||
|
|
d51dd35376 | ||
|
|
196086498e | ||
|
|
f6ff76f9ce | ||
|
|
20a2db6739 | ||
|
|
aa3b8ebe82 | ||
|
|
d03afa1793 | ||
|
|
056cee2f94 | ||
|
|
f80e087429 | ||
|
|
eea765300e | ||
|
|
c2396d7e81 | ||
|
|
0b94acf7f6 | ||
|
|
324cb23f75 | ||
|
|
b341ba8297 | ||
|
|
f5b9da0908 | ||
|
|
c83672a4bc | ||
|
|
af8e3f6a71 | ||
|
|
5025d38a76 | ||
|
|
f3c7fb97fb | ||
|
|
cb2ab5f67b | ||
|
|
003c8850b7 | ||
|
|
d9461c170d | ||
|
|
57b5de4a4a | ||
|
|
c406c52086 | ||
|
|
df4855ec47 | ||
|
|
8c7f4a4c20 | ||
|
|
3ec733d9c6 | ||
|
|
71c64d1ae5 | ||
|
|
98becfd368 | ||
|
|
4eea90bd97 | ||
|
|
2344935357 | ||
|
|
550279ec68 | ||
|
|
44aefb5d3b | ||
|
|
7d3cf4d364 | ||
|
|
f6ef383598 | ||
|
|
a6149e3cd8 | ||
|
|
07f1098418 | ||
|
|
3d00f33dbf | ||
|
|
98c859fbf8 | ||
|
|
9460bee72f | ||
|
|
5a957fe904 | ||
|
|
59a4b6e4ca | ||
|
|
9000316224 | ||
|
|
da96c57819 | ||
|
|
b3cef0b009 | ||
|
|
78ace237dd | ||
|
|
e94e065eca | ||
|
|
4ffe0f3f46 | ||
|
|
7af4150e44 | ||
|
|
71950369e1 | ||
|
|
adb4815c9b | ||
|
|
1841feb643 | ||
|
|
f14a0393b7 | ||
|
|
710f77764b | ||
|
|
ae2e86d1d1 | ||
|
|
167fcb2921 | ||
|
|
47145ab9d1 | ||
|
|
441ee8e948 | ||
|
|
9c4528dfcb | ||
|
|
d7c60631b4 | ||
|
|
530f5c2dbc | ||
|
|
03dc2afe8d | ||
|
|
7d35d91415 | ||
|
|
4843fae0eb | ||
|
|
3dfbeac541 | ||
|
|
4fa4bbb08b | ||
|
|
4204e619db | ||
|
|
a663b83daa | ||
|
|
fb218a9331 | ||
|
|
75b677576e | ||
|
|
04bda0bf10 | ||
|
|
8ca33dec6f | ||
|
|
eed9303e41 | ||
|
|
5277e7b47d | ||
|
|
727c86a804 | ||
|
|
21edae5944 | ||
|
|
a0d48a1191 | ||
|
|
086ba9e577 | ||
|
|
bda5fdbecd | ||
|
|
13d2eeb9b2 | ||
|
|
1bbf814ea3 | ||
|
|
6d0e5add0b | ||
|
|
06849ca666 | ||
|
|
4346a5554f | ||
|
|
2a174df697 | ||
|
|
28df51092c | ||
|
|
ca70852060 | ||
|
|
86df5cda6e | ||
|
|
7db7b7d98f | ||
|
|
3cede88a3d | ||
|
|
af63b71ab8 | ||
|
|
2805c46a22 | ||
|
|
104353f013 | ||
|
|
435f346d98 | ||
|
|
2b8caa924f | ||
|
|
dd22f303ac | ||
|
|
02141ae16f | ||
|
|
b9b10a69d5 | ||
|
|
d8631a8594 | ||
|
|
463769aba9 | ||
|
|
a9fbaf15b2 | ||
|
|
b04c3b9f78 | ||
|
|
f2229c2393 | ||
|
|
b4aadfee3e | ||
|
|
840e8a774e | ||
|
|
c429d391f5 | ||
|
|
2fda3dd1d5 | ||
|
|
4018093c9d | ||
|
|
2f5b8ce4ed | ||
|
|
2174c04e4f | ||
|
|
109a4d7f54 | ||
|
|
080ee5cff0 | ||
|
|
257668ab96 | ||
|
|
d747ac7659 | ||
|
|
d7c04ebbc7 | ||
|
|
c5b01b4bb7 | ||
|
|
e8cc90b83d | ||
|
|
d3974018d8 | ||
|
|
10ce9dbcde | ||
|
|
5be4bd2ae9 | ||
|
|
ea1d8ab037 | ||
|
|
adde2ce5b9 | ||
|
|
9d37a7293b | ||
|
|
975d4aab60 | ||
|
|
5ead9ee661 | ||
|
|
95e876b37f | ||
|
|
4c72d0b3ef | ||
|
|
e7dc030304 | ||
|
|
c9d5c84d35 | ||
|
|
4b01ba1d2f | ||
|
|
723e56ada2 | ||
|
|
e9851da12f | ||
|
|
5826b0419b | ||
|
|
77124c4549 | ||
|
|
00dbd1c87e | ||
|
|
e0e732dd2c | ||
|
|
ce69c0ba1f | ||
|
|
58c4e115ba | ||
|
|
44478057dd | ||
|
|
d1efae37a4 | ||
|
|
8d8e4bab26 | ||
|
|
db113f0433 | ||
|
|
5a66167709 | ||
|
|
7326201b0a | ||
|
|
f116a6b0f2 | ||
|
|
3087ab36da | ||
|
|
28a7189905 | ||
|
|
0f8e215706 | ||
|
|
fd92bed465 | ||
|
|
281f6975ec | ||
|
|
e2c40eae48 | ||
|
|
26968b02a1 | ||
|
|
0d8070455d | ||
|
|
2537e964a5 | ||
|
|
ca7134e610 | ||
|
|
a8c591affd | ||
|
|
e3842f200d | ||
|
|
cc952d8c79 | ||
|
|
e11daa0b36 | ||
|
|
873c77d659 | ||
|
|
1756a6eb28 | ||
|
|
9636d3671c | ||
|
|
22e2ebb96c | ||
|
|
c53d3807e7 | ||
|
|
23a6392979 | ||
|
|
fa599ad183 | ||
|
|
e79fdcfe58 | ||
|
|
3746f356b9 | ||
|
|
a5f3362d6e | ||
|
|
2072264918 | ||
|
|
a5cb01133e | ||
|
|
62b55cbf16 | ||
|
|
7ea0c4d36c | ||
|
|
546200844e | ||
|
|
007e3d1c0e | ||
|
|
74a508e3a8 | ||
|
|
5f5dc171be | ||
|
|
bccba6e9b9 | ||
|
|
c2073a5db5 | ||
|
|
52ad229d93 | ||
|
|
f6a7352672 | ||
|
|
46e0322e6f | ||
|
|
618538a854 | ||
|
|
d62396717a | ||
|
|
a734fa5566 | ||
|
|
f98b302b94 | ||
|
|
215d36900a | ||
|
|
2df55d2839 | ||
|
|
e00051caa7 | ||
|
|
5138b2f1d5 | ||
|
|
65dfb9103f | ||
|
|
39bbc036cd | ||
|
|
aaf6dd36f0 | ||
|
|
e7519e68a3 | ||
|
|
ad5803ef9c | ||
|
|
c04b514a2a | ||
|
|
cb9f567154 | ||
|
|
80afa789e7 | ||
|
|
43f2ce52a5 | ||
|
|
cf2e24269e | ||
|
|
6899650bf8 | ||
|
|
c16df51892 | ||
|
|
c549737ad0 | ||
|
|
a85b51843a | ||
|
|
60d401f5ea | ||
|
|
ca02b9001f | ||
|
|
2fc5e2865d | ||
|
|
261b2bfb3c | ||
|
|
30e32e89b2 | ||
|
|
8b1a2b9bff | ||
|
|
f71289b248 | ||
|
|
54eab9af49 | ||
|
|
a05546e811 | ||
|
|
276c648f29 | ||
|
|
8c389f4790 | ||
|
|
a09144d21a | ||
|
|
cb9a43f496 | ||
|
|
30606e4743 | ||
|
|
d05913fbdf | ||
|
|
a34efb50e0 | ||
|
|
a26c69fc8d | ||
|
|
107803705c | ||
|
|
858b1689bf | ||
|
|
641acbd1f4 | ||
|
|
1de76ae6c1 | ||
|
|
94f535b8ec | ||
|
|
f072e285fc | ||
|
|
2c363bbb8e | ||
|
|
1c2b87d584 | ||
|
|
e55154bd5e | ||
|
|
71098abb65 | ||
|
|
7a3a4d1413 | ||
|
|
7858fb0283 | ||
|
|
2f9959c009 | ||
|
|
e7d3b20295 | ||
|
|
264fa4982f | ||
|
|
9636614761 | ||
|
|
ac6561ca52 | ||
|
|
923172d39b | ||
|
|
d46c42d26b | ||
|
|
d628233982 | ||
|
|
e9e1d471ec | ||
|
|
f4740916f5 | ||
|
|
3a2c9b1b05 | ||
|
|
4cc1147579 | ||
|
|
976f23a90e | ||
|
|
8ed500adf7 | ||
|
|
8658044c0c | ||
|
|
0edc2cc3af | ||
|
|
f4db4cde13 | ||
|
|
6bb9313b95 | ||
|
|
6447dfef50 | ||
|
|
55cb3a1267 | ||
|
|
7c5e7208b9 | ||
|
|
aad4b13fda | ||
|
|
839a20df97 | ||
|
|
d497763e38 | ||
|
|
12c088c10b | ||
|
|
8c6a6bece6 | ||
|
|
819ca8a212 | ||
|
|
8d1becbd8c | ||
|
|
ebb7491c58 | ||
|
|
4f1278c37a | ||
|
|
8ddee9013b | ||
|
|
7fe233ae2e | ||
|
|
f7b37b1559 | ||
|
|
0b3624dbd5 | ||
|
|
fb066eb2e9 | ||
|
|
22fadbb87b | ||
|
|
3b119d528c | ||
|
|
bb3d3d759e | ||
|
|
3c08ae9399 | ||
|
|
7a6fa2afa5 | ||
|
|
ba4e3c3adb | ||
|
|
66892f69ce | ||
|
|
e37469ac2b | ||
|
|
cdc2d7bbcb | ||
|
|
92c0e6ff09 | ||
|
|
9b3fd324c3 | ||
|
|
23bd692f8e | ||
|
|
29b4573ca9 | ||
|
|
8b6755d866 | ||
|
|
6da20aab05 | ||
|
|
5aa5942bcd | ||
|
|
68872d0e06 | ||
|
|
d53c1dc402 | ||
|
|
6c2b03ae76 | ||
|
|
9f79d2b737 | ||
|
|
2241b125d6 | ||
|
|
152624302c | ||
|
|
6a703ee6a4 | ||
|
|
0c0caa422d | ||
|
|
6fa7c1d4eb | ||
|
|
509fff3972 | ||
|
|
bcacd8b98e | ||
|
|
d2c8178772 | ||
|
|
365a246461 | ||
|
|
098ae13f94 | ||
|
|
6a92225630 | ||
|
|
c98044be9a | ||
|
|
e6eb81cc61 | ||
|
|
0e50caadec | ||
|
|
aad44ad42f | ||
|
|
59fd055526 | ||
|
|
5fce75a60d | ||
|
|
74390726b4 | ||
|
|
10f37b88c3 | ||
|
|
9bfacd9da9 | ||
|
|
a286770fd2 | ||
|
|
3101e84830 | ||
|
|
815b3bebda | ||
|
|
c71eda1229 | ||
|
|
60518be5bd | ||
|
|
83254d9d70 | ||
|
|
d34cebc90d | ||
|
|
c7ef51a73c | ||
|
|
ab34fb08c1 | ||
|
|
6f99e1e8c1 | ||
|
|
5fe87a04f0 | ||
|
|
4ac71381da | ||
|
|
f95e1ad4b8 | ||
|
|
168726c131 | ||
|
|
4545aeb9c6 | ||
|
|
84cf3e6a15 | ||
|
|
cd123b3479 | ||
|
|
4b99de8841 | ||
|
|
147ca0a41a | ||
|
|
3f24d55945 | ||
|
|
70871330d3 | ||
|
|
08bc354fa5 | ||
|
|
54c7322b23 | ||
|
|
4d2ceee26b | ||
|
|
b6321e9698 | ||
|
|
dd197c9826 | ||
|
|
01427f4926 | ||
|
|
c47a7ba2a5 | ||
|
|
04564bc9cf | ||
|
|
04661ce340 | ||
|
|
fcc54e2d6a | ||
|
|
52fc3b03b7 | ||
|
|
70c29ed7b6 | ||
|
|
d33741a90d | ||
|
|
484f117b8e | ||
|
|
317739b508 | ||
|
|
d8062b4859 | ||
|
|
452eb70faf | ||
|
|
83889d7e3c | ||
|
|
2eb970a6a2 | ||
|
|
7838762a4e | ||
|
|
f370a670ad | ||
|
|
41fa6f4b10 | ||
|
|
18aa9a77dd | ||
|
|
e53f6c0c52 | ||
|
|
642539cdfc | ||
|
|
6534fa7171 | ||
|
|
ff08f4c0b5 | ||
|
|
134c62d543 | ||
|
|
1243842c68 | ||
|
|
c1093be548 | ||
|
|
b6f58758f2 | ||
|
|
1dbb59bc3f | ||
|
|
5e32857729 | ||
|
|
e3a611f33d | ||
|
|
8fb2a9094e | ||
|
|
46b9309336 | ||
|
|
e60d2db8cb | ||
|
|
e5e6c00100 | ||
|
|
40709b7480 | ||
|
|
900c7154b6 | ||
|
|
2f4ea02544 | ||
|
|
c24c10a13a | ||
|
|
22cd2e4bb3 | ||
|
|
53ac43eb49 | ||
|
|
fa29c46a95 | ||
|
|
d871b4c78e | ||
|
|
03acdce2c8 | ||
|
|
9aa3b61efc | ||
|
|
0a62e3deca | ||
|
|
f454d5f045 | ||
|
|
a5dca65e57 | ||
|
|
979a7e5d18 | ||
|
|
d1e7154040 | ||
|
|
4750ff8cd5 | ||
|
|
72d02010c7 | ||
|
|
b49be42f2d | ||
|
|
eddc183b85 | ||
|
|
812cf83de4 | ||
|
|
502cb8403f | ||
|
|
cf78bff21d | ||
|
|
1e352f4a7e | ||
|
|
47be85fdc0 | ||
|
|
5c9849e729 | ||
|
|
e695b4e764 | ||
|
|
d4d2e33619 | ||
|
|
1603f1ae66 | ||
|
|
88da476249 | ||
|
|
1218fde6ea | ||
|
|
5046398e80 | ||
|
|
20e5b6cc5a | ||
|
|
c867c4ef51 | ||
|
|
f2e804783b | ||
|
|
817e18ded2 | ||
|
|
f99b498608 | ||
|
|
d6cd4763f5 | ||
|
|
fdac846ede | ||
|
|
d75c73df30 | ||
|
|
55c8dffe37 | ||
|
|
8b35861602 | ||
|
|
6db7e64ca9 | ||
|
|
9484f78fb6 | ||
|
|
1949aeb10f | ||
|
|
65fad4cc37 | ||
|
|
876194cdc8 | ||
|
|
cefeac72fc | ||
|
|
71708c3874 | ||
|
|
b1eae7b768 | ||
|
|
f02985e367 | ||
|
|
2848f672c1 | ||
|
|
f2210946c2 | ||
|
|
4b79b9a417 | ||
|
|
10f8735f55 | ||
|
|
19a3a14417 | ||
|
|
aeaea1289c | ||
|
|
014ffa9b74 | ||
|
|
0a9efe0122 | ||
|
|
82082a4b89 | ||
|
|
55bb5b5a1c | ||
|
|
fd399edce7 | ||
|
|
5611b69ad2 | ||
|
|
7b181046d3 | ||
|
|
b593d50b9a | ||
|
|
8fe9426f5c | ||
|
|
0ce5f72df4 | ||
|
|
d88c6570d0 | ||
|
|
2980f7c9b8 | ||
|
|
7b2825e5ce | ||
|
|
e47f79bcd4 | ||
|
|
415020bf5d | ||
|
|
6ab1582db8 | ||
|
|
751a02aae8 | ||
|
|
bee637aa48 | ||
|
|
ad6c7ffda8 | ||
|
|
7f59ca37f2 | ||
|
|
9056964bb9 | ||
|
|
154441bc1b | ||
|
|
873ec75586 | ||
|
|
e8232a9ea0 | ||
|
|
751b361528 | ||
|
|
8990a3e243 | ||
|
|
bc44490e96 | ||
|
|
fe2d0b1d2a | ||
|
|
bf3c5c1602 | ||
|
|
6364931322 | ||
|
|
536d7141d9 | ||
|
|
69e0bfe89a | ||
|
|
aeabb99be6 | ||
|
|
38ee6d836d | ||
|
|
7bb4bd3da5 | ||
|
|
7524615671 | ||
|
|
f5fe883d49 | ||
|
|
bec6406216 | ||
|
|
c13c7ba626 | ||
|
|
ef041f2702 | ||
|
|
46b222180a | ||
|
|
cd69b317c0 | ||
|
|
05fa751137 | ||
|
|
820317b9bd | ||
|
|
bce01ad7a1 | ||
|
|
bbe014798e | ||
|
|
beea4dea04 | ||
|
|
71505362b4 | ||
|
|
ff6904d436 | ||
|
|
1915bb3a9b | ||
|
|
df0f15419e | ||
|
|
04474d2e07 | ||
|
|
518bf0f217 | ||
|
|
ac8f06c3a2 | ||
|
|
feaf7b8abd | ||
|
|
ac71057a3d | ||
|
|
0cb8900374 | ||
|
|
dc531eaa37 | ||
|
|
9a51a9e635 | ||
|
|
754a0988ee | ||
|
|
1985c0f815 | ||
|
|
889b778d43 | ||
|
|
26e90aa39e | ||
|
|
1eaabd14bd | ||
|
|
f406342b53 | ||
|
|
b7c800b550 | ||
|
|
b6780ba876 | ||
|
|
c9c8987cca | ||
|
|
f74d336072 | ||
|
|
6aaaa910af | ||
|
|
35eee03aa5 | ||
|
|
06dc6ea23f | ||
|
|
c1f8e7f511 | ||
|
|
6d39acc627 | ||
|
|
11f768d26c | ||
|
|
602afc2954 | ||
|
|
b7203b8219 | ||
|
|
513774bb7b | ||
|
|
7375e306fb | ||
|
|
785d58cb59 | ||
|
|
af61d145da | ||
|
|
0b75e967f3 | ||
|
|
cfa4210b0a | ||
|
|
0d6d570ae8 | ||
|
|
65add36b2f | ||
|
|
793b3dde12 | ||
|
|
8b3a76dfc5 | ||
|
|
60398210c7 | ||
|
|
9112bcc52f | ||
|
|
e534cffcf7 | ||
|
|
a184dcc38f | ||
|
|
2c80223fc4 | ||
|
|
59a578fb2d | ||
|
|
91c3f3520b | ||
|
|
e169200f40 | ||
|
|
486c7ef530 | ||
|
|
26c75e8309 | ||
|
|
bc6eb0b5a0 | ||
|
|
9a057ef646 | ||
|
|
a7b06bd5fc | ||
|
|
9d706d3aa3 | ||
|
|
fdc4253117 | ||
|
|
ff168937aa | ||
|
|
b7d060a1f3 | ||
|
|
c37466e948 | ||
|
|
09513c0beb | ||
|
|
a3f4277bdc | ||
|
|
b43d8918bd | ||
|
|
4546adb894 | ||
|
|
db5ac37ad3 | ||
|
|
098c14f9e0 | ||
|
|
94131097a5 | ||
|
|
6d69e009dc | ||
|
|
6d9b132ab8 | ||
|
|
461a353e92 | ||
|
|
efec1aff18 | ||
|
|
258d6d9a49 | ||
|
|
1c4b7c7b97 | ||
|
|
8bc6306813 | ||
|
|
2923c00738 | ||
|
|
b30b6a062a | ||
|
|
8f5df889ab | ||
|
|
4ec8b19251 | ||
|
|
1035a94775 | ||
|
|
3ca2ae7175 | ||
|
|
4ba1ca890c | ||
|
|
cba012bd15 | ||
|
|
9515ccd816 | ||
|
|
46622f5028 | ||
|
|
1fd896fb72 | ||
|
|
d081cc6c21 | ||
|
|
9190c8e5bf | ||
|
|
109498e2df | ||
|
|
21cfc63fc0 | ||
|
|
a5f14146b9 | ||
|
|
2c18f6d975 | ||
|
|
84d9146c04 | ||
|
|
6d4006fd93 | ||
|
|
b4a2e5ee11 | ||
|
|
304b814bb1 | ||
|
|
c5e4774b29 | ||
|
|
e90651b55b | ||
|
|
60d7c395bc | ||
|
|
2b5c9c2d61 | ||
|
|
a703f1db73 | ||
|
|
4aaba7619e | ||
|
|
1a88dd801d | ||
|
|
83352ab9fe | ||
|
|
6e268a1bf4 | ||
|
|
4d65e54576 | ||
|
|
c131ec722e | ||
|
|
574e12f336 | ||
|
|
1705868457 | ||
|
|
8392d111dc | ||
|
|
8c5ccbadac | ||
|
|
e4aa081e64 | ||
|
|
8cc74eceb6 | ||
|
|
45365e3860 | ||
|
|
3739560956 | ||
|
|
b8cff3e699 | ||
|
|
d1d44afc9d | ||
|
|
782d847e54 | ||
|
|
be2bfa0087 | ||
|
|
1ea28d66df | ||
|
|
8c51957bfa | ||
|
|
858a1bba4f | ||
|
|
17e4227978 | ||
|
|
f8b5e07518 | ||
|
|
d96e4019aa | ||
|
|
acc9760690 | ||
|
|
56dab535c3 | ||
|
|
94670711e7 | ||
|
|
673c206e02 | ||
|
|
decd3bd134 | ||
|
|
6e5c7aeab5 | ||
|
|
2647550324 | ||
|
|
424a63011b | ||
|
|
0e6a125c60 | ||
|
|
758cae4f86 | ||
|
|
6e8368c62a | ||
|
|
a14e554323 | ||
|
|
6435202fa1 | ||
|
|
cf8425ff14 | ||
|
|
9bb1c1b233 | ||
|
|
c9ccc5e27e | ||
|
|
7115b2ff54 | ||
|
|
b89e234ba4 | ||
|
|
70fbaa0bfd | ||
|
|
f6cdd4ff36 | ||
|
|
1857f46452 | ||
|
|
f95a6f4fd7 | ||
|
|
b0bc66f548 | ||
|
|
34b4a6c3d8 | ||
|
|
b4c7463226 | ||
|
|
ca5b33ef69 | ||
|
|
acd980091d | ||
|
|
de5317987e | ||
|
|
6b438bc4aa | ||
|
|
9b1495a490 | ||
|
|
f638011d63 | ||
|
|
8447a95c8a | ||
|
|
4feceaa1d1 | ||
|
|
8383e381d1 | ||
|
|
a064a7471e | ||
|
|
f0e3d7d09a | ||
|
|
2b7f4ccd6c | ||
|
|
46fa89233b | ||
|
|
591099e42b | ||
|
|
d08398ea57 | ||
|
|
83f49742b6 | ||
|
|
50d07f81fd | ||
|
|
594ee21fcd | ||
|
|
ea2763c48c | ||
|
|
925fe1cce0 | ||
|
|
d927b462b6 | ||
|
|
5a79556ab2 | ||
|
|
260870ad8a | ||
|
|
7d69e64adc | ||
|
|
5af51096d8 | ||
|
|
898392725a | ||
|
|
9089035f18 | ||
|
|
66d2a68167 | ||
|
|
4a41e40592 | ||
|
|
2a75b920a0 | ||
|
|
2851eae423 | ||
|
|
efc2295b8d | ||
|
|
2bee28a1d8 | ||
|
|
a8cc995558 | ||
|
|
9a11c41424 | ||
|
|
2a4d056b59 | ||
|
|
5a77a398bd | ||
|
|
4cf2238c99 | ||
|
|
58df4f1481 | ||
|
|
da3f99a254 | ||
|
|
c2fa6095cc | ||
|
|
f0b8ed20a2 | ||
|
|
0b8b72be5c | ||
|
|
18c6455837 | ||
|
|
e0477015c4 | ||
|
|
e99a4e2b08 | ||
|
|
c44d06b0dc | ||
|
|
0e8327c085 | ||
|
|
5c5a86c7fc | ||
|
|
a785213cb2 | ||
|
|
e041440c97 | ||
|
|
688ca8a604 | ||
|
|
fd6f0967b0 | ||
|
|
9fe58935c4 | ||
|
|
0dfb35730f | ||
|
|
dc52f4c692 | ||
|
|
bcf5395868 | ||
|
|
3e96a89adf | ||
|
|
c0a882251d | ||
|
|
6a53b895e5 | ||
|
|
c5354d014c | ||
|
|
5e9ef37646 | ||
|
|
cb96bea73d | ||
|
|
95fa2440ce | ||
|
|
ca9698f75d | ||
|
|
0f1413f130 | ||
|
|
52a4b604dd | ||
|
|
3c64ee7af2 | ||
|
|
026719cd88 | ||
|
|
9bac00ee29 | ||
|
|
828c0f66a6 | ||
|
|
9841e92634 | ||
|
|
171e7ddcae | ||
|
|
968a5bd789 | ||
|
|
be119a69af | ||
|
|
800c3c11be | ||
|
|
1242da5ed1 | ||
|
|
8bf4fa0cf1 | ||
|
|
0693acc07b | ||
|
|
17eecfca9d | ||
|
|
4d24d6d17b | ||
|
|
1fe4ee5b81 | ||
|
|
137aeac91a | ||
|
|
ccb0b58a2d | ||
|
|
ffc202f6a3 | ||
|
|
f7fd728683 | ||
|
|
46c04e5a81 | ||
|
|
f43feb825f | ||
|
|
05cd21d44e | ||
|
|
4182af75ff | ||
|
|
680123eb64 | ||
|
|
aec04f0b8c | ||
|
|
e75bbc0a22 | ||
|
|
81fc625c5d | ||
|
|
f85683239f | ||
|
|
507f769357 | ||
|
|
e3f7e8c97a | ||
|
|
49e9e26bff | ||
|
|
fccd4c12ca | ||
|
|
06c9ff481e | ||
|
|
50e5775062 | ||
|
|
91da8db589 | ||
|
|
0d854ae42b | ||
|
|
ec21050fad | ||
|
|
67c61a5829 | ||
|
|
e685668959 | ||
|
|
de13eb5b96 | ||
|
|
c0f54c334e | ||
|
|
f134fcb528 | ||
|
|
d5954a3a32 | ||
|
|
bd28e312fc | ||
|
|
7208d5b2bf | ||
|
|
5c2d4e4718 | ||
|
|
8cdeae6c3f | ||
|
|
e7bc6d09f2 | ||
|
|
4ce2699a48 | ||
|
|
7c5cdb9161 | ||
|
|
64a0aa6157 | ||
|
|
34d04e57dd | ||
|
|
1317c5bddc | ||
|
|
74b6f565e9 | ||
|
|
08f49d4d0b | ||
|
|
99605b6a55 | ||
|
|
beeeabc377 | ||
|
|
ff2e40d49a | ||
|
|
31c5eeb6c3 | ||
|
|
8004ee48c9 | ||
|
|
a1d48a28e9 | ||
|
|
0f81f45c5f | ||
|
|
05f7957557 | ||
|
|
1ed8f5d124 | ||
|
|
2ee5be7402 | ||
|
|
1226e7bee1 | ||
|
|
dcbc52efc6 | ||
|
|
92b0a1478a | ||
|
|
f27c7fdf31 | ||
|
|
18a427b501 | ||
|
|
b7951b730d | ||
|
|
342203bb81 | ||
|
|
e4bc526a09 | ||
|
|
f75e30afd0 | ||
|
|
9f11238d43 | ||
|
|
070a1b47e5 | ||
|
|
3e8661f5ca | ||
|
|
9f8c27ddc1 | ||
|
|
bafaaf9c47 | ||
|
|
1ee5863da7 | ||
|
|
ace4d83789 | ||
|
|
1da1c178d0 | ||
|
|
c429cb2ed1 | ||
|
|
40c40f81fc | ||
|
|
6647a3b083 | ||
|
|
782eaef440 | ||
|
|
6003310a39 | ||
|
|
229ac5006b | ||
|
|
322687c658 | ||
|
|
fe3963dfe2 | ||
|
|
e4a57b97b7 | ||
|
|
9b48c498f5 | ||
|
|
4228177920 | ||
|
|
e98637321d | ||
|
|
5941bd4b68 | ||
|
|
a686360c1f | ||
|
|
20ee9da1ec | ||
|
|
c89baf34a8 | ||
|
|
00230d1b8f | ||
|
|
4396d57e3d | ||
|
|
86789f677a | ||
|
|
8fb2deeab0 | ||
|
|
2099bbe58f | ||
|
|
1c95319608 | ||
|
|
eeea948844 | ||
|
|
c4b1820d08 | ||
|
|
59cc2741b8 | ||
|
|
cc34d33090 | ||
|
|
59bb0070e9 | ||
|
|
ec2206ade0 | ||
|
|
06a3e6b472 | ||
|
|
9108882921 | ||
|
|
7796f7d3bc | ||
|
|
00a0ae6561 | ||
|
|
6310293190 | ||
|
|
809930df9a | ||
|
|
f1874d4ab1 | ||
|
|
cc0f401855 | ||
|
|
42626f3bce | ||
|
|
22d570b024 | ||
|
|
6d0a07f212 | ||
|
|
a512b5a110 | ||
|
|
bde3dade14 | ||
|
|
de2058d966 | ||
|
|
7f191764be | ||
|
|
7f9da757aa | ||
|
|
f07e8cfe14 | ||
|
|
b806bf80b1 | ||
|
|
173ea58701 | ||
|
|
3ad5b72ebf | ||
|
|
567e2e5d6d | ||
|
|
616bd0ac91 | ||
|
|
108a169e7c | ||
|
|
eab902d68e | ||
|
|
985f6e89ec | ||
|
|
0480989fd2 | ||
|
|
72ffe420b7 | ||
|
|
775b6ff4fd | ||
|
|
b0f18461b3 | ||
|
|
b8ccbfd222 | ||
|
|
c2fa497137 | ||
|
|
bdcfa6929c | ||
|
|
8470b58b60 | ||
|
|
002413c067 | ||
|
|
ecce59e734 | ||
|
|
f5d169eaa2 | ||
|
|
b3b921e1ae | ||
|
|
91f15b723e | ||
|
|
303dcb1eb6 | ||
|
|
4eaeb1b020 | ||
|
|
f13427ca27 | ||
|
|
458f2cdf16 | ||
|
|
df588f25bf | ||
|
|
bd0fdff29c | ||
|
|
774da61da1 | ||
|
|
42e67e01aa | ||
|
|
36e201e824 | ||
|
|
497233c9f1 | ||
|
|
4b7c9a1bd3 | ||
|
|
7c2d6d6618 | ||
|
|
c44e0afb81 | ||
|
|
d3ef3c7452 | ||
|
|
1935c76f30 | ||
|
|
71056d8f15 | ||
|
|
8d34119e7a | ||
|
|
f159ee77cd | ||
|
|
d336c4f5b7 | ||
|
|
81b7a3e665 | ||
|
|
1870f74f0c | ||
|
|
d19f9c6888 | ||
|
|
a68bf6fc8f | ||
|
|
c2d2745777 | ||
|
|
fc8bf841bf | ||
|
|
08f435597a | ||
|
|
58a4e475ad | ||
|
|
5bfc911e1b | ||
|
|
d2c7362736 | ||
|
|
82cac690fa | ||
|
|
3c3c902087 | ||
|
|
964538eb43 | ||
|
|
5e8b2bdb50 | ||
|
|
e3d10495f3 | ||
|
|
6c3886ad24 | ||
|
|
ba727f53c4 | ||
|
|
35a4737e43 | ||
|
|
abde8652b2 | ||
|
|
cadef0bf81 | ||
|
|
caac696244 | ||
|
|
6910a0b4bd | ||
|
|
74e2584e4d | ||
|
|
459dd2d9c7 | ||
|
|
0f5c83c1c2 | ||
|
|
fed4cc2a97 | ||
|
|
c238711b3e | ||
|
|
f42334917e | ||
|
|
09004d4c09 | ||
|
|
68da9b2f69 | ||
|
|
454ff37a72 | ||
|
|
ca13d18d7d | ||
|
|
1657a7dbe3 | ||
|
|
61e925eaab | ||
|
|
09d3313e15 | ||
|
|
a20d61037e | ||
|
|
7eaa692712 | ||
|
|
691bae9a96 | ||
|
|
d5a8c9b7d1 | ||
|
|
8c20e7c661 | ||
|
|
47a2d28c6a | ||
|
|
31f8961e27 | ||
|
|
424bd0bc28 | ||
|
|
9c078583dd | ||
|
|
ca27048679 | ||
|
|
4e65663748 | ||
|
|
c7c5cbde83 | ||
|
|
a4905ad207 | ||
|
|
bebf0e692a | ||
|
|
8ff9a87dfe | ||
|
|
62f2d8ac16 | ||
|
|
8fef2a6232 | ||
|
|
94064fe78c | ||
|
|
2ffcc43adc | ||
|
|
3846fce73a | ||
|
|
ea950e9dbc | ||
|
|
f2639c4ff1 | ||
|
|
32c1798eb8 | ||
|
|
75e3167b65 | ||
|
|
ad07a61aa7 | ||
|
|
c91b6329f3 | ||
|
|
9cc60efd5a | ||
|
|
08eeea6b9c | ||
|
|
8dea7335de | ||
|
|
2ad6d43422 | ||
|
|
12c2e7aefb | ||
|
|
6b62e46950 | ||
|
|
853c58e0a0 | ||
|
|
eb0abc425a | ||
|
|
c808e40bf6 | ||
|
|
f0bbb14f3f | ||
|
|
95dd0ea6fb | ||
|
|
7f34102ae6 | ||
|
|
7623962da5 | ||
|
|
cfb34b59df | ||
|
|
e5004bb55e | ||
|
|
c0193fdf73 | ||
|
|
6cbafd557c | ||
|
|
ee8ab75907 | ||
|
|
f2e93ad69e | ||
|
|
5faf3fd61c | ||
|
|
956a8f4864 | ||
|
|
d26bc56b5c | ||
|
|
7457770ef8 | ||
|
|
54af9073cb | ||
|
|
a8dcf5e8f5 | ||
|
|
9e3334d75f | ||
|
|
cca6e71911 | ||
|
|
7fbd377ab2 | ||
|
|
24417feba3 | ||
|
|
f8c24964e3 | ||
|
|
1ae2ebfaf0 | ||
|
|
4feea6d153 | ||
|
|
ec6b658685 | ||
|
|
fb0f05a08d | ||
|
|
11bc477f1f | ||
|
|
9760375855 | ||
|
|
a6e20bd9f0 | ||
|
|
90fedbf9a2 | ||
|
|
eb03262abc | ||
|
|
59eb6e5f1b | ||
|
|
edf513aca9 | ||
|
|
efed63519a | ||
|
|
d78f781506 | ||
|
|
93fe269b09 | ||
|
|
8cad6c4e56 | ||
|
|
f92049dc71 | ||
|
|
a3497a9d39 | ||
|
|
bfc0a2ed57 | ||
|
|
c49b45d262 | ||
|
|
15678cf96a | ||
|
|
feeaaa7f2b | ||
|
|
50df1a2212 | ||
|
|
ac9254d049 | ||
|
|
e15eeb36a5 | ||
|
|
e275e03d4e | ||
|
|
41c8826ca8 | ||
|
|
d8af31ba5b | ||
|
|
2eb7cb1687 | ||
|
|
207e75f5b9 | ||
|
|
7b9e1a71a3 | ||
|
|
345838c6ce | ||
|
|
b02a60f4b3 | ||
|
|
ecd3a4e490 | ||
|
|
8c0c9bd60a | ||
|
|
943a8bf02d | ||
|
|
d3beb72652 | ||
|
|
c62dd2014e | ||
|
|
62fee7827b | ||
|
|
80b9d16494 | ||
|
|
cb5581c49f | ||
|
|
0098000ae0 | ||
|
|
ddc8429499 | ||
|
|
0424961d46 | ||
|
|
cbf510cfd1 | ||
|
|
cbb44ae253 | ||
|
|
4dd4f045aa | ||
|
|
ab0d7f8dc6 | ||
|
|
69f93fcb59 | ||
|
|
de68e0d7c2 | ||
|
|
cdbcb451e1 | ||
|
|
105c543a98 | ||
|
|
ab421e3184 | ||
|
|
d76b7a99b8 | ||
|
|
e8dae63e05 | ||
|
|
ea58b70435 | ||
|
|
f90f6f364a | ||
|
|
7fc967c64c | ||
|
|
8969a229d1 | ||
|
|
9601e0428e | ||
|
|
94fd91ce4a | ||
|
|
310f972c7f | ||
|
|
4378a5843c | ||
|
|
9bd403ec51 | ||
|
|
2f53786ca9 | ||
|
|
07ed213c94 | ||
|
|
05a2eca9a7 | ||
|
|
d30c836d04 | ||
|
|
8c623adad8 | ||
|
|
5191edfc0c | ||
|
|
ff99663d5c | ||
|
|
360335a608 | ||
|
|
1c83e5eeab | ||
|
|
122ebb12f4 | ||
|
|
fed242315d | ||
|
|
84e8e18ef8 | ||
|
|
36a1916b5f | ||
|
|
a1089460d7 | ||
|
|
c62f0dea6f | ||
|
|
a6c121dc33 | ||
|
|
c627c65a7d | ||
|
|
72006aff21 | ||
|
|
68338ebeff | ||
|
|
49b8503b64 | ||
|
|
0fc41df7e7 | ||
|
|
bb82c52747 | ||
|
|
a79367fb1c | ||
|
|
4dbc6db6f0 | ||
|
|
a50cee62be | ||
|
|
382aa5cb16 | ||
|
|
d1c2ff277b | ||
|
|
92b08b5550 | ||
|
|
da85470fef | ||
|
|
9d1e7d94cc | ||
|
|
65438286ec | ||
|
|
ffa7d27148 | ||
|
|
4a7d951d0d | ||
|
|
89f1911a6e | ||
|
|
b990bd1792 | ||
|
|
88667416d8 | ||
|
|
216491012e | ||
|
|
c88f3dcf75 | ||
|
|
6c3e21339d | ||
|
|
e7f9f9f13d | ||
|
|
6b8d6da5be | ||
|
|
8c73c5d662 | ||
|
|
f7dc2c9a9e | ||
|
|
eadf825b67 | ||
|
|
150999d71b | ||
|
|
7cd89a594e | ||
|
|
b67f1cb4b8 | ||
|
|
4678f8c7da | ||
|
|
0577f48437 | ||
|
|
0c079482f0 | ||
|
|
684fe3945d | ||
|
|
d91d325744 | ||
|
|
040d7564ed | ||
|
|
d1db34445e | ||
|
|
9639dd422a | ||
|
|
f60bfe8c54 | ||
|
|
fe53c11447 | ||
|
|
9bd17bdf6f | ||
|
|
4b64308951 | ||
|
|
bb7dacea91 | ||
|
|
0a369621a3 | ||
|
|
e0ee1a50ae | ||
|
|
6b49fc4294 | ||
|
|
ed20ea6af4 | ||
|
|
73fe4dc7a0 | ||
|
|
c4967de530 | ||
|
|
bcf3d36ba1 | ||
|
|
d52bd7f012 | ||
|
|
e6232be244 | ||
|
|
b33f313e2e | ||
|
|
0b4372fe88 | ||
|
|
4e07c7f2dc | ||
|
|
941e194df3 | ||
|
|
2b8f94f457 | ||
|
|
7ec8c0cea5 | ||
|
|
c69384dabd | ||
|
|
8c92216a1d | ||
|
|
41537c0bad | ||
|
|
c112f56b37 | ||
|
|
f22de50527 | ||
|
|
a22e08f39d | ||
|
|
210d470473 | ||
|
|
0eebb77438 | ||
|
|
f819cb9c5f | ||
|
|
240963f1f3 | ||
|
|
16819d98fa | ||
|
|
8be7e0f0cb | ||
|
|
3a51daf51b | ||
|
|
7622e72b70 | ||
|
|
b59173cac4 | ||
|
|
18411ee5bd | ||
|
|
6e1c6fab2d | ||
|
|
98eb2d8836 | ||
|
|
504e32f922 | ||
|
|
c096054b1f | ||
|
|
ac2f198851 | ||
|
|
9aed659f17 | ||
|
|
0b8f5d3b22 | ||
|
|
55c74e8891 | ||
|
|
3a49aa6a67 | ||
|
|
10770b6fe1 | ||
|
|
c81ea08f42 | ||
|
|
73b6ab4a18 | ||
|
|
7497235d7b | ||
|
|
27191e4234 | ||
|
|
7b0110ce42 | ||
|
|
117a635a1e | ||
|
|
98c922fb3e | ||
|
|
bf84d04f1f | ||
|
|
f4e358b509 | ||
|
|
060ad7966e | ||
|
|
f0301fd1a4 | ||
|
|
ae8212a51d | ||
|
|
393a0d5cdc | ||
|
|
4cf43a8d74 | ||
|
|
74b2f47e3a | ||
|
|
1e727db09a | ||
|
|
1daa120d06 | ||
|
|
a1d2445ae6 | ||
|
|
4d4e35e24b | ||
|
|
400cc599e3 | ||
|
|
e55352346b | ||
|
|
cca226dec0 | ||
|
|
fec95c91f8 | ||
|
|
9955418a8e | ||
|
|
90c7539956 | ||
|
|
a751e45602 | ||
|
|
b50d388f9e | ||
|
|
fd60292b5d | ||
|
|
4ebb0c432e | ||
|
|
897b2478e8 | ||
|
|
b8ebb7f6c4 | ||
|
|
f32dba72b4 | ||
|
|
498ad280e0 | ||
|
|
32358de718 | ||
|
|
2474a6ce01 | ||
|
|
1ba45200ee | ||
|
|
da793856ce | ||
|
|
d950588c36 | ||
|
|
2b4a5d2ce7 | ||
|
|
86daedc802 | ||
|
|
3788487196 | ||
|
|
25559b7e3e | ||
|
|
246db33ee6 | ||
|
|
d435e9b58b | ||
|
|
09ecc79050 | ||
|
|
1914435707 | ||
|
|
f6c237afc5 | ||
|
|
a1f2579047 | ||
|
|
1ea6617a5d | ||
|
|
489175aa45 | ||
|
|
cb72f43b03 | ||
|
|
4bbbcc7c39 | ||
|
|
af1e4884b7 | ||
|
|
5213d6255a | ||
|
|
a9af689aa5 | ||
|
|
407a9f7780 | ||
|
|
a0ca667ca7 | ||
|
|
c2f6f97c34 | ||
|
|
2daefbe2f4 | ||
|
|
84b0c9d4b7 | ||
|
|
0d848569f0 | ||
|
|
611f8397ca | ||
|
|
11ed0a1367 | ||
|
|
ff51966fbb | ||
|
|
5491d51eba | ||
|
|
61a5a7e929 | ||
|
|
3de000bc94 | ||
|
|
ef456e6ea0 | ||
|
|
2a8b67e22a | ||
|
|
c35b66f6e1 | ||
|
|
c8348dcaaa | ||
|
|
e38174110e | ||
|
|
a95130c01f | ||
|
|
0e93417090 | ||
|
|
07054bf55a | ||
|
|
368eab476a | ||
|
|
996679a2d2 | ||
|
|
85a6943cd5 | ||
|
|
0b96893f3b | ||
|
|
846e2e27ba | ||
|
|
43ea9b7696 | ||
|
|
9dd4df2ca9 | ||
|
|
2b4fb55526 | ||
|
|
72cf16301f | ||
|
|
c512dde028 | ||
|
|
1e13c7ab31 | ||
|
|
cdbab86dee | ||
|
|
fec03d1fd4 | ||
|
|
6aa24e23c0 | ||
|
|
78770d1da5 | ||
|
|
6f72447e2e | ||
|
|
cb75a15a6f | ||
|
|
c3555237b3 | ||
|
|
e4a2cc7ac8 | ||
|
|
3900d305b9 | ||
|
|
cb3d501649 | ||
|
|
28323a486a | ||
|
|
dfcad4b9fd | ||
|
|
6fb2869cd8 | ||
|
|
e764e39ba9 | ||
|
|
128077dcbc | ||
|
|
1c51107f1e | ||
|
|
d154cab054 | ||
|
|
7ed4368d5b | ||
|
|
ee64df2376 | ||
|
|
b13f03eb97 | ||
|
|
8d20829428 | ||
|
|
97401f609e | ||
|
|
fe074729ea | ||
|
|
db5141e010 | ||
|
|
4564fdc6aa | ||
|
|
a477b36a57 | ||
|
|
3b8ae2c879 | ||
|
|
ebe3a51398 | ||
|
|
76d22f0cb5 | ||
|
|
c61d676dfb | ||
|
|
b1913e7204 | ||
|
|
b6609e0a14 | ||
|
|
55fa759344 | ||
|
|
8992a713cc | ||
|
|
c55dcec252 | ||
|
|
e3dd6cbef5 | ||
|
|
dd3e5ea368 | ||
|
|
ac2e77e0d6 | ||
|
|
9f57622f54 | ||
|
|
cfed460eba | ||
|
|
06f97b671f | ||
|
|
aebf83d735 | ||
|
|
31894dd117 | ||
|
|
e041d802ec | ||
|
|
82ea15388c | ||
|
|
bf9ed8ff00 | ||
|
|
c02606df6a | ||
|
|
7372e2e385 | ||
|
|
ba86fa6d3e | ||
|
|
0e434cbd1c | ||
|
|
c89300022a | ||
|
|
1300756d6f | ||
|
|
c4ad02ff92 | ||
|
|
b3f47f140a | ||
|
|
2206b3d5b5 | ||
|
|
b08f8a450d | ||
|
|
37c8be8a6e | ||
|
|
ae58c265a0 | ||
|
|
54e6d1aa16 | ||
|
|
4ddb5f14d9 | ||
|
|
623aec495b | ||
|
|
f6d2b9bad0 | ||
|
|
08b5a278f3 | ||
|
|
f62b30b50d | ||
|
|
50e3b8e7d4 | ||
|
|
e26956dbe8 | ||
|
|
cff2c12d70 | ||
|
|
5781d532a4 | ||
|
|
f161a593f8 | ||
|
|
5725d5a2fe | ||
|
|
23280fd97b | ||
|
|
fe6679f16a | ||
|
|
19a95a3670 | ||
|
|
90cffb3791 | ||
|
|
31168fbeca | ||
|
|
c4cce5d184 | ||
|
|
08b59dd082 | ||
|
|
4aaf1a5868 | ||
|
|
6e78fa0b1f | ||
|
|
e1a42189a6 | ||
|
|
386e0c9b6b | ||
|
|
3b1b423936 | ||
|
|
8e8e8161bb | ||
|
|
b368fde82d | ||
|
|
7267111083 | ||
|
|
d05dab6633 | ||
|
|
e1409a8045 | ||
|
|
ae69fec7ce | ||
|
|
a2862f22f6 | ||
|
|
7db8e18bcc | ||
|
|
0ffe1272fe | ||
|
|
92b54075c4 | ||
|
|
ce5c679d6b | ||
|
|
4f61386b21 | ||
|
|
2738ae1abc | ||
|
|
f5e43ff7b4 | ||
|
|
63c499bf2c | ||
|
|
9e72720bda | ||
|
|
bbe10b2dab | ||
|
|
f3b0784651 | ||
|
|
9c0ea9b1c7 | ||
|
|
620a088c6c | ||
|
|
867a74cffb | ||
|
|
f2316fdd3a | ||
|
|
7d49d4f948 | ||
|
|
f85b2b889c | ||
|
|
9471ac4a52 | ||
|
|
db520c39e3 | ||
|
|
cc59fbe2ba | ||
|
|
e260af58f2 | ||
|
|
166fc6dad9 | ||
|
|
959433d737 | ||
|
|
f9fa9ce6d8 | ||
|
|
6b3a41dfe0 | ||
|
|
37428ecca4 | ||
|
|
6934df253f | ||
|
|
00782598a4 | ||
|
|
565c500810 | ||
|
|
e3c16166e6 | ||
|
|
cfa8d1b689 | ||
|
|
a19397f9b5 | ||
|
|
ddfc80b45f | ||
|
|
8591f9b2a1 | ||
|
|
cb26a55e65 | ||
|
|
ef92394685 | ||
|
|
d588ef438e | ||
|
|
09cd363b11 | ||
|
|
2d5c7fdbb5 | ||
|
|
2f0e28368d | ||
|
|
f7f1a2a3b3 | ||
|
|
30afb85260 | ||
|
|
78d883a1b4 | ||
|
|
7913b673a3 | ||
|
|
5edc27297f | ||
|
|
ebc24c2476 | ||
|
|
ed7dd037e5 | ||
|
|
277924c04d | ||
|
|
26ea0feddb | ||
|
|
63c1eab930 | ||
|
|
813e7711df | ||
|
|
6c1f50a230 | ||
|
|
470b6359ba | ||
|
|
2f45233748 | ||
|
|
82fd52f572 | ||
|
|
ed6331e6a4 | ||
|
|
2ae9188535 | ||
|
|
1a55a5394a | ||
|
|
99d2f37cfc | ||
|
|
09b531e0c1 | ||
|
|
232e872c0d | ||
|
|
acdb0d2838 | ||
|
|
bd0ea1379f | ||
|
|
5461ea1a3a | ||
|
|
200ee075b5 | ||
|
|
79e9e5fcf1 | ||
|
|
a2df23d562 | ||
|
|
55af3d7f65 | ||
|
|
ef54f3fe59 | ||
|
|
9d84ff6aa7 | ||
|
|
ee26006f3c | ||
|
|
be03035574 | ||
|
|
619f3ca700 | ||
|
|
8553e63338 | ||
|
|
be4d9fe24b | ||
|
|
497f727b08 | ||
|
|
74a7569f4c | ||
|
|
66185e3b91 | ||
|
|
1b2beda695 | ||
|
|
feb3b5ef5f | ||
|
|
b2439331b3 | ||
|
|
f1000afc27 | ||
|
|
5fc2a82423 | ||
|
|
a27f884418 | ||
|
|
cae4b73226 | ||
|
|
50ed293de2 | ||
|
|
616b772a45 | ||
|
|
c1d00e21db | ||
|
|
2e8e2b61d3 | ||
|
|
ba595c9719 | ||
|
|
e392f6a2b7 | ||
|
|
7457e71776 | ||
|
|
982d0dd72e | ||
|
|
d345f96518 | ||
|
|
469874e975 | ||
|
|
6ba817cd43 | ||
|
|
42f2e69e3a | ||
|
|
12442b4bd3 | ||
|
|
305d37a13b | ||
|
|
4baf60174f | ||
|
|
8cd1ac6a4b | ||
|
|
c65fef638e | ||
|
|
a030cd7e28 | ||
|
|
59a3b7eac5 | ||
|
|
2faac48adf | ||
|
|
3883039764 | ||
|
|
307ed0c637 | ||
|
|
96ffdb65d0 | ||
|
|
5ca55798b2 | ||
|
|
cd32e11c6d | ||
|
|
774cbe4c9d | ||
|
|
1d0bb20506 | ||
|
|
8064e107f4 | ||
|
|
c1d1121ed1 | ||
|
|
07603f11db | ||
|
|
ec22c857d5 | ||
|
|
364e808261 | ||
|
|
1d47ad0c4b | ||
|
|
c9d0eac6cc | ||
|
|
97fc72b78a | ||
|
|
7b1111430b | ||
|
|
4f3306cd0f | ||
|
|
d3f7056ece | ||
|
|
9f3286c570 | ||
|
|
16fc737b2d | ||
|
|
3e0ae709d9 | ||
|
|
39ddb7c8f9 | ||
|
|
3c509ce0e4 | ||
|
|
048cf2fb8f | ||
|
|
0a20821c41 | ||
|
|
e0eaf6267f | ||
|
|
3ddf98277f | ||
|
|
85294bcd33 | ||
|
|
acff4523f3 | ||
|
|
bf71e1f9b8 | ||
|
|
f0bcdc1c25 | ||
|
|
43526c58bd | ||
|
|
ce3c7a545e | ||
|
|
9498e4e7eb | ||
|
|
4ec7c207f4 | ||
|
|
000479463f | ||
|
|
6b2065e43c | ||
|
|
e97e1363ae | ||
|
|
697a1f8e31 | ||
|
|
035f43311a | ||
|
|
c597f1252e | ||
|
|
cc1e7a715c | ||
|
|
80057e3014 | ||
|
|
79ffba873f | ||
|
|
673e1cf212 | ||
|
|
7e878ecff2 | ||
|
|
88cf51a602 | ||
|
|
1860fffe07 | ||
|
|
fa925543db | ||
|
|
825e99c59b | ||
|
|
955bed80fb | ||
|
|
03b9ac3ec4 | ||
|
|
c255d9a5d8 | ||
|
|
401d973a51 | ||
|
|
a507d559e1 | ||
|
|
9225982ca5 | ||
|
|
6f831530cc | ||
|
|
e6b4443074 | ||
|
|
1c800cbd8f | ||
|
|
a65924799e | ||
|
|
adbfa1e73e | ||
|
|
44a4226ad2 | ||
|
|
07ca3f13a0 | ||
|
|
87a052b89c | ||
|
|
2216543ac3 | ||
|
|
4254d57d12 | ||
|
|
30d93898d8 | ||
|
|
4c7ed2c2c5 | ||
|
|
4fb327cef8 | ||
|
|
588af3613b | ||
|
|
5b5f325a4e | ||
|
|
ae62196dff | ||
|
|
27e66ee770 | ||
|
|
8fb8134898 | ||
|
|
a59489f804 | ||
|
|
cbf3938784 | ||
|
|
c45ebfe598 | ||
|
|
a75aad1fdc | ||
|
|
a0635a1026 | ||
|
|
27353e160f | ||
|
|
b9619efbbf | ||
|
|
1712d32ef7 | ||
|
|
014deb2118 | ||
|
|
6077cf81f2 | ||
|
|
0422c38096 | ||
|
|
8c902ae04d | ||
|
|
0a0b916067 | ||
|
|
6822635a0b | ||
|
|
f9b15fd110 | ||
|
|
131a458e69 | ||
|
|
7260807d78 | ||
|
|
df83d8a3e5 | ||
|
|
0f45424458 | ||
|
|
60f92d019b | ||
|
|
2189487982 | ||
|
|
3a44997795 | ||
|
|
1f04134aac | ||
|
|
ce44538240 | ||
|
|
5fd53883be | ||
|
|
f064cc89ba | ||
|
|
5dd8b3ee36 | ||
|
|
f2f9c37ee2 | ||
|
|
6836777629 | ||
|
|
beefdd280f | ||
|
|
4b9ad0da7a | ||
|
|
f9fdd1686c | ||
|
|
25fc3d931e | ||
|
|
fc7d0f2cd5 | ||
|
|
60c91d9fe4 | ||
|
|
cc2d6849a8 | ||
|
|
4a5379ea42 | ||
|
|
ba84c644df | ||
|
|
37217b4219 | ||
|
|
41dab03a5f | ||
|
|
6e48bf2a71 | ||
|
|
1c1c6f513c | ||
|
|
49c54f5593 | ||
|
|
d083e49d0b | ||
|
|
8dc2b833f4 | ||
|
|
7d5726be50 | ||
|
|
246c1674d1 | ||
|
|
06b81f2b64 | ||
|
|
ee57797890 | ||
|
|
a94000e114 | ||
|
|
e6655b35f3 | ||
|
|
696ffde184 | ||
|
|
9e74e99923 | ||
|
|
bc5c6dadfb | ||
|
|
0d173a0bfe | ||
|
|
cd78920edd | ||
|
|
9a7ec62cf9 | ||
|
|
2b4580cfe8 | ||
|
|
b790c06294 | ||
|
|
61d87b46d9 | ||
|
|
143cb4cbab | ||
|
|
22709dac36 | ||
|
|
d97be93449 | ||
|
|
5864de7dea | ||
|
|
4ea5890e92 | ||
|
|
876d51b009 | ||
|
|
5b0d55c1a2 | ||
|
|
3ddb1421c3 | ||
|
|
58f9a7bc02 | ||
|
|
e8e4b728ce | ||
|
|
0a4868192d | ||
|
|
9d81ffffe8 | ||
|
|
e6fe4a09e5 | ||
|
|
77c5ad7b09 | ||
|
|
b850e9615a | ||
|
|
c2ea307821 | ||
|
|
fb588c0d60 | ||
|
|
fecbdf6190 | ||
|
|
bbbbf6892f | ||
|
|
e1a11053a6 | ||
|
|
f0a62191ea | ||
|
|
a8311923fb | ||
|
|
cd1d88760d | ||
|
|
004949d3a0 | ||
|
|
f6d26042da | ||
|
|
270a73a470 | ||
|
|
018e80e59d | ||
|
|
cb5cb1e594 | ||
|
|
6c5eb156a1 | ||
|
|
8abef33840 | ||
|
|
1d6b8951e8 | ||
|
|
711d57d91f | ||
|
|
65fd847251 | ||
|
|
73a170a5f1 | ||
|
|
9a32d1c0f7 | ||
|
|
59918032c6 | ||
|
|
55394cbf09 | ||
|
|
83dcc0c4f2 | ||
|
|
b4b93f0572 | ||
|
|
ab0e59215c | ||
|
|
5669ce207c | ||
|
|
37f6cd96a4 | ||
|
|
c0ec74fb12 | ||
|
|
226dc45190 | ||
|
|
11e3f53a2f | ||
|
|
31d7f7e3e9 | ||
|
|
128edc08e2 | ||
|
|
5158c5f359 | ||
|
|
a70b33ce13 | ||
|
|
d787c3caa0 | ||
|
|
a554af939e | ||
|
|
06604ff0d1 | ||
|
|
9490f79c6d | ||
|
|
311a624698 | ||
|
|
3e2e77f9fb | ||
|
|
b2e02cd0e7 | ||
|
|
87ead71766 | ||
|
|
b8517a5b3e | ||
|
|
c29cdf44fb | ||
|
|
4b2ab2894a | ||
|
|
c9a01ab5ad | ||
|
|
90d1046312 | ||
|
|
14e749a18d | ||
|
|
a4be1af0ef | ||
|
|
f4185d0a2a | ||
|
|
ffb8324b5a | ||
|
|
6df44f1632 | ||
|
|
9570819f59 | ||
|
|
f2afc94ed2 | ||
|
|
050b95946c | ||
|
|
e33ef92334 | ||
|
|
0d7ff46aec | ||
|
|
042913e080 | ||
|
|
98bc8be642 | ||
|
|
2c6d2f4255 | ||
|
|
6293556837 | ||
|
|
641721d199 | ||
|
|
036a2b9014 | ||
|
|
9ad092d340 | ||
|
|
d24884f651 | ||
|
|
fa1c498716 | ||
|
|
25635239d4 | ||
|
|
c816688de3 | ||
|
|
43d79bd1e9 | ||
|
|
ba88c7b0f6 | ||
|
|
4359d92ffe | ||
|
|
16c7513e82 | ||
|
|
4572478ad8 | ||
|
|
02b5cd61bd | ||
|
|
bff07311b2 | ||
|
|
44cc89b9d5 | ||
|
|
fa1e6c6c64 | ||
|
|
4ebbdb284b | ||
|
|
51302a7c5a | ||
|
|
ba984592ed | ||
|
|
60a97e5815 | ||
|
|
3275a1ecb4 | ||
|
|
af72c7a2d3 | ||
|
|
c07ada1fc4 | ||
|
|
c19c8f9c5d | ||
|
|
43fe7ae7db | ||
|
|
22916868df | ||
|
|
7d00ff8869 | ||
|
|
4ea2088485 | ||
|
|
e421b40093 | ||
|
|
a9dd7562ac | ||
|
|
8f62ed67d3 | ||
|
|
cfd89a14f7 | ||
|
|
55011842f5 | ||
|
|
3079a3f51c | ||
|
|
c4ec390ca0 | ||
|
|
f99b7f3589 | ||
|
|
887b170c0e | ||
|
|
c696cfd8d8 | ||
|
|
25966973a2 | ||
|
|
d0a57d4b7c | ||
|
|
9341b49fd1 | ||
|
|
bbc3c922a6 | ||
|
|
17b8d63e6c | ||
|
|
c751a8168a | ||
|
|
f2509dbe5d | ||
|
|
6d44c22982 | ||
|
|
4bed489610 | ||
|
|
8edf488636 | ||
|
|
8fe7d249f8 | ||
|
|
6ed14e1d3c | ||
|
|
a5459acdaf | ||
|
|
61cd198d35 | ||
|
|
49ea2b304d | ||
|
|
27231d1764 | ||
|
|
8744620220 | ||
|
|
4590be6d42 | ||
|
|
fa93b43c32 | ||
|
|
3c47f84a24 | ||
|
|
8a371c26de | ||
|
|
088a594468 | ||
|
|
c551913551 | ||
|
|
05e81053e0 | ||
|
|
981c0ab980 | ||
|
|
1f083b335f | ||
|
|
10603900df | ||
|
|
c22e36d219 | ||
|
|
26fc2ae9db | ||
|
|
2a0b298ae5 | ||
|
|
96f0a9bc5d | ||
|
|
5054e78864 | ||
|
|
f1fa6b03d5 | ||
|
|
8f15bf9668 | ||
|
|
4b8e7b19a3 | ||
|
|
67bba1dd09 | ||
|
|
b826dec79d | ||
|
|
62b200c5d9 | ||
|
|
c2ed772f34 | ||
|
|
bbce6d4ad0 | ||
|
|
45f6a0ec02 | ||
|
|
6b681278e0 | ||
|
|
2281ff06c7 | ||
|
|
572a81fd4e | ||
|
|
5fd7df69fd | ||
|
|
3401c6305e | ||
|
|
269f9ac52c | ||
|
|
4712171d43 | ||
|
|
632c7b91f4 | ||
|
|
f72dd79dff | ||
|
|
4d109c0481 | ||
|
|
c046b77223 | ||
|
|
56ba3b5e5f | ||
|
|
fa99247cb7 | ||
|
|
653fd37c08 | ||
|
|
9d053beafb | ||
|
|
f33c451a19 | ||
|
|
9543148887 | ||
|
|
029ee4ed2f | ||
|
|
688e826e9d | ||
|
|
deea0c54d4 | ||
|
|
028f62aa9c | ||
|
|
d3aef9c1d1 | ||
|
|
72edce511a | ||
|
|
37fade8f7a | ||
|
|
0d2aa6738c | ||
|
|
e3e0e5cba8 | ||
|
|
e40de189c3 | ||
|
|
c9c99f7b2a | ||
|
|
e6400918c8 | ||
|
|
d08557ad0e | ||
|
|
ba84dce7a7 | ||
|
|
7044725bf1 | ||
|
|
1812966fe6 | ||
|
|
a9bac25c9b | ||
|
|
5c967f11f0 | ||
|
|
1b5f080495 | ||
|
|
50a27fa3f6 | ||
|
|
f8ed53c1b9 | ||
|
|
2163830a54 | ||
|
|
cae5e3b99f | ||
|
|
cc0a7941ea | ||
|
|
18901c0e2d | ||
|
|
d4ea239185 | ||
|
|
813c6aab13 | ||
|
|
f606e131a7 | ||
|
|
ccefa61b3d | ||
|
|
606cae411f | ||
|
|
901e4012cc | ||
|
|
d03b667194 | ||
|
|
d30954167e | ||
|
|
0ee514ea15 | ||
|
|
7e60792be8 | ||
|
|
244a325394 | ||
|
|
53df16a7ca | ||
|
|
420576da09 | ||
|
|
d5a9d8ffdb | ||
|
|
1873ad1a02 | ||
|
|
9dec238f41 | ||
|
|
b93a018dc1 | ||
|
|
6b1d5bf7db | ||
|
|
4580866281 | ||
|
|
5b743772ac | ||
|
|
0ecf08e8e6 | ||
|
|
7ca53d30b2 | ||
|
|
18a0ba1981 | ||
|
|
11a35ed589 | ||
|
|
eb5aa12d7f | ||
|
|
1065b67073 | ||
|
|
0d7b278003 | ||
|
|
05093a9d49 | ||
|
|
87f9b2b72c | ||
|
|
2d4833d199 | ||
|
|
610e08e690 | ||
|
|
1192224c15 | ||
|
|
4c3a9928e7 | ||
|
|
433a4359e6 | ||
|
|
f1854b5120 | ||
|
|
6961b0c2f5 | ||
|
|
8b26f30e37 | ||
|
|
071724949f | ||
|
|
28898aa1db | ||
|
|
11fae19e33 | ||
|
|
13b9dd0262 | ||
|
|
b47520c938 | ||
|
|
f6869b9e1c | ||
|
|
96046a5d1f | ||
|
|
524d0b278b | ||
|
|
6e2348eb06 | ||
|
|
e4b57e6ca3 | ||
|
|
9640e558cd | ||
|
|
07b13d1374 | ||
|
|
7e4389abd9 | ||
|
|
0f424e7f0d | ||
|
|
455e5735ff | ||
|
|
6577d2ae3c | ||
|
|
56ed543dfb | ||
|
|
5549e3a398 | ||
|
|
2ff7c111af | ||
|
|
a7af072ca7 | ||
|
|
a4a455f31e | ||
|
|
99d55b4314 | ||
|
|
811e2155a6 | ||
|
|
ab7a49351d | ||
|
|
efdfba0575 | ||
|
|
af9b5f6ca4 | ||
|
|
65144b9a3d | ||
|
|
621f57d702 | ||
|
|
a0444fbeee | ||
|
|
cff81eea14 | ||
|
|
5738c90721 | ||
|
|
a229231c0c | ||
|
|
6bf5bd97b5 | ||
|
|
35c50a7c60 | ||
|
|
042e6584eb | ||
|
|
bcce1b7ea8 | ||
|
|
73181f9e33 | ||
|
|
b0a7b6c7cd | ||
|
|
09744818dc | ||
|
|
f93b3109b9 | ||
|
|
48d4836f0a | ||
|
|
5d4f70e943 | ||
|
|
9e05197a9a | ||
|
|
11671e884d | ||
|
|
dcce818678 | ||
|
|
f6c23bc9a0 | ||
|
|
15eca895fb | ||
|
|
d5d5dd7855 | ||
|
|
c79f5fd8a5 | ||
|
|
409e40f3b7 | ||
|
|
67a83cb164 | ||
|
|
908bdc7c86 | ||
|
|
b1c2bd3d64 | ||
|
|
ffd317aff0 | ||
|
|
84a10afea1 | ||
|
|
32036ef64d | ||
|
|
a8b8036311 | ||
|
|
b813716f7c | ||
|
|
7bd6061a59 | ||
|
|
7682a6e708 | ||
|
|
fe9c592107 | ||
|
|
9ff24dc446 | ||
|
|
3036711fb4 | ||
|
|
53363f293b | ||
|
|
e9791984ee | ||
|
|
ddca96a60e | ||
|
|
be3607dd4d | ||
|
|
6000a7a60f | ||
|
|
cc64c9f9d8 | ||
|
|
5c699d956c | ||
|
|
e1757e5ac5 | ||
|
|
31167234be | ||
|
|
807638ca04 | ||
|
|
ff6b78252c | ||
|
|
93bdcaab7f | ||
|
|
d06c580bbc | ||
|
|
a5b32b356c | ||
|
|
d117e666fd | ||
|
|
09da94b2ab | ||
|
|
9ebf5919a2 | ||
|
|
9c46452a4d | ||
|
|
b34536491b | ||
|
|
a7c1e240c1 | ||
|
|
44618d3d73 | ||
|
|
1f92af64f0 | ||
|
|
5bcd081e88 | ||
|
|
b141622e75 | ||
|
|
f96bdee71d | ||
|
|
0af70d3298 | ||
|
|
e044c59627 | ||
|
|
cce1c902e5 | ||
|
|
d845474644 | ||
|
|
cd67aba2ad | ||
|
|
1e47162357 | ||
|
|
230400846f | ||
|
|
ebb29ad04b | ||
|
|
2cd603357d | ||
|
|
bee26838e1 | ||
|
|
5b0572879d | ||
|
|
6e86275dce | ||
|
|
03a007b9b6 | ||
|
|
dadb215ce0 | ||
|
|
a1e3e12c6b | ||
|
|
4274c817d3 | ||
|
|
5abedc15dc | ||
|
|
947c9639e8 | ||
|
|
c4cdf4a834 | ||
|
|
44b9bfee68 | ||
|
|
ac9d43892b | ||
|
|
9f62f8eff9 | ||
|
|
13dd400795 | ||
|
|
37343a4114 | ||
|
|
3df6a4048a | ||
|
|
c9fb87b571 | ||
|
|
44ca1d507d | ||
|
|
30b236548a | ||
|
|
21b7d1c3fb | ||
|
|
8d5ea66ecc | ||
|
|
b5ed10689d | ||
|
|
a55cdfd7fa | ||
|
|
39a4c10ac9 | ||
|
|
c542cd4d7d | ||
|
|
01de338a65 | ||
|
|
f23f7b1983 | ||
|
|
a349ab62ec | ||
|
|
e620010f10 | ||
|
|
70509355de | ||
|
|
f25654ead7 | ||
|
|
f1741d4dac | ||
|
|
f3245d092b | ||
|
|
a039a8600e | ||
|
|
ee56f4a7a2 | ||
|
|
c40b6ca7f4 | ||
|
|
2c0e1e498b | ||
|
|
0262ea31eb | ||
|
|
4b671d7fb0 | ||
|
|
d8f9419eb9 | ||
|
|
aa65bab486 | ||
|
|
849c3967fd | ||
|
|
83562cf7d8 | ||
|
|
2631a44410 | ||
|
|
ddfea43b79 | ||
|
|
68f19ffa5f | ||
|
|
8800d42c32 | ||
|
|
416a8a7cb2 | ||
|
|
7088f249a5 | ||
|
|
a2301cc980 | ||
|
|
af545404e8 | ||
|
|
2e96f19476 | ||
|
|
e3f26b7f75 | ||
|
|
f45c98a6a7 | ||
|
|
a565a3c909 | ||
|
|
b5e3dd6c06 | ||
|
|
928f592d9c | ||
|
|
8ee8edcd36 | ||
|
|
1e128348e5 | ||
|
|
6ef5655b7d | ||
|
|
f6ba5329ce | ||
|
|
93cef0d580 | ||
|
|
797b088cc8 | ||
|
|
6d23d3510f | ||
|
|
f2d7d0af43 | ||
|
|
e55c0461db | ||
|
|
8eca511a53 | ||
|
|
f20e46dee0 | ||
|
|
b79f22f4fe | ||
|
|
3287dc77e2 | ||
|
|
78a08b35e7 | ||
|
|
e86196999a | ||
|
|
4d50339041 | ||
|
|
edc5a2c0f2 | ||
|
|
598b88b1f0 | ||
|
|
c22b9f8ff5 | ||
|
|
3c654ab495 | ||
|
|
2f0fabea7a | ||
|
|
099b14efc3 | ||
|
|
ee42dee366 | ||
|
|
deae081cb3 | ||
|
|
6479f14d3d | ||
|
|
60707c3868 | ||
|
|
e78d8e1ae6 | ||
|
|
fee0d0aed9 | ||
|
|
178abc77ce | ||
|
|
7001f97d96 | ||
|
|
55432e61ff | ||
|
|
4ee993ef3b | ||
|
|
64d471bb9b | ||
|
|
4dfcdcb0b2 | ||
|
|
8e69a84e7a | ||
|
|
8ec643d882 | ||
|
|
f4d6192c80 | ||
|
|
c0aa2b85fc | ||
|
|
61a376fb6d | ||
|
|
8786cb5180 | ||
|
|
c305ef1360 | ||
|
|
f662ce0b7a | ||
|
|
71af9345a5 | ||
|
|
a819a19c77 | ||
|
|
c65fad06b7 | ||
|
|
9e6e1931b1 | ||
|
|
3b22273f5a | ||
|
|
fc5ff1782b | ||
|
|
4d3b3d984d | ||
|
|
514976561f | ||
|
|
fb4998d21b | ||
|
|
0feec978d3 | ||
|
|
5f1c39aba5 | ||
|
|
17973619de | ||
|
|
478d7a2d2d | ||
|
|
f2af0be1e1 | ||
|
|
d0725f5098 | ||
|
|
646d614d94 | ||
|
|
4c337ef5e9 | ||
|
|
2b633b8566 | ||
|
|
6b16454217 | ||
|
|
b7086deeac | ||
|
|
f021afb6a4 | ||
|
|
99622bd3d6 | ||
|
|
50a76519ea | ||
|
|
1e806054ab | ||
|
|
89d7f335fc | ||
|
|
7a664ec4ec | ||
|
|
a8a4d029f8 | ||
|
|
501b5dce76 | ||
|
|
e9b3504370 | ||
|
|
7b20c78e73 | ||
|
|
a343ce69aa | ||
|
|
d52ce400fb | ||
|
|
0ee574eaaa | ||
|
|
74c4392b6d | ||
|
|
d844c330e9 | ||
|
|
b50cb78fa6 | ||
|
|
26c138f42c | ||
|
|
0ec7e65926 | ||
|
|
3588cc4c03 | ||
|
|
da8c7749c8 | ||
|
|
79fe999e77 | ||
|
|
439c65ad6d | ||
|
|
81b3aa5ac1 | ||
|
|
c4beb9ae4d | ||
|
|
9d286d8378 | ||
|
|
19e7a43fe3 | ||
|
|
ab59e2deac | ||
|
|
043f22e6ec | ||
|
|
4abb6af31e | ||
|
|
a17ba4a81f | ||
|
|
bc8a6847e3 | ||
|
|
18f97f9df2 | ||
|
|
4a204d8d89 | ||
|
|
062c6c2364 | ||
|
|
477716ef67 | ||
|
|
ef973df7c9 | ||
|
|
c40bb6a4d5 | ||
|
|
20e942dccd | ||
|
|
598cbc4d11 | ||
|
|
70a3d5af07 | ||
|
|
9cabb1afbd | ||
|
|
4267224f59 | ||
|
|
02d910f53c | ||
|
|
e81b7b5b9f | ||
|
|
17e0a8eec1 | ||
|
|
eb322c9d41 | ||
|
|
751af92c21 | ||
|
|
fb4962a41a | ||
|
|
daf6598599 | ||
|
|
e1e4f71f3a | ||
|
|
a2fa7ec9c4 | ||
|
|
5cd37b74b4 | ||
|
|
beed7e83f2 | ||
|
|
a7726edca6 | ||
|
|
eed0c21c41 | ||
|
|
094a43157e | ||
|
|
4033958bf5 | ||
|
|
f18784ecc1 | ||
|
|
418494b7f3 | ||
|
|
343753ee2a | ||
|
|
1ceda066c4 | ||
|
|
3c13dea55f | ||
|
|
6ced2cbf7c | ||
|
|
b41f16a736 | ||
|
|
42fa02a887 | ||
|
|
2ce87cdac0 | ||
|
|
e4864d3871 | ||
|
|
b6e0052013 | ||
|
|
9ef83a59c7 | ||
|
|
325724ff85 | ||
|
|
998bfa0656 | ||
|
|
e476df5e7d | ||
|
|
83a3601cdb | ||
|
|
996dcc4b23 | ||
|
|
04304f8283 | ||
|
|
3418f73390 | ||
|
|
b07b6c8960 | ||
|
|
4f2cf37d73 | ||
|
|
408e017f2f | ||
|
|
62b42266e8 | ||
|
|
80e9e23965 | ||
|
|
c73154aeb1 | ||
|
|
66c4786ec2 | ||
|
|
143f5a2085 | ||
|
|
699c7df798 | ||
|
|
d3de7b95aa | ||
|
|
2dc6f76da9 | ||
|
|
63ccf6b553 | ||
|
|
f49ffe3cb0 | ||
|
|
c1b578350d | ||
|
|
48e4af41ae | ||
|
|
5e915f9c40 | ||
|
|
70b5f91f82 | ||
|
|
792df08c78 | ||
|
|
032b5d3580 | ||
|
|
8f93e43bb3 | ||
|
|
04f95e648b | ||
|
|
511b8eb407 | ||
|
|
a6e6dd255d | ||
|
|
c3c53d4056 | ||
|
|
2f700d9a4c | ||
|
|
e4da9f5afe | ||
|
|
3f919813f2 | ||
|
|
4063ffc163 | ||
|
|
0d08b89853 | ||
|
|
6c9da364d0 | ||
|
|
c1614e8241 | ||
|
|
75a458f2be | ||
|
|
602291736f | ||
|
|
9f2d15e590 | ||
|
|
5e88201d47 | ||
|
|
598b8bd1cd | ||
|
|
9186a44860 | ||
|
|
61e3dae708 | ||
|
|
94d46299d0 | ||
|
|
7070c05f2f | ||
|
|
0b7038cc65 | ||
|
|
4882d04ece | ||
|
|
4b2d34491e | ||
|
|
79d8230821 | ||
|
|
c7e3305a76 | ||
|
|
81feccf0d2 | ||
|
|
9666bee006 | ||
|
|
44d54057d0 | ||
|
|
beb4251688 | ||
|
|
598395cd38 | ||
|
|
0a6913f5d0 | ||
|
|
fa36458303 | ||
|
|
3f96f88027 | ||
|
|
131ab714ba | ||
|
|
333d0c933a | ||
|
|
2a5c0e05cc | ||
|
|
4bda9da860 | ||
|
|
4c579cf862 | ||
|
|
1b74ce7ac0 | ||
|
|
29e3625c7b | ||
|
|
a41b9381a1 | ||
|
|
b4980a968c | ||
|
|
ea91751217 | ||
|
|
1ceffc3391 | ||
|
|
3de31427a3 | ||
|
|
4abb9c2ea6 | ||
|
|
e7bfbe77c2 | ||
|
|
776282ed6b | ||
|
|
d1621684df | ||
|
|
ba183e71e1 | ||
|
|
aac34d4fad | ||
|
|
8e28e4ecbf | ||
|
|
48665aa1ad | ||
|
|
f34968bcf5 | ||
|
|
4a5c1ed582 | ||
|
|
6d87ab08e2 | ||
|
|
5ae18bf4f9 | ||
|
|
1bac12259d | ||
|
|
434dc408c3 | ||
|
|
6601ee3b12 | ||
|
|
fde1731365 | ||
|
|
7725952776 | ||
|
|
e18ee08b70 | ||
|
|
5aaaeb426c | ||
|
|
1f55a0cbd8 | ||
|
|
4ad026b398 | ||
|
|
d36825da52 | ||
|
|
bf2715c2be | ||
|
|
80953a0148 | ||
|
|
bb9a08d00d | ||
|
|
3e8fa7cba7 | ||
|
|
da8b88b6b2 | ||
|
|
4bd21a1ccb | ||
|
|
29f7586b93 | ||
|
|
39b6b725f5 | ||
|
|
631c68029a | ||
|
|
93aefc127f | ||
|
|
19ac88b560 | ||
|
|
99e775a283 | ||
|
|
7fc05b96a2 | ||
|
|
9b811da43d | ||
|
|
4199b609b4 | ||
|
|
958e6d8519 | ||
|
|
9dea22ab05 | ||
|
|
3dc7c6b36f | ||
|
|
44e76f36b4 | ||
|
|
010333a190 | ||
|
|
ba833a265a | ||
|
|
e0bf156272 | ||
|
|
a654e21b27 | ||
|
|
de6f149e3b | ||
|
|
29893b89b3 | ||
|
|
7783e9ed20 | ||
|
|
d93d1ed48a | ||
|
|
32c461e93b | ||
|
|
d88e6153c1 | ||
|
|
7b980ae4d4 | ||
|
|
b249d37bab | ||
|
|
fa34e081cc | ||
|
|
9f795d7256 | ||
|
|
c8d7d6be43 | ||
|
|
c31124eb14 | ||
|
|
e999b7a8f8 | ||
|
|
49353a5ec5 | ||
|
|
229fbdd306 | ||
|
|
4562dd08dc | ||
|
|
f24f4ea8f9 | ||
|
|
6338d38ab6 | ||
|
|
527d93c6b4 | ||
|
|
3f5f8d9f57 | ||
|
|
245c913ba1 | ||
|
|
7d3ef52f03 | ||
|
|
6cd7556bc5 | ||
|
|
123f0594a3 | ||
|
|
652cebc7d0 | ||
|
|
a4cb9a8923 | ||
|
|
eb954fb10d | ||
|
|
845eab6f53 | ||
|
|
4fe20db497 | ||
|
|
c40d503f6e | ||
|
|
1ea843bde4 | ||
|
|
9ed5d70250 | ||
|
|
f6209b97e2 | ||
|
|
5221ad6da7 | ||
|
|
cd0bded428 | ||
|
|
ff5fddf353 | ||
|
|
28b29ed086 | ||
|
|
765b2b1d69 | ||
|
|
599a434faa | ||
|
|
a2abee986d | ||
|
|
d57c0712b0 | ||
|
|
4166d78e87 | ||
|
|
5322291402 | ||
|
|
dc2ffd758d | ||
|
|
b73cdb2e7d | ||
|
|
3a83e5d519 | ||
|
|
3c0cdcadc0 | ||
|
|
0a23ef8b5d | ||
|
|
c8a38ac709 | ||
|
|
31d15fadbe | ||
|
|
3fc481e302 | ||
|
|
803605c318 | ||
|
|
1d138e3b4b | ||
|
|
5b6c5326b6 | ||
|
|
60a1c303da | ||
|
|
b9f32da7b8 | ||
|
|
fde0b1d8bf | ||
|
|
cf07004fcd | ||
|
|
b41b52df84 | ||
|
|
9632dd170a | ||
|
|
9dc334dea9 | ||
|
|
0741079450 | ||
|
|
562df0f48f | ||
|
|
3b87c078f4 | ||
|
|
fc091665ff | ||
|
|
3f2842a9a3 | ||
|
|
f2418c81d7 | ||
|
|
3c85797cc9 | ||
|
|
9187bf0b83 | ||
|
|
5a2381d9dd | ||
|
|
5fb8bb0dac | ||
|
|
0d55da18d4 | ||
|
|
73148d65bb | ||
|
|
e81438e49f | ||
|
|
e247d8095e | ||
|
|
46ddb36c79 | ||
|
|
a87fee906f | ||
|
|
888d94131e | ||
|
|
63dd018756 | ||
|
|
db38571646 | ||
|
|
8111d96a20 | ||
|
|
a1b5b7c03c | ||
|
|
91e95b1ef2 | ||
|
|
1491f35f5e | ||
|
|
9bb127dda7 | ||
|
|
c4348b0cb2 | ||
|
|
a3fc0c7f96 | ||
|
|
c7387068cc | ||
|
|
658ce390e2 | ||
|
|
f0b6f66be6 | ||
|
|
304812e14f | ||
|
|
92d8a05393 | ||
|
|
d96d98b8f4 | ||
|
|
0d059187ec | ||
|
|
1b73b0b861 | ||
|
|
29f8d6b981 | ||
|
|
7826de9d29 | ||
|
|
78c56e4f28 | ||
|
|
3ef7736e85 | ||
|
|
73e6194551 | ||
|
|
7ceed3dfbc | ||
|
|
7a0c2dc261 | ||
|
|
5807c4d97f | ||
|
|
a689607e98 | ||
|
|
b6b3e27408 | ||
|
|
ac30bd6e51 | ||
|
|
174fc4f72b | ||
|
|
047ec982f4 | ||
|
|
e427f37f0e | ||
|
|
810ac1fcfa | ||
|
|
5ee3cc6712 | ||
|
|
5ad3d5697e | ||
|
|
874ab093d5 | ||
|
|
fb668859b0 | ||
|
|
be7a2d7f41 | ||
|
|
154b6b9f74 | ||
|
|
23c91386dc | ||
|
|
741b6ce0d9 | ||
|
|
600c2f6061 | ||
|
|
359de2dbe0 | ||
|
|
84eec4655a | ||
|
|
7e8c69a02d | ||
|
|
730d47f2f7 | ||
|
|
5afb74e606 | ||
|
|
8a21547668 | ||
|
|
3ee3044270 | ||
|
|
782dc24eba | ||
|
|
475b96178e | ||
|
|
4beba53675 | ||
|
|
efb7cad993 | ||
|
|
7b7705866d | ||
|
|
b7e06d51ea | ||
|
|
95476276ac | ||
|
|
2347e10458 | ||
|
|
85051f1340 | ||
|
|
42c6e70ebe | ||
|
|
b9fe83e7a8 | ||
|
|
841108623f | ||
|
|
8ce221e41b | ||
|
|
f8c41ab39f | ||
|
|
79e7fd175e | ||
|
|
fbcf755591 | ||
|
|
6168a47e24 | ||
|
|
d788114be3 | ||
|
|
497814f80c | ||
|
|
7297edf16f | ||
|
|
714407eb46 | ||
|
|
dd3523ddd7 | ||
|
|
7739de5db9 | ||
|
|
99c08026ee | ||
|
|
19f7ea70f0 | ||
|
|
49050c042d | ||
|
|
18ccff5759 | ||
|
|
9f6f646e77 | ||
|
|
b8c0d8ef79 | ||
|
|
2ccd41bfb9 | ||
|
|
fa64b51d4a | ||
|
|
f5ac194008 | ||
|
|
816cf0141b | ||
|
|
7baabc6d2c | ||
|
|
37d1c7338b | ||
|
|
404ea9d838 | ||
|
|
98c5c5827c | ||
|
|
79525284b1 | ||
|
|
c14ea7afdf | ||
|
|
c437753d64 | ||
|
|
441cc35e5a | ||
|
|
dc03144773 | ||
|
|
992921b24c | ||
|
|
28f38dca46 | ||
|
|
53155ccef0 | ||
|
|
ba6f0a1aab | ||
|
|
2d89d06bcb | ||
|
|
54ff50ce68 | ||
|
|
22aa8cdd6c | ||
|
|
06b0195d74 | ||
|
|
a99b4ded7f | ||
|
|
2405a0e778 | ||
|
|
84544b1e84 | ||
|
|
95fce39502 | ||
|
|
99c5b26241 | ||
|
|
6e07e49c84 | ||
|
|
0bcfea9d20 | ||
|
|
2658331fd2 | ||
|
|
2ab49cc545 | ||
|
|
a39fe5ff3b | ||
|
|
01578b4e34 | ||
|
|
95718c889d | ||
|
|
6279cc9ec1 | ||
|
|
f7fb9034ef | ||
|
|
15f3af2020 | ||
|
|
97288ed6ce | ||
|
|
5e168c2561 | ||
|
|
358b3f96ae | ||
|
|
c0d9c3808a | ||
|
|
7404bb8e64 | ||
|
|
93eccd7dcf | ||
|
|
05d9d41860 | ||
|
|
c47c41548f | ||
|
|
013d1980a3 | ||
|
|
df9f4a23b4 | ||
|
|
c41da47a48 | ||
|
|
e7214ad8df | ||
|
|
d6671de842 | ||
|
|
aad218db5d | ||
|
|
724ba1e271 | ||
|
|
97d554f638 | ||
|
|
c5a7655d26 | ||
|
|
403e896e3e | ||
|
|
1a15f43cad | ||
|
|
399b460c53 | ||
|
|
acc0362180 | ||
|
|
00db93e03f | ||
|
|
d1997794c8 | ||
|
|
aa1ebe69f2 | ||
|
|
4e7f5f56f1 | ||
|
|
28cb7359ce | ||
|
|
91c272d21c | ||
|
|
3c00125e83 | ||
|
|
f359848a2f | ||
|
|
989769e5e8 | ||
|
|
0f2f1b6211 | ||
|
|
ffe8f4acc6 | ||
|
|
edb09777de | ||
|
|
5262c7863e | ||
|
|
54256826fe | ||
|
|
3d3c224b3a | ||
|
|
049eccb872 | ||
|
|
269828c79e | ||
|
|
b4e25ae66d | ||
|
|
b20dd74d23 | ||
|
|
bc3e2ec358 | ||
|
|
6133a6d6d8 | ||
|
|
46a16c04e6 | ||
|
|
8469b3b26f | ||
|
|
2ed04f57fe | ||
|
|
b19bac679a | ||
|
|
3c33d5982c | ||
|
|
5b934eeb87 | ||
|
|
795d96f8d5 | ||
|
|
a8e7119b4a | ||
|
|
38569ff7fc | ||
|
|
e404557d62 | ||
|
|
96cbc75a5e | ||
|
|
c989af6cf0 | ||
|
|
4eac9d03ea | ||
|
|
6292009b0b | ||
|
|
3272be967d | ||
|
|
1c015da440 | ||
|
|
0d047cc956 | ||
|
|
e682070b85 | ||
|
|
9f08694d9b | ||
|
|
70f0db73e5 | ||
|
|
9dc8f44379 | ||
|
|
59f7ccd723 | ||
|
|
0710e95a6d | ||
|
|
4d1b5e3919 | ||
|
|
0cc2cb92dd | ||
|
|
dba4d168f7 | ||
|
|
d87ac7843c | ||
|
|
040535b004 | ||
|
|
c8acd2c0b1 | ||
|
|
d67fecea6e | ||
|
|
61f80f9ee6 | ||
|
|
9da8f9a5d1 | ||
|
|
f381468d5a | ||
|
|
6ae97266e4 | ||
|
|
66060f345c | ||
|
|
c61f568170 | ||
|
|
dcd108bda3 | ||
|
|
9d89f98987 | ||
|
|
ca7b959fce | ||
|
|
4a30793595 | ||
|
|
35e2d53f0f | ||
|
|
503efa4572 | ||
|
|
b0c33d9dff | ||
|
|
012b156b46 | ||
|
|
25d0d3bf59 | ||
|
|
0f1babc82b | ||
|
|
e2b93ea785 | ||
|
|
b1cedfa81e | ||
|
|
701ee36f6a | ||
|
|
4e5db86434 | ||
|
|
f45e9e657c | ||
|
|
4936fcdb1e | ||
|
|
374e05c422 | ||
|
|
9c00798373 | ||
|
|
db82fce925 | ||
|
|
acaa28e476 | ||
|
|
f297ce5809 | ||
|
|
3dc3fc5f67 | ||
|
|
4884fc4418 | ||
|
|
adc17842ec | ||
|
|
daa48b0b7c | ||
|
|
17c0362df3 | ||
|
|
29b9a63fc9 | ||
|
|
2a9fae160e | ||
|
|
0c49a1e3bd | ||
|
|
e896c41be1 | ||
|
|
187250fa24 | ||
|
|
9035b18584 | ||
|
|
4534d78978 | ||
|
|
f4ab0e982c | ||
|
|
3e7c6629a6 | ||
|
|
3ea17331fe | ||
|
|
1057fcc271 | ||
|
|
5a31c36097 | ||
|
|
1677a69bba | ||
|
|
315c49165d | ||
|
|
aae70e7ec0 | ||
|
|
5cb9e13ca7 | ||
|
|
0187010f94 | ||
|
|
2c2ed21e59 | ||
|
|
f8b2ccec40 | ||
|
|
e858dc582d | ||
|
|
dd737f4b46 | ||
|
|
f0bc238b6d | ||
|
|
af55424850 | ||
|
|
902534baff | ||
|
|
6daa630040 | ||
|
|
0b2b86673b | ||
|
|
6aa5b58208 | ||
|
|
4430201cd2 | ||
|
|
7c7963a83e | ||
|
|
e2202cd2d8 | ||
|
|
a931be83bc | ||
|
|
7350bea345 | ||
|
|
9b1e39dbb4 | ||
|
|
15cd118845 | ||
|
|
d58dff047c | ||
|
|
a2f83c896c | ||
|
|
6ef77c731c | ||
|
|
29b0f61958 | ||
|
|
e944b2ecdd | ||
|
|
41819c46a3 | ||
|
|
13f391a6f0 | ||
|
|
85a3d44f2c | ||
|
|
0792392058 | ||
|
|
ff5083ada0 | ||
|
|
62841677bc | ||
|
|
1761cf53a2 | ||
|
|
a771efc5fa | ||
|
|
ed049da76a | ||
|
|
5d1d357a2e | ||
|
|
30d0706a1c | ||
|
|
e9667e1266 | ||
|
|
73109483e7 | ||
|
|
a9c1acf204 | ||
|
|
81c4f5814c | ||
|
|
c595f6d781 | ||
|
|
24bb6b1d3d | ||
|
|
49eeb6020d | ||
|
|
7c272bd2a2 | ||
|
|
cfbd865937 | ||
|
|
fe472f33ef | ||
|
|
8d6b3d650f | ||
|
|
3b0d5b5eb7 | ||
|
|
875e8a99bd | ||
|
|
6c19d81844 | ||
|
|
ba535a931f | ||
|
|
45dca5218d | ||
|
|
da3cb9971b | ||
|
|
b39270dc1e | ||
|
|
ae8a7d0de9 | ||
|
|
2d501415bf | ||
|
|
da639ccaac | ||
|
|
a352770e2d | ||
|
|
e3e1899466 | ||
|
|
e67288e623 | ||
|
|
4019e49b07 | ||
|
|
cd8711f3bc | ||
|
|
0d119379de | ||
|
|
aa2b6ff112 | ||
|
|
3482f7dc98 | ||
|
|
16c321f114 | ||
|
|
a81e7f3c44 | ||
|
|
d7cc001521 | ||
|
|
eb11962231 | ||
|
|
9f73b8f159 | ||
|
|
873a4abe24 | ||
|
|
56bc584f5e | ||
|
|
2a9f2f3c2e | ||
|
|
ee719cdd39 | ||
|
|
a571b57b30 | ||
|
|
5ee7a23bea | ||
|
|
fe159ea195 | ||
|
|
8fcdf6176b | ||
|
|
715166bbca | ||
|
|
1d58072c70 | ||
|
|
d667cde699 | ||
|
|
4cd8889c38 | ||
|
|
93896f6fb7 | ||
|
|
3b3f0387bb | ||
|
|
2875c9af95 | ||
|
|
93ef1bfccc | ||
|
|
a886af1d87 | ||
|
|
d731ff3ae6 | ||
|
|
d44864637d | ||
|
|
674ee34ec6 | ||
|
|
a93eeda243 | ||
|
|
80fd92e2a1 | ||
|
|
d4ff2da473 | ||
|
|
9b7b271580 | ||
|
|
e1b340966a | ||
|
|
7ec4c331af | ||
|
|
3102d596ee | ||
|
|
af56dc546e | ||
|
|
15d47499fa | ||
|
|
53a34d0470 | ||
|
|
3ee675cefe | ||
|
|
d98c7bdc03 | ||
|
|
bb4f1ebed6 | ||
|
|
c8f73ea23b | ||
|
|
8292b12787 | ||
|
|
0f518e3c35 | ||
|
|
1c2f67d43d | ||
|
|
a5560a3123 | ||
|
|
1332096360 | ||
|
|
80381a6375 | ||
|
|
acf92bd005 | ||
|
|
da4f8a3a19 | ||
|
|
3a332192e3 | ||
|
|
1fdb1d87cc | ||
|
|
b99aa55d7a | ||
|
|
de20da2dad | ||
|
|
9444f0a68b | ||
|
|
48fd223a28 | ||
|
|
0845efe419 | ||
|
|
57b7ba91bc | ||
|
|
97af8a4892 | ||
|
|
d6f237e289 | ||
|
|
aba7109b35 | ||
|
|
d3ec71052e | ||
|
|
1be63f396b | ||
|
|
9308742146 | ||
|
|
b32241082d | ||
|
|
1f8504d685 | ||
|
|
97c5c48150 | ||
|
|
afe84dc46a | ||
|
|
ffafd42f03 | ||
|
|
7dca715c91 | ||
|
|
7695e1d8dd | ||
|
|
84b86d1db7 | ||
|
|
bae3ef6460 | ||
|
|
97c6ec8875 | ||
|
|
d33128dc26 | ||
|
|
10bdecabb6 | ||
|
|
de88f530c8 | ||
|
|
fb511b7596 | ||
|
|
322665ce91 | ||
|
|
baeca1fcfb | ||
|
|
095b98c36a | ||
|
|
29bb7e7608 | ||
|
|
e3d137efba | ||
|
|
207e915393 | ||
|
|
614e629a2b | ||
|
|
f35de5c749 | ||
|
|
c1623bd4df | ||
|
|
8690da5017 | ||
|
|
696adcdc24 | ||
|
|
2756bd06c1 | ||
|
|
4893f6ea00 | ||
|
|
35a7348197 | ||
|
|
cdd6333d0a | ||
|
|
54399b5b5d | ||
|
|
f6b192cc1e | ||
|
|
cd231b90d8 | ||
|
|
87fe788358 | ||
|
|
3e9bd21ea8 | ||
|
|
b6d4029797 | ||
|
|
ec65e96148 | ||
|
|
926f1f971f | ||
|
|
5d69fad73f | ||
|
|
a796761023 | ||
|
|
5d1338e485 | ||
|
|
ce25a167f1 | ||
|
|
1c44969580 | ||
|
|
b6e04e3ede | ||
|
|
84c26be703 | ||
|
|
d201160722 | ||
|
|
e112361b43 | ||
|
|
3e69795c9d | ||
|
|
b11baf2e5d | ||
|
|
233770b553 | ||
|
|
187db73798 | ||
|
|
0e3fc6f682 | ||
|
|
d11e3a4ac4 | ||
|
|
d3b4ca3e66 | ||
|
|
f37fbbfb8b | ||
|
|
52b7aac424 | ||
|
|
d42f3f8f0c | ||
|
|
91b5c7c9bc | ||
|
|
48feebc092 | ||
|
|
14e2d66d96 | ||
|
|
10d844a195 | ||
|
|
bbf91ae5d6 | ||
|
|
cb82eda49a | ||
|
|
bc1dbb1c27 | ||
|
|
9496a7f1ce | ||
|
|
7241fa31b4 | ||
|
|
fed7216436 | ||
|
|
ffe7d7c4c6 | ||
|
|
f430ac8d6c | ||
|
|
70dfd7c9a3 | ||
|
|
ed3140932b | ||
|
|
3cd2bd6ce8 | ||
|
|
982bf45fc4 | ||
|
|
aaba8569fc | ||
|
|
4111e15eb9 | ||
|
|
2012478f26 | ||
|
|
88869d3239 | ||
|
|
f3c2549b18 | ||
|
|
57e3b839d0 | ||
|
|
faf3f43413 | ||
|
|
52e5bb3386 | ||
|
|
89405f6670 | ||
|
|
73111c4139 | ||
|
|
04e9c5db8c | ||
|
|
69278902de | ||
|
|
efa95b0858 | ||
|
|
660128cd5c | ||
|
|
ef1e052e47 | ||
|
|
0b346bc343 | ||
|
|
2272eaf833 | ||
|
|
4adee98bce | ||
|
|
cbdb2c0705 | ||
|
|
4f438aabbf | ||
|
|
b6ccc06963 | ||
|
|
5b89a15bfc | ||
|
|
5596ae551d | ||
|
|
1360df592a | ||
|
|
13684ff83c | ||
|
|
ae88f7870e | ||
|
|
810b6da60c | ||
|
|
7bdf3e08f9 | ||
|
|
fdad2a087f | ||
|
|
c437a8c426 | ||
|
|
ef861e6d1d | ||
|
|
928a008688 | ||
|
|
638a124adb | ||
|
|
c2a63ae9bb | ||
|
|
28cf31e6e7 | ||
|
|
3cf416167d | ||
|
|
ebf03923a0 | ||
|
|
82797d2421 | ||
|
|
52b6be946c | ||
|
|
dc46724d7b | ||
|
|
ed7d43b6a9 | ||
|
|
6f3fc51278 | ||
|
|
a446acc282 |
117
.github/DISCUSSION_TEMPLATE/share-custom-prompts-for-ai-notifications.yml
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
title: "[Prompt] "
|
||||
labels:
|
||||
- custom-prompt
|
||||
- community
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Share Your Custom Prompt
|
||||
|
||||
Thank you for sharing your custom prompt with the community!
|
||||
|
||||
**Title format suggestion:** Include the provider in the title for easy filtering.
|
||||
Example: `[Gemini] Clean Spanish - Structured, no emojis`
|
||||
|
||||
This helps others find prompts for their specific AI provider.
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: AI Provider
|
||||
description: Which AI provider did you test this prompt with?
|
||||
options:
|
||||
- OpenAI
|
||||
- Gemini
|
||||
- Groq
|
||||
- Ollama
|
||||
- Anthropic
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: The specific model you tested with
|
||||
placeholder: "e.g., gpt-4o-mini, gemini-2.0-flash, llama3.2:3b"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe what your prompt does, main features, and output language
|
||||
placeholder: |
|
||||
This prompt generates concise notifications in Spanish.
|
||||
|
||||
Features:
|
||||
- Brief format (2-3 lines)
|
||||
- Includes severity indicators
|
||||
- Uses emojis for visual clarity
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: prompt-content
|
||||
attributes:
|
||||
label: Prompt Content
|
||||
description: Paste your complete custom prompt here
|
||||
render: text
|
||||
placeholder: |
|
||||
You are a notification formatter for ProxMenux Monitor.
|
||||
|
||||
Your task is to...
|
||||
|
||||
RULES:
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
OUTPUT FORMAT:
|
||||
[TITLE]
|
||||
...
|
||||
[BODY]
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: example-output
|
||||
attributes:
|
||||
label: Example Output
|
||||
description: Show an example of how a notification looks with your prompt
|
||||
placeholder: |
|
||||
**Input notification:**
|
||||
CPU usage high on node pve01
|
||||
|
||||
**Output with this prompt:**
|
||||
pve01: High CPU Usage
|
||||
CPU at 95% for 5 minutes. Check running processes.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-notes
|
||||
attributes:
|
||||
label: Additional Notes
|
||||
description: Any tips, variations, or known limitations
|
||||
placeholder: |
|
||||
- Works best with models that support system prompts
|
||||
- May need adjustment for very long notifications
|
||||
- Tested with Proxmox VE 8.x
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: I have tested this prompt and it works correctly
|
||||
required: true
|
||||
- label: I am sharing this prompt for the community to use freely
|
||||
required: true
|
||||
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a problem in the project
|
||||
title: "[BUG] Describe the issue"
|
||||
labels: bug
|
||||
assignees: 'MacRimi'
|
||||
---
|
||||
|
||||
## Description
|
||||
Describe the bug clearly and concisely.
|
||||
|
||||
## Steps to Reproduce
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
## Expected Behavior
|
||||
What should happen?
|
||||
|
||||
## Screenshots (Required)
|
||||
Add images to help illustrate the issue.
|
||||
|
||||
## Environment
|
||||
- Operating system:
|
||||
- Software version:
|
||||
- Other relevant details:
|
||||
|
||||
## Additional Information
|
||||
Add any other context about the problem here.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Soporte General
|
||||
url: https://github.com/MacRimi/ProxMenux/discussions
|
||||
about: If your request is neither a bug nor a feature, please use Discussions.
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
title: "[FEATURE] Describe your proposal"
|
||||
labels: enhancement
|
||||
assignees: 'MacRimi'
|
||||
---
|
||||
|
||||
## Description
|
||||
Explain the feature you are proposing.
|
||||
|
||||
## Motivation
|
||||
Why is this improvement important? What problem does it solve?
|
||||
|
||||
## Alternatives Considered
|
||||
Are there other solutions you have thought about?
|
||||
|
||||
## Additional Information
|
||||
Add any extra details that help understand your proposal.
|
||||
299
.github/scripts/generate_helpers_cache.py
vendored
@@ -1,76 +1,265 @@
|
||||
import requests, json
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# GitHub API URL to fetch all .json files describing scripts
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
import requests
|
||||
|
||||
# Base path to build the full URL for the installable scripts
|
||||
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
POCKETBASE_BASE = "https://db.community-scripts.org/api/collections"
|
||||
SCRIPT_COLLECTION_URL = f"{POCKETBASE_BASE}/script_scripts/records"
|
||||
CATEGORY_COLLECTION_URL = f"{POCKETBASE_BASE}/script_categories/records"
|
||||
|
||||
# Output file where the consolidated helper scripts cache will be stored
|
||||
OUTPUT_FILE = Path("json/helpers_cache.json")
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
res = requests.get(API_URL)
|
||||
data = res.json()
|
||||
cache = []
|
||||
TYPE_TO_PATH_PREFIX = {
|
||||
"lxc": "ct",
|
||||
"vm": "vm",
|
||||
"addon": "tools/addon",
|
||||
"pve": "tools/pve",
|
||||
}
|
||||
|
||||
# Loop over each file in the JSON directory
|
||||
for item in data:
|
||||
url = item.get("download_url")
|
||||
if not url or not url.endswith(".json"):
|
||||
continue
|
||||
|
||||
def to_mirror_url(raw_url: str) -> str:
|
||||
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
|
||||
if not m:
|
||||
return ""
|
||||
org, repo, branch, path = m.groups()
|
||||
if org.lower() != "community-scripts" or repo != "ProxmoxVE":
|
||||
return ""
|
||||
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
|
||||
|
||||
|
||||
def fetch_json(url: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
r = requests.get(url, params=params, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"Unexpected response from {url}: expected object")
|
||||
return data
|
||||
|
||||
|
||||
def fetch_all_records(url: str, *, expand: str | None = None, per_page: int = 500) -> list[dict[str, Any]]:
|
||||
page = 1
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
while True:
|
||||
params: dict[str, Any] = {"page": page, "perPage": per_page}
|
||||
if expand:
|
||||
params["expand"] = expand
|
||||
|
||||
data = fetch_json(url, params=params)
|
||||
page_items = data.get("items", [])
|
||||
if not isinstance(page_items, list):
|
||||
raise RuntimeError(f"Unexpected items list from {url}")
|
||||
|
||||
items.extend(page_items)
|
||||
|
||||
total_pages = data.get("totalPages", page)
|
||||
if not isinstance(total_pages, int) or page >= total_pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def normalize_os_variants(install_methods: list[dict[str, Any]]) -> list[str]:
|
||||
os_values: list[str] = []
|
||||
for item in install_methods:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
resources = item.get("resources", {})
|
||||
if not isinstance(resources, dict):
|
||||
continue
|
||||
os_name = resources.get("os")
|
||||
if isinstance(os_name, str) and os_name.strip():
|
||||
normalized = os_name.strip().lower()
|
||||
if normalized not in os_values:
|
||||
os_values.append(normalized)
|
||||
return os_values
|
||||
|
||||
|
||||
def split_notes(notes_raw: list[dict[str, Any]]) -> tuple[list[str], list[str]]:
|
||||
"""Split PocketBase notes into (info_notes, warnings).
|
||||
|
||||
Each entry has shape ``{"text": str, "type": "warning"|...}``. Anything
|
||||
flagged ``type == "warning"`` lands in the warnings list so the bash
|
||||
menu can render those in red with a dedicated WARNINGS header. Other
|
||||
notes go to the regular notes list.
|
||||
"""
|
||||
info: list[str] = []
|
||||
warns: list[str] = []
|
||||
for note in notes_raw or []:
|
||||
if not isinstance(note, dict):
|
||||
continue
|
||||
text = note.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
text = text.strip()
|
||||
ntype = (note.get("type") or "").strip().lower()
|
||||
if ntype == "warning":
|
||||
warns.append(text)
|
||||
else:
|
||||
info.append(text)
|
||||
return info, warns
|
||||
|
||||
|
||||
def build_script_path(type_name: str, slug: str) -> str:
|
||||
type_name = (type_name or "").strip().lower()
|
||||
slug = (slug or "").strip()
|
||||
|
||||
if type_name == "turnkey":
|
||||
return "turnkey/turnkey.sh"
|
||||
|
||||
prefix = TYPE_TO_PATH_PREFIX.get(type_name)
|
||||
if not prefix or not slug:
|
||||
return ""
|
||||
|
||||
return f"{prefix}/{slug}.sh"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
raw = requests.get(url).json()
|
||||
scripts = fetch_all_records(SCRIPT_COLLECTION_URL, expand="type,categories")
|
||||
categories = fetch_all_records(CATEGORY_COLLECTION_URL)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Unable to fetch PocketBase data: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
category_map: dict[str, dict[str, Any]] = {}
|
||||
for category in categories:
|
||||
category_id = category.get("id")
|
||||
if isinstance(category_id, str) and category_id:
|
||||
category_map[category_id] = category
|
||||
|
||||
cache: list[dict[str, Any]] = []
|
||||
|
||||
print(f"Fetched {len(scripts)} scripts and {len(category_map)} categories")
|
||||
|
||||
for idx, raw in enumerate(scripts, start=1):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
except:
|
||||
continue
|
||||
|
||||
# Extract fields required to identify a valid helper script
|
||||
name = raw.get("name", "")
|
||||
slug = raw.get("slug")
|
||||
type_ = raw.get("type", "")
|
||||
script = raw.get("install_methods", [{}])[0].get("script", "")
|
||||
if not slug or not script:
|
||||
continue # Skip if it's not a valid script
|
||||
slug = raw.get("slug")
|
||||
name = raw.get("name", "")
|
||||
desc = raw.get("description", "")
|
||||
|
||||
desc = raw.get("description", "")
|
||||
categories = raw.get("categories", [])
|
||||
notes = [note.get("text", "") for note in raw.get("notes", []) if isinstance(note, dict)]
|
||||
full_script_url = f"{SCRIPT_BASE}/{script}"
|
||||
if not isinstance(slug, str) or not slug.strip():
|
||||
continue
|
||||
|
||||
expand = raw.get("expand", {}) if isinstance(raw.get("expand"), dict) else {}
|
||||
type_expanded = expand.get("type", {}) if isinstance(expand.get("type"), dict) else {}
|
||||
type_name = type_expanded.get("type", "") if isinstance(type_expanded.get("type"), str) else ""
|
||||
|
||||
credentials = raw.get("default_credentials", {})
|
||||
cred_username = credentials.get("username")
|
||||
cred_password = credentials.get("password")
|
||||
|
||||
add_credentials = (
|
||||
(cred_username is not None and str(cred_username).strip() != "") or
|
||||
(cred_password is not None and str(cred_password).strip() != "")
|
||||
)
|
||||
script_path = build_script_path(type_name, slug)
|
||||
if not script_path:
|
||||
print(f"[{idx:03d}] WARNING: Unable to build script path for slug={slug} type={type_name!r}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script,
|
||||
"script_url": full_script_url,
|
||||
"categories": categories,
|
||||
"notes": notes,
|
||||
"type": type_
|
||||
}
|
||||
if add_credentials:
|
||||
entry["default_credentials"] = {
|
||||
"username": cred_username,
|
||||
"password": cred_password
|
||||
full_script_url = f"{SCRIPT_BASE}/{script_path}"
|
||||
script_url_mirror = to_mirror_url(full_script_url)
|
||||
|
||||
# Sprint 11.7: PocketBase exposes these as `install_methods` and
|
||||
# `notes`, not `install_methods_json` / `notes_json`. The legacy field
|
||||
# names silently returned [] for every entry, which is why the cache
|
||||
# had empty notes and missing OS variants for every script.
|
||||
install_methods = raw.get("install_methods", [])
|
||||
if not isinstance(install_methods, list):
|
||||
install_methods = []
|
||||
|
||||
notes_raw = raw.get("notes", [])
|
||||
if not isinstance(notes_raw, list):
|
||||
notes_raw = []
|
||||
|
||||
notes, warnings = split_notes(notes_raw)
|
||||
|
||||
category_ids = raw.get("categories", [])
|
||||
if not isinstance(category_ids, list):
|
||||
category_ids = []
|
||||
|
||||
expanded_categories = expand.get("categories", []) if isinstance(expand.get("categories"), list) else []
|
||||
category_names: list[str] = []
|
||||
for cat in expanded_categories:
|
||||
if isinstance(cat, dict):
|
||||
cat_name = cat.get("name")
|
||||
if isinstance(cat_name, str) and cat_name.strip():
|
||||
category_names.append(cat_name.strip())
|
||||
|
||||
if not category_names:
|
||||
for cat_id in category_ids:
|
||||
cat = category_map.get(cat_id, {})
|
||||
cat_name = cat.get("name")
|
||||
if isinstance(cat_name, str) and cat_name.strip():
|
||||
category_names.append(cat_name.strip())
|
||||
|
||||
# Shared fields across all install method entries
|
||||
default_user = raw.get("default_user")
|
||||
default_passwd = raw.get("default_passwd")
|
||||
default_credentials: dict[str, str] | None = None
|
||||
if (isinstance(default_user, str) and default_user.strip()) or (isinstance(default_passwd, str) and default_passwd.strip()):
|
||||
default_credentials = {
|
||||
"username": default_user if isinstance(default_user, str) else "",
|
||||
"password": default_passwd if isinstance(default_passwd, str) else "",
|
||||
}
|
||||
|
||||
base_entry: dict[str, Any] = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script_path,
|
||||
"script_url": full_script_url,
|
||||
"script_url_mirror": script_url_mirror,
|
||||
"type": type_name,
|
||||
"type_id": raw.get("type", ""),
|
||||
"categories": category_ids,
|
||||
"category_names": category_names,
|
||||
"notes": notes,
|
||||
"warnings": warnings,
|
||||
"port": raw.get("port", 0),
|
||||
"website": raw.get("website", ""),
|
||||
"documentation": raw.get("documentation", ""),
|
||||
"logo": raw.get("logo", ""),
|
||||
"updateable": bool(raw.get("updateable", False)),
|
||||
"privileged": bool(raw.get("privileged", False)),
|
||||
"has_arm": bool(raw.get("has_arm", False)),
|
||||
"is_dev": bool(raw.get("is_dev", False)),
|
||||
"execute_in": raw.get("execute_in", []),
|
||||
"config_path": raw.get("config_path", ""),
|
||||
}
|
||||
if default_credentials:
|
||||
base_entry["default_credentials"] = default_credentials
|
||||
|
||||
cache.append(entry)
|
||||
# Emit one entry per install method so the menu shell can offer an
|
||||
# explicit OS choice. When there is only one method (or none), a
|
||||
# single entry is emitted with os="" (script decides at runtime).
|
||||
os_variants = normalize_os_variants(install_methods)
|
||||
|
||||
if len(os_variants) > 1:
|
||||
for os_name in os_variants:
|
||||
entry = {**base_entry, "os": os_name}
|
||||
cache.append(entry)
|
||||
print(f"[{len(cache):03d}] {slug:<24} → {script_path:<28} type={type_name:<7} os={os_name}")
|
||||
else:
|
||||
os_name = os_variants[0] if os_variants else ""
|
||||
entry = {**base_entry, "os": os_name}
|
||||
cache.append(entry)
|
||||
print(f"[{len(cache):03d}] {slug:<24} → {script_path:<28} type={type_name:<7} os={os_name or 'n/a'}")
|
||||
|
||||
# Write the JSON cache to disk
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, indent=2)
|
||||
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
|
||||
|
||||
print(f"✅ helpers_cache.json created at {OUTPUT_FILE} with {len(cache)} valid scripts.")
|
||||
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
total_notes = sum(len(e.get("notes", [])) for e in cache)
|
||||
total_warns = sum(len(e.get("warnings", [])) for e in cache)
|
||||
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
|
||||
print(f" Guardados: {len(cache)} entries, {total_notes} notes, {total_warns} warnings")
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
76
.github/scripts/generate_helpers_cache_.py
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
import requests, json
|
||||
from pathlib import Path
|
||||
|
||||
# GitHub API URL to fetch all .json files describing scripts
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
|
||||
# Base path to build the full URL for the installable scripts
|
||||
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
|
||||
# Output file where the consolidated helper scripts cache will be stored
|
||||
OUTPUT_FILE = Path("json/helpers_cache.json")
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
res = requests.get(API_URL)
|
||||
data = res.json()
|
||||
cache = []
|
||||
|
||||
# Loop over each file in the JSON directory
|
||||
for item in data:
|
||||
url = item.get("download_url")
|
||||
if not url or not url.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
raw = requests.get(url).json()
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
except:
|
||||
continue
|
||||
|
||||
# Extract fields required to identify a valid helper script
|
||||
name = raw.get("name", "")
|
||||
slug = raw.get("slug")
|
||||
type_ = raw.get("type", "")
|
||||
script = raw.get("install_methods", [{}])[0].get("script", "")
|
||||
if not slug or not script:
|
||||
continue # Skip if it's not a valid script
|
||||
|
||||
desc = raw.get("description", "")
|
||||
categories = raw.get("categories", [])
|
||||
notes = [note.get("text", "") for note in raw.get("notes", []) if isinstance(note, dict)]
|
||||
full_script_url = f"{SCRIPT_BASE}/{script}"
|
||||
|
||||
|
||||
credentials = raw.get("default_credentials", {})
|
||||
cred_username = credentials.get("username")
|
||||
cred_password = credentials.get("password")
|
||||
|
||||
add_credentials = (
|
||||
(cred_username is not None and str(cred_username).strip() != "") or
|
||||
(cred_password is not None and str(cred_password).strip() != "")
|
||||
)
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script,
|
||||
"script_url": full_script_url,
|
||||
"categories": categories,
|
||||
"notes": notes,
|
||||
"type": type_
|
||||
}
|
||||
if add_credentials:
|
||||
entry["default_credentials"] = {
|
||||
"username": cred_username,
|
||||
"password": cred_password
|
||||
}
|
||||
|
||||
cache.append(entry)
|
||||
|
||||
|
||||
# Write the JSON cache to disk
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, indent=2)
|
||||
|
||||
print(f"✅ helpers_cache.json created at {OUTPUT_FILE} with {len(cache)} valid scripts.")
|
||||
178
.github/scripts/generate_helpers_cache_back.py
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# ---------- Config ----------
|
||||
# API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE-Frontend-Archive/contents/public/json"
|
||||
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
|
||||
# Escribimos siempre en <raiz_repo>/json/helpers_cache.json, independientemente del cwd
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ----------------------------
|
||||
|
||||
|
||||
def to_mirror_url(raw_url: str) -> str:
|
||||
"""
|
||||
Convierte una URL raw de GitHub al raw del mirror.
|
||||
GH : https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/docker.sh
|
||||
MIR: https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/main/ct/docker.sh
|
||||
"""
|
||||
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
|
||||
if not m:
|
||||
return ""
|
||||
org, repo, branch, path = m.groups()
|
||||
if org.lower() != "community-scripts" or repo != "ProxmoxVE":
|
||||
return ""
|
||||
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
|
||||
|
||||
|
||||
def guess_os_from_script_path(script_path: str) -> str | None:
|
||||
"""
|
||||
Heurística suave cuando el JSON no publica resources.os:
|
||||
- tools/pve/* -> proxmox
|
||||
- ct/alpine-* -> alpine
|
||||
- tools/addon/* -> generic (suele ejecutarse sobre LXC existente)
|
||||
- ct/* -> debian (por defecto para CTs)
|
||||
"""
|
||||
if not script_path:
|
||||
return None
|
||||
if script_path.startswith("tools/pve/") or script_path == "tools/pve/host-backup.sh" or script_path.startswith("vm/"):
|
||||
return "proxmox"
|
||||
if "/alpine-" in script_path or script_path.startswith("ct/alpine-"):
|
||||
return "alpine"
|
||||
if script_path.startswith("tools/addon/"):
|
||||
return "generic"
|
||||
if script_path.startswith("ct/"):
|
||||
return "debian"
|
||||
return None
|
||||
|
||||
|
||||
def fetch_directory_json(api_url: str) -> list[dict]:
|
||||
r = requests.get(api_url, timeout=30)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError("GitHub API no devolvió una lista.")
|
||||
return data
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
directory = fetch_directory_json(API_URL)
|
||||
except Exception as e:
|
||||
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cache: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set() # (slug, script) para evitar duplicados
|
||||
|
||||
total_items = len(directory)
|
||||
processed = 0
|
||||
kept = 0
|
||||
|
||||
for item in directory:
|
||||
url = item.get("download_url")
|
||||
name_in_dir = item.get("name", "")
|
||||
if not url or not url.endswith(".json"):
|
||||
continue
|
||||
|
||||
try:
|
||||
raw = requests.get(url, timeout=30).json()
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
except Exception:
|
||||
print(f"❌ Error al obtener/parsing {name_in_dir}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
|
||||
name = raw.get("name", "")
|
||||
slug = raw.get("slug")
|
||||
type_ = raw.get("type", "")
|
||||
desc = raw.get("description", "")
|
||||
categories = raw.get("categories", [])
|
||||
notes = [n.get("text", "") for n in raw.get("notes", []) if isinstance(n, dict)]
|
||||
|
||||
# Credenciales (si existen, se copian tal cual)
|
||||
credentials = raw.get("default_credentials", {})
|
||||
cred_username = credentials.get("username") if isinstance(credentials, dict) else None
|
||||
cred_password = credentials.get("password") if isinstance(credentials, dict) else None
|
||||
add_credentials = any([
|
||||
cred_username not in (None, ""),
|
||||
cred_password not in (None, "")
|
||||
])
|
||||
|
||||
install_methods = raw.get("install_methods", [])
|
||||
if not isinstance(install_methods, list) or not install_methods:
|
||||
# Sin install_methods válidos -> continuamos
|
||||
continue
|
||||
|
||||
for im in install_methods:
|
||||
if not isinstance(im, dict):
|
||||
continue
|
||||
script = im.get("script", "")
|
||||
if not script:
|
||||
continue
|
||||
|
||||
# OS desde resources u heurística
|
||||
resources = im.get("resources", {}) if isinstance(im, dict) else {}
|
||||
os_name = resources.get("os") if isinstance(resources, dict) else None
|
||||
if not os_name:
|
||||
os_name = guess_os_from_script_path(script)
|
||||
if isinstance(os_name, str):
|
||||
os_name = os_name.strip().lower()
|
||||
|
||||
full_script_url = f"{SCRIPT_BASE}/{script}"
|
||||
script_url_mirror = to_mirror_url(full_script_url)
|
||||
|
||||
key = (slug or "", script)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script,
|
||||
"script_url": full_script_url,
|
||||
"script_url_mirror": script_url_mirror, # nuevo
|
||||
"os": os_name, # nuevo
|
||||
"categories": categories,
|
||||
"notes": notes,
|
||||
"type": type_,
|
||||
}
|
||||
if add_credentials:
|
||||
entry["default_credentials"] = {
|
||||
"username": cred_username,
|
||||
"password": cred_password,
|
||||
}
|
||||
|
||||
cache.append(entry)
|
||||
kept += 1
|
||||
|
||||
# Progreso ligero
|
||||
print(f"[{kept:03d}] {slug or name:<24} → {script:<28} os={os_name or 'n/a'} src={'GH+MR' if script_url_mirror else 'GH'}")
|
||||
|
||||
# Orden estable para commits reproducibles
|
||||
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
|
||||
|
||||
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
|
||||
print(f" Total JSON en índice: {total_items}")
|
||||
print(f" Procesados: {processed} | Guardados: {kept} | Únicos (slug,script): {len(seen)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
83
.github/workflows/build-appimage-Release.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Build AppImage Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd AppImage/dist
|
||||
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: |
|
||||
AppImage/dist/*.AppImage
|
||||
AppImage/dist/*.sha256
|
||||
retention-days: 30
|
||||
|
||||
- name: Commit AppImage to main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage release build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin main
|
||||
83
.github/workflows/build-appimage-beta.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Build AppImage Beta
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: develop
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd AppImage/dist
|
||||
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-beta-AppImage
|
||||
path: |
|
||||
AppImage/dist/*.AppImage
|
||||
AppImage/dist/*.sha256
|
||||
retention-days: 30
|
||||
|
||||
- name: Commit AppImage to develop
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage beta build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin develop
|
||||
81
.github/workflows/build-appimage-manual.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Build ProxMenux Monitor AppImage
|
||||
|
||||
on:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
retention-days: 30
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd AppImage/dist
|
||||
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage and checksum to /AppImage folder in main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
|
||||
# Copy new files
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin main
|
||||
59
.github/workflows/build-appimage.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Build ProxMenux Monitor AppImage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths: [ 'AppImage/**' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths: [ 'AppImage/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
retention-days: 30
|
||||
2
.gitignore
vendored
@@ -51,3 +51,5 @@ Thumbs.db
|
||||
!guides/
|
||||
!web/
|
||||
|
||||
# GitHub authentication
|
||||
.github/auth.sh
|
||||
|
||||
BIN
AppImage/ProxMenux-1.2.0.AppImage
Executable file
1
AppImage/ProxMenux-Monitor.AppImage.sha256
Normal file
@@ -0,0 +1 @@
|
||||
db5bc199adba9c231f344428ac902a0cbf7473778e8a79a4535263599d975449 ProxMenux-1.2.0.AppImage
|
||||
753
AppImage/README.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# ProxMenux Monitor
|
||||
|
||||
A modern, responsive dashboard for monitoring Proxmox VE systems built with Next.js and React.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Technology Stack](#technology-stack)
|
||||
- [Installation](#installation)
|
||||
- [Authentication & Security](#authentication--security)
|
||||
- [Setup Authentication](#setup-authentication)
|
||||
- [Two-Factor Authentication (2FA)](#two-factor-authentication-2fa)
|
||||
- [Security Best Practices for API Tokens](#security-best-practices-for-api-tokens)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [API Authentication](#api-authentication)
|
||||
- [Generating API Tokens](#generating-api-tokens)
|
||||
- [Available Endpoints](#available-endpoints)
|
||||
- [Integration Examples](#integration-examples)
|
||||
- [Homepage Integration](#homepage-integration)
|
||||
- [Home Assistant Integration](#home-assistant-integration)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, and system logs.
|
||||
|
||||
The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network.
|
||||
|
||||
|
||||
## Screenshots
|
||||
|
||||
Get a quick overview of ProxMenux Monitor's main features:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen1.png" alt="Overview Dashboard" width="800"/>
|
||||
<br/>
|
||||
<em>System Overview - Monitor CPU, memory, temperature, and uptime in real-time</em>
|
||||
</p>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **System Overview**: Real-time monitoring of CPU, memory, temperature, and system uptime
|
||||
- **Storage Management**: Visual representation of storage distribution, disk health, and SMART data
|
||||
- **Network Monitoring**: Network interface statistics, real-time traffic graphs, and bandwidth usage
|
||||
- **Virtual Machines & LXC**: Comprehensive view of all VMs and containers with resource usage and controls
|
||||
- **Hardware Information**: Detailed hardware specifications including CPU, GPU, PCIe devices, and disks
|
||||
- **System Logs**: Real-time system log monitoring with filtering and search capabilities
|
||||
- **Health Monitoring**: Proactive system health checks with persistent error tracking
|
||||
- **Authentication & 2FA**: Optional password protection with TOTP-based two-factor authentication
|
||||
- **RESTful API**: Complete API access for integrations with Homepage, Home Assistant, and custom dashboards
|
||||
- **Dark/Light Theme**: Toggle between themes with Proxmox-inspired design
|
||||
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
|
||||
- **Release Notes**: Automatic notifications of new features and improvements
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: Next.js 15, React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS v4 with custom Proxmox-inspired theme
|
||||
- **Charts**: Recharts for data visualization
|
||||
- **UI Components**: Radix UI primitives with shadcn/ui
|
||||
- **Backend**: Flask (Python) server for system data collection
|
||||
- **Packaging**: AppImage for easy distribution and deployment
|
||||
|
||||
## Installation
|
||||
|
||||
**ProxMenux Monitor is integrated into [ProxMenux](https://proxmenux.com) and comes enabled by default.** No manual installation is required if you're using ProxMenux.
|
||||
|
||||
The monitor automatically starts when ProxMenux is installed and runs as a systemd service on your Proxmox server.
|
||||
|
||||
### Accessing the Dashboard
|
||||
|
||||
You can access ProxMenux Monitor in two ways:
|
||||
|
||||
1. **Direct Access**: `http://your-proxmox-ip:8008`
|
||||
2. **Via Proxy** (Recommended): `https://your-domain.com/proxmenux-monitor/`
|
||||
|
||||
**Note**: All API endpoints work seamlessly with both direct access and proxy configurations. When using a reverse proxy, the application automatically detects and adapts to the proxied environment.
|
||||
|
||||
### Proxy Configuration
|
||||
|
||||
ProxMenux Monitor includes built-in support for reverse proxy configurations. If you're using Nginx, Caddy, or Traefik, the application will automatically:
|
||||
|
||||
- Detect the proxy headers (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`)
|
||||
- Adjust API endpoints to work correctly through the proxy
|
||||
- Maintain full functionality for all features including authentication and API access
|
||||
|
||||
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
ProxMenux Monitor includes an optional authentication system to protect your dashboard with a password and two-factor authentication.
|
||||
|
||||
### Setup Authentication
|
||||
|
||||
On first launch, you'll be presented with three options:
|
||||
|
||||
1. **Set up authentication** - Create a username and password to protect your dashboard
|
||||
2. **Enable 2FA** - Add TOTP-based two-factor authentication for enhanced security
|
||||
3. **Skip** - Continue without authentication (not recommended for production environments)
|
||||
|
||||

|
||||
|
||||
### Two-Factor Authentication (2FA)
|
||||
|
||||
After setting up your password, you can enable 2FA using any TOTP authenticator app (Google Authenticator, Authy, 1Password, etc.):
|
||||
|
||||
1. Navigate to **Settings > Authentication**
|
||||
2. Click **Enable 2FA**
|
||||
3. Scan the QR code with your authenticator app
|
||||
4. Enter the 6-digit code to verify
|
||||
5. Save your backup codes in a secure location
|
||||
|
||||

|
||||
|
||||
### Security Best Practices for API Tokens
|
||||
|
||||
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
|
||||
|
||||
**Option 1: Environment Variables**
|
||||
|
||||
Store your token in an environment variable:
|
||||
|
||||
```bash
|
||||
# Linux/macOS - Add to ~/.bashrc or ~/.zshrc
|
||||
export PROXMENUX_API_TOKEN="your_actual_token_here"
|
||||
|
||||
# Windows PowerShell - Add to profile
|
||||
$env:PROXMENUX_API_TOKEN = "your_actual_token_here"
|
||||
```
|
||||
|
||||
Then reference it in your scripts:
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
curl -H "Authorization: Bearer $PROXMENUX_API_TOKEN" \
|
||||
http://your-proxmox-ip:8008/api/system
|
||||
|
||||
# Windows PowerShell
|
||||
curl -H "Authorization: Bearer $env:PROXMENUX_API_TOKEN" `
|
||||
http://your-proxmox-ip:8008/api/system
|
||||
```
|
||||
|
||||
**Option 2: Secrets File**
|
||||
|
||||
Create a dedicated secrets file (make sure to add it to `.gitignore`):
|
||||
|
||||
```bash
|
||||
# Create secrets file
|
||||
echo "PROXMENUX_API_TOKEN=your_actual_token_here" > ~/.proxmenux_secrets
|
||||
|
||||
# Secure the file (Linux/macOS only)
|
||||
chmod 600 ~/.proxmenux_secrets
|
||||
|
||||
# Load in your script
|
||||
source ~/.proxmenux_secrets
|
||||
```
|
||||
|
||||
**Option 3: Homepage Secrets (Recommended)**
|
||||
|
||||
Homepage supports secrets management. Create a `secrets.yaml` file:
|
||||
|
||||
```yaml
|
||||
# secrets.yaml (add to .gitignore!)
|
||||
proxmenux_token: "your_actual_token_here"
|
||||
```
|
||||
|
||||
Then reference it in your `services.yaml`:
|
||||
|
||||
```yaml
|
||||
- ProxMenux Monitor:
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
```
|
||||
|
||||
**Option 4: Home Assistant Secrets**
|
||||
|
||||
Home Assistant has built-in secrets support. Edit `secrets.yaml`:
|
||||
|
||||
```yaml
|
||||
# secrets.yaml
|
||||
proxmenux_api_token: "your_actual_token_here"
|
||||
```
|
||||
|
||||
Then reference it in `configuration.yaml`:
|
||||
|
||||
```yaml
|
||||
sensor:
|
||||
- platform: rest
|
||||
name: ProxMenux CPU
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
```
|
||||
|
||||
**Token Security Checklist:**
|
||||
- ✅ Store tokens in environment variables or secrets files
|
||||
- ✅ Add secrets files to `.gitignore`
|
||||
- ✅ Set proper file permissions (chmod 600 on Linux/macOS)
|
||||
- ✅ Rotate tokens periodically (every 3-6 months)
|
||||
- ✅ Use different tokens for different integrations
|
||||
- ✅ Delete tokens you no longer use
|
||||
- ❌ Never commit tokens to version control
|
||||
- ❌ Never share tokens in screenshots or logs
|
||||
- ❌ Never hardcode tokens in configuration files
|
||||
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
|
||||
ProxMenux Monitor provides a comprehensive RESTful API for integrating with external services like Homepage, Home Assistant, or custom dashboards.
|
||||
|
||||
### API Authentication
|
||||
|
||||
When authentication is enabled on ProxMenux Monitor, all API endpoints (except `/api/health` and `/api/auth/*`) require a valid JWT token in the `Authorization` header.
|
||||
|
||||
### API Endpoint Base URL
|
||||
|
||||
**Direct Access:**
|
||||
```
|
||||
http://your-proxmox-ip:8008/api/
|
||||
```
|
||||
|
||||
**Via Proxy:**
|
||||
```
|
||||
https://your-domain.com/proxmenux-monitor/api/
|
||||
```
|
||||
|
||||
**Note**: All API examples in this documentation work with both direct and proxied URLs. Simply replace the base URL with your preferred access method.
|
||||
|
||||
### Generating API Tokens
|
||||
|
||||
To use the API with authentication enabled, you need to generate a long-lived API token.
|
||||
|
||||
#### Option 1: Generate via Web Panel (Recommended)
|
||||
|
||||
The easiest way to generate an API token is through the ProxMenux Monitor web interface:
|
||||
|
||||
1. Navigate to **Settings** tab in the dashboard
|
||||
2. Scroll to the **API Access Tokens** section
|
||||
3. Enter your password
|
||||
4. If 2FA is enabled, enter your 6-digit code
|
||||
5. Provide a name for the token (e.g., "Homepage Integration")
|
||||
6. Click **Generate Token**
|
||||
7. Copy the token immediately - it will not be shown again
|
||||
|
||||

|
||||
|
||||
The token will be valid for **365 days** (1 year) and can be used for integrations with Homepage, Home Assistant, or any custom application.
|
||||
|
||||
#### Option 2: Generate via API Call
|
||||
|
||||
For advanced users or automation, you can generate tokens programmatically:
|
||||
|
||||
```bash
|
||||
curl -X POST http://your-proxmox-ip:8008/api/auth/generate-api-token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "your-username",
|
||||
"password": "your-password",
|
||||
"totp_token": "123456",
|
||||
"token_name": "Homepage Integration"
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_name": "Homepage Integration",
|
||||
"expires_in": "365 days",
|
||||
"message": "API token generated successfully. Store this token securely, it will not be shown again."
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- If 2FA is enabled, include the `totp_token` field with your 6-digit code
|
||||
- If 2FA is not enabled, omit the `totp_token` field
|
||||
- The token is valid for **365 days** (1 year)
|
||||
- Store the token securely - it cannot be retrieved again
|
||||
|
||||
#### Option 3: Generate via cURL (without 2FA)
|
||||
|
||||
```bash
|
||||
# Without 2FA
|
||||
curl -X POST http://your-proxmox-ip:8008/api/auth/generate-api-token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"pedro","password":"your-password","token_name":"Homepage"}'
|
||||
```
|
||||
|
||||
### Using API Tokens
|
||||
|
||||
Once you have your API token, include it in the `Authorization` header of all API requests:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN_HERE" \
|
||||
http://your-proxmox-ip:8008/api/system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Available Endpoints
|
||||
|
||||
Below is a complete list of all API endpoints with descriptions and example responses.
|
||||
|
||||
#### System & Metrics
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/system` | GET | Yes | Complete system information (CPU, memory, temperature, uptime) |
|
||||
| `/api/system-info` | GET | No | Lightweight system info for header (hostname, uptime, health) |
|
||||
| `/api/node/metrics` | GET | Yes | Historical metrics data (RRD) for CPU, memory, disk I/O |
|
||||
| `/api/prometheus` | GET | Yes | Export metrics in Prometheus format |
|
||||
|
||||
**Example `/api/system` Response:**
|
||||
```json
|
||||
{
|
||||
"hostname": "pve",
|
||||
"cpu_usage": 15.2,
|
||||
"memory_usage": 45.8,
|
||||
"temperature": 42.5,
|
||||
"uptime": 345600,
|
||||
"kernel": "6.2.16-3-pve",
|
||||
"pve_version": "8.0.3"
|
||||
}
|
||||
```
|
||||
|
||||
#### Storage
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/storage` | GET | Yes | Complete storage information with SMART data |
|
||||
| `/api/storage/summary` | GET | Yes | Optimized storage summary (without SMART) |
|
||||
| `/api/proxmox-storage` | GET | Yes | Proxmox storage pools information |
|
||||
| `/api/backups` | GET | Yes | List of all backup files |
|
||||
|
||||
**Example `/api/storage/summary` Response:**
|
||||
```json
|
||||
{
|
||||
"total_capacity": 1431894917120,
|
||||
"used_space": 197414092800,
|
||||
"free_space": 1234480824320,
|
||||
"usage_percentage": 13.8,
|
||||
"disks": [
|
||||
{
|
||||
"device": "/dev/sda",
|
||||
"model": "Samsung SSD 970",
|
||||
"size": "476.94 GB",
|
||||
"type": "SSD"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Network
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/network` | GET | Yes | Complete network information for all interfaces |
|
||||
| `/api/network/summary` | GET | Yes | Optimized network summary |
|
||||
| `/api/network/<interface>/metrics` | GET | Yes | Historical metrics (RRD) for specific interface |
|
||||
|
||||
**Example `/api/network/summary` Response:**
|
||||
```json
|
||||
{
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "vmbr0",
|
||||
"ip": "192.168.1.100",
|
||||
"state": "up",
|
||||
"rx_bytes": 1234567890,
|
||||
"tx_bytes": 987654321
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Virtual Machines & Containers
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/vms` | GET | Yes | List of all VMs and LXC containers |
|
||||
| `/api/vms/<vmid>` | GET | Yes | Detailed configuration for specific VM/LXC |
|
||||
| `/api/vms/<vmid>/metrics` | GET | Yes | Historical metrics (RRD) for specific VM/LXC |
|
||||
| `/api/vms/<vmid>/logs` | GET | Yes | Download real logs for specific VM/LXC |
|
||||
| `/api/vms/<vmid>/control` | POST | Yes | Control VM/LXC (start, stop, shutdown, reboot) |
|
||||
| `/api/vms/<vmid>/config` | PUT | Yes | Update VM/LXC configuration (description/notes) |
|
||||
|
||||
**Example `/api/vms` Response:**
|
||||
```json
|
||||
{
|
||||
"vms": [
|
||||
{
|
||||
"vmid": "100",
|
||||
"name": "ubuntu-server",
|
||||
"type": "qemu",
|
||||
"status": "running",
|
||||
"cpu": 2,
|
||||
"maxcpu": 4,
|
||||
"mem": 2147483648,
|
||||
"maxmem": 4294967296,
|
||||
"uptime": 86400
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Hardware
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/hardware` | GET | Yes | Complete hardware information (CPU, GPU, PCIe, disks) |
|
||||
| `/api/gpu/<slot>/realtime` | GET | Yes | Real-time monitoring for specific GPU |
|
||||
|
||||
**Example `/api/hardware` Response:**
|
||||
```json
|
||||
{
|
||||
"cpu": {
|
||||
"model": "AMD Ryzen 9 5950X",
|
||||
"cores": 16,
|
||||
"threads": 32,
|
||||
"frequency": "3.4 GHz"
|
||||
},
|
||||
"gpus": [
|
||||
{
|
||||
"slot": "0000:01:00.0",
|
||||
"vendor": "NVIDIA",
|
||||
"model": "GeForce RTX 3080",
|
||||
"driver": "nvidia"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Logs, Events & Notifications
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/logs` | GET | Yes | System logs (journalctl) with filters |
|
||||
| `/api/logs/download` | GET | Yes | Download logs as text file |
|
||||
| `/api/notifications` | GET | Yes | Proxmox notification history |
|
||||
| `/api/notifications/download` | GET | Yes | Download full notification log |
|
||||
| `/api/events` | GET | Yes | Recent Proxmox tasks and events |
|
||||
| `/api/task-log/<upid>` | GET | Yes | Full log for specific task using UPID |
|
||||
|
||||
**Example `/api/logs` Query Parameters:**
|
||||
```
|
||||
/api/logs?severity=error&since=1h&search=failed
|
||||
```
|
||||
|
||||
#### Health Monitoring
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/health` | GET | No | Basic health check (for external monitoring) |
|
||||
| `/api/health/status` | GET | Yes | Summary of system health status |
|
||||
| `/api/health/details` | GET | Yes | Detailed health check results |
|
||||
| `/api/health/acknowledge` | POST | Yes | Dismiss/acknowledge health warnings |
|
||||
| `/api/health/active-errors` | GET | Yes | Get active persistent errors |
|
||||
|
||||
#### ProxMenux Optimizations
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/proxmenux/installed-tools` | GET | Yes | List of installed ProxMenux optimizations |
|
||||
|
||||
#### Authentication
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/auth/status` | GET | No | Current authentication status |
|
||||
| `/api/auth/login` | POST | No | Authenticate and receive JWT token |
|
||||
| `/api/auth/generate-api-token` | POST | No | Generate long-lived API token (365 days) |
|
||||
| `/api/auth/setup` | POST | No | Initial setup of username/password |
|
||||
| `/api/auth/enable` | POST | No | Enable authentication |
|
||||
| `/api/auth/disable` | POST | Yes | Disable authentication |
|
||||
| `/api/auth/change-password` | POST | No | Change password |
|
||||
| `/api/auth/totp/setup` | POST | Yes | Initialize 2FA setup |
|
||||
| `/api/auth/totp/enable` | POST | Yes | Enable 2FA after verification |
|
||||
| `/api/auth/totp/disable` | POST | Yes | Disable 2FA |
|
||||
|
||||
---
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Homepage Integration
|
||||
|
||||
[Homepage](https://gethomepage.dev/) is a modern, fully static, fast, secure fully proxied, highly customizable application dashboard.
|
||||
|
||||
#### Basic Configuration (No Authentication)
|
||||
|
||||
```yaml
|
||||
- ProxMenux Monitor:
|
||||
href: http://proxmox.example.tld:8008/
|
||||
icon: lucide:flask-round
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/system
|
||||
refreshInterval: 10000
|
||||
mappings:
|
||||
- field: uptime
|
||||
label: Uptime
|
||||
icon: lucide:clock-4
|
||||
format: text
|
||||
- field: cpu_usage
|
||||
label: CPU
|
||||
icon: lucide:cpu
|
||||
format: percent
|
||||
- field: memory_usage
|
||||
label: RAM
|
||||
icon: lucide:memory-stick
|
||||
format: percent
|
||||
- field: temperature
|
||||
label: Temp
|
||||
icon: lucide:thermometer-sun
|
||||
format: number
|
||||
suffix: °C
|
||||
```
|
||||
|
||||
#### With Authentication Enabled (Using Secrets)
|
||||
|
||||
First, generate an API token via the web interface (Settings > API Access Tokens) or via API.
|
||||
|
||||
Then, store your token securely in Homepage's `secrets.yaml`:
|
||||
|
||||
```yaml
|
||||
# secrets.yaml (add to .gitignore!)
|
||||
proxmenux_token: "your_actual_api_token_here"
|
||||
```
|
||||
|
||||
Finally, reference the secret in your `services.yaml`:
|
||||
|
||||
```yaml
|
||||
- ProxMenux Monitor:
|
||||
href: http://proxmox.example.tld:8008/
|
||||
icon: lucide:flask-round
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
refreshInterval: 10000
|
||||
mappings:
|
||||
- field: uptime
|
||||
label: Uptime
|
||||
icon: lucide:clock-4
|
||||
format: text
|
||||
- field: cpu_usage
|
||||
label: CPU
|
||||
icon: lucide:cpu
|
||||
format: percent
|
||||
- field: memory_usage
|
||||
label: RAM
|
||||
icon: lucide:memory-stick
|
||||
format: percent
|
||||
- field: temperature
|
||||
label: Temp
|
||||
icon: lucide:thermometer-sun
|
||||
format: number
|
||||
suffix: °C
|
||||
```
|
||||
|
||||
#### Advanced Multi-Widget Configuration
|
||||
|
||||
```yaml
|
||||
# Store token in secrets.yaml
|
||||
# proxmenux_token: "your_actual_api_token_here"
|
||||
|
||||
- ProxMenux System:
|
||||
href: http://proxmox.example.tld:8008/
|
||||
icon: lucide:server
|
||||
description: Proxmox VE Host
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
refreshInterval: 5000
|
||||
mappings:
|
||||
- field: cpu_usage
|
||||
label: CPU
|
||||
icon: lucide:cpu
|
||||
format: percent
|
||||
- field: memory_usage
|
||||
label: RAM
|
||||
icon: lucide:memory-stick
|
||||
format: percent
|
||||
- field: temperature
|
||||
label: Temp
|
||||
icon: lucide:thermometer-sun
|
||||
format: number
|
||||
suffix: °C
|
||||
|
||||
- ProxMenux Storage:
|
||||
href: http://proxmox.example.tld:8008/#/storage
|
||||
icon: lucide:hard-drive
|
||||
description: Storage Overview
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/storage/summary
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
refreshInterval: 30000
|
||||
mappings:
|
||||
- field: usage_percentage
|
||||
label: Used
|
||||
icon: lucide:database
|
||||
format: percent
|
||||
- field: used_space
|
||||
label: Space
|
||||
icon: lucide:folder
|
||||
format: bytes
|
||||
|
||||
- ProxMenux Network:
|
||||
href: http://proxmox.example.tld:8008/#/network
|
||||
icon: lucide:network
|
||||
description: Network Stats
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/network/summary
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
refreshInterval: 5000
|
||||
mappings:
|
||||
- field: interfaces[0].rx_bytes
|
||||
label: Received
|
||||
icon: lucide:download
|
||||
format: bytes
|
||||
- field: interfaces[0].tx_bytes
|
||||
label: Sent
|
||||
icon: lucide:upload
|
||||
format: bytes
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform.
|
||||
|
||||
#### Store Token Securely
|
||||
|
||||
First, add your API token to Home Assistant's `secrets.yaml`:
|
||||
|
||||
```yaml
|
||||
# secrets.yaml
|
||||
proxmenux_api_token: "Bearer your_actual_api_token_here"
|
||||
```
|
||||
|
||||
**Note**: Include "Bearer " prefix in the secrets file for Home Assistant.
|
||||
|
||||
#### Configuration.yaml
|
||||
|
||||
```yaml
|
||||
# ProxMenux Monitor Sensors
|
||||
sensor:
|
||||
- platform: rest
|
||||
name: ProxMenux CPU
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
value_template: "{{ value_json.cpu_usage }}"
|
||||
unit_of_measurement: "%"
|
||||
scan_interval: 30
|
||||
|
||||
- platform: rest
|
||||
name: ProxMenux Memory
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
value_template: "{{ value_json.memory_usage }}"
|
||||
unit_of_measurement: "%"
|
||||
scan_interval: 30
|
||||
|
||||
- platform: rest
|
||||
name: ProxMenux Temperature
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
value_template: "{{ value_json.temperature }}"
|
||||
unit_of_measurement: "°C"
|
||||
device_class: temperature
|
||||
scan_interval: 30
|
||||
|
||||
- platform: rest
|
||||
name: ProxMenux Uptime
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
value_template: >
|
||||
{% set uptime_seconds = value_json.uptime | int %}
|
||||
{% set days = (uptime_seconds / 86400) | int %}
|
||||
{% set hours = ((uptime_seconds % 86400) / 3600) | int %}
|
||||
{% set minutes = ((uptime_seconds % 3600) / 60) | int %}
|
||||
{{ days }}d {{ hours }}h {{ minutes }}m
|
||||
scan_interval: 60
|
||||
```
|
||||
|
||||
#### Lovelace Card Example
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
title: Proxmox Monitor
|
||||
entities:
|
||||
- entity: sensor.proxmenux_cpu
|
||||
name: CPU Usage
|
||||
icon: mdi:cpu-64-bit
|
||||
- entity: sensor.proxmenux_memory
|
||||
name: Memory Usage
|
||||
icon: mdi:memory
|
||||
- entity: sensor.proxmenux_temperature
|
||||
name: Temperature
|
||||
icon: mdi:thermometer
|
||||
- entity: sensor.proxmenux_uptime
|
||||
name: Uptime
|
||||
icon: mdi:clock-outline
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**.
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format
|
||||
- Adapt — remix, transform, and build upon the material
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made
|
||||
- NonCommercial — You may not use the material for commercial purposes
|
||||
|
||||
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**ProxMenux Monitor** - Made with ❤️ for the Proxmox community
|
||||
9
AppImage/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ProxmoxDashboard } from "../../components/proxmox-dashboard"
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<ProxmoxDashboard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
177
AppImage/app/globals.css
Normal file
@@ -0,0 +1,177 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ===================== */
|
||||
/* Light Mode (default) */
|
||||
/* ===================== */
|
||||
:root {
|
||||
--background: oklch(1 0 0); /* blanco */
|
||||
--foreground: oklch(0.145 0 0); /* casi negro */
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
--primary: oklch(0.205 0 0); /* gris oscuro */
|
||||
--primary-foreground: oklch(0.985 0 0); /* blanco */
|
||||
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: var(--primary);
|
||||
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0); /* gris medio */
|
||||
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: var(--primary);
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.145 0 0);
|
||||
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: var(--border);
|
||||
--ring: oklch(0.708 0 0);
|
||||
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
|
||||
--radius: 0.625rem;
|
||||
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: var(--foreground);
|
||||
--sidebar-primary: var(--primary);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: var(--primary);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Dark Mode (gris) */
|
||||
/* ===================== */
|
||||
.dark {
|
||||
--background: oklch(0.22 0 0); /* gris oscuro */
|
||||
--foreground: oklch(0.97 0 0); /* blanco/gris claro */
|
||||
|
||||
--card: oklch(0.24 0 0);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
--primary: oklch(0.83 0 0); /* casi blanco */
|
||||
--primary-foreground: var(--background);
|
||||
|
||||
--secondary: oklch(0.28 0 0);
|
||||
--secondary-foreground: oklch(0.92 0 0);
|
||||
|
||||
--muted: oklch(0.26 0 0);
|
||||
--muted-foreground: oklch(0.72 0 0);
|
||||
|
||||
--accent: oklch(0.28 0 0);
|
||||
--accent-foreground: var(--primary);
|
||||
|
||||
--destructive: oklch(0.53 0.25 27);
|
||||
--destructive-foreground: oklch(0.9 0 0);
|
||||
|
||||
--border: oklch(0.34 0 0);
|
||||
--input: var(--border);
|
||||
--ring: oklch(0.55 0 0);
|
||||
|
||||
--chart-1: oklch(0.60 0.20 255);
|
||||
--chart-2: oklch(0.70 0.16 165);
|
||||
--chart-3: oklch(0.76 0.19 70);
|
||||
--chart-4: oklch(0.63 0.25 305);
|
||||
--chart-5: oklch(0.66 0.24 20);
|
||||
|
||||
--sidebar: oklch(0.24 0 0);
|
||||
--sidebar-foreground: var(--foreground);
|
||||
--sidebar-primary: var(--chart-1);
|
||||
--sidebar-primary-foreground: var(--foreground);
|
||||
--sidebar-accent: oklch(0.28 0 0);
|
||||
--sidebar-accent-foreground: var(--foreground);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Base layer */
|
||||
/* ===================== */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Foco accesible */
|
||||
:is(button,[role="button"],a,input,select,textarea,[tabindex]:not([tabindex="-1"])):focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
:is(button,[role="button"],a,input,select,textarea,[tabindex]:not([tabindex="-1"])):focus-visible {
|
||||
@apply ring-2;
|
||||
--tw-ring-color: var(--ring);
|
||||
--tw-ring-opacity: 0.5; /* equivalente al /50 */
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Ajustes para Charts */
|
||||
/* ===================== */
|
||||
@layer components {
|
||||
/* Recharts axis */
|
||||
.recharts-cartesian-axis-tick tspan {
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
.recharts-cartesian-axis-line,
|
||||
.recharts-cartesian-grid line {
|
||||
stroke: var(--border);
|
||||
}
|
||||
|
||||
/* Chart.js axis */
|
||||
.chartjs-render-monitor text {
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
.chartjs-render-monitor line {
|
||||
stroke: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Ajustes para xterm.js */
|
||||
/* ===================== */
|
||||
|
||||
/* Quitar padding para que la terminal ocupe el 100% del ancho */
|
||||
.xterm {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Por si acaso el viewport añade padding extra */
|
||||
.xterm .xterm-viewport {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Opcional: asegurar que no haya margen raro */
|
||||
.xterm-rows {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Progress Animations */
|
||||
/* ===================== */
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
5
AppImage/app/hardware/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Hardware from "@/components/hardware"
|
||||
|
||||
export default function HardwarePage() {
|
||||
return <Hardware />
|
||||
}
|
||||
52
AppImage/app/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type React from "react"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { ThemeProvider } from "../components/theme-provider"
|
||||
import { Suspense } from "react"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ProxMenux Monitor",
|
||||
description: "Proxmox System Dashboard and Monitor",
|
||||
generator: "v0.app",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{ url: "/icon.svg", type: "image/svg+xml" },
|
||||
{ url: "/icon.png", type: "image/png", sizes: "32x32" },
|
||||
],
|
||||
shortcut: "/favicon.ico",
|
||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#2b2f36" },
|
||||
],
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
98
AppImage/app/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ProxmoxDashboard } from "../components/proxmox-dashboard"
|
||||
import { Login } from "../components/login"
|
||||
import { AuthSetup } from "../components/auth-setup"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
export default function Home() {
|
||||
const [authStatus, setAuthStatus] = useState<{
|
||||
loading: boolean
|
||||
authEnabled: boolean
|
||||
authConfigured: boolean
|
||||
authenticated: boolean
|
||||
}>({
|
||||
loading: true,
|
||||
authEnabled: false,
|
||||
authConfigured: false,
|
||||
authenticated: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus()
|
||||
}, [])
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
const response = await fetch(getApiUrl("/api/auth/status"), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
|
||||
// Check if response is valid JSON before parsing
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
throw new Error("Response is not JSON")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const authenticated = data.auth_enabled ? data.authenticated : true
|
||||
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: data.auth_enabled,
|
||||
authConfigured: data.auth_configured,
|
||||
authenticated,
|
||||
})
|
||||
} catch {
|
||||
// API not available - assume no auth configured (silent fail, no console error)
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
authConfigured: false,
|
||||
authenticated: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthComplete = () => {
|
||||
checkAuthStatus()
|
||||
}
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
checkAuthStatus()
|
||||
}
|
||||
|
||||
if (authStatus.loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading...</div>
|
||||
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (authStatus.authEnabled && !authStatus.authenticated) {
|
||||
return <Login onLogin={handleLoginSuccess} />
|
||||
}
|
||||
|
||||
// Show dashboard in all other cases
|
||||
return (
|
||||
<>
|
||||
{!authStatus.authConfigured && <AuthSetup onComplete={handleAuthComplete} />}
|
||||
<ProxmoxDashboard />
|
||||
</>
|
||||
)
|
||||
}
|
||||
286
AppImage/components/auth-setup.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
interface AuthSetupProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<"choice" | "setup">("choice")
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/auth/status"))
|
||||
|
||||
// Check if response is valid JSON before parsing
|
||||
if (!response.ok) {
|
||||
// API not available - don't show modal in preview
|
||||
return
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Show modal if auth is not configured and not declined
|
||||
if (!data.auth_configured) {
|
||||
setTimeout(() => setOpen(true), 500)
|
||||
}
|
||||
} catch {
|
||||
// API not available (preview environment) - don't show modal
|
||||
}
|
||||
}
|
||||
|
||||
checkOnboardingStatus()
|
||||
}, [])
|
||||
|
||||
const handleSkipAuth = async () => {
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
console.log("[v0] Skipping authentication setup...")
|
||||
const response = await fetch(getApiUrl("/api/auth/skip"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
console.log("[v0] Auth skip response:", data)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to skip authentication")
|
||||
}
|
||||
|
||||
if (data.auth_declined) {
|
||||
console.log("[v0] Authentication skipped successfully - APIs should be accessible without token")
|
||||
}
|
||||
|
||||
console.log("[v0] Authentication skipped successfully")
|
||||
localStorage.setItem("proxmenux-auth-declined", "true")
|
||||
localStorage.removeItem("proxmenux-auth-token") // Remove any old token
|
||||
setOpen(false)
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("[v0] Auth skip error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to save preference")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetupAuth = async () => {
|
||||
setError("")
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please fill in all fields")
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
console.log("[v0] Setting up authentication...")
|
||||
const response = await fetch(getApiUrl("/api/auth/setup"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
console.log("[v0] Auth setup response:", data)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to setup authentication")
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||
localStorage.removeItem("proxmenux-auth-declined")
|
||||
console.log("[v0] Authentication setup successful")
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("[v0] Auth setup error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogTitle className="sr-only">
|
||||
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
|
||||
</DialogTitle>
|
||||
{step === "choice" ? (
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||
<Shield className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Yes, Setup Password
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSkipAuth}
|
||||
variant="outline"
|
||||
className="w-full bg-transparent"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
No, Continue Without Protection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||
<Lock className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Setup Authentication</h2>
|
||||
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-sm">
|
||||
Username
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
disabled={loading}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" className="text-sm">
|
||||
Confirm Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
disabled={loading}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Setting up..." : "Setup Authentication"}
|
||||
</Button>
|
||||
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
395
AppImage/components/gpu-switch-mode-indicator.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SriovInfo {
|
||||
role: "vf" | "pf-active" | "pf-idle"
|
||||
physfn?: string // VF only: parent PF BDF
|
||||
vfCount?: number // PF only: active VF count
|
||||
totalvfs?: number // PF only: maximum VFs
|
||||
}
|
||||
|
||||
interface GpuSwitchModeIndicatorProps {
|
||||
mode: "lxc" | "vm" | "sriov" | "unknown"
|
||||
isEditing?: boolean
|
||||
pendingMode?: "lxc" | "vm" | null
|
||||
onToggle?: (e: React.MouseEvent) => void
|
||||
className?: string
|
||||
sriovInfo?: SriovInfo
|
||||
}
|
||||
|
||||
export function GpuSwitchModeIndicator({
|
||||
mode,
|
||||
isEditing = false,
|
||||
pendingMode = null,
|
||||
onToggle,
|
||||
className,
|
||||
sriovInfo,
|
||||
}: GpuSwitchModeIndicatorProps) {
|
||||
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
|
||||
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
|
||||
const isLxcActive = displayMode === "lxc"
|
||||
const isVmActive = displayMode === "vm"
|
||||
const isSriovActive = displayMode === "sriov"
|
||||
const hasChanged =
|
||||
mode !== "sriov" && pendingMode !== null && pendingMode !== mode
|
||||
|
||||
// Colors
|
||||
const sriovColor = "#14b8a6" // teal-500
|
||||
const activeColor = isSriovActive
|
||||
? sriovColor
|
||||
: isLxcActive
|
||||
? "#3b82f6"
|
||||
: isVmActive
|
||||
? "#a855f7"
|
||||
: "#6b7280"
|
||||
const inactiveColor = "#374151" // gray-700 for dark theme
|
||||
const dimmedColor = "#4b5563" // gray-600 for dashed SR-IOV branches
|
||||
const lxcColor = isLxcActive ? "#3b82f6" : inactiveColor
|
||||
const vmColor = isVmActive ? "#a855f7" : inactiveColor
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// SR-IOV state can't be toggled — swallow the click so it doesn't reach
|
||||
// the card (which would open the detail modal unexpectedly from this
|
||||
// area). For lxc/vm, preserve the original behavior.
|
||||
if (isSriovActive) {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (isEditing) {
|
||||
e.stopPropagation()
|
||||
if (onToggle) {
|
||||
onToggle(e)
|
||||
}
|
||||
}
|
||||
// When not editing, let the click propagate to the card to open the modal
|
||||
}
|
||||
|
||||
// Build the VF count label shown in the SR-IOV badge. For PFs we know
|
||||
// exactly how many VFs are active; for a VF we show its parent PF.
|
||||
const sriovBadgeText = (() => {
|
||||
if (!isSriovActive) return ""
|
||||
if (sriovInfo?.role === "vf") return "SR-IOV VF"
|
||||
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
|
||||
return "SR-IOV"
|
||||
})()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-6",
|
||||
isEditing && !isSriovActive && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Large SVG Diagram */}
|
||||
<svg
|
||||
viewBox="0 0 220 100"
|
||||
className="h-24 w-56 flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* GPU Chip - Large with "GPU" text */}
|
||||
<g transform="translate(0, 22)">
|
||||
{/* Main chip body */}
|
||||
<rect
|
||||
x="4"
|
||||
y="8"
|
||||
width="44"
|
||||
height="36"
|
||||
rx="6"
|
||||
fill={`${activeColor}20`}
|
||||
stroke={activeColor}
|
||||
strokeWidth="2.5"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Chip pins - top */}
|
||||
<line x1="14" y1="2" x2="14" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="26" y1="2" x2="26" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="38" y1="2" x2="38" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* Chip pins - bottom */}
|
||||
<line x1="14" y1="44" x2="14" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="26" y1="44" x2="26" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="38" y1="44" x2="38" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* GPU text */}
|
||||
<text
|
||||
x="26"
|
||||
y="32"
|
||||
textAnchor="middle"
|
||||
fill={activeColor}
|
||||
className="text-[14px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
GPU
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Connection line from GPU to switch */}
|
||||
<line
|
||||
x1="52"
|
||||
y1="50"
|
||||
x2="78"
|
||||
y2="50"
|
||||
stroke={activeColor}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* Central Switch Node - Large circle with inner dot */}
|
||||
<circle
|
||||
cx="95"
|
||||
cy="50"
|
||||
r="14"
|
||||
fill={isEditing && !isSriovActive ? "#f59e0b20" : `${activeColor}20`}
|
||||
stroke={isEditing && !isSriovActive ? "#f59e0b" : activeColor}
|
||||
strokeWidth="3"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<circle
|
||||
cx="95"
|
||||
cy="50"
|
||||
r="6"
|
||||
fill={isEditing && !isSriovActive ? "#f59e0b" : activeColor}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* LXC Branch Line - going up-right.
|
||||
In SR-IOV mode the branch is dashed + dimmed to show that the
|
||||
target is theoretically reachable via a VF but not controlled
|
||||
by ProxMenux. */}
|
||||
<path
|
||||
d="M 109 42 L 135 20"
|
||||
fill="none"
|
||||
stroke={isSriovActive ? dimmedColor : lxcColor}
|
||||
strokeWidth={isLxcActive ? "3.5" : "2"}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={isSriovActive ? "3 3" : undefined}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* VM Branch Line - going down-right (dashed/dimmed in SR-IOV). */}
|
||||
<path
|
||||
d="M 109 58 L 135 80"
|
||||
fill="none"
|
||||
stroke={isSriovActive ? dimmedColor : vmColor}
|
||||
strokeWidth={isVmActive ? "3.5" : "2"}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={isSriovActive ? "3 3" : undefined}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* SR-IOV in-line connector + badge (only when mode === 'sriov').
|
||||
A horizontal line from the switch node leads to a pill-shaped
|
||||
badge carrying the "SR-IOV ×N" label. Placed on the GPU's
|
||||
baseline to visually read as an in-line extension, not as a
|
||||
third branch. */}
|
||||
{isSriovActive && (
|
||||
<>
|
||||
<line
|
||||
x1="109"
|
||||
y1="50"
|
||||
x2="130"
|
||||
y2="50"
|
||||
stroke={sriovColor}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<rect
|
||||
x="132"
|
||||
y="40"
|
||||
width="60"
|
||||
height="20"
|
||||
rx="10"
|
||||
fill={`${sriovColor}25`}
|
||||
stroke={sriovColor}
|
||||
strokeWidth="2"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<text
|
||||
x="162"
|
||||
y="54"
|
||||
textAnchor="middle"
|
||||
fill={sriovColor}
|
||||
className="text-[11px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
{sriovBadgeText}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LXC Container Icon - dimmed/smaller in SR-IOV mode. */}
|
||||
{!isSriovActive && (
|
||||
<g transform="translate(138, 2)">
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="28"
|
||||
rx="4"
|
||||
fill={isLxcActive ? `${lxcColor}25` : "transparent"}
|
||||
stroke={lxcColor}
|
||||
strokeWidth={isLxcActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<line x1="0" y1="10" x2="32" y2="10" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
<line x1="0" y1="19" x2="32" y2="19" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="14.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="23.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
</g>
|
||||
)}
|
||||
{/* SR-IOV: compact dimmed LXC glyph so the geometry stays recognizable
|
||||
but it's clearly not the active target. */}
|
||||
{isSriovActive && (
|
||||
<g transform="translate(138, 6)" opacity="0.35">
|
||||
<rect x="0" y="0" width="20" height="18" rx="3" fill="transparent" stroke={dimmedColor} strokeWidth="1.5" />
|
||||
<line x1="0" y1="6" x2="20" y2="6" stroke={dimmedColor} strokeWidth="1" />
|
||||
<line x1="0" y1="12" x2="20" y2="12" stroke={dimmedColor} strokeWidth="1" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* LXC Label */}
|
||||
{!isSriovActive && (
|
||||
<text
|
||||
x="188"
|
||||
y="22"
|
||||
textAnchor="start"
|
||||
fill={lxcColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isLxcActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
<text
|
||||
x="162"
|
||||
y="16"
|
||||
fill={dimmedColor}
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* VM Monitor Icon - active view */}
|
||||
{!isSriovActive && (
|
||||
<g transform="translate(138, 65)">
|
||||
<rect
|
||||
x="2"
|
||||
y="0"
|
||||
width="28"
|
||||
height="18"
|
||||
rx="3"
|
||||
fill={isVmActive ? `${vmColor}25` : "transparent"}
|
||||
stroke={vmColor}
|
||||
strokeWidth={isVmActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<rect
|
||||
x="5"
|
||||
y="3"
|
||||
width="22"
|
||||
height="12"
|
||||
rx="1"
|
||||
fill={isVmActive ? `${vmColor}30` : `${vmColor}10`}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<line x1="16" y1="18" x2="16" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="8" y1="24" x2="24" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
</g>
|
||||
)}
|
||||
{/* SR-IOV: compact dimmed VM monitor glyph, mirror of the LXC glyph. */}
|
||||
{isSriovActive && (
|
||||
<g transform="translate(138, 72)" opacity="0.35">
|
||||
<rect x="0" y="0" width="20" height="13" rx="2" fill="transparent" stroke={dimmedColor} strokeWidth="1.5" />
|
||||
<line x1="10" y1="13" x2="10" y2="17" stroke={dimmedColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="5" y1="17" x2="15" y2="17" stroke={dimmedColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* VM Label */}
|
||||
{!isSriovActive && (
|
||||
<text
|
||||
x="188"
|
||||
y="84"
|
||||
textAnchor="start"
|
||||
fill={vmColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isVmActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
<text
|
||||
x="162"
|
||||
y="82"
|
||||
fill={dimmedColor}
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Status Text - Large like GPU name */}
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-base font-semibold transition-all duration-300",
|
||||
isSriovActive
|
||||
? "text-teal-500"
|
||||
: isLxcActive
|
||||
? "text-blue-500"
|
||||
: isVmActive
|
||||
? "text-purple-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isSriovActive
|
||||
? "SR-IOV active"
|
||||
: isLxcActive
|
||||
? "Ready for LXC containers"
|
||||
: isVmActive
|
||||
? "Ready for VM passthrough"
|
||||
: "Mode unknown"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isSriovActive
|
||||
? "Virtual Functions managed externally"
|
||||
: isLxcActive
|
||||
? "Native driver active"
|
||||
: isVmActive
|
||||
? "VFIO-PCI driver active"
|
||||
: "No driver detected"}
|
||||
</span>
|
||||
{isSriovActive && sriovInfo && (
|
||||
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
|
||||
{sriovInfo.role === "vf"
|
||||
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
|
||||
: sriovInfo.vfCount !== undefined
|
||||
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
|
||||
: null}
|
||||
</span>
|
||||
)}
|
||||
{hasChanged && (
|
||||
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
||||
Change pending...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
AppImage/components/hardware-monitor.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"
|
||||
import { Cpu } from "@/components/icons/cpu" // Added import for Cpu
|
||||
import type { PCIDevice } from "../types/hardware" // Fixed import to use relative path instead of alias
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
function GPUCard({ device }: { device: PCIDevice }) {
|
||||
const hasMonitoring = device.gpu_temperature !== undefined || device.gpu_utilization !== undefined
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5" />
|
||||
{device.device}
|
||||
</CardTitle>
|
||||
<CardDescription>{device.vendor}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Slot</div>
|
||||
<div className="font-medium">{device.slot}</div>
|
||||
</div>
|
||||
{device.driver && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Driver</div>
|
||||
<div className="font-medium">{device.driver}</div>
|
||||
</div>
|
||||
)}
|
||||
{device.gpu_driver_version && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Driver Version</div>
|
||||
<div className="font-medium">{device.gpu_driver_version}</div>
|
||||
</div>
|
||||
)}
|
||||
{device.gpu_memory && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Memory</div>
|
||||
<div className="font-medium">{device.gpu_memory}</div>
|
||||
</div>
|
||||
)}
|
||||
{device.gpu_compute_capability && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Compute Capability</div>
|
||||
<div className="font-medium">{device.gpu_compute_capability}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMonitoring && (
|
||||
<div className="space-y-3 pt-4 border-t">
|
||||
<h4 className="text-sm font-semibold">Real-time Monitoring</h4>
|
||||
|
||||
{device.gpu_temperature !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Temperature</span>
|
||||
<span className="font-medium">{device.gpu_temperature}°C</span>
|
||||
</div>
|
||||
<Progress value={(device.gpu_temperature / 100) * 100} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_utilization !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">GPU Utilization</span>
|
||||
<span className="font-medium">{device.gpu_utilization}%</span>
|
||||
</div>
|
||||
<Progress value={device.gpu_utilization} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_memory_used && device.gpu_memory_total && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Memory Usage</span>
|
||||
<span className="font-medium">
|
||||
{device.gpu_memory_used} / {device.gpu_memory_total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(Number.parseInt(device.gpu_memory_used) / Number.parseInt(device.gpu_memory_total)) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_power_draw && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Power Draw</span>
|
||||
<span className="font-medium">{device.gpu_power_draw}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_clock_speed && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">GPU Clock</span>
|
||||
<span className="font-medium">{device.gpu_clock_speed}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_memory_clock && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Memory Clock</span>
|
||||
<span className="font-medium">{device.gpu_memory_clock}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
2931
AppImage/components/hardware.tsx
Normal file
842
AppImage/components/health-status-modal.tsx
Normal file
@@ -0,0 +1,842 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getAuthToken } from "@/lib/api-config"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Info,
|
||||
Activity,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Disc,
|
||||
Network,
|
||||
Box,
|
||||
Settings,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
X,
|
||||
Clock,
|
||||
BellOff,
|
||||
ChevronRight,
|
||||
Settings2,
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
reason?: string
|
||||
details?: any
|
||||
checks?: Record<string, { status: string; detail: string; [key: string]: any }>
|
||||
dismissable?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface DismissedError {
|
||||
error_key: string
|
||||
category: string
|
||||
severity: string
|
||||
reason: string
|
||||
dismissed: boolean
|
||||
permanent?: boolean
|
||||
suppression_remaining_hours: number
|
||||
suppression_hours?: number
|
||||
resolved_at: string
|
||||
}
|
||||
|
||||
interface CustomSuppression {
|
||||
key: string
|
||||
label: string
|
||||
category: string
|
||||
icon: string
|
||||
hours: number
|
||||
}
|
||||
|
||||
interface HealthDetails {
|
||||
overall: string
|
||||
summary: string
|
||||
details: {
|
||||
cpu: CategoryCheck
|
||||
memory: CategoryCheck
|
||||
storage: CategoryCheck
|
||||
disks: CategoryCheck
|
||||
network: CategoryCheck
|
||||
vms: CategoryCheck
|
||||
services: CategoryCheck
|
||||
logs: CategoryCheck
|
||||
updates: CategoryCheck
|
||||
security: CategoryCheck
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface FullHealthData {
|
||||
health: HealthDetails
|
||||
active_errors: any[]
|
||||
dismissed: DismissedError[]
|
||||
custom_suppressions: CustomSuppression[]
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface HealthStatusModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
getApiUrl: (path: string) => string
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu },
|
||||
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick },
|
||||
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
|
||||
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc },
|
||||
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network },
|
||||
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box },
|
||||
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings },
|
||||
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText },
|
||||
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw },
|
||||
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield },
|
||||
]
|
||||
|
||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
const fetchHealthDetails = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
let newOverallStatus = "OK"
|
||||
|
||||
// Use the new combined endpoint for fewer round-trips
|
||||
const token = getAuthToken()
|
||||
const authHeaders: Record<string, string> = {}
|
||||
if (token) {
|
||||
authHeaders["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl("/api/health/full"), { headers: authHeaders })
|
||||
let infoCount = 0
|
||||
|
||||
if (!response.ok) {
|
||||
// Fallback to legacy endpoint
|
||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
||||
if (!legacyResponse.ok) throw new Error("Failed to fetch health details")
|
||||
const data = await legacyResponse.json()
|
||||
setHealthData(data)
|
||||
setDismissedItems([])
|
||||
setCustomSuppressions([])
|
||||
newOverallStatus = data?.overall || "OK"
|
||||
|
||||
// Count INFO categories from legacy data
|
||||
if (data?.details) {
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const cat = data.details[key as keyof typeof data.details]
|
||||
if (cat && cat.status?.toUpperCase() === "INFO") {
|
||||
infoCount++
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const fullData: FullHealthData = await response.json()
|
||||
setHealthData(fullData.health)
|
||||
setDismissedItems(fullData.dismissed || [])
|
||||
setCustomSuppressions(fullData.custom_suppressions || [])
|
||||
newOverallStatus = fullData.health?.overall || "OK"
|
||||
|
||||
// Get categories that have dismissed items (these become INFO)
|
||||
const customCats = new Set((fullData.custom_suppressions || []).map((cs: { category: string }) => cs.category))
|
||||
const filteredDismissed = (fullData.dismissed || []).filter((item: { category: string }) => !customCats.has(item.category))
|
||||
const categoriesWithDismissed = new Set<string>()
|
||||
filteredDismissed.forEach((item: { category: string }) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
if (catMeta) {
|
||||
categoriesWithDismissed.add(catMeta.key)
|
||||
}
|
||||
})
|
||||
|
||||
// Count effective INFO categories (original INFO + OK categories with dismissed)
|
||||
if (fullData.health?.details) {
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const cat = fullData.health.details[key as keyof typeof fullData.health.details]
|
||||
if (cat) {
|
||||
const originalStatus = cat.status?.toUpperCase()
|
||||
// Count as INFO if: originally INFO OR (originally OK and has dismissed items)
|
||||
if (originalStatus === "INFO" || (originalStatus === "OK" && categoriesWithDismissed.has(key))) {
|
||||
infoCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totalInfoCount = infoCount
|
||||
|
||||
// Emit event with the FRESH data from the response, not the stale state
|
||||
const event = new CustomEvent("healthStatusUpdated", {
|
||||
detail: { status: newOverallStatus, infoCount: totalInfoCount },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [getApiUrl])
|
||||
|
||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const tickInterval = setInterval(() => setTick(t => t + 1), 30000)
|
||||
return () => clearInterval(tickInterval)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHealthDetails()
|
||||
// Auto-refresh every 5 minutes while modal is open
|
||||
const refreshInterval = setInterval(fetchHealthDetails, 300000)
|
||||
return () => clearInterval(refreshInterval)
|
||||
}
|
||||
}, [open, fetchHealthDetails])
|
||||
|
||||
// Auto-expand non-OK categories when data loads
|
||||
useEffect(() => {
|
||||
if (healthData?.details) {
|
||||
const nonOkCategories = new Set<string>()
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const cat = healthData.details[key as keyof typeof healthData.details]
|
||||
if (cat && cat.status?.toUpperCase() !== "OK") {
|
||||
// Updates section: only auto-expand on WARNING+, not INFO
|
||||
if (key === "updates" && cat.status?.toUpperCase() === "INFO") {
|
||||
return
|
||||
}
|
||||
nonOkCategories.add(key)
|
||||
}
|
||||
})
|
||||
setExpandedCategories(nonOkCategories)
|
||||
}
|
||||
}, [healthData])
|
||||
|
||||
const toggleCategory = (key: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string, size: "sm" | "md" = "md") => {
|
||||
const statusUpper = status?.toUpperCase()
|
||||
const cls = size === "sm" ? "h-4 w-4" : "h-5 w-5"
|
||||
switch (statusUpper) {
|
||||
case "OK":
|
||||
return <CheckCircle2 className={`${cls} text-green-500`} />
|
||||
case "INFO":
|
||||
return <Info className={`${cls} text-blue-500`} />
|
||||
case "WARNING":
|
||||
return <AlertTriangle className={`${cls} text-yellow-500`} />
|
||||
case "CRITICAL":
|
||||
return <XCircle className={`${cls} text-red-500`} />
|
||||
case "UNKNOWN":
|
||||
return <HelpCircle className={`${cls} text-amber-400`} />
|
||||
default:
|
||||
return <Activity className={`${cls} text-muted-foreground`} />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusUpper = status?.toUpperCase()
|
||||
switch (statusUpper) {
|
||||
case "OK":
|
||||
return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge>
|
||||
case "INFO":
|
||||
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">Info</Badge>
|
||||
case "WARNING":
|
||||
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge>
|
||||
case "CRITICAL":
|
||||
return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge>
|
||||
case "UNKNOWN":
|
||||
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">UNKNOWN</Badge>
|
||||
default:
|
||||
return <Badge>Unknown</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
// Get categories that have dismissed items (to show as INFO)
|
||||
const getCategoriesWithDismissed = () => {
|
||||
const customCats = new Set(customSuppressions.map(cs => cs.category))
|
||||
const filteredDismissed = dismissedItems.filter(item => !customCats.has(item.category))
|
||||
const categoriesWithDismissed = new Set<string>()
|
||||
filteredDismissed.forEach(item => {
|
||||
// Map dismissed category to our CATEGORIES keys
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
if (catMeta) {
|
||||
categoriesWithDismissed.add(catMeta.key)
|
||||
}
|
||||
})
|
||||
return categoriesWithDismissed
|
||||
}
|
||||
|
||||
const categoriesWithDismissed = getCategoriesWithDismissed()
|
||||
|
||||
// Get effective status for a category (considers dismissed items)
|
||||
const getEffectiveStatus = (key: string, originalStatus: string) => {
|
||||
// If category has dismissed items and original status is OK, show as INFO
|
||||
if (categoriesWithDismissed.has(key) && originalStatus?.toUpperCase() === "OK") {
|
||||
return "INFO"
|
||||
}
|
||||
return originalStatus?.toUpperCase() || "UNKNOWN"
|
||||
}
|
||||
|
||||
const getHealthStats = () => {
|
||||
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
|
||||
|
||||
let healthy = 0
|
||||
let info = 0
|
||||
let warnings = 0
|
||||
let critical = 0
|
||||
let unknown = 0
|
||||
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
if (categoryData) {
|
||||
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
|
||||
if (effectiveStatus === "OK") healthy++
|
||||
else if (effectiveStatus === "INFO") info++
|
||||
else if (effectiveStatus === "WARNING") warnings++
|
||||
else if (effectiveStatus === "CRITICAL") critical++
|
||||
else if (effectiveStatus === "UNKNOWN") unknown++
|
||||
}
|
||||
})
|
||||
|
||||
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
|
||||
}
|
||||
|
||||
const stats = getHealthStats()
|
||||
|
||||
const handleCategoryClick = (categoryKey: string, status: string) => {
|
||||
if (status === "OK" || status === "INFO") return
|
||||
|
||||
onOpenChange(false)
|
||||
|
||||
const categoryToTab: Record<string, string> = {
|
||||
storage: "storage",
|
||||
disks: "storage",
|
||||
network: "network",
|
||||
vms: "vms",
|
||||
logs: "logs",
|
||||
hardware: "hardware",
|
||||
services: "hardware",
|
||||
}
|
||||
|
||||
const targetTab = categoryToTab[categoryKey]
|
||||
if (targetTab) {
|
||||
const event = new CustomEvent("changeTab", { detail: { tab: targetTab } })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setDismissingKey(errorKey)
|
||||
|
||||
try {
|
||||
const url = getApiUrl("/api/health/acknowledge")
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ error_key: errorKey }),
|
||||
})
|
||||
|
||||
const responseData = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.error || `Failed to dismiss error (${response.status})`)
|
||||
}
|
||||
|
||||
// Optimistically update local state to avoid slow re-fetch
|
||||
// Add the dismissed item to the local list immediately
|
||||
if (responseData.result || responseData.success) {
|
||||
const dismissedItem = {
|
||||
error_key: errorKey,
|
||||
category: responseData.result?.category || responseData.category || '',
|
||||
severity: responseData.result?.original_severity || 'WARNING',
|
||||
reason: 'Dismissed by user',
|
||||
dismissed: true,
|
||||
acknowledged_at: new Date().toISOString()
|
||||
}
|
||||
setDismissedItems(prev => [...prev, dismissedItem])
|
||||
}
|
||||
|
||||
// Fetch fresh data in background (non-blocking)
|
||||
fetchHealthDetails().catch(() => {})
|
||||
} catch (err) {
|
||||
console.error("Error dismissing:", err)
|
||||
} finally {
|
||||
setDismissingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getTimeSinceCheck = () => {
|
||||
if (!healthData?.timestamp) return null
|
||||
const checkTime = new Date(healthData.timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - checkTime.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return "just now"
|
||||
if (diffMin === 1) return "1 minute ago"
|
||||
if (diffMin < 60) return `${diffMin} minutes ago`
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
return `${diffHours}h ${diffMin % 60}m ago`
|
||||
}
|
||||
|
||||
const getCategoryRowStyle = (status: string) => {
|
||||
const s = status?.toUpperCase()
|
||||
if (s === "CRITICAL") return "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer"
|
||||
if (s === "WARNING") return "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
|
||||
if (s === "UNKNOWN") return "bg-amber-500/5 border-amber-500/20 hover:bg-amber-500/10 cursor-pointer"
|
||||
if (s === "INFO") return "bg-blue-500/5 border-blue-500/20 hover:bg-blue-500/10"
|
||||
return "bg-card border-border hover:bg-muted/30"
|
||||
}
|
||||
|
||||
const getOutlineBadgeStyle = (status: string) => {
|
||||
const s = status?.toUpperCase()
|
||||
if (s === "OK") return "border-green-500 text-green-500 bg-transparent"
|
||||
if (s === "INFO") return "border-blue-500 text-blue-500 bg-blue-500/5"
|
||||
if (s === "WARNING") return "border-yellow-500 text-yellow-500 bg-yellow-500/5"
|
||||
if (s === "CRITICAL") return "border-red-500 text-red-500 bg-red-500/5"
|
||||
if (s === "UNKNOWN") return "border-amber-400 text-amber-400 bg-amber-500/5"
|
||||
return ""
|
||||
}
|
||||
|
||||
const formatCheckLabel = (key: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
// CPU
|
||||
cpu_usage: "CPU Usage",
|
||||
cpu_temperature: "Temperature",
|
||||
// Memory
|
||||
ram_usage: "RAM Usage",
|
||||
swap_usage: "Swap Usage",
|
||||
// Disk I/O
|
||||
root_filesystem: "Root Filesystem",
|
||||
smart_health: "SMART Health",
|
||||
io_errors: "I/O Errors",
|
||||
zfs_pools: "ZFS Pools",
|
||||
lvm_volumes: "LVM Volumes",
|
||||
lvm_check: "LVM Status",
|
||||
// Network
|
||||
connectivity: "Connectivity",
|
||||
// VMs & CTs
|
||||
qmp_communication: "QMP Communication",
|
||||
container_startup: "Container Startup",
|
||||
vm_startup: "VM Startup",
|
||||
oom_killer: "OOM Killer",
|
||||
// Services
|
||||
cluster_mode: "Cluster Mode",
|
||||
// Logs (prefixed with log_)
|
||||
log_error_cascade: "Error Cascade",
|
||||
log_error_spike: "Error Spike",
|
||||
log_persistent_errors: "Persistent Errors",
|
||||
log_critical_errors: "Critical Errors",
|
||||
// Updates
|
||||
pve_version: "Proxmox VE Version",
|
||||
security_updates: "Security Updates",
|
||||
system_age: "System Age",
|
||||
pending_updates: "Pending Updates",
|
||||
kernel_pve: "Kernel / PVE",
|
||||
// Security
|
||||
uptime: "Uptime",
|
||||
certificates: "Certificates",
|
||||
login_attempts: "Login Attempts",
|
||||
fail2ban: "Fail2Ban",
|
||||
// Storage (Proxmox)
|
||||
proxmox_storages: "Proxmox Storages",
|
||||
}
|
||||
if (labels[key]) return labels[key]
|
||||
// Convert snake_case or camelCase to Title Case
|
||||
return key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
const renderChecks = (
|
||||
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
|
||||
categoryKey: string
|
||||
) => {
|
||||
if (!checks || Object.keys(checks).length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{Object.entries(checks)
|
||||
.filter(([, checkData]) => checkData.installed !== false)
|
||||
.map(([checkKey, checkData]) => {
|
||||
const isDismissable = checkData.dismissable === true
|
||||
const checkStatus = checkData.status?.toUpperCase() || "OK"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={checkKey}
|
||||
className="flex items-center justify-between gap-1.5 sm:gap-2 text-[10px] sm:text-xs py-1.5 px-2 sm:px-3 rounded-md hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
|
||||
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
|
||||
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span>
|
||||
{checkData.dismissed && (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
||||
Dismissed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
|
||||
{(checkStatus === "WARNING" || checkStatus === "CRITICAL" || checkStatus === "UNKNOWN") && isDismissable && !checkData.dismissed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 px-1 sm:px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
|
||||
disabled={dismissingKey === (checkData.error_key || checkKey)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAcknowledge(checkData.error_key || checkKey, e)
|
||||
}}
|
||||
>
|
||||
{dismissingKey === (checkData.error_key || checkKey) ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 sm:mr-0.5" />
|
||||
<span className="hidden sm:inline">Dismiss</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl w-[calc(100vw-2rem)] sm:w-[95vw] max-h-[85vh] overflow-y-auto overflow-x-hidden p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
|
||||
<span className="truncate text-base sm:text-lg">System Health Status</span>
|
||||
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
|
||||
<span>Detailed health checks for all system components</span>
|
||||
{getTimeSinceCheck() && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{getTimeSinceCheck()}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
|
||||
<p className="font-medium">Error loading health status</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData && !loading && (
|
||||
<div className="space-y-4">
|
||||
{/* Overall Stats Summary */}
|
||||
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Total</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Healthy</div>
|
||||
</div>
|
||||
{stats.info > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Info</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Warn</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Critical</div>
|
||||
</div>
|
||||
{stats.unknown > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Unknown</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{healthData.summary && healthData.summary !== "All systems operational" && (
|
||||
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{healthData.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category List */}
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map(({ key, label, Icon }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
const originalStatus = categoryData?.status || "UNKNOWN"
|
||||
const status = getEffectiveStatus(key, originalStatus)
|
||||
const reason = categoryData?.reason
|
||||
const checks = categoryData?.checks
|
||||
const isExpanded = expandedCategories.has(key)
|
||||
const hasChecks = checks && Object.keys(checks).length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`rounded-lg border transition-colors overflow-hidden ${getCategoryRowStyle(status)}`}
|
||||
>
|
||||
{/* Clickable header row */}
|
||||
<div
|
||||
className="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 cursor-pointer select-none overflow-hidden"
|
||||
onClick={() => toggleCategory(key)}
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-1.5 sm:gap-2">
|
||||
<Icon className="h-4 w-4 text-blue-500 hidden sm:block" />
|
||||
{getStatusIcon(status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{label}</p>
|
||||
{hasChecks && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{reason && !isExpanded && (
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-0.5 line-clamp-2 break-words">{reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable checks section */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/50 bg-muted/5 px-1.5 sm:px-2 py-1.5 overflow-hidden">
|
||||
{reason && (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 mb-1">
|
||||
<p className="text-xs text-muted-foreground break-words whitespace-pre-wrap flex-1">{reason}</p>
|
||||
{/* Show dismiss button for UNKNOWN status at category level when dismissable */}
|
||||
{status === "UNKNOWN" && categoryData?.dismissable && !hasChecks && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
|
||||
disabled={dismissingKey === `category_${key}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAcknowledge(`category_${key}_unknown`, e)
|
||||
}}
|
||||
>
|
||||
{dismissingKey === `category_${key}` ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 sm:mr-0.5" />
|
||||
<span className="hidden sm:inline">Dismiss</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasChecks ? (
|
||||
renderChecks(checks, key)
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
No issues detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Dismissed Items Section -- hide items whose category has custom suppression */}
|
||||
{(() => {
|
||||
const customCats = new Set(customSuppressions.map(cs => cs.category))
|
||||
const filteredDismissed = dismissedItems.filter(item => !customCats.has(item.category))
|
||||
if (filteredDismissed.length === 0) return null
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
|
||||
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
Dismissed Items ({filteredDismissed.length})
|
||||
</div>
|
||||
{filteredDismissed.map((item) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
const CatIcon = catMeta?.Icon || BellOff
|
||||
const catLabel = catMeta?.label || item.category
|
||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.error_key}
|
||||
className="flex items-start gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border bg-muted/10 border-muted opacity-75"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 flex items-center gap-1.5 sm:gap-2">
|
||||
<CatIcon className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{item.reason}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{isPermanent ? (
|
||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
|
||||
Permanent
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
|
||||
Dismissed
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
|
||||
was {item.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{isPermanent
|
||||
? "Permanently suppressed"
|
||||
: `Suppressed for ${
|
||||
item.suppression_remaining_hours < 24
|
||||
? `${Math.round(item.suppression_remaining_hours)}h`
|
||||
: item.suppression_remaining_hours < 720
|
||||
? `${Math.round(item.suppression_remaining_hours / 24)} days`
|
||||
: `${Math.round(item.suppression_remaining_hours / 720)} month(s)`
|
||||
} more`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Custom Suppression Settings Summary */}
|
||||
{customSuppressions.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
Custom Suppression Settings
|
||||
</div>
|
||||
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
|
||||
<div className="space-y-1.5">
|
||||
{customSuppressions.map((cs) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label)
|
||||
const CatIcon = catMeta?.Icon || Settings2
|
||||
const durationLabel = cs.hours === -1
|
||||
? "Permanent"
|
||||
: cs.hours >= 8760
|
||||
? `${Math.floor(cs.hours / 8760)} year(s)`
|
||||
: cs.hours >= 720
|
||||
? `${Math.floor(cs.hours / 720)} month(s)`
|
||||
: cs.hours >= 168
|
||||
? `${Math.floor(cs.hours / 168)} week(s)`
|
||||
: cs.hours >= 72
|
||||
? `${Math.floor(cs.hours / 24)} days`
|
||||
: `${cs.hours}h`
|
||||
|
||||
return (
|
||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{cs.label}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||
{durationLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
|
||||
Alerts in these categories are auto-suppressed when detected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData.timestamp && (
|
||||
<div className="text-xs text-muted-foreground text-center pt-2">
|
||||
Last updated: {new Date(healthData.timestamp).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
1144
AppImage/components/latency-detail-modal.tsx
Normal file
258
AppImage/components/login.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import Image from "next/image"
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: LoginProps) {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [totpCode, setTotpCode] = useState("")
|
||||
const [requiresTotp, setRequiresTotp] = useState(false)
|
||||
const [rememberMe, setRememberMe] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const savedUsername = localStorage.getItem("proxmenux-saved-username")
|
||||
const savedPassword = localStorage.getItem("proxmenux-saved-password")
|
||||
|
||||
if (savedUsername && savedPassword) {
|
||||
setUsername(savedUsername)
|
||||
setPassword(savedPassword)
|
||||
setRememberMe(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please enter username and password")
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresTotp && !totpCode) {
|
||||
setError("Please enter your 2FA code")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/auth/login"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
totp_token: totpCode || undefined, // Include 2FA code if provided
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.requires_totp) {
|
||||
setRequiresTotp(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Login failed")
|
||||
}
|
||||
|
||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("proxmenux-saved-username", username)
|
||||
localStorage.setItem("proxmenux-saved-password", password)
|
||||
} else {
|
||||
localStorage.removeItem("proxmenux-saved-username")
|
||||
localStorage.removeItem("proxmenux-saved-password")
|
||||
}
|
||||
|
||||
onLogin()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-20 h-20 relative flex items-center justify-center bg-primary/10 rounded-lg">
|
||||
<Image
|
||||
src="/images/proxmenux-logo.png"
|
||||
alt="ProxMenux Logo"
|
||||
width={80}
|
||||
height={80}
|
||||
className="object-contain"
|
||||
priority
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.parentElement?.querySelector(".fallback-icon")
|
||||
if (fallback) {
|
||||
fallback.classList.remove("hidden")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Server className="h-12 w-12 text-primary absolute fallback-icon hidden" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
|
||||
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-lg p-6 shadow-lg">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!requiresTotp ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-username" className="text-sm">
|
||||
Username
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-password" className="text-sm">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 pr-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={loading}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember-me"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
|
||||
Remember me
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p>
|
||||
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code" className="text-sm">
|
||||
Authentication Code
|
||||
</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
className="text-center text-lg tracking-widest font-mono text-base"
|
||||
maxLength={6}
|
||||
disabled={loading}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
You can also use a backup code (format: XXXX-XXXX)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRequiresTotp(false)
|
||||
setTotpCode("")
|
||||
setError("")
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
857
AppImage/components/lxc-terminal-modal.tsx
Normal file
@@ -0,0 +1,857 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
GripHorizontal,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Send,
|
||||
Lightbulb,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DialogHeader, DialogDescription } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog"
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||
|
||||
interface LxcTerminalModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
vmid: number
|
||||
vmName: string
|
||||
}
|
||||
|
||||
interface CheatSheetResult {
|
||||
command: string
|
||||
description: string
|
||||
examples: string[]
|
||||
}
|
||||
|
||||
const proxmoxCommands = [
|
||||
{ cmd: "ls -la", desc: "List all files with details" },
|
||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
||||
{ cmd: "cat filename", desc: "Display file contents" },
|
||||
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
|
||||
{ cmd: "find . -name 'file'", desc: "Find files by name" },
|
||||
{ cmd: "df -h", desc: "Show disk usage" },
|
||||
{ cmd: "du -sh *", desc: "Show directory sizes" },
|
||||
{ cmd: "free -h", desc: "Show memory usage" },
|
||||
{ cmd: "top", desc: "Show running processes" },
|
||||
{ cmd: "ps aux | grep process", desc: "Find running process" },
|
||||
{ cmd: "systemctl status service", desc: "Check service status" },
|
||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
||||
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
|
||||
{ cmd: "apt install package", desc: "Install package" },
|
||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
|
||||
{ cmd: "chmod 755 file", desc: "Change file permissions" },
|
||||
{ cmd: "chown user:group file", desc: "Change file owner" },
|
||||
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
|
||||
{ cmd: "docker ps", desc: "List running containers" },
|
||||
{ cmd: "docker images", desc: "List Docker images" },
|
||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
||||
{ cmd: "history", desc: "Show command history" },
|
||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
||||
]
|
||||
|
||||
function getWebSocketUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "ws://localhost:8008/ws/terminal"
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
if (isStandardPort) {
|
||||
return `${wsProtocol}//${hostname}/ws/terminal`
|
||||
} else {
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal`
|
||||
}
|
||||
}
|
||||
|
||||
export function LxcTerminalModal({
|
||||
open: isOpen,
|
||||
onClose,
|
||||
vmid,
|
||||
vmName,
|
||||
}: LxcTerminalModalProps) {
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
const isInsideLxcRef = useRef(false)
|
||||
const outputBufferRef = useRef<string>("")
|
||||
|
||||
const [modalHeight, setModalHeight] = useState(500)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const resizeBarRef = useRef<HTMLDivElement>(null)
|
||||
const modalHeightRef = useRef(500)
|
||||
|
||||
// Search state
|
||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||
const [useOnline, setUseOnline] = useState(true)
|
||||
|
||||
|
||||
|
||||
// Detect mobile/tablet
|
||||
useEffect(() => {
|
||||
const checkDevice = () => {
|
||||
const width = window.innerWidth
|
||||
setIsMobile(width < 640)
|
||||
setIsTablet(width >= 640 && width < 1024)
|
||||
}
|
||||
checkDevice()
|
||||
window.addEventListener("resize", checkDevice)
|
||||
return () => window.removeEventListener("resize", checkDevice)
|
||||
}, [])
|
||||
|
||||
// Cleanup on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
pingIntervalRef.current = null
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
setConnectionStatus("connecting")
|
||||
isInsideLxcRef.current = false
|
||||
outputBufferRef.current = ""
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Initialize terminal
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
// Small delay to ensure Dialog content is rendered
|
||||
const initTimeout = setTimeout(() => {
|
||||
if (!terminalContainerRef.current) return
|
||||
initTerminal()
|
||||
}, 100)
|
||||
|
||||
const initTerminal = async () => {
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
])
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
const term = new TerminalClass({
|
||||
rendererType: "dom",
|
||||
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: true,
|
||||
scrollback: 2000,
|
||||
disableStdin: false,
|
||||
customGlyphs: true,
|
||||
fontWeight: "500",
|
||||
fontWeightBold: "700",
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#ffffff",
|
||||
cursorAccent: "#000000",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddonClass()
|
||||
term.loadAddon(fitAddon)
|
||||
|
||||
if (terminalContainerRef.current) {
|
||||
term.open(terminalContainerRef.current)
|
||||
fitAddon.fit()
|
||||
}
|
||||
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
// Connect WebSocket to host terminal
|
||||
const wsUrl = getWebSocketUrl()
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
// Reset state for new connection
|
||||
isInsideLxcRef.current = false
|
||||
outputBufferRef.current = ""
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("online")
|
||||
|
||||
// Start heartbeat ping
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} else {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, 25000)
|
||||
|
||||
// Sync terminal size
|
||||
fitAddon.fit()
|
||||
ws.send(JSON.stringify({
|
||||
type: "resize",
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}))
|
||||
|
||||
// Auto-execute pct enter after connection is ready
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`pct enter ${vmid}\r`)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnectionStatus("offline")
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
// Helper to strip ANSI escape codes for pattern matching
|
||||
const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
|
||||
// Buffer output until we detect we're inside the LXC
|
||||
// pct enter always enters directly without login prompt when run as root
|
||||
if (!isInsideLxcRef.current) {
|
||||
outputBufferRef.current += event.data
|
||||
|
||||
const buffer = outputBufferRef.current
|
||||
const cleanBuffer = stripAnsi(buffer)
|
||||
|
||||
// Look for pct enter command followed by a new prompt
|
||||
const pctEnterMatch = cleanBuffer.match(/pct enter (\d+)\r?\n/)
|
||||
|
||||
if (pctEnterMatch) {
|
||||
const afterPctEnter = cleanBuffer.substring(cleanBuffer.indexOf(pctEnterMatch[0]) + pctEnterMatch[0].length)
|
||||
|
||||
// Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd")
|
||||
const hostPromptMatch = cleanBuffer.match(/@([a-zA-Z0-9_-]+).*pct enter/)
|
||||
const hostName = hostPromptMatch ? hostPromptMatch[1] : null
|
||||
|
||||
// Look for a new prompt after pct enter that ends with # or $
|
||||
// This works for both bash (user@host:~#) and ash/Alpine ([user@host /]#)
|
||||
const promptMatch = afterPctEnter.match(/[@\[]([a-zA-Z0-9_-]+)[^\r\n]*[#$]\s*$/)
|
||||
|
||||
if (promptMatch) {
|
||||
const lxcHostname = promptMatch[1]
|
||||
|
||||
// If we found a prompt with a DIFFERENT hostname than the Proxmox host,
|
||||
// we're inside the LXC container
|
||||
if (!hostName || lxcHostname !== hostName) {
|
||||
isInsideLxcRef.current = true
|
||||
|
||||
// Find the original prompt with ANSI codes to display it properly
|
||||
const afterPctEnterWithAnsi = buffer.substring(buffer.indexOf('pct enter') + pctEnterMatch[0].length)
|
||||
|
||||
// Write the LXC prompt (last line with # or $)
|
||||
const lastPromptMatch = afterPctEnterWithAnsi.match(/[^\r\n]*[#$]\s*$/)
|
||||
if (lastPromptMatch) {
|
||||
term.write(lastPromptMatch[0])
|
||||
}
|
||||
|
||||
// Detect if this is Alpine/ash shell by checking prompt format
|
||||
// Alpine uses: [root@hostname ~]# or [root@hostname /]#
|
||||
// Other distros use: root@hostname:/# or root@hostname:~#
|
||||
const isAlpine = afterPctEnter.match(/\[[^\]]+@[^\]]+\s+[^\]]*\][#$]/)
|
||||
|
||||
if (isAlpine) {
|
||||
// Send an extra Enter ONLY for Alpine containers (ash shell)
|
||||
// This forces the prompt to refresh properly
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('\r')
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Already inside LXC, write directly
|
||||
term.write(event.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimeout)
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
}
|
||||
}
|
||||
}, [isOpen, vmid])
|
||||
|
||||
// Resize handling
|
||||
useEffect(() => {
|
||||
if (termRef.current && fitAddonRef.current && isOpen) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit()
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: "resize",
|
||||
cols: termRef.current.cols,
|
||||
rows: termRef.current.rows,
|
||||
}))
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [modalHeight, isOpen])
|
||||
|
||||
// Resize bar handlers
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
modalHeightRef.current = modalHeight
|
||||
}, [modalHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMove = (e: MouseEvent | TouchEvent) => {
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
|
||||
const windowHeight = window.innerHeight
|
||||
const newHeight = windowHeight - clientY - 20
|
||||
const clampedHeight = Math.max(300, Math.min(windowHeight - 100, newHeight))
|
||||
modalHeightRef.current = clampedHeight
|
||||
setModalHeight(clampedHeight)
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleEnd)
|
||||
document.addEventListener("touchmove", handleMove)
|
||||
document.addEventListener("touchend", handleEnd)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleEnd)
|
||||
document.removeEventListener("touchmove", handleMove)
|
||||
document.removeEventListener("touchend", handleEnd)
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
// Send key helpers for mobile/tablet
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(key)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendEsc = useCallback(() => sendKey("\x1b"), [sendKey])
|
||||
const sendTab = useCallback(() => sendKey("\t"), [sendKey])
|
||||
const sendArrowUp = useCallback(() => sendKey("\x1b[A"), [sendKey])
|
||||
const sendArrowDown = useCallback(() => sendKey("\x1b[B"), [sendKey])
|
||||
const sendArrowLeft = useCallback(() => sendKey("\x1b[D"), [sendKey])
|
||||
const sendArrowRight = useCallback(() => sendKey("\x1b[C"), [sendKey])
|
||||
const sendEnter = useCallback(() => sendKey("\r"), [sendKey])
|
||||
const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C
|
||||
|
||||
// Search effect - debounced search with cheat.sh
|
||||
useEffect(() => {
|
||||
const searchCheatSh = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true)
|
||||
const searchEndpoint = `/api/terminal/search-command?q=${encodeURIComponent(query)}`
|
||||
const data = await fetchApi<{ success: boolean; examples: any[] }>(searchEndpoint, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||
throw new Error("No examples found")
|
||||
}
|
||||
|
||||
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
||||
command: example.command,
|
||||
description: example.description || "",
|
||||
examples: [example.command],
|
||||
}))
|
||||
|
||||
setUseOnline(true)
|
||||
setSearchResults(formattedResults)
|
||||
} catch (error) {
|
||||
const filtered = proxmoxCommands.filter(
|
||||
(item) =>
|
||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
setFilteredCommands(filtered)
|
||||
setSearchResults([])
|
||||
setUseOnline(false)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const debounce = setTimeout(() => {
|
||||
if (searchQuery && searchQuery.length >= 2) {
|
||||
searchCheatSh(searchQuery)
|
||||
} else {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchQuery])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendToTerminal = useCallback((command: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(command)
|
||||
setTimeout(() => {
|
||||
setSearchModalOpen(false)
|
||||
}, 100)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showMobileControls = isMobile || isTablet
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className="max-w-4xl w-[95vw] p-0 gap-0 bg-black border-border overflow-hidden flex flex-col"
|
||||
style={{ height: `${modalHeight}px` }}
|
||||
hideClose
|
||||
>
|
||||
{/* Resize bar */}
|
||||
<div
|
||||
ref={resizeBarRef}
|
||||
className="h-3 w-full cursor-ns-resize flex items-center justify-center bg-zinc-900 hover:bg-zinc-800 transition-colors touch-none"
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
>
|
||||
<GripHorizontal className="h-4 w-4 text-zinc-500" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||
<DialogTitle className="text-sm font-medium text-white">
|
||||
Terminal: {vmName} (ID: {vmid})
|
||||
</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setSearchModalOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={connectionStatus !== "online"}
|
||||
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClear}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={connectionStatus !== "online"}
|
||||
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal container */}
|
||||
<div className="flex-1 overflow-hidden bg-black p-1">
|
||||
<div
|
||||
ref={terminalContainerRef}
|
||||
className="w-full h-full"
|
||||
style={{ minHeight: "200px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet control buttons */}
|
||||
{showMobileControls && (
|
||||
<div className="px-2 py-2 bg-zinc-900 border-t border-zinc-800">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendEsc}
|
||||
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
|
||||
>
|
||||
ESC
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendTab}
|
||||
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
|
||||
>
|
||||
TAB
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendArrowUp}
|
||||
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendArrowDown}
|
||||
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendArrowLeft}
|
||||
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendArrowRight}
|
||||
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendEnter}
|
||||
className="h-8 px-2 text-xs bg-blue-600/20 border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4 mr-1" />
|
||||
Enter
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300 gap-1"
|
||||
>
|
||||
Ctrl
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||
<span className="text-muted-foreground text-xs">Search history</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status bar at bottom */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-t border-zinc-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-blue-500" />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionStatus === "online"
|
||||
? "bg-green-500"
|
||||
: connectionStatus === "connecting"
|
||||
? "bg-yellow-500 animate-pulse"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Search Commands Modal */}
|
||||
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
||||
<SearchDialogTitle className="text-xl font-semibold">Search Commands</SearchDialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search commands... (e.g., tar, docker, systemctl)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSearching && (
|
||||
<div className="text-center py-4 text-zinc-400">
|
||||
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
||||
<p className="text-sm">Searching cheat.sh...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2 max-h-[50vh]">
|
||||
{searchResults.length > 0 ? (
|
||||
<>
|
||||
{searchResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
{result.description && (
|
||||
<p className="text-xs text-zinc-400 mb-2 leading-relaxed"># {result.description}</p>
|
||||
)}
|
||||
<div
|
||||
onClick={() => sendToTerminal(result.command)}
|
||||
className="flex items-start justify-between gap-2 cursor-pointer group hover:bg-zinc-800/50 rounded p-2 -m-2"
|
||||
>
|
||||
<code className="text-sm text-blue-400 font-mono break-all flex-1">{result.command}</code>
|
||||
<Send className="h-4 w-4 text-zinc-600 group-hover:text-blue-400 flex-shrink-0 mt-0.5 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-zinc-500">
|
||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||
Powered by cheat.sh
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : filteredCommands.length > 0 && !useOnline ? (
|
||||
filteredCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToTerminal(item.cmd)}
|
||||
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
|
||||
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
sendToTerminal(item.cmd)
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching && !searchQuery && !useOnline ? (
|
||||
proxmoxCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToTerminal(item.cmd)}
|
||||
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
|
||||
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
sendToTerminal(item.cmd)
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching ? (
|
||||
<div className="text-center py-12 space-y-4">
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<div>
|
||||
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<div>
|
||||
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
|
||||
<div className="text-sm text-zinc-500 space-y-1">
|
||||
<p>Try searching for:</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
||||
<code
|
||||
key={cmd}
|
||||
onClick={() => setSearchQuery(cmd)}
|
||||
className="px-2 py-1 bg-zinc-800 rounded text-blue-400 cursor-pointer hover:bg-zinc-700"
|
||||
>
|
||||
{cmd}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{useOnline && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Powered by cheat.sh</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Tip: Search for any Linux command</span>
|
||||
</div>
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
||||
</div>
|
||||
</div>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
489
AppImage/components/metrics-dialog.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ArrowLeft, Loader2 } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
interface MetricsViewProps {
|
||||
vmid: number
|
||||
vmName: string
|
||||
vmType: "qemu" | "lxc"
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ value: "year", label: "1 Year" },
|
||||
]
|
||||
|
||||
const CustomCPUTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomMemoryTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} GB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomDiskTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} MB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} MB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
|
||||
const [timeframe, setTimeframe] = useState("week")
|
||||
const [data, setData] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hiddenDiskLines, setHiddenDiskLines] = useState<string[]>([])
|
||||
const [hiddenNetworkLines, setHiddenNetworkLines] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics()
|
||||
}, [vmid, timeframe])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await fetchApi<any>(`/api/vms/${vmid}/metrics?timeframe=${timeframe}`)
|
||||
|
||||
const transformedData = result.data.map((item: any) => {
|
||||
const date = new Date(item.time * 1000)
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "month") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
|
||||
memory: item.mem ? Number(((item.mem / item.maxmem) * 100).toFixed(2)) : 0,
|
||||
memoryGB: item.mem ? Number((item.mem / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
maxMemoryGB: item.maxmem ? Number((item.maxmem / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
netin: item.netin ? Number((item.netin / 1024 / 1024).toFixed(2)) : 0,
|
||||
netout: item.netout ? Number((item.netout / 1024 / 1024).toFixed(2)) : 0,
|
||||
diskread: item.diskread ? Number((item.diskread / 1024 / 1024).toFixed(2)) : 0,
|
||||
diskwrite: item.diskwrite ? Number((item.diskwrite / 1024 / 1024).toFixed(2)) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
setData(transformedData)
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error loading metrics")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatXAxisTick = (tick: any) => {
|
||||
return tick
|
||||
}
|
||||
|
||||
const renderAllCharts = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<p className="text-red-500">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<p className="text-muted-foreground">No data available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tickInterval = Math.ceil(data.length / 8)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* CPU Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">CPU Usage</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
label={{ value: "%", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomCPUTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="CPU %"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Memory Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Memory Usage</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
label={{ value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomMemoryTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryGB"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Memory GB"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Disk I/O Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Disk I/O</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
label={{ value: "MB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomDiskTooltip />} />
|
||||
<Legend content={renderDiskLegend} verticalAlign="top" />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskread"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Read"
|
||||
hide={hiddenDiskLines.includes("diskread")}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskwrite"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Write"
|
||||
hide={hiddenDiskLines.includes("diskwrite")}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Network I/O Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Network I/O</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
label={{ value: "MB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomNetworkTooltip />} />
|
||||
<Legend content={renderNetworkLegend} verticalAlign="top" />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netin"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Download"
|
||||
hide={hiddenNetworkLines.includes("netin")}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netout"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Upload"
|
||||
hide={hiddenNetworkLines.includes("netout")}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleDiskLegendClick = (dataKey: string) => {
|
||||
setHiddenDiskLines((prev) => {
|
||||
if (prev.includes(dataKey)) {
|
||||
return prev.filter((key) => key !== dataKey)
|
||||
} else {
|
||||
return [...prev, dataKey]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleNetworkLegendClick = (dataKey: string) => {
|
||||
setHiddenNetworkLines((prev) => {
|
||||
if (prev.includes(dataKey)) {
|
||||
return prev.filter((key) => key !== dataKey)
|
||||
} else {
|
||||
return [...prev, dataKey]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderDiskLegend = (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div className="flex justify-center gap-6 pb-2">
|
||||
{payload.map((entry: any) => (
|
||||
<button
|
||||
key={entry.dataKey}
|
||||
onClick={() => handleDiskLegendClick(entry.dataKey)}
|
||||
className={`flex items-center gap-2 cursor-pointer transition-opacity hover:opacity-100 ${
|
||||
hiddenDiskLines.includes(entry.dataKey) ? "opacity-40" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-sm">{entry.value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderNetworkLegend = (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div className="flex justify-center gap-6 pb-2">
|
||||
{payload.map((entry: any) => (
|
||||
<button
|
||||
key={entry.dataKey}
|
||||
onClick={() => handleNetworkLegendClick(entry.dataKey)}
|
||||
className={`flex items-center gap-2 cursor-pointer transition-opacity hover:opacity-100 ${
|
||||
hiddenNetworkLines.includes(entry.dataKey) ? "opacity-40" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-sm">{entry.value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-[90vh]">
|
||||
{/* Fixed Header */}
|
||||
<div className="p-6 pb-4 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Metrics - {vmName}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
VMID: {vmid} • Type: {vmType.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content with all charts */}
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0">{renderAllCharts()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
AppImage/components/network-card.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Wifi, Zap } from 'lucide-react'
|
||||
import { useState, useEffect } from "react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface NetworkCardProps {
|
||||
interface_: {
|
||||
name: string
|
||||
type: string
|
||||
status: string
|
||||
speed: number
|
||||
duplex?: string
|
||||
mtu?: number
|
||||
mac_address: string | null
|
||||
addresses: Array<{
|
||||
ip: string
|
||||
netmask: string
|
||||
}>
|
||||
bytes_sent?: number
|
||||
bytes_recv?: number
|
||||
bridge_physical_interface?: string
|
||||
bridge_bond_slaves?: string[]
|
||||
vmid?: number
|
||||
vm_name?: string
|
||||
vm_type?: string
|
||||
}
|
||||
timeframe: "hour" | "day" | "week" | "month" | "year"
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const getInterfaceTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case "physical":
|
||||
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" }
|
||||
case "bridge":
|
||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" }
|
||||
case "bond":
|
||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" }
|
||||
case "vlan":
|
||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" }
|
||||
case "vm_lxc":
|
||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
||||
case "virtual":
|
||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
||||
default:
|
||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
||||
}
|
||||
}
|
||||
|
||||
const getVMTypeBadge = (vmType: string | undefined) => {
|
||||
if (vmType === "lxc") {
|
||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
||||
} else if (vmType === "vm") {
|
||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
|
||||
}
|
||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
||||
}
|
||||
|
||||
const formatSpeed = (speed: number): string => {
|
||||
if (speed === 0) return "N/A"
|
||||
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
||||
return `${speed} Mbps`
|
||||
}
|
||||
|
||||
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
|
||||
const typeBadge = getInterfaceTypeBadge(interface_.type)
|
||||
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null
|
||||
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
|
||||
|
||||
const [trafficData, setTrafficData] = useState<{ received: number; sent: number }>({
|
||||
received: 0,
|
||||
sent: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnitChange = () => {
|
||||
setNetworkUnit(getNetworkUnit())
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged", handleUnitChange)
|
||||
window.addEventListener("storage", handleUnitChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("networkUnitChanged", handleUnitChange)
|
||||
window.removeEventListener("storage", handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrafficData = async () => {
|
||||
try {
|
||||
const data = await fetchApi(`/api/network/${interface_.name}/metrics?timeframe=${timeframe}`)
|
||||
|
||||
if (data.data && data.data.length > 0) {
|
||||
const lastPoint = data.data[data.data.length - 1]
|
||||
const firstPoint = data.data[0]
|
||||
|
||||
const receivedGB = Math.max(0, (lastPoint.netin || 0) - (firstPoint.netin || 0))
|
||||
const sentGB = Math.max(0, (lastPoint.netout || 0) - (firstPoint.netout || 0))
|
||||
|
||||
setTrafficData({
|
||||
received: receivedGB,
|
||||
sent: sentGB,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch traffic data for card:", error)
|
||||
setTrafficData({ received: 0, sent: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
if (interface_.status.toLowerCase() === "up" && interface_.vm_type !== "vm") {
|
||||
fetchTrafficData()
|
||||
|
||||
const interval = setInterval(fetchTrafficData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [interface_.name, interface_.status, interface_.vm_type, timeframe])
|
||||
|
||||
const getTimeframeLabel = () => {
|
||||
switch (timeframe) {
|
||||
case "hour":
|
||||
return "Last Hour"
|
||||
case "day":
|
||||
return "Last 24 Hours"
|
||||
case "week":
|
||||
return "Last 7 Days"
|
||||
case "month":
|
||||
return "Last 30 Days"
|
||||
case "year":
|
||||
return "Last Year"
|
||||
default:
|
||||
return "Last 24 Hours"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border hover:bg-white/5 transition-colors cursor-pointer" onClick={onClick}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* First row: Icon, Name, Type Badge, Status */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Wifi className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 flex-wrap">
|
||||
<div className="font-medium text-foreground">{interface_.name}</div>
|
||||
{vmTypeBadge ? (
|
||||
<Badge variant="outline" className={vmTypeBadge.color}>
|
||||
{vmTypeBadge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className={typeBadge.color}>
|
||||
{typeBadge.label}
|
||||
</Badge>
|
||||
)}
|
||||
{interface_.vm_name && (
|
||||
<div className="text-sm text-muted-foreground truncate">→ {interface_.vm_name}</div>
|
||||
)}
|
||||
{interface_.type === "bridge" && interface_.bridge_physical_interface && (
|
||||
<div className="text-sm text-blue-500 font-medium flex items-center gap-1 flex-wrap break-all">
|
||||
→ {interface_.bridge_physical_interface}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
interface_.status === "up"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
>
|
||||
{interface_.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Second row: Details - Responsive layout */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{interface_.type === "vm_lxc" ? "VMID" : "IP Address"}
|
||||
</div>
|
||||
<div className="font-medium text-foreground font-mono text-sm truncate">
|
||||
{interface_.type === "vm_lxc"
|
||||
? (interface_.vmid ?? "N/A")
|
||||
: interface_.addresses.length > 0
|
||||
? interface_.addresses[0].ip
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Speed</div>
|
||||
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
|
||||
<Zap className="h-3 w-3" />
|
||||
{formatSpeed(interface_.speed)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="text-muted-foreground text-xs">{getTimeframeLabel()}</div>
|
||||
<div className="font-medium text-foreground text-xs">
|
||||
{interface_.status.toLowerCase() === "up" && interface_.vm_type !== "vm" ? (
|
||||
<>
|
||||
<span className="text-green-500">↓ {formatNetworkTraffic(trafficData.received * 1024 * 1024 * 1024, networkUnit)}</span>
|
||||
{" / "}
|
||||
<span className="text-blue-500">↑ {formatNetworkTraffic(trafficData.sent * 1024 * 1024 * 1024, networkUnit)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-green-500">↓ {formatNetworkTraffic(interface_.bytes_recv || 0, networkUnit)}</span>
|
||||
{" / "}
|
||||
<span className="text-blue-500">↑ {formatNetworkTraffic(interface_.bytes_sent || 0, networkUnit)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{interface_.mac_address && (
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="text-muted-foreground text-xs">MAC</div>
|
||||
<div className="font-medium text-foreground font-mono text-xs truncate">{interface_.mac_address}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
1162
AppImage/components/network-metrics.tsx
Normal file
330
AppImage/components/network-traffic-chart.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface NetworkMetricsData {
|
||||
time: string
|
||||
timestamp: number
|
||||
netIn: number
|
||||
netOut: number
|
||||
}
|
||||
|
||||
interface NetworkTrafficChartProps {
|
||||
timeframe: string
|
||||
interfaceName?: string
|
||||
onTotalsCalculated?: (totals: { received: number; sent: number }) => void
|
||||
refreshInterval?: number // En milisegundos, por defecto 60000 (60 segundos)
|
||||
networkUnit?: "Bytes" | "Bits" // Added networkUnit prop
|
||||
}
|
||||
|
||||
const CustomNetworkTooltip = ({ active, payload, label, networkUnit }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{entry.value.toFixed(3)} {networkUnit === "Bits" ? "Gb" : "GB"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function NetworkTrafficChart({
|
||||
timeframe,
|
||||
interfaceName,
|
||||
onTotalsCalculated,
|
||||
refreshInterval = 60000,
|
||||
networkUnit: networkUnitProp, // Rename prop to avoid conflict
|
||||
}: NetworkTrafficChartProps) {
|
||||
const [data, setData] = useState<NetworkMetricsData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||
const [visibleLines, setVisibleLines] = useState({
|
||||
netIn: true,
|
||||
netOut: true,
|
||||
})
|
||||
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(
|
||||
networkUnitProp || getNetworkUnit()
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnitChange = () => {
|
||||
const newUnit = getNetworkUnit()
|
||||
setNetworkUnit(newUnit)
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged", handleUnitChange)
|
||||
window.addEventListener("storage", handleUnitChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("networkUnitChanged", handleUnitChange)
|
||||
window.removeEventListener("storage", handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (networkUnitProp) {
|
||||
setNetworkUnit(networkUnitProp)
|
||||
}
|
||||
}, [networkUnitProp])
|
||||
|
||||
useEffect(() => {
|
||||
setIsInitialLoad(true)
|
||||
fetchMetrics()
|
||||
}, [timeframe, interfaceName, networkUnit])
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(() => {
|
||||
fetchMetrics()
|
||||
}, refreshInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [timeframe, interfaceName, refreshInterval, networkUnit]) // Added networkUnit to dependencies
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (isInitialLoad) {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const apiPath = interfaceName
|
||||
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
||||
: `/api/node/metrics?timeframe=${timeframe}`
|
||||
|
||||
console.log("[v0] Fetching network metrics from:", apiPath)
|
||||
|
||||
const result = await fetchApi<any>(apiPath)
|
||||
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
throw new Error("Invalid data format received from server")
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
setData([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const transformedData = result.data.map((item: any, index: number) => {
|
||||
const date = new Date(item.time * 1000)
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "year") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
let intervalSeconds = 60
|
||||
if (index > 0) {
|
||||
intervalSeconds = item.time - result.data[index - 1].time
|
||||
}
|
||||
|
||||
const netInBytes = (item.netin || 0) * intervalSeconds
|
||||
const netOutBytes = (item.netout || 0) * intervalSeconds
|
||||
|
||||
if (networkUnit === "Bits") {
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
|
||||
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
|
||||
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
|
||||
}
|
||||
})
|
||||
|
||||
setData(transformedData)
|
||||
|
||||
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
|
||||
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
|
||||
const netInBytes = (item.netin || 0) * intervalSeconds
|
||||
return sum + (netInBytes / 1024 / 1024 / 1024)
|
||||
}, 0)
|
||||
|
||||
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
|
||||
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
|
||||
const netOutBytes = (item.netout || 0) * intervalSeconds
|
||||
return sum + (netOutBytes / 1024 / 1024 / 1024)
|
||||
}, 0)
|
||||
|
||||
if (onTotalsCalculated) {
|
||||
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[v0] Error fetching network metrics:", err)
|
||||
setError(err.message || "Error loading metrics")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tickInterval = Math.ceil(data.length / 8)
|
||||
|
||||
const handleLegendClick = (dataKey: string) => {
|
||||
setVisibleLines((prev) => ({
|
||||
...prev,
|
||||
[dataKey as keyof typeof prev]: !prev[dataKey as keyof typeof prev],
|
||||
}))
|
||||
}
|
||||
|
||||
const renderLegend = (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div className="flex justify-center gap-4 pb-2 flex-wrap">
|
||||
{payload.map((entry: any, index: number) => {
|
||||
const isVisible = visibleLines[entry.dataKey as keyof typeof visibleLines]
|
||||
return (
|
||||
<div
|
||||
key={`legend-${index}`}
|
||||
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleLegendClick(entry.dataKey)}
|
||||
style={{ opacity: isVisible ? 1 : 0.4 }}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-sm text-foreground">{entry.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && isInitialLoad) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
||||
<p className="text-muted-foreground text-sm">Network metrics not available yet</p>
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<p className="text-muted-foreground text-sm">No network metrics available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={{
|
||||
value: networkUnit === "Bits" ? "Gb" : "GB", // Dynamic label based on unit
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: "currentColor",
|
||||
}}
|
||||
domain={[0, "auto"]}
|
||||
/>
|
||||
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netIn"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Received"
|
||||
hide={!visibleLines.netIn}
|
||||
isAnimationActive={true}
|
||||
animationDuration={300}
|
||||
animationEasing="ease-in-out"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netOut"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="Sent"
|
||||
hide={!visibleLines.netOut}
|
||||
isAnimationActive={true}
|
||||
animationDuration={300}
|
||||
animationEasing="ease-in-out"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
473
AppImage/components/node-metrics-charts.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
]
|
||||
|
||||
interface NodeMetricsData {
|
||||
time: string
|
||||
timestamp: number
|
||||
cpu: number
|
||||
load: number
|
||||
memoryTotal: number
|
||||
memoryUsed: number
|
||||
memoryFree: number
|
||||
memoryZfsArc: number
|
||||
}
|
||||
|
||||
const CustomCpuTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomMemoryTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} GB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function NodeMetricsCharts() {
|
||||
const [timeframe, setTimeframe] = useState("day")
|
||||
const [data, setData] = useState<NodeMetricsData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const [visibleLines, setVisibleLines] = useState({
|
||||
cpu: { cpu: true, load: true },
|
||||
memory: { memoryTotal: true, memoryUsed: true, memoryZfsArc: true, memoryFree: true },
|
||||
})
|
||||
|
||||
// Check if ZFS ARC or Free memory have any non-zero values to decide if we should show them
|
||||
const hasZfsArc = data.some(d => d.memoryZfsArc > 0)
|
||||
const hasMemoryFree = data.some(d => d.memoryFree > 0)
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[v0] NodeMetricsCharts component mounted")
|
||||
fetchMetrics()
|
||||
}, [timeframe])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
console.log("[v0] fetchMetrics called with timeframe:", timeframe)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
|
||||
|
||||
console.log("[v0] Node metrics result:", result)
|
||||
console.log("[v0] Result keys:", Object.keys(result))
|
||||
console.log("[v0] Data array length:", result.data?.length || 0)
|
||||
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
console.error("[v0] Invalid data format - data is not an array:", result)
|
||||
throw new Error("Invalid data format received from server")
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
console.warn("[v0] No data points received")
|
||||
setData([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[v0] First data point sample:", result.data[0])
|
||||
console.log("[v0] First data point loadavg field:", result.data[0]?.loadavg)
|
||||
console.log("[v0] loadavg type:", typeof result.data[0]?.loadavg)
|
||||
console.log("[v0] loadavg is array:", Array.isArray(result.data[0]?.loadavg))
|
||||
if (result.data[0]?.loadavg) {
|
||||
console.log("[v0] loadavg length:", result.data[0].loadavg.length)
|
||||
console.log("[v0] loadavg[0]:", result.data[0].loadavg[0])
|
||||
}
|
||||
|
||||
const transformedData = result.data.map((item: any) => {
|
||||
const date = new Date(item.time * 1000)
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
|
||||
load: item.loadavg
|
||||
? typeof item.loadavg === "number"
|
||||
? Number(item.loadavg.toFixed(2))
|
||||
: Array.isArray(item.loadavg) && item.loadavg.length > 0
|
||||
? Number(item.loadavg[0].toFixed(2))
|
||||
: 0
|
||||
: 0,
|
||||
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
setData(transformedData)
|
||||
} catch (err: any) {
|
||||
console.error("[v0] Error fetching node metrics:", err)
|
||||
console.error("[v0] Error message:", err.message)
|
||||
console.error("[v0] Error stack:", err.stack)
|
||||
setError(err.message || "Error loading metrics")
|
||||
} finally {
|
||||
console.log("[v0] fetchMetrics finally block - setting loading to false")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tickInterval = Math.ceil(data.length / 8)
|
||||
|
||||
const handleLegendClick = (chartType: "cpu" | "memory", dataKey: string) => {
|
||||
setVisibleLines((prev) => ({
|
||||
...prev,
|
||||
[chartType]: {
|
||||
...prev[chartType],
|
||||
[dataKey as keyof (typeof prev)[typeof chartType]]:
|
||||
!prev[chartType][dataKey as keyof (typeof prev)[typeof chartType]],
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const renderLegend = (chartType: "cpu" | "memory") => (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div className="flex justify-center gap-4 pb-2 flex-wrap">
|
||||
{payload.map((entry: any, index: number) => {
|
||||
// For memory chart, hide ZFS ARC and Free from legend if they have no data
|
||||
if (chartType === "memory") {
|
||||
if (entry.dataKey === "memoryZfsArc" && !hasZfsArc) return null
|
||||
if (entry.dataKey === "memoryFree" && !hasMemoryFree) return null
|
||||
}
|
||||
const isVisible = visibleLines[chartType][entry.dataKey as keyof (typeof visibleLines)[typeof chartType]]
|
||||
return (
|
||||
<div
|
||||
key={`legend-${index}`}
|
||||
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleLegendClick(chartType, entry.dataKey)}
|
||||
style={{ opacity: isVisible ? 1 : 0.4 }}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-sm text-foreground">{entry.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
console.log("[v0] Render state - loading:", loading, "error:", error, "data length:", data.length)
|
||||
|
||||
if (loading) {
|
||||
console.log("[v0] Rendering loading state")
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.log("[v0] Rendering error state:", error)
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
||||
<p className="text-muted-foreground text-sm">Metrics data not available yet</p>
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
||||
<p className="text-muted-foreground text-sm">Metrics data not available yet</p>
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
console.log("[v0] Rendering no data state")
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<p className="text-muted-foreground text-sm">No metrics data available</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<p className="text-muted-foreground text-sm">No metrics data available</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
console.log("[v0] Rendering charts with", data.length, "data points")
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Timeframe Selector */}
|
||||
<div className="flex justify-end">
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* CPU Usage + Load Average Chart */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="px-4 md:px-6">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<TrendingUp className="h-5 w-5 mr-2" />
|
||||
CPU Usage & Load Average
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "CPU %", angle: -90, position: "insideLeft", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomCpuTooltip />} />
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend("cpu")} />
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="CPU %"
|
||||
hide={!visibleLines.cpu.cpu}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="load"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Load Avg"
|
||||
hide={!visibleLines.cpu.load}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Usage Chart */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="px-4 md:px-6">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<MemoryStick className="h-5 w-5 mr-2" />
|
||||
Memory Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pr-2 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomMemoryTooltip />} />
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend("memory")} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryTotal"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.1}
|
||||
name="Total"
|
||||
hide={!visibleLines.memory.memoryTotal}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryUsed"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Used"
|
||||
hide={!visibleLines.memory.memoryUsed}
|
||||
/>
|
||||
{/* Only show ZFS ARC if there's data */}
|
||||
{hasZfsArc && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryZfsArc"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
fill="#f59e0b"
|
||||
fillOpacity={0.3}
|
||||
name="ZFS ARC"
|
||||
hide={!visibleLines.memory.memoryZfsArc}
|
||||
/>
|
||||
)}
|
||||
{/* Only show Free memory if there's data */}
|
||||
{hasMemoryFree && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryFree"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
fill="#06b6d4"
|
||||
fillOpacity={0.3}
|
||||
name="Free"
|
||||
hide={!visibleLines.memory.memoryFree}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2324
AppImage/components/notification-settings.tsx
Normal file
290
AppImage/components/onboarding-carousel.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
Sparkles,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
Network,
|
||||
Box,
|
||||
Cpu,
|
||||
FileText,
|
||||
Rocket,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
interface OnboardingSlide {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
image?: string
|
||||
icon: React.ReactNode
|
||||
gradient: string
|
||||
}
|
||||
|
||||
const slides: OnboardingSlide[] = [
|
||||
{
|
||||
id: 0,
|
||||
title: "Welcome to ProxMenux Monitor!",
|
||||
description:
|
||||
"Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.",
|
||||
icon: <Sparkles className="h-16 w-16" />,
|
||||
gradient: "from-blue-500 via-purple-500 to-pink-500",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "System Overview",
|
||||
description:
|
||||
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
|
||||
image: "/images/onboarding/imagen1.png",
|
||||
icon: <LayoutDashboard className="h-12 w-12" />,
|
||||
gradient: "from-blue-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Storage Management",
|
||||
description:
|
||||
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
|
||||
image: "/images/onboarding/imagen2.png",
|
||||
icon: <HardDrive className="h-12 w-12" />,
|
||||
gradient: "from-cyan-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Network Metrics",
|
||||
description:
|
||||
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
|
||||
image: "/images/onboarding/imagen3.png",
|
||||
icon: <Network className="h-12 w-12" />,
|
||||
gradient: "from-teal-500 to-green-500",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Virtual Machines & Containers",
|
||||
description:
|
||||
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
|
||||
image: "/images/onboarding/imagen4.png",
|
||||
icon: <Box className="h-12 w-12" />,
|
||||
gradient: "from-green-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Hardware Information",
|
||||
description:
|
||||
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
|
||||
image: "/images/onboarding/imagen5.png",
|
||||
icon: <Cpu className="h-12 w-12" />,
|
||||
gradient: "from-emerald-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "System Logs",
|
||||
description:
|
||||
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
|
||||
image: "/images/onboarding/imagen6.png",
|
||||
icon: <FileText className="h-12 w-12" />,
|
||||
gradient: "from-blue-500 to-indigo-500",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Ready for the Future!",
|
||||
description:
|
||||
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
|
||||
icon: <Rocket className="h-16 w-16" />,
|
||||
gradient: "from-indigo-500 via-purple-500 to-pink-500",
|
||||
},
|
||||
]
|
||||
|
||||
export function OnboardingCarousel() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [direction, setDirection] = useState<"next" | "prev">("next")
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const hasSeenOnboarding = localStorage.getItem("proxmenux-onboarding-seen")
|
||||
if (!hasSeenOnboarding) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentSlide < slides.length - 1) {
|
||||
setDirection("next")
|
||||
setCurrentSlide(currentSlide + 1)
|
||||
} else {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentSlide > 0) {
|
||||
setDirection("prev")
|
||||
setCurrentSlide(currentSlide - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleDotClick = (index: number) => {
|
||||
setDirection(index > currentSlide ? "next" : "prev")
|
||||
setCurrentSlide(index)
|
||||
}
|
||||
|
||||
const slide = slides[currentSlide]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
||||
<DialogTitle className="sr-only">ProxMenux Onboarding</DialogTitle>
|
||||
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={`relative h-48 md:h-64 bg-gradient-to-br ${slide.gradient} flex items-center justify-center overflow-hidden`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/10" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(255,255,255,0.1),transparent)]" />
|
||||
|
||||
<div className="relative z-10 text-white">
|
||||
{slide.image ? (
|
||||
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
||||
<Image
|
||||
src={slide.image || "/placeholder.svg"}
|
||||
alt={slide.title}
|
||||
width={600}
|
||||
height={400}
|
||||
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.parentElement?.querySelector(".fallback-icon")
|
||||
if (fallback) {
|
||||
fallback.classList.remove("hidden")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="fallback-icon hidden">{slide.icon}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-pulse">{slide.icon}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-2xl" />
|
||||
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
|
||||
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||
{slide.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 py-2 md:py-4">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDotClick(index)}
|
||||
className={`transition-all duration-300 rounded-full ${
|
||||
index === currentSlide
|
||||
? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50"
|
||||
: "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40"
|
||||
}`}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 md:gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handlePrev}
|
||||
disabled={currentSlide === 0}
|
||||
className="gap-2 w-full sm:w-auto text-sm"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
{currentSlide < slides.length - 1 ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSkip}
|
||||
className="flex-1 sm:flex-none bg-transparent text-sm"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
|
||||
>
|
||||
Get Started!
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 pt-2 pb-1">
|
||||
<Checkbox
|
||||
id="dont-show-again"
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(checked) => setDontShowAgain(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="dont-show-again"
|
||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||
>
|
||||
Don't show this again
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
805
AppImage/components/proxmox-dashboard.tsx
Normal file
@@ -0,0 +1,805 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
||||
import { SystemOverview } from "./system-overview"
|
||||
import { StorageOverview } from "./storage-overview"
|
||||
import { NetworkMetrics } from "./network-metrics"
|
||||
import { VirtualMachines } from "./virtual-machines"
|
||||
import Hardware from "./hardware"
|
||||
import { SystemLogs } from "./system-logs"
|
||||
import { Settings } from "./settings"
|
||||
import { Security } from "./security"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||
import { TerminalPanel } from "./terminal-panel"
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Server,
|
||||
Menu,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
NetworkIcon,
|
||||
Box,
|
||||
Cpu,
|
||||
FileText,
|
||||
SettingsIcon,
|
||||
Terminal,
|
||||
ShieldCheck,
|
||||
Info,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
|
||||
|
||||
interface SystemStatus {
|
||||
status: "healthy" | "warning" | "critical"
|
||||
uptime: string
|
||||
lastUpdate: string
|
||||
serverName: string
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
interface FlaskSystemData {
|
||||
hostname: string
|
||||
node_id: string
|
||||
uptime: string
|
||||
cpu_usage: number
|
||||
memory_usage: number
|
||||
temperature: number
|
||||
load_average: number[]
|
||||
}
|
||||
|
||||
interface FlaskSystemInfo {
|
||||
hostname: string
|
||||
node_id: string
|
||||
uptime: string
|
||||
health: {
|
||||
status: "healthy" | "warning" | "critical"
|
||||
}
|
||||
}
|
||||
|
||||
export function ProxmoxDashboard() {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||
status: "healthy",
|
||||
uptime: "Loading...",
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
serverName: "Loading...",
|
||||
nodeId: "Loading...",
|
||||
})
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [isServerConnected, setIsServerConnected] = useState(true)
|
||||
const [componentKey, setComponentKey] = useState(0)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
const [infoCount, setInfoCount] = useState(0)
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [showNavigation, setShowNavigation] = useState(true)
|
||||
const [lastScrollY, setLastScrollY] = useState(0)
|
||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
|
||||
|
||||
// Category keys for health info count calculation
|
||||
const HEALTH_CATEGORY_KEYS = [
|
||||
{ key: "cpu", category: "temperature" },
|
||||
{ key: "memory", category: "memory" },
|
||||
{ key: "storage", category: "storage" },
|
||||
{ key: "disks", category: "disks" },
|
||||
{ key: "network", category: "network" },
|
||||
{ key: "vms", category: "vms" },
|
||||
{ key: "services", category: "pve_services" },
|
||||
{ key: "logs", category: "logs" },
|
||||
{ key: "updates", category: "updates" },
|
||||
{ key: "security", category: "security" },
|
||||
]
|
||||
|
||||
// Fetch ProxMenux update status
|
||||
const fetchUpdateStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchApi("/api/proxmenux/update-status")
|
||||
if (response?.success && response?.update_available) {
|
||||
const { stable, beta } = response.update_available
|
||||
setUpdateAvailable(stable || beta)
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - updateAvailable will remain false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch health info count independently (for initial load and refresh)
|
||||
const fetchHealthInfoCount = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchApi("/api/health/full")
|
||||
let calculatedInfoCount = 0
|
||||
|
||||
if (response && response.health?.details) {
|
||||
// Get categories that have dismissed items (these become INFO)
|
||||
const customCats = new Set((response.custom_suppressions || []).map((cs: { category: string }) => cs.category))
|
||||
const filteredDismissed = (response.dismissed || []).filter((item: { category: string }) => !customCats.has(item.category))
|
||||
const categoriesWithDismissed = new Set<string>()
|
||||
filteredDismissed.forEach((item: { category: string }) => {
|
||||
const catMeta = HEALTH_CATEGORY_KEYS.find(c => c.category === item.category || c.key === item.category)
|
||||
if (catMeta) {
|
||||
categoriesWithDismissed.add(catMeta.key)
|
||||
}
|
||||
})
|
||||
|
||||
// Count effective INFO categories (original INFO + OK categories with dismissed)
|
||||
HEALTH_CATEGORY_KEYS.forEach(({ key }) => {
|
||||
const cat = response.health.details[key as keyof typeof response.health.details]
|
||||
if (cat) {
|
||||
const originalStatus = cat.status?.toUpperCase()
|
||||
// Count as INFO if: originally INFO OR (originally OK and has dismissed items)
|
||||
if (originalStatus === "INFO" || (originalStatus === "OK" && categoriesWithDismissed.has(key))) {
|
||||
calculatedInfoCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setInfoCount(calculatedInfoCount)
|
||||
} catch (error) {
|
||||
// Silently fail - infoCount will remain at 0
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSystemData = useCallback(async () => {
|
||||
try {
|
||||
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
||||
|
||||
const uptimeValue =
|
||||
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
|
||||
|
||||
const backendStatus = data.health?.status?.toUpperCase() || "OK"
|
||||
let healthStatus: "healthy" | "warning" | "critical"
|
||||
|
||||
if (backendStatus === "CRITICAL") {
|
||||
healthStatus = "critical"
|
||||
} else if (backendStatus === "WARNING") {
|
||||
healthStatus = "warning"
|
||||
} else {
|
||||
healthStatus = "healthy"
|
||||
}
|
||||
|
||||
setSystemStatus({
|
||||
status: healthStatus,
|
||||
uptime: uptimeValue,
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
serverName: data.hostname || "Unknown",
|
||||
nodeId: data.node_id || "Unknown",
|
||||
})
|
||||
setIsServerConnected(true)
|
||||
} catch (error) {
|
||||
// Expected to fail in v0 preview (no Flask server)
|
||||
|
||||
setIsServerConnected(false)
|
||||
setSystemStatus((prev) => ({
|
||||
...prev,
|
||||
status: "critical",
|
||||
serverName: "Server Offline",
|
||||
nodeId: "Server Offline",
|
||||
uptime: "N/A",
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Siempre fetch inicial
|
||||
fetchSystemData()
|
||||
fetchHealthInfoCount()
|
||||
fetchUpdateStatus()
|
||||
|
||||
// En overview: cada 30 segundos para actualización frecuente del estado de salud
|
||||
// En otras tabs: cada 60 segundos para reducir carga
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let healthInterval: ReturnType<typeof setInterval> | null = null
|
||||
if (activeTab === "overview") {
|
||||
interval = setInterval(fetchSystemData, 30000) // 30 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
|
||||
} else {
|
||||
interval = setInterval(fetchSystemData, 60000) // 60 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
if (healthInterval) clearInterval(healthInterval)
|
||||
}
|
||||
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChangeTab = (event: CustomEvent) => {
|
||||
const { tab } = event.detail
|
||||
if (tab) {
|
||||
setActiveTab(tab)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("changeTab", handleChangeTab as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener("changeTab", handleChangeTab as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-refresh terminal on mobile devices
|
||||
// This fixes the issue where terminal doesn't connect properly on mobile/VPN
|
||||
useEffect(() => {
|
||||
if (activeTab === "terminal") {
|
||||
const isMobileDevice = window.innerWidth < 768 ||
|
||||
('ontouchstart' in window && navigator.maxTouchPoints > 0)
|
||||
|
||||
if (isMobileDevice) {
|
||||
// Delay to allow initial connection attempt, then refresh to ensure proper connection
|
||||
const timeoutId = setTimeout(() => {
|
||||
setComponentKey(prev => prev + 1)
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const handleHealthStatusUpdate = (event: CustomEvent) => {
|
||||
const { status, infoCount: newInfoCount } = event.detail
|
||||
let healthStatus: "healthy" | "warning" | "critical"
|
||||
|
||||
if (status === "CRITICAL") {
|
||||
healthStatus = "critical"
|
||||
} else if (status === "WARNING") {
|
||||
healthStatus = "warning"
|
||||
} else {
|
||||
healthStatus = "healthy"
|
||||
}
|
||||
|
||||
setSystemStatus((prev) => ({
|
||||
...prev,
|
||||
status: healthStatus,
|
||||
}))
|
||||
|
||||
// Update info count (INFO categories + dismissed items)
|
||||
if (typeof newInfoCount === "number") {
|
||||
setInfoCount(newInfoCount)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
systemStatus.serverName &&
|
||||
systemStatus.serverName !== "Loading..." &&
|
||||
systemStatus.serverName !== "Server Offline"
|
||||
) {
|
||||
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
|
||||
} else {
|
||||
document.title = "ProxMenux Monitor"
|
||||
}
|
||||
}, [systemStatus.serverName])
|
||||
|
||||
useEffect(() => {
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let lastPosition = window.scrollY
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentScrollY = window.scrollY
|
||||
const delta = currentScrollY - lastPosition
|
||||
|
||||
if (currentScrollY < 50) {
|
||||
setShowNavigation(true)
|
||||
} else if (delta > 2) {
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
hideTimeout = setTimeout(() => setShowNavigation(false), 20)
|
||||
} else if (delta < -2) {
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
setShowNavigation(true)
|
||||
}
|
||||
|
||||
lastPosition = currentScrollY
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll)
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
await fetchSystemData()
|
||||
setComponentKey((prev) => prev + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
switch (systemStatus.status) {
|
||||
case "healthy":
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
case "critical":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />
|
||||
}
|
||||
}, [systemStatus.status])
|
||||
|
||||
const statusColor = useMemo(() => {
|
||||
switch (systemStatus.status) {
|
||||
case "healthy":
|
||||
return "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
case "warning":
|
||||
return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
case "critical":
|
||||
return "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
}, [systemStatus.status])
|
||||
|
||||
const getActiveTabLabel = () => {
|
||||
switch (activeTab) {
|
||||
case "overview":
|
||||
return "Overview"
|
||||
case "storage":
|
||||
return "Storage"
|
||||
case "network":
|
||||
return "Network"
|
||||
case "vms":
|
||||
return "VMs & LXCs"
|
||||
case "hardware":
|
||||
return "Hardware"
|
||||
case "terminal":
|
||||
return "Terminal"
|
||||
case "logs":
|
||||
return "System Logs"
|
||||
case "security":
|
||||
return "Security"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<OnboardingCarousel />
|
||||
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
|
||||
|
||||
{!isServerConnected && (
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
|
||||
<div className="container mx-auto">
|
||||
<div className="flex items-center space-x-2 text-red-500 mb-2">
|
||||
<XCircle className="h-5 w-5" />
|
||||
<span className="font-medium">ProxMenux Server Connection Failed</span>
|
||||
</div>
|
||||
<div className="text-sm text-red-500/80 space-y-1 ml-7">
|
||||
<p>• Check that the monitor.service is running correctly.</p>
|
||||
<p>• The ProxMenux server should start automatically on port 8008</p>
|
||||
<p>
|
||||
• Try accessing:{" "}
|
||||
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{getApiUrl("/api/health")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header
|
||||
className="border-b border-border bg-card sticky top-0 z-50 shadow-sm cursor-pointer hover:bg-accent/5 transition-colors"
|
||||
onClick={() => setShowHealthModal(true)}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 py-4 md:py-4">
|
||||
{/* Logo and Title */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Logo and Title */}
|
||||
<div className="flex items-center space-x-2 md:space-x-3 min-w-0">
|
||||
<div className="w-16 h-16 md:w-10 md:h-10 relative flex items-center justify-center bg-primary/10 flex-shrink-0">
|
||||
<Image
|
||||
src={updateAvailable ? "/images/proxmenux_update-logo.png" : "/images/proxmenux-logo.png"}
|
||||
alt="ProxMenux Logo"
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-contain md:w-10 md:h-10"
|
||||
priority
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.parentElement?.querySelector(".fallback-icon")
|
||||
if (fallback) {
|
||||
fallback.classList.remove("hidden")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">ProxMenux Monitor</h1>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">Proxmox System Dashboard</p>
|
||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Server className="h-3 w-3" />
|
||||
<span className="truncate">Node: {systemStatus.serverName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-foreground">Node: {systemStatus.serverName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="ml-1">{infoCount} info</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Uptime: {systemStatus.uptime || "N/A"}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="border-border/50 bg-transparent hover:bg-secondary"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex lg:hidden items-start gap-2 pt-2">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||
{statusIcon}
|
||||
</Badge>
|
||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="ml-1">{infoCount}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 w-8 p-0 -mt-1"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()} className="-mt-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Server Info */}
|
||||
<div className="lg:hidden mt-2 flex items-center justify-end text-xs text-muted-foreground">
|
||||
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime || "N/A"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`sticky z-40 bg-background
|
||||
top-[120px] lg:top-[76px]
|
||||
transition-all duration-700 ease-in-out
|
||||
${showNavigation ? "translate-y-0 opacity-100" : "-translate-y-[120%] opacity-0 pointer-events-none"}
|
||||
`}
|
||||
>
|
||||
<div className="container mx-auto px-4 lg:px-6 pt-4 lg:pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-9 bg-card border border-border">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="storage"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="network"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Network
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="vms"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
VMs & LXCs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="hardware"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Hardware
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="logs"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
System Logs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Security
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<div className="lg:hidden">
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`w-full justify-between border-border ${
|
||||
activeTab ? "bg-blue-500/10 text-blue-500" : "bg-card"
|
||||
}`}
|
||||
>
|
||||
<span>{getActiveTabLabel()}</span>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</div>
|
||||
<SheetContent side="top" className="bg-card border-border">
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("overview")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "overview"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("storage")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "storage"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("network")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "network"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<NetworkIcon className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("vms")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "vms"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Box className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("hardware")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "hardware"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("logs")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "logs"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("terminal")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "terminal"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("security")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "security"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<span>Security</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("settings")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "settings"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6">
|
||||
<TabsContent value="overview" className="space-y-4 md:space-y-6 mt-0">
|
||||
<SystemOverview key={`overview-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0">
|
||||
<StorageOverview key={`storage-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="space-y-4 md:space-y-6 mt-0">
|
||||
<NetworkMetrics key={`network-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vms" className="space-y-4 md:space-y-6 mt-0">
|
||||
<VirtualMachines key={`vms-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Hardware key={`hardware-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Security key={`security-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Settings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.0</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
|
||||
>
|
||||
Support and contribute to the project
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
227
AppImage/components/release-notes-modal.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Thermometer, Terminal, Activity, HardDrive, Bell, Shield, Globe, Cpu, Zap } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.2.0" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
changes: {
|
||||
added?: string[]
|
||||
changed?: string[]
|
||||
fixed?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.1.2-beta": {
|
||||
date: "March 18, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Temperature & Latency Charts - Real-time visual monitoring with interactive graphs",
|
||||
"WebSocket Terminal - Direct access to Proxmox host and LXC containers terminal",
|
||||
"AI-Enhanced Notifications - Intelligent message formatting with multi-provider support (OpenAI, Groq, Anthropic, Ollama)",
|
||||
"Security Section - Comprehensive security settings for ProxMenux and Proxmox",
|
||||
"VPN Integration - Easy Tailscale VPN installation and configuration",
|
||||
"GPU Scripts - Installation utilities for Intel, AMD and NVIDIA drivers",
|
||||
"Disk Observations System - Track and document disk health observations over time",
|
||||
"Enhanced Health Monitor - Configurable monitoring with advanced settings panel",
|
||||
],
|
||||
changed: [
|
||||
"Improved overall performance with optimized data fetching",
|
||||
"Notifications now support rich formatting with contextual emojis",
|
||||
"Health monitor now configurable from Settings section",
|
||||
"Better Proxmox service name translation for non-expert users",
|
||||
],
|
||||
fixed: [
|
||||
"Fixed notification message truncation for large backup reports",
|
||||
"Improved disk error deduplication to prevent repeated alerts",
|
||||
"Corrected AI provider base URL handling for OpenAI-compatible APIs",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.0.1": {
|
||||
date: "November 11, 2025",
|
||||
changes: {
|
||||
added: [
|
||||
"Proxy Support - Access ProxMenux through reverse proxies with full functionality",
|
||||
"Authentication System - Secure your dashboard with password protection",
|
||||
"PCIe Link Speed Detection - View NVMe drive connection speeds and detect performance issues",
|
||||
"Two-Factor Authentication (2FA) - Enhanced security with TOTP support",
|
||||
"Health Monitoring System - Comprehensive system health checks with dismissible warnings",
|
||||
],
|
||||
changed: [
|
||||
"Optimized VM & LXC page - Reduced CPU usage by 85% through intelligent caching",
|
||||
"Storage metrics now separate local and remote storage for clarity",
|
||||
],
|
||||
fixed: [
|
||||
"Fixed dark mode text contrast issues in various components",
|
||||
"Corrected storage calculation discrepancies between Overview and Storage pages",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.0.0": {
|
||||
date: "October 15, 2025",
|
||||
changes: {
|
||||
added: [
|
||||
"Initial release of ProxMenux Monitor",
|
||||
"Real-time system monitoring dashboard",
|
||||
"Storage management with SMART health monitoring",
|
||||
"Network metrics and bandwidth tracking",
|
||||
"VM & LXC container management",
|
||||
"Hardware information display",
|
||||
"System logs viewer with filtering",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Thermometer className="h-5 w-5" />,
|
||||
text: "Temperature & Latency Charts - Real-time visual monitoring with interactive historical graphs",
|
||||
},
|
||||
{
|
||||
icon: <Terminal className="h-5 w-5" />,
|
||||
text: "WebSocket Terminal - Direct terminal access to Proxmox host and LXC containers from the browser",
|
||||
},
|
||||
{
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
text: "Enhanced Health Monitor - Configurable health monitoring with advanced settings and disk observations",
|
||||
},
|
||||
{
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
text: "AI-Enhanced Notifications - Intelligent message formatting with support for OpenAI, Groq, Anthropic and Ollama",
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
text: "Security Section - Comprehensive security configuration for both ProxMenux and Proxmox systems",
|
||||
},
|
||||
{
|
||||
icon: <Globe className="h-5 w-5" />,
|
||||
text: "VPN Integration - Easy Tailscale VPN installation and configuration for secure remote access",
|
||||
},
|
||||
{
|
||||
icon: <Cpu className="h-5 w-5" />,
|
||||
text: "GPU Drivers - Installation scripts for Intel, AMD and NVIDIA graphics drivers and utilities",
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
text: "Performance Improvements - Optimized data fetching and reduced resource consumption",
|
||||
},
|
||||
]
|
||||
|
||||
interface ReleaseNotesModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-last-seen-version", APP_VERSION)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
|
||||
<DialogTitle className="sr-only">Release Notes - Version {APP_VERSION}</DialogTitle>
|
||||
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="relative h-32 md:h-40 bg-gradient-to-br from-amber-500 via-orange-500 to-red-500 flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-black/10" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(255,255,255,0.1),transparent)]" />
|
||||
|
||||
<div className="relative z-10 text-white animate-pulse">
|
||||
<Sparkles className="h-12 w-12 md:h-14 md:w-14" />
|
||||
</div>
|
||||
|
||||
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-2xl" />
|
||||
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-4 md:space-y-6 min-h-0">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-foreground text-balance">
|
||||
What's New in Version {APP_VERSION}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
We've added exciting new features and improvements to make ProxMenux Monitor even better!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{CURRENT_VERSION_FEATURES.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 md:gap-3 p-3 rounded-lg bg-muted/50 border border-border/50 hover:bg-muted/70 transition-colors"
|
||||
>
|
||||
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
||||
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 p-6 md:p-8 pt-4 border-t border-border/50 bg-card">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Got it!
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Checkbox
|
||||
id="dont-show-version-again"
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(checked) => setDontShowAgain(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="dont-show-version-again"
|
||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||
>
|
||||
Don't show again for this version
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function useVersionCheck() {
|
||||
const [showReleaseNotes, setShowReleaseNotes] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const lastSeenVersion = localStorage.getItem("proxmenux-last-seen-version")
|
||||
|
||||
if (lastSeenVersion !== APP_VERSION) {
|
||||
setShowReleaseNotes(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { showReleaseNotes, setShowReleaseNotes }
|
||||
}
|
||||
|
||||
export { APP_VERSION }
|
||||
950
AppImage/components/script-terminal-modal.tsx
Normal file
@@ -0,0 +1,950 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Loader2,
|
||||
Activity,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
GripHorizontal,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
|
||||
interface WebInteraction {
|
||||
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
||||
id: string
|
||||
title: string
|
||||
message: string
|
||||
options?: Array<{ label: string; value: string }>
|
||||
default?: string
|
||||
}
|
||||
|
||||
interface ScriptTerminalModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
scriptPath: string
|
||||
title: string
|
||||
description: string
|
||||
scriptName?: string
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
export function ScriptTerminalModal({
|
||||
open: isOpen,
|
||||
onClose,
|
||||
scriptPath,
|
||||
title,
|
||||
description,
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
}: ScriptTerminalModalProps) {
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [currentInteraction, setCurrentInteraction] = useState<WebInteraction | null>(null)
|
||||
const [interactionInput, setInteractionInput] = useState("")
|
||||
const checkConnectionInterval = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
|
||||
const [isWaitingNextInteraction, setIsWaitingNextInteraction] = useState(false)
|
||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const [modalHeight, setModalHeight] = useState(600)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const resizeBarRef = useRef<HTMLDivElement>(null)
|
||||
const modalHeightRef = useRef(600)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
const paramsRef = useRef(params)
|
||||
|
||||
// Keep paramsRef updated with latest params
|
||||
useEffect(() => {
|
||||
paramsRef.current = params
|
||||
}, [params])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current++
|
||||
setConnectionStatus("connecting")
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("online")
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
}
|
||||
keepAliveIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }))
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
const cols = termRef.current.cols
|
||||
const rows = termRef.current.rows
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }))
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses from heartbeat
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
setCurrentInteraction({
|
||||
type: msg.interaction.type,
|
||||
id: msg.interaction.id,
|
||||
title: msg.interaction.title || "",
|
||||
message: msg.interaction.message || "",
|
||||
options: msg.interaction.options,
|
||||
default: msg.interaction.default,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (msg.type === "error") {
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
termRef.current?.write(event.data)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnectionStatus("offline")
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
escape: "\x1b",
|
||||
tab: "\t",
|
||||
up: "\x1bOA",
|
||||
down: "\x1bOB",
|
||||
left: "\x1bOD",
|
||||
right: "\x1bOC",
|
||||
enter: "\r",
|
||||
ctrlc: "\x03",
|
||||
}
|
||||
|
||||
const sequence = keyMap[key]
|
||||
if (sequence && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(sequence)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const initializeTerminal = async () => {
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
import("xterm/css/xterm.css"),
|
||||
])
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
const term = new TerminalClass({
|
||||
rendererType: "dom",
|
||||
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: true,
|
||||
scrollback: 2000,
|
||||
disableStdin: false,
|
||||
customGlyphs: true,
|
||||
fontWeight: "500",
|
||||
fontWeightBold: "700",
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#ffffff",
|
||||
cursorAccent: "#000000",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddonClass()
|
||||
term.loadAddon(fitAddon)
|
||||
if (terminalContainerRef.current) {
|
||||
term.open(terminalContainerRef.current)
|
||||
}
|
||||
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current) {
|
||||
fitAddonRef.current.fit()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("online")
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
}
|
||||
keepAliveIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }))
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
const cols = termRef.current.cols
|
||||
const rows = termRef.current.rows
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
setCurrentInteraction({
|
||||
type: msg.interaction.type,
|
||||
id: msg.interaction.id,
|
||||
title: msg.interaction.title || "",
|
||||
message: msg.interaction.message || "",
|
||||
options: msg.interaction.options,
|
||||
default: msg.interaction.default,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, es output normal de terminal
|
||||
}
|
||||
|
||||
term.write(event.data)
|
||||
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[33mConnection closed\x1b[0m")
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
setIsComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
checkConnectionInterval.current = setInterval(() => {
|
||||
if (wsRef.current) {
|
||||
setConnectionStatus(
|
||||
wsRef.current.readyState === WebSocket.OPEN
|
||||
? "online"
|
||||
: wsRef.current.readyState === WebSocket.CONNECTING
|
||||
? "connecting"
|
||||
: "offline",
|
||||
)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout)
|
||||
resizeTimeout = setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
fitAddonRef.current.fit()
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: termRef.current.cols,
|
||||
rows: termRef.current.rows,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
|
||||
if (terminalContainerRef.current) {
|
||||
resizeObserver.observe(terminalContainerRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const savedHeight = localStorage.getItem("scriptModalHeight")
|
||||
if (savedHeight) {
|
||||
const height = Number.parseInt(savedHeight, 10)
|
||||
setModalHeight(height)
|
||||
modalHeightRef.current = height
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
|
||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||
reconnectAttemptsRef.current = 0
|
||||
setIsComplete(false)
|
||||
setInteractionInput("")
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
setConnectionStatus("connecting")
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
const width = window.innerWidth
|
||||
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0
|
||||
const isTabletSize = width >= 768 && width <= 1366
|
||||
|
||||
setIsMobile(width < 768)
|
||||
setIsTablet(isTouchDevice && isTabletSize)
|
||||
}
|
||||
|
||||
updateDeviceType()
|
||||
const handleResize = () => updateDeviceType()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden && isOpen) {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (isOpen && wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
let wakeLock: any = null
|
||||
const requestWakeLock = async () => {
|
||||
if ("wakeLock" in navigator && isOpen) {
|
||||
try {
|
||||
wakeLock = await (navigator as any).wakeLock.request("screen")
|
||||
} catch (err) {
|
||||
// Wake Lock no soportado o denegado, continuar sin él
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestWakeLock()
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.addEventListener("focus", handleFocus)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.removeEventListener("focus", handleFocus)
|
||||
if (wakeLock) {
|
||||
wakeLock.release().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [isOpen, isComplete, attemptReconnect])
|
||||
|
||||
const getScriptWebSocketUrl = (sid: string): string => {
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://localhost:${API_PORT}/ws/script/${sid}`
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
if (isStandardPort) {
|
||||
return `${wsProtocol}//${hostname}/ws/script/${sid}`
|
||||
} else {
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleInteractionResponse = (value: string) => {
|
||||
if (!wsRef.current || !currentInteraction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value === "cancel" || value === "") {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
handleCloseModal()
|
||||
return
|
||||
}
|
||||
|
||||
const response = JSON.stringify({
|
||||
type: "interaction_response",
|
||||
id: currentInteraction.id,
|
||||
value: value,
|
||||
})
|
||||
|
||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(response)
|
||||
}
|
||||
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
|
||||
waitingTimeoutRef.current = setTimeout(() => {
|
||||
setIsWaitingNextInteraction(true)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setIsResizing(true)
|
||||
|
||||
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY
|
||||
const startY = clientY
|
||||
const startHeight = modalHeight
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||||
const currentY = "touches" in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY
|
||||
const deltaY = currentY - startY
|
||||
const newHeight = Math.max(300, Math.min(window.innerHeight - 50, startHeight + deltaY))
|
||||
|
||||
modalHeightRef.current = newHeight
|
||||
setModalHeight(newHeight)
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
const finalHeight = modalHeightRef.current
|
||||
setIsResizing(false)
|
||||
|
||||
document.removeEventListener("mousemove", handleMove as any)
|
||||
document.removeEventListener("mouseup", handleEnd)
|
||||
document.removeEventListener("touchmove", handleMove as any)
|
||||
document.removeEventListener("touchend", handleEnd)
|
||||
document.removeEventListener("touchcancel", handleEnd)
|
||||
|
||||
localStorage.setItem("scriptModalHeight", finalHeight.toString())
|
||||
|
||||
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit()
|
||||
wsRef.current?.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: termRef.current.cols,
|
||||
rows: termRef.current.rows,
|
||||
}),
|
||||
)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove as any)
|
||||
document.addEventListener("mouseup", handleEnd)
|
||||
document.addEventListener("touchmove", handleMove as any, { passive: false })
|
||||
document.addEventListener("touchend", handleEnd)
|
||||
document.addEventListener("touchcancel", handleEnd)
|
||||
}
|
||||
|
||||
const sendCommand = (command: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(command)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
|
||||
style={{
|
||||
height: isMobile ? "80vh" : `${modalHeight}px`,
|
||||
maxHeight: "none",
|
||||
}}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
hideClose
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
|
||||
<div className="flex items-center gap-2 p-4 border-b">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden relative flex-1">
|
||||
<div ref={terminalContainerRef} className="w-full h-full" />
|
||||
|
||||
{isWaitingNextInteraction && !currentInteraction && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-muted-foreground">Processing...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div
|
||||
ref={resizeBarRef}
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
className={`h-4 w-full cursor-row-resize transition-colors flex items-center justify-center group relative ${
|
||||
isResizing ? "bg-blue-500" : "bg-zinc-800 hover:bg-blue-600"
|
||||
}`}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
<GripHorizontal
|
||||
className={`h-5 w-5 transition-colors pointer-events-none ${
|
||||
isResizing ? "text-white" : "text-zinc-600 group-hover:text-white"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isMobile || isTablet) && (
|
||||
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1b")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
|
||||
>
|
||||
ESC
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\t")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
|
||||
>
|
||||
TAB
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOA")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOB")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOD")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOC")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\r")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4 mr-1" />
|
||||
Enter
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[65px] gap-1"
|
||||
>
|
||||
Ctrl
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||
<span className="text-muted-foreground text-xs">Search history</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-blue-500" />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionStatus === "online"
|
||||
? "bg-green-500"
|
||||
: connectionStatus === "connecting"
|
||||
? "bg-blue-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
title={
|
||||
connectionStatus === "online"
|
||||
? "Connected"
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting"
|
||||
: "Disconnected"
|
||||
}
|
||||
></div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connectionStatus === "online"
|
||||
? "Online"
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting..."
|
||||
: "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
variant="outline"
|
||||
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{currentInteraction && (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
className="max-w-4xl max-h-[80vh] overflow-y-auto animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
hideClose
|
||||
>
|
||||
<DialogTitle>{currentInteraction.title}</DialogTitle>
|
||||
<div className="space-y-4">
|
||||
<p
|
||||
className="whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: currentInteraction.message.replace(/\\n/g, "<br/>").replace(/\n/g, "<br/>"),
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentInteraction.type === "yesno" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("yes")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentInteraction.type === "menu" && currentInteraction.options && (
|
||||
<div className="space-y-2">
|
||||
{currentInteraction.options.map((option, index) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
onClick={() => handleInteractionResponse(option.value)}
|
||||
variant="outline"
|
||||
className="w-full justify-start hover:bg-blue-600 hover:text-white transition-all duration-100 animate-in fade-in-0 slide-in-from-left-2"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
||||
<div className="space-y-2">
|
||||
<Label>Your input:</Label>
|
||||
<Input
|
||||
value={interactionInput}
|
||||
onChange={(e) => setInteractionInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleInteractionResponse(interactionInput)
|
||||
}
|
||||
}}
|
||||
placeholder={currentInteraction.default || ""}
|
||||
className="transition-all duration-150"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse(interactionInput)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentInteraction.type === "msgbox" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("ok")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
1302
AppImage/components/secure-gateway-setup.tsx
Normal file
4234
AppImage/components/security.tsx
Normal file
1156
AppImage/components/settings.tsx
Normal file
125
AppImage/components/sidebar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
{ name: "Storage", href: "/storage", icon: HardDrive },
|
||||
{ name: "Network", href: "/network", icon: Network },
|
||||
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
|
||||
{ name: "Hardware", href: "/hardware", icon: Cpu },
|
||||
{ name: "System Logs", href: "/logs", icon: FileText },
|
||||
{ name: "Terminal", href: "/terminal", icon: Terminal },
|
||||
{ name: "Settings", href: "/settings", icon: SettingsIcon },
|
||||
]
|
||||
|
||||
const Sidebar = ({ currentPath, setOpen }) => {
|
||||
const handleNavigation = (tabName: string) => {
|
||||
// Dispatch custom event to change tab in dashboard
|
||||
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
|
||||
window.dispatchEvent(event)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => handleNavigation("overview")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/" || currentPath === "/overview"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("storage")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/storage"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("network")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/network"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Network className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("vms")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/virtual-machines"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Server className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("hardware")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/hardware"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("logs")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/logs"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("terminal")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/terminal"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("settings")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/settings"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
238
AppImage/components/storage-metrics.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { HardDrive, Database, Archive, AlertTriangle, CheckCircle, Activity, AlertCircle } from "lucide-react"
|
||||
import { formatStorage } from "@/lib/utils"
|
||||
|
||||
interface StorageData {
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
disks: DiskInfo[]
|
||||
}
|
||||
|
||||
interface DiskInfo {
|
||||
name: string
|
||||
mountpoint: string
|
||||
fstype: string
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
usage_percent: number
|
||||
health: string
|
||||
temperature: number
|
||||
}
|
||||
|
||||
const fetchStorageData = async (): Promise<StorageData | null> => {
|
||||
try {
|
||||
console.log("[v0] Fetching storage data from Flask server...")
|
||||
const response = await fetch("/api/storage", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Flask server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log("[v0] Successfully fetched storage data from Flask:", data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch storage data from Flask server:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageMetrics() {
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const result = await fetchStorageData()
|
||||
|
||||
if (!result) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
} else {
|
||||
setStorageData(result)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
const interval = setInterval(fetchData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg font-medium text-foreground mb-2">Loading storage data...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !storageData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-red-500/10 border-red-500/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<div>
|
||||
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
|
||||
<div className="text-sm">
|
||||
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const usagePercent = storageData.total > 0 ? (storageData.used / storageData.total) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Overview Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Storage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.total)}</div>
|
||||
<Progress value={usagePercent} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatStorage(storageData.used)} used • {formatStorage(storageData.available)} available
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Used Storage</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.used)}</div>
|
||||
<Progress value={usagePercent} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-2">{usagePercent.toFixed(1)}% of total space</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Archive className="h-5 w-5 mr-2" />
|
||||
Available
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.available)}</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{((storageData.available / storageData.total) * 100).toFixed(1)}% Free
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Available space</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
Disks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.disks.length}</div>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{storageData.disks.filter((d) => d.health === "healthy").length} Healthy
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Storage devices</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Disk Details */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Database className="h-5 w-5 mr-2" />
|
||||
Storage Devices
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.disks.map((disk, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-border bg-card/50"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{disk.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{disk.fstype} • {disk.mountpoint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{formatStorage(disk.used)} / {formatStorage(disk.total)}
|
||||
</div>
|
||||
<Progress value={disk.usage_percent} className="w-24 mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-muted-foreground">Temp</div>
|
||||
<div className="text-sm font-medium text-foreground">{disk.temperature}°C</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
disk.health === "healthy"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
}
|
||||
>
|
||||
{disk.health === "healthy" ? (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{disk.health}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4037
AppImage/components/storage-overview.tsx
Normal file
1277
AppImage/components/system-logs.tsx
Normal file
838
AppImage/components/system-overview.tsx
Normal file
@@ -0,0 +1,838 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
||||
|
||||
interface TempDataPoint {
|
||||
timestamp: number
|
||||
value: number
|
||||
}
|
||||
|
||||
interface SystemData {
|
||||
cpu_usage: number
|
||||
memory_usage: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
temperature: number
|
||||
temperature_sparkline?: TempDataPoint[]
|
||||
uptime: string
|
||||
load_average: number[]
|
||||
hostname: string
|
||||
node_id: string
|
||||
timestamp: string
|
||||
cpu_cores?: number
|
||||
cpu_threads?: number
|
||||
proxmox_version?: string
|
||||
kernel_version?: string
|
||||
available_updates?: number
|
||||
}
|
||||
|
||||
interface VMData {
|
||||
vmid: number
|
||||
name: string
|
||||
status: string
|
||||
cpu: number
|
||||
mem: number
|
||||
maxmem: number
|
||||
disk: number
|
||||
maxdisk: number
|
||||
uptime: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface StorageData {
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
disk_count: number
|
||||
disks: Array<{
|
||||
name: string
|
||||
mountpoint: string
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
usage_percent: number
|
||||
}>
|
||||
}
|
||||
|
||||
interface NetworkData {
|
||||
interfaces: Array<{
|
||||
name: string
|
||||
status: string
|
||||
addresses: Array<{ ip: string; netmask: string }>
|
||||
}>
|
||||
traffic: {
|
||||
bytes_sent: number
|
||||
bytes_recv: number
|
||||
packets_sent: number
|
||||
packets_recv: number
|
||||
}
|
||||
physical_active_count?: number
|
||||
physical_total_count?: number
|
||||
bridge_active_count?: number
|
||||
bridge_total_count?: number
|
||||
physical_interfaces?: Array<{
|
||||
name: string
|
||||
status: string
|
||||
addresses: Array<{ ip: string; netmask: string }>
|
||||
}>
|
||||
bridge_interfaces?: Array<{
|
||||
name: string
|
||||
status: string
|
||||
addresses: Array<{ ip: string; netmask: string }>
|
||||
}>
|
||||
}
|
||||
|
||||
interface ProxmoxStorageData {
|
||||
storage: Array<{
|
||||
name: string
|
||||
type: string
|
||||
status: string
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
percent: number
|
||||
}>
|
||||
}
|
||||
|
||||
const fetchSystemData = async (retries = 3, delayMs = 500): Promise<SystemData | null> => {
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const data = await fetchApi<SystemData>("/api/system")
|
||||
return data
|
||||
} catch {
|
||||
if (attempt === retries - 1) {
|
||||
// Silent fail - API not available (expected in preview environment)
|
||||
return null
|
||||
}
|
||||
// Wait before retry
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fetchVMData = async (): Promise<VMData[]> => {
|
||||
try {
|
||||
const data = await fetchApi<any>("/api/vms")
|
||||
return Array.isArray(data) ? data : data.vms || []
|
||||
} catch {
|
||||
// Silent fail - API not available
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStorageData = async (): Promise<StorageData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<StorageData>("/api/storage/summary")
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNetworkData = async (): Promise<NetworkData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<NetworkData>("/api/network/summary")
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorage[] | null> => {
|
||||
try {
|
||||
const data = await fetchApi<ProxmoxStorage[]>("/api/proxmox-storage")
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
if (typeof window === "undefined") return "Bytes"
|
||||
const raw = window.localStorage.getItem("proxmenux-network-unit")
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
export function SystemOverview() {
|
||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||
const [vmData, setVmData] = useState<VMData[]>([])
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
const [proxmoxStorageData, setProxmoxStorageData] = useState<ProxmoxStorageData | null>(null)
|
||||
const [networkData, setNetworkData] = useState<NetworkData | null>(null)
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
system: true,
|
||||
vms: true,
|
||||
storage: true,
|
||||
network: true,
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false) // Added hasAttemptedLoad state
|
||||
const [networkTimeframe, setNetworkTimeframe] = useState("day")
|
||||
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
|
||||
const [tempModalOpen, setTempModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = async () => {
|
||||
const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([
|
||||
fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))),
|
||||
fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
|
||||
Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() =>
|
||||
setLoadingStates((prev) => ({ ...prev, storage: false })),
|
||||
),
|
||||
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
|
||||
])
|
||||
|
||||
setHasAttemptedLoad(true)
|
||||
|
||||
if (!systemResult) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
return
|
||||
}
|
||||
|
||||
setSystemData(systemResult)
|
||||
setVmData(vmResult)
|
||||
setStorageData(storageResults[0])
|
||||
setProxmoxStorageData(storageResults[1])
|
||||
setNetworkData(networkResult)
|
||||
|
||||
setTimeout(async () => {
|
||||
const refreshedSystemData = await fetchSystemData()
|
||||
if (refreshedSystemData) {
|
||||
setSystemData(refreshedSystemData)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
fetchAllData()
|
||||
|
||||
const systemInterval = setInterval(async () => {
|
||||
const data = await fetchSystemData()
|
||||
if (data) setSystemData(data)
|
||||
}, 5000)
|
||||
|
||||
const vmInterval = setInterval(async () => {
|
||||
const data = await fetchVMData()
|
||||
setVmData(data)
|
||||
}, 59000)
|
||||
|
||||
const storageInterval = setInterval(async () => {
|
||||
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()])
|
||||
if (storage) setStorageData(storage)
|
||||
if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage)
|
||||
}, 59000)
|
||||
|
||||
const networkInterval = setInterval(async () => {
|
||||
const data = await fetchNetworkData()
|
||||
if (data) setNetworkData(data)
|
||||
}, 59000)
|
||||
|
||||
setNetworkUnit(getNetworkUnit()) // Load initial setting
|
||||
|
||||
const handleUnitChange = (e: CustomEvent) => {
|
||||
setNetworkUnit(e.detail === "Bits" ? "Bits" : "Bytes")
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
|
||||
return () => {
|
||||
clearInterval(systemInterval)
|
||||
clearInterval(vmInterval)
|
||||
clearInterval(storageInterval)
|
||||
clearInterval(networkInterval)
|
||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!hasAttemptedLoad || loadingStates.system) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
|
||||
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !systemData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-red-500/10 border-red-500/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<div>
|
||||
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
|
||||
<div className="text-sm">
|
||||
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const vmStats = {
|
||||
total: vmData.length,
|
||||
running: vmData.filter((vm) => vm.status === "running").length,
|
||||
stopped: vmData.filter((vm) => vm.status === "stopped").length,
|
||||
lxc: vmData.filter((vm) => vm.type === "lxc").length,
|
||||
vms: vmData.filter((vm) => vm.type === "qemu" || !vm.type).length,
|
||||
}
|
||||
|
||||
const getTemperatureStatus = (temp: number) => {
|
||||
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
if (!seconds || seconds === 0) return "Stopped"
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
return (bytes / 1024 ** 3).toFixed(2)
|
||||
}
|
||||
|
||||
const tempStatus = getTemperatureStatus(systemData.temperature)
|
||||
|
||||
const localStorage = proxmoxStorageData?.storage.find((s) => s.name === "local")
|
||||
|
||||
const vmLxcStorages = proxmoxStorageData?.storage.filter(
|
||||
(s) =>
|
||||
(s.type === "lvm" || s.type === "lvmthin" || s.type === "zfspool" || s.type === "btrfs" || s.type === "dir") &&
|
||||
s.type !== "nfs" &&
|
||||
s.type !== "cifs" &&
|
||||
s.type !== "iscsi" &&
|
||||
s.name !== "local",
|
||||
)
|
||||
|
||||
const vmLxcStorageTotal = vmLxcStorages?.reduce((acc, s) => acc + s.total, 0) || 0
|
||||
const vmLxcStorageUsed = vmLxcStorages?.reduce((acc, s) => acc + s.used, 0) || 0
|
||||
const vmLxcStorageAvailable = vmLxcStorages?.reduce((acc, s) => acc + s.available, 0) || 0
|
||||
const vmLxcStoragePercent = vmLxcStorageTotal > 0 ? (vmLxcStorageUsed / vmLxcStorageTotal) * 100 : 0
|
||||
|
||||
const getLoadStatus = (load: number, cores: number) => {
|
||||
if (load < cores) {
|
||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
} else if (load < cores * 1.5) {
|
||||
return { status: "Moderate", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
} else {
|
||||
return { status: "High", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
}
|
||||
|
||||
const systemAlerts = []
|
||||
if (systemData.available_updates && systemData.available_updates > 0) {
|
||||
systemAlerts.push({
|
||||
type: "warning",
|
||||
message: `${systemData.available_updates} updates available`,
|
||||
})
|
||||
}
|
||||
if (vmStats.stopped > 0) {
|
||||
systemAlerts.push({
|
||||
type: "info",
|
||||
message: `${vmStats.stopped} VM${vmStats.stopped > 1 ? "s" : ""} stopped`,
|
||||
})
|
||||
}
|
||||
if (systemData.temperature > 75) {
|
||||
systemAlerts.push({
|
||||
type: "warning",
|
||||
message: "High temperature detected",
|
||||
})
|
||||
}
|
||||
if (localStorage && localStorage.percent > 90) {
|
||||
systemAlerts.push({
|
||||
type: "warning",
|
||||
message: "System storage almost full",
|
||||
})
|
||||
}
|
||||
|
||||
const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8)
|
||||
|
||||
const getTimeframeLabel = (timeframe: string): string => {
|
||||
switch (timeframe) {
|
||||
case "hour":
|
||||
return "1h"
|
||||
case "day":
|
||||
return "24h"
|
||||
case "week":
|
||||
return "7d"
|
||||
case "month":
|
||||
return "30d"
|
||||
case "year":
|
||||
return "1y"
|
||||
default:
|
||||
return timeframe
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{systemData.cpu_usage}%</div>
|
||||
<Progress value={systemData.cpu_usage} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<p className="text-xs text-muted-foreground mt-2">Real-time usage</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Memory Usage</CardTitle>
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{systemData.memory_used.toFixed(1)} GB</div>
|
||||
<Progress value={systemData.memory_usage} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
<span className="text-green-500 font-medium">{systemData.memory_usage.toFixed(1)}%</span> of{" "}
|
||||
{systemData.memory_total} GB
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
Active VM & LXC
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingStates.vms ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-8 bg-muted rounded w-12"></div>
|
||||
<div className="h-5 bg-muted rounded w-24"></div>
|
||||
<div className="h-4 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{vmStats.running}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{vmStats.running} Running
|
||||
</Badge>
|
||||
{vmStats.stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">
|
||||
{vmStats.stopped} Stopped
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
|
||||
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
|
||||
<Thermometer className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
|
||||
</span>
|
||||
<Badge variant="outline" className={tempStatus.color}>
|
||||
{tempStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{systemData.temperature > 0 && systemData.temperature_sparkline && systemData.temperature_sparkline.length > 1 ? (
|
||||
<div className="mt-2 h-10">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={systemData.temperature_sparkline} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="tempSparkGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"}
|
||||
strokeWidth={1.5}
|
||||
fill="url(#tempSparkGradient)"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<TemperatureDetailModal
|
||||
open={tempModalOpen}
|
||||
onOpenChange={setTempModalOpen}
|
||||
liveTemperature={systemData.temperature}
|
||||
/>
|
||||
|
||||
<NodeMetricsCharts />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2" />
|
||||
Storage Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingStates.storage ? (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-6 bg-muted rounded w-full"></div>
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-2/3"></div>
|
||||
</div>
|
||||
) : storageData ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const totalCapacity = (vmLxcStorageTotal || 0) + (localStorage?.total || 0)
|
||||
const totalUsed = (vmLxcStorageUsed || 0) + (localStorage?.used || 0)
|
||||
const totalAvailable = (vmLxcStorageAvailable || 0) + (localStorage?.available || 0)
|
||||
const totalPercent = totalCapacity > 0 ? (totalUsed / totalCapacity) * 100 : 0
|
||||
|
||||
return totalCapacity > 0 ? (
|
||||
<div className="space-y-2 pb-4 border-b-2 border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
|
||||
<span className="text-lg font-bold text-foreground">
|
||||
{formatStorage(totalCapacity)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={totalPercent}
|
||||
className="mt-2 h-3 [&>div]:bg-gradient-to-r [&>div]:from-blue-500 [&>div]:to-purple-500"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Used:{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatStorage(totalUsed)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Free:{" "}
|
||||
<span className="font-semibold text-green-500">
|
||||
{formatStorage(totalAvailable)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-muted-foreground">{totalPercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total Capacity:</span>
|
||||
<span className="text-lg font-semibold text-foreground">{storageData.total} TB</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Physical Disks:</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Used:</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatStorage(vmLxcStorageUsed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Available:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatStorage(vmLxcStorageAvailable)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={vmLxcStoragePercent} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatStorage(vmLxcStorageUsed)} /{" "}
|
||||
{formatStorage(vmLxcStorageTotal)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{vmLxcStoragePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
{vmLxcStorages.length > 1 && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{vmLxcStorages.length} storage volume{vmLxcStorages.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">No VM/LXC storage configured</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localStorage && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Local Storage (System)</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Used:</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatStorage(localStorage.used)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Available:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatStorage(localStorage.available)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={localStorage.percent} className="mt-2 [&>div]:bg-purple-500" />
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatStorage(localStorage.used)} /{" "}
|
||||
{formatStorage(localStorage.total)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{localStorage.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">Storage data not available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Network className="h-5 w-5 mr-2" />
|
||||
Network Overview
|
||||
</div>
|
||||
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hour">1 Hour</SelectItem>
|
||||
<SelectItem value="day">24 Hours</SelectItem>
|
||||
<SelectItem value="week">7 Days</SelectItem>
|
||||
<SelectItem value="month">30 Days</SelectItem>
|
||||
<SelectItem value="year">1 Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingStates.network ? (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-6 bg-muted rounded w-full"></div>
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-2/3"></div>
|
||||
</div>
|
||||
) : networkData ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Active Interfaces:</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{networkData.physical_interfaces && networkData.physical_interfaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{networkData.physical_interfaces
|
||||
.filter((iface) => iface.status === "up")
|
||||
.map((iface) => (
|
||||
<Badge
|
||||
key={iface.name}
|
||||
variant="outline"
|
||||
className="bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||||
>
|
||||
{iface.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkData.bridge_interfaces && networkData.bridge_interfaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{networkData.bridge_interfaces
|
||||
.filter((iface) => iface.status === "up")
|
||||
.map((iface) => (
|
||||
<Badge
|
||||
key={iface.name}
|
||||
variant="outline"
|
||||
className="bg-green-500/10 text-green-500 border-green-500/20"
|
||||
>
|
||||
{iface.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Received:</span>
|
||||
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
|
||||
↓{" "}
|
||||
{networkUnit === "Bytes"
|
||||
? `${networkTotals.received.toFixed(2)} GB`
|
||||
: formatNetworkTraffic(networkTotals.received * 1024 * 1024 * 1024, "Bits")}
|
||||
<span className="text-xs text-muted-foreground">({getTimeframeLabel(networkTimeframe)})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Sent:</span>
|
||||
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
|
||||
↑{" "}
|
||||
{networkUnit === "Bytes"
|
||||
? `${networkTotals.sent.toFixed(2)} GB`
|
||||
: formatNetworkTraffic(networkTotals.sent * 1024 * 1024 * 1024, "Bits")}
|
||||
<span className="text-xs text-muted-foreground">({getTimeframeLabel(networkTimeframe)})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-border">
|
||||
<NetworkTrafficChart
|
||||
timeframe={networkTimeframe}
|
||||
onTotalsCalculated={setNetworkTotals}
|
||||
networkUnit={networkUnit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">Network data not available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
System Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Uptime:</span>
|
||||
<span className="text-foreground">{systemData.uptime}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Proxmox Version:</span>
|
||||
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Kernel:</span>
|
||||
<span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span>
|
||||
</div>
|
||||
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Available Updates:</span>
|
||||
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
||||
{systemData.available_updates} packages
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Zap className="h-5 w-5 mr-2" />
|
||||
System Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">Load Average (1m):</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-foreground font-mono">
|
||||
{systemData.load_average[0].toFixed(2)}
|
||||
</span>
|
||||
<Badge variant="outline" className={loadStatus.color}>
|
||||
{loadStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">CPU Threads:</span>
|
||||
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Physical Disks:</span>
|
||||
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Network Interfaces:</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
AppImage/components/temperature-detail-modal.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
]
|
||||
|
||||
interface TempHistoryPoint {
|
||||
timestamp: number
|
||||
value: number
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
interface TempStats {
|
||||
min: number
|
||||
max: number
|
||||
avg: number
|
||||
current: number
|
||||
}
|
||||
|
||||
interface TemperatureDetailModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
liveTemperature?: number
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value}°C</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusColor = (temp: number) => {
|
||||
if (temp >= 75) return "#ef4444"
|
||||
if (temp >= 60) return "#f59e0b"
|
||||
return "#22c55e"
|
||||
}
|
||||
|
||||
const getStatusInfo = (temp: number) => {
|
||||
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
|
||||
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
|
||||
const [timeframe, setTimeframe] = useState("hour")
|
||||
const [data, setData] = useState<TempHistoryPoint[]>([])
|
||||
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHistory()
|
||||
}
|
||||
}, [open, timeframe])
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await fetchApi<{ data: TempHistoryPoint[]; stats: TempStats }>(
|
||||
`/api/temperature/history?timeframe=${timeframe}`
|
||||
)
|
||||
if (result && result.data) {
|
||||
setData(result.data)
|
||||
setStats(result.stats)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[v0] Failed to fetch temperature history:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (timeframe === "hour") {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
} else if (timeframe === "day") {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
...d,
|
||||
time: formatTime(d.timestamp),
|
||||
}))
|
||||
|
||||
// Use live temperature from the overview card (real-time) instead of last DB record
|
||||
const currentTemp = liveTemperature && liveTemperature > 0 ? Math.round(liveTemperature * 10) / 10 : stats.current
|
||||
const currentStatus = getStatusInfo(currentTemp)
|
||||
const chartColor = getStatusColor(currentTemp)
|
||||
|
||||
// Calculate Y axis domain based on plotted data values only.
|
||||
// Stats cards already show the real historical min/max separately.
|
||||
// Using only graphed values keeps the chart readable and avoids
|
||||
// large empty gaps caused by momentary spikes that get averaged out.
|
||||
const values = data.map((d) => d.value)
|
||||
const yMin = values.length > 0 ? Math.max(0, Math.floor(Math.min(...values) - 3)) : 0
|
||||
const yMax = values.length > 0 ? Math.ceil(Math.max(...values) + 3) : 100
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl bg-card border-border px-3 sm:px-6">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between pr-6">
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Thermometer className="h-5 w-5" />
|
||||
CPU Temperature
|
||||
</DialogTitle>
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-[130px] bg-card border-border">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
|
||||
<div className="text-xs opacity-80 mb-1">Current</div>
|
||||
<div className="text-lg font-bold">{currentTemp}°C</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||
<TrendingDown className="h-3 w-3" /> Min
|
||||
</div>
|
||||
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||
<Minus className="h-3 w-3" /> Avg
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" /> Max
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="h-[300px] lg:h-[350px]">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="space-y-3 w-full animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-1/4 mx-auto" />
|
||||
<div className="h-[250px] bg-muted/50 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No temperature data available for this period</p>
|
||||
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="tempGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={chartColor} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={chartColor} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={isMobile ? 40 : 60}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[yMin, yMax]}
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
|
||||
tickFormatter={(v) => `${v}°`}
|
||||
width={isMobile ? 40 : 45}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name="Temperature"
|
||||
stroke={chartColor}
|
||||
strokeWidth={2}
|
||||
fill="url(#tempGradient)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: chartColor, stroke: "#fff", strokeWidth: 2 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
1210
AppImage/components/terminal-panel.tsx
Normal file
7
AppImage/components/theme-provider.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
39
AppImage/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Button } from "./ui/button"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
console.log("[v0] Current theme:", theme)
|
||||
const newTheme = theme === "light" ? "dark" : "light"
|
||||
console.log("[v0] Switching to theme:", newTheme)
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 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>
|
||||
)
|
||||
}
|
||||
297
AppImage/components/two-factor-setup.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
interface TwoFactorSetupProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [qrCode, setQrCode] = useState("")
|
||||
const [secret, setSecret] = useState("")
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([])
|
||||
const [verificationCode, setVerificationCode] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copiedSecret, setCopiedSecret] = useState(false)
|
||||
const [copiedCodes, setCopiedCodes] = useState(false)
|
||||
|
||||
const handleSetupStart = async () => {
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
const response = await fetch(getApiUrl("/api/auth/totp/setup"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Failed to setup 2FA")
|
||||
}
|
||||
|
||||
setQrCode(data.qr_code)
|
||||
setSecret(data.secret)
|
||||
setBackupCodes(data.backup_codes)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode || verificationCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code")
|
||||
return
|
||||
}
|
||||
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
const response = await fetch(getApiUrl("/api/auth/totp/enable"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ token: verificationCode }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Invalid verification code")
|
||||
}
|
||||
|
||||
setStep(3)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verification failed")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string, type: "secret" | "codes") => {
|
||||
let ok = false
|
||||
|
||||
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
|
||||
// so we catch and fall through to the textarea fallback.
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ok = true
|
||||
}
|
||||
} catch {
|
||||
// fall through to execCommand fallback
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
try {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = text
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.left = "-9999px"
|
||||
textarea.style.top = "-9999px"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.readOnly = true
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
ok = document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
console.error("Failed to copy to clipboard")
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "secret") {
|
||||
setCopiedSecret(true)
|
||||
setTimeout(() => setCopiedSecret(false), 2000)
|
||||
} else {
|
||||
setCopiedCodes(true)
|
||||
setTimeout(() => setCopiedCodes(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setStep(1)
|
||||
setQrCode("")
|
||||
setSecret("")
|
||||
setBackupCodes([])
|
||||
setVerificationCode("")
|
||||
setError("")
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
handleClose()
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-blue-500" />
|
||||
Setup Two-Factor Authentication
|
||||
</DialogTitle>
|
||||
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-500">
|
||||
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your
|
||||
authentication app in addition to your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">You will need:</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||
<li>An authentication app (Google Authenticator, Authy, etc.)</li>
|
||||
<li>Scan a QR code or enter a key manually</li>
|
||||
<li>Store backup codes securely</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Starting..." : "Start Setup"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">1. Scan the QR code</h4>
|
||||
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p>
|
||||
{qrCode && (
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<img src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} className="rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Or enter the key manually:</h4>
|
||||
<div className="flex gap-2">
|
||||
<Input value={secret} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(secret, "secret")}
|
||||
title="Copy key"
|
||||
>
|
||||
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">2. Enter the verification code</h4>
|
||||
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
className="text-center text-lg tracking-widest font-mono text-base"
|
||||
maxLength={6}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Verifying..." : "Verify and Enable"}
|
||||
</Button>
|
||||
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-green-500">2FA Enabled Successfully</p>
|
||||
<p className="text-sm text-green-500 mt-1">
|
||||
Your account is now protected with two-factor authentication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-orange-500">Important: Save your backup codes</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These codes will allow you to access your account if you lose access to your authentication app. Store
|
||||
them in a safe place.
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium">Backup Codes</span>
|
||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
|
||||
{copiedCodes ? (
|
||||
<Check className="h-4 w-4 text-green-500 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Copy All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="bg-background rounded px-3 py-2 font-mono text-sm text-center">
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
||||
Finish
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
28
AppImage/components/ui/badge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
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: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
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} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
46
AppImage/components/ui/button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 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",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
42
AppImage/components/ui/card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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-sm", 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 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 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"
|
||||
|
||||
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"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
27
AppImage/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
102
AppImage/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
hideClose?: boolean
|
||||
}
|
||||
>(({ className, children, hideClose, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className,
|
||||
)}
|
||||
aria-describedby={props["aria-describedby"] || undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
257
AppImage/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
22
AppImage/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
17
AppImage/components/ui/label.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
25
AppImage/components/ui/progress.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-secondary", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
40
AppImage/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
144
AppImage/components/ui/select.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
109
AppImage/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = DialogPrimitive.Root
|
||||
|
||||
const SheetTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const SheetClose = DialogPrimitive.Close
|
||||
|
||||
const SheetPortal = DialogPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
)
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
|
||||
))
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
29
AppImage/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
52
AppImage/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
24
AppImage/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
2341
AppImage/components/virtual-machines.tsx
Normal file
77
AppImage/config/verified_ai_models.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
|
||||
"_updated": "2026-04-19",
|
||||
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
|
||||
|
||||
"groq": {
|
||||
"models": [
|
||||
"llama-3.3-70b-versatile",
|
||||
"llama-3.1-70b-versatile",
|
||||
"llama-3.1-8b-instant",
|
||||
"llama3-70b-8192",
|
||||
"llama3-8b-8192",
|
||||
"mixtral-8x7b-32768",
|
||||
"gemma2-9b-it"
|
||||
],
|
||||
"recommended": "llama-3.3-70b-versatile",
|
||||
"_note": "Not yet re-verified in 2026-04 refresh — kept from previous curation. Run the verifier with a Groq key to prune deprecated entries."
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"models": [
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3-flash-preview"
|
||||
],
|
||||
"recommended": "gemini-2.5-flash-lite",
|
||||
"_note": "flash-lite / flash pass the verifier consistently; pro variants reject thinkingBudget=0 and are overkill for notification translation anyway. 'latest' aliases (gemini-flash-latest, gemini-flash-lite-latest) are intentionally omitted because they resolved to different models across runs and produced timeouts in some regions.",
|
||||
"_deprecated": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.0-pro", "gemini-pro"]
|
||||
},
|
||||
|
||||
"openai": {
|
||||
"models": [
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"gpt-5-chat-latest",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5.4-mini"
|
||||
],
|
||||
"recommended": "gpt-4.1-nano",
|
||||
"_note": "Reasoning models (o-series, gpt-5/5.1/5.2 non-chat variants) are supported by openai_provider.py via max_completion_tokens + reasoning_effort=minimal, but not listed here by default: their latency is higher than the chat models and they do not improve translation quality for notifications. Add specific reasoning IDs to this list only if a user explicitly wants them."
|
||||
},
|
||||
|
||||
"anthropic": {
|
||||
"models": [
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-opus-latest"
|
||||
],
|
||||
"recommended": "claude-3-5-haiku-latest",
|
||||
"_note": "Not re-verified in 2026-04 refresh — kept from previous curation. Add claude-4.x / claude-4.5 / claude-4.6 / claude-4.7 variants after running the verifier with an Anthropic key."
|
||||
},
|
||||
|
||||
"openrouter": {
|
||||
"models": [
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"meta-llama/llama-3.1-8b-instruct",
|
||||
"anthropic/claude-3.5-haiku",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"google/gemini-flash-1.5",
|
||||
"openai/gpt-4o-mini",
|
||||
"mistralai/mistral-7b-instruct",
|
||||
"mistralai/mixtral-8x7b-instruct"
|
||||
],
|
||||
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
||||
"_note": "Not re-verified in 2026-04 refresh. google/gemini-flash-2.5-flash-lite was malformed in the previous entry and has been replaced with google/gemini-flash-1.5."
|
||||
},
|
||||
|
||||
"ollama": {
|
||||
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
||||
"models": [],
|
||||
"recommended": ""
|
||||
}
|
||||
}
|
||||
23
AppImage/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
// Check on mount
|
||||
checkMobile()
|
||||
|
||||
// Listen for resize
|
||||
window.addEventListener("resize", checkMobile)
|
||||
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
114
AppImage/lib/api-config.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* API Configuration for ProxMenux Monitor
|
||||
* Handles API URL generation with automatic proxy detection
|
||||
*/
|
||||
|
||||
/**
|
||||
* API Server Port Configuration
|
||||
* Default: 8008 (production)
|
||||
* Can be changed to 8009 for beta testing
|
||||
* This can also be set via NEXT_PUBLIC_API_PORT environment variable
|
||||
*/
|
||||
export const API_PORT = process.env.NEXT_PUBLIC_API_PORT || "8008"
|
||||
|
||||
/**
|
||||
* Gets the base URL for API calls
|
||||
* Automatically detects if running behind a proxy by checking if we're on a standard port
|
||||
*
|
||||
* @returns Base URL for API endpoints
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return ""
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
|
||||
// If accessing via standard ports (80/443) or no port, assume we're behind a proxy
|
||||
// In this case, use relative URLs so the proxy handles routing
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
|
||||
if (isStandardPort) {
|
||||
return ""
|
||||
} else {
|
||||
return `${protocol}//${hostname}:${API_PORT}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a full API URL
|
||||
*
|
||||
* @param endpoint - API endpoint path (e.g., '/api/system')
|
||||
* @returns Full API URL
|
||||
*/
|
||||
export function getApiUrl(endpoint: string): string {
|
||||
const baseUrl = getApiBaseUrl()
|
||||
|
||||
// Ensure endpoint starts with /
|
||||
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||
|
||||
return `${baseUrl}${normalizedEndpoint}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the JWT token from localStorage
|
||||
*
|
||||
* @returns JWT token or null if not authenticated
|
||||
*/
|
||||
export function getAuthToken(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
return localStorage.getItem("proxmenux-auth-token")
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches data from an API endpoint with error handling
|
||||
*
|
||||
* @param endpoint - API endpoint path
|
||||
* @param options - Fetch options
|
||||
* @returns Promise with the response data
|
||||
*/
|
||||
export async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = getApiUrl(endpoint)
|
||||
|
||||
const token = getAuthToken()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options?.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
console.error("[v0] fetchApi: 401 UNAUTHORIZED -", endpoint, "- Token present:", !!token)
|
||||
throw new Error(`Unauthorized: ${endpoint}`)
|
||||
}
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Check content type to ensure we're getting JSON
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text()
|
||||
console.error("[v0] fetchApi: Expected JSON but got:", contentType, "- Body preview:", text.substring(0, 200))
|
||||
throw new Error(`Expected JSON response but got ${contentType || "unknown content type"}`)
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json()
|
||||
} catch (jsonError) {
|
||||
console.error("[v0] fetchApi: JSON parse error for", endpoint, "-", jsonError)
|
||||
throw new Error(`Invalid JSON response from ${endpoint}`)
|
||||
}
|
||||
}
|
||||
68
AppImage/lib/format-network.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Utility functions for formatting network traffic data
|
||||
* Supports conversion between Bytes and Bits based on user preferences
|
||||
*/
|
||||
|
||||
export type NetworkUnit = 'Bytes' | 'Bits';
|
||||
|
||||
/**
|
||||
* Format network traffic value with appropriate unit
|
||||
* @param bytes - Value in bytes
|
||||
* @param unit - Target unit ('Bytes' or 'Bits')
|
||||
* @param decimals - Number of decimal places (default: 2)
|
||||
* @returns Formatted string with value and unit
|
||||
*/
|
||||
export function formatNetworkTraffic(
|
||||
bytes: number,
|
||||
unit: NetworkUnit = 'Bytes',
|
||||
decimals: number = 2
|
||||
): string {
|
||||
if (bytes === 0) return unit === 'Bits' ? '0 b' : '0 B';
|
||||
|
||||
const k = unit === 'Bits' ? 1000 : 1024;
|
||||
const dm = decimals < 0 ? 0 : Math.min(decimals, 2);
|
||||
|
||||
// For Bits: convert bytes to bits first (multiply by 8)
|
||||
const value = unit === 'Bits' ? bytes * 8 : bytes;
|
||||
|
||||
const sizes = unit === 'Bits'
|
||||
? ['b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb']
|
||||
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
const i = Math.floor(Math.log(value) / Math.log(k));
|
||||
const finalDecimals = 2; // Always use 2 decimals for consistency
|
||||
const formattedValue = parseFloat((value / Math.pow(k, i)).toFixed(finalDecimals));
|
||||
|
||||
return `${formattedValue} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current network unit preference from localStorage
|
||||
* @returns 'Bytes' or 'Bits'
|
||||
*/
|
||||
export function getNetworkUnit(): NetworkUnit {
|
||||
if (typeof window === 'undefined') return 'Bytes';
|
||||
|
||||
const stored = localStorage.getItem('proxmenux-network-unit');
|
||||
return stored === 'Bits' ? 'Bits' : 'Bytes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the label for network traffic based on current unit
|
||||
* @param direction - 'received' or 'sent'
|
||||
* @returns Label string
|
||||
*/
|
||||
export function getNetworkLabel(direction: 'received' | 'sent'): string {
|
||||
const unit = getNetworkUnit();
|
||||
const prefix = direction === 'received' ? 'Received' : 'Sent';
|
||||
return unit === 'Bits' ? `${prefix}` : `${prefix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unit suffix for displaying in charts
|
||||
* @returns Unit suffix string (e.g., 'GB' or 'Gb')
|
||||
*/
|
||||
export function getNetworkUnitSuffix(): string {
|
||||
const unit = getNetworkUnit();
|
||||
return unit === 'Bits' ? 'b' : 'B';
|
||||
}
|
||||
39
AppImage/lib/script-executor.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { exec } from "child_process"
|
||||
import { promisify } from "util"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface ScriptExecutorOptions {
|
||||
env?: Record<string, string>
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
interface ScriptResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export async function executeScript(scriptPath: string, options: ScriptExecutorOptions = {}): Promise<ScriptResult> {
|
||||
const { env = {}, timeout = 300000 } = options // 5 minutes default timeout
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(`bash ${scriptPath}`, {
|
||||
env: { ...process.env, ...env },
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
|
||||
})
|
||||
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: 0,
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
stdout: error.stdout || "",
|
||||
stderr: error.stderr || error.message || "Unknown error",
|
||||
exitCode: error.code || 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
21
AppImage/lib/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatStorage(sizeInGB: number): string {
|
||||
if (sizeInGB < 1) {
|
||||
// Less than 1 GB, show in MB
|
||||
const mb = sizeInGB * 1024
|
||||
return `${mb % 1 === 0 ? mb.toFixed(0) : mb.toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB % 1 === 0 ? sizeInGB.toFixed(0) : sizeInGB.toFixed(1)} GB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
const tb = sizeInGB / 1024
|
||||
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
|
||||
}
|
||||
}
|
||||
30
AppImage/next.config.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
experimental: {
|
||||
esmExternals: 'loose',
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
net: false,
|
||||
tls: false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
77
AppImage/package.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.2.0",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"export": "next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-context-menu": "2.2.4",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-hover-card": "1.1.4",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-menubar": "1.1.4",
|
||||
"@radix-ui/react-navigation-menu": "1.2.3",
|
||||
"@radix-ui/react-popover": "1.1.4",
|
||||
"@radix-ui/react-progress": "1.1.1",
|
||||
"@radix-ui/react-radio-group": "1.2.2",
|
||||
"@radix-ui/react-scroll-area": "1.2.2",
|
||||
"@radix-ui/react-select": "2.1.4",
|
||||
"@radix-ui/react-separator": "1.1.1",
|
||||
"@radix-ui/react-slider": "1.2.2",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-switch": "1.1.2",
|
||||
"@radix-ui/react-tabs": "1.1.2",
|
||||
"@radix-ui/react-toast": "1.2.4",
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"geist": "^1.3.1",
|
||||
"input-otp": "1.4.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "15.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19",
|
||||
"react-day-picker": "9.8.0",
|
||||
"react-dom": "^19",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^1.7.4",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"zod": "3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
9
AppImage/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
AppImage/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
AppImage/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
AppImage/public/icon.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
3
AppImage/public/icon.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
10
AppImage/public/icons/Groq Logo_Black 25.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200.18 69.76">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path d="M114.26.13c-13.19,0-23.88,10.68-23.88,23.88s10.68,23.9,23.88,23.9,23.88-10.68,23.88-23.88h0c-.02-13.19-10.71-23.88-23.88-23.9ZM114.26,38.94c-8.24,0-14.93-6.69-14.93-14.93s6.69-14.93,14.93-14.93,14.93,6.69,14.93,14.93c-.02,8.24-6.71,14.93-14.93,14.93h0Z"/>
|
||||
<path d="M24.11,0C10.92-.11.13,10.47,0,23.66c-.13,13.19,10.47,23.98,23.66,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74-.11-8.24,6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v21.98h0c0,8.18-6.65,14.83-14.81,14.93-3.91-.04-7.63-1.59-10.39-4.38l-6.33,6.31c4.4,4.42,10.34,6.92,16.57,6.99h.32c13.02-.19,23.49-10.75,23.56-23.77v-22.69C47.65,10.35,37.05.02,24.11,0Z"/>
|
||||
<path d="M191.28,68.74V23.43c-.32-12.96-10.92-23.28-23.88-23.3-13.19-.13-23.98,10.47-24.11,23.66-.13,13.19,10.49,23.98,23.68,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74s6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v44.63h0l8.92.06Z"/>
|
||||
<path d="M54.8,47.9h8.92v-23.88c0-8.24,6.69-14.93,14.93-14.93,2.72,0,5.25.72,7.46,2l4.48-7.75c-3.5-2.02-7.58-3.19-11.92-3.19-13.19,0-23.88,10.68-23.88,23.88v23.88Z"/>
|
||||
<path d="M198.01.74c.68.38,1.21.91,1.59,1.59.38.68.57,1.42.57,2.25s-.19,1.57-.59,2.27c-.4.68-.93,1.23-1.61,1.61-.68.4-1.44.59-2.25.59s-1.57-.19-2.25-.59c-.68-.4-1.21-.93-1.59-1.61-.38-.68-.59-1.42-.59-2.25s.19-1.57.59-2.25c.38-.68.93-1.21,1.61-1.61s1.44-.59,2.27-.59c.83,0,1.57.19,2.25.59ZM197.57,7.75c.55-.32.98-.76,1.3-1.32.32-.55.47-1.17.47-1.85s-.15-1.3-.47-1.85-.74-.98-1.27-1.3c-.55-.32-1.17-.47-1.85-.47s-1.3.17-1.85.49c-.55.32-.98.76-1.3,1.32s-.47,1.17-.47,1.85.15,1.3.47,1.85c.32.55.74,1,1.27,1.32.55.32,1.15.49,1.83.49.7-.04,1.32-.21,1.87-.53ZM197.84,4.82c-.15.25-.38.45-.68.59l1.06,1.64h-1.32l-.91-1.42h-.87v1.42h-1.32V2.17h2.12c.66,0,1.19.15,1.57.47.38.32.57.74.57,1.27,0,.34-.08.66-.23.91ZM195.85,4.65c.3,0,.53-.06.68-.19.17-.13.25-.32.25-.55s-.08-.42-.25-.57-.4-.19-.68-.19h-.74v1.53h.74v-.02Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
17
AppImage/public/icons/Groq Logo_White 25.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200.18 69.76">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M114.26.13c-13.19,0-23.88,10.68-23.88,23.88s10.68,23.9,23.88,23.9,23.88-10.68,23.88-23.88h0c-.02-13.19-10.71-23.88-23.88-23.9ZM114.26,38.94c-8.24,0-14.93-6.69-14.93-14.93s6.69-14.93,14.93-14.93,14.93,6.69,14.93,14.93c-.02,8.24-6.71,14.93-14.93,14.93h0Z"/>
|
||||
<path class="cls-1" d="M24.11,0C10.92-.11.13,10.47,0,23.66c-.13,13.19,10.47,23.98,23.66,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74-.11-8.24,6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v21.98h0c0,8.18-6.65,14.83-14.81,14.93-3.91-.04-7.63-1.59-10.39-4.38l-6.33,6.31c4.4,4.42,10.34,6.92,16.57,6.99h.32c13.02-.19,23.49-10.75,23.56-23.77v-22.69C47.65,10.35,37.05.02,24.11,0Z"/>
|
||||
<path class="cls-1" d="M191.28,68.74V23.43c-.32-12.96-10.92-23.28-23.88-23.3-13.19-.13-23.98,10.47-24.11,23.66-.13,13.19,10.49,23.98,23.68,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74s6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v44.63h0l8.92.06Z"/>
|
||||
<path class="cls-1" d="M54.8,47.9h8.92v-23.88c0-8.24,6.69-14.93,14.93-14.93,2.72,0,5.25.72,7.46,2l4.48-7.75c-3.5-2.02-7.58-3.19-11.92-3.19-13.19,0-23.88,10.68-23.88,23.88v23.88Z"/>
|
||||
<path class="cls-1" d="M198.01.74c.68.38,1.21.91,1.59,1.59.38.68.57,1.42.57,2.25s-.19,1.57-.59,2.27c-.4.68-.93,1.23-1.61,1.61-.68.4-1.44.59-2.25.59s-1.57-.19-2.25-.59c-.68-.4-1.21-.93-1.59-1.61-.38-.68-.59-1.42-.59-2.25s.19-1.57.59-2.25c.38-.68.93-1.21,1.61-1.61s1.44-.59,2.27-.59c.83,0,1.57.19,2.25.59ZM197.57,7.75c.55-.32.98-.76,1.3-1.32.32-.55.47-1.17.47-1.85s-.15-1.3-.47-1.85-.74-.98-1.27-1.3c-.55-.32-1.17-.47-1.85-.47s-1.3.17-1.85.49c-.55.32-.98.76-1.3,1.32s-.47,1.17-.47,1.85.15,1.3.47,1.85c.32.55.74,1,1.27,1.32.55.32,1.15.49,1.83.49.7-.04,1.32-.21,1.87-.53ZM197.84,4.82c-.15.25-.38.45-.68.59l1.06,1.64h-1.32l-.91-1.42h-.87v1.42h-1.32V2.17h2.12c.66,0,1.19.15,1.57.47.38.32.57.74.57,1.27,0,.34-.08.66-.23.91ZM195.85,4.65c.3,0,.53-.06.68-.19.17-.13.25-.32.25-.55s-.08-.42-.25-.57-.4-.19-.68-.19h-.74v1.53h.74v-.02Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
AppImage/public/icons/alpine.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#0d597f"><path d="M23.25 38.81v-6.745l-4.855 4.864c.474.333.968.635 1.48.906.463.243.87.434 1.303.58s.782.24 1.13.304.66.093.95.096m24.822-.562c.045.037.092.07.142.1a2.77 2.77 0 0 0 .385.203 2.93 2.93 0 0 0 .637.194c.296.06.598.088.9.087.3 0 .608-.03.955-.087a7.24 7.24 0 0 0 1.138-.301 9.96 9.96 0 0 0 1.32-.579c.52-.274 1.02-.58 1.503-.918l-3.685-3.6-12.21-12.258-5.356 5.356-7.23-7.455-18.14 17.935a13.82 13.82 0 0 0 1.5.918c.47.246.91.434 1.317.58a7.18 7.18 0 0 0 1.135.301 5.53 5.53 0 0 0 .955.087c.302.001.604-.028.9-.087a3.29 3.29 0 0 0 .637-.194 2.49 2.49 0 0 0 .385-.197l.145-.104 8.193-8.193 2.924-2.808 8.106 8.106 2.837 2.912a1.29 1.29 0 0 0 .145.101 2.52 2.52 0 0 0 .385.2c.206.085.42.15.637.194.255.052.556.087.903.087.3 0 .608-.03.955-.087a6.89 6.89 0 0 0 1.138-.301 9.95 9.95 0 0 0 1.32-.579c.52-.274 1.02-.58 1.503-.918l-6.508-6.37 1.2-1.2 5.63 5.63 3.283 3.254m-.07-33.96l15.998 27.714L48.003 59.71H15.996L-.002 31.997 15.996 4.283z"/><path d="M38.02 30.65l-4.262-4.256.304-.304 4.3 4.244z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
20
AppImage/public/icons/arch.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256" version="1.0">
|
||||
<defs>
|
||||
<linearGradient xlink:href="#a" id="d" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-.39377 0 0 .39375 978.34969 416.9815)" x1="541.33502" y1="104.50665" x2="606.91248" y2="303.14029"/>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="a" y2="129.3468" x2="112.49853" y1="6.1372099" x1="112.49854" gradientTransform="translate(287 -83)">
|
||||
<stop offset="0" style="stop-color:#fff;stop-opacity:0"/>
|
||||
<stop offset="1" style="stop-color:#fff;stop-opacity:.27450982"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b">
|
||||
<stop style="stop-color:#00bdec" offset="0"/>
|
||||
<stop style="stop-color:#40bfde" offset="1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="c">
|
||||
<stop style="stop-color:#6e6e6e" offset="0"/>
|
||||
<stop style="stop-color:#4d4d4d" offset="1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path style="fill:#1793d1" d="M128 0c-11.39482 27.937051-18.31337 46.237163-31 73.34375 7.7785 8.245207 17.33826 17.811753 32.84375 28.65625-16.66992-6.859577-28.03357-13.728504-36.53125-20.875C77.076039 115.00489 51.621645 163.24639 0 256c40.562707-23.41756 72.007597-37.86167 101.3125-43.375-1.25376-5.40435-1.923505-11.27752-1.875-17.375l.03125-1.28125c.64379-25.99398 14.16934-45.98224 30.1875-44.625 16.01815 1.35723 28.48754 23.53727 27.84375 49.53125-.12127 4.89622-.6905 9.60082-1.65625 13.96875C184.83328 218.51691 215.98162 232.89667 256 256c-7.89193-14.52962-14.96051-27.61983-21.6875-40.09375-10.59609-8.21269-21.64301-18.89743-44.1875-30.46875 15.4958 4.02645 26.60184 8.6825 35.25 13.875C156.97985 71.972668 151.45422 55.040376 128 0z" transform="matrix(1 0 0 1 -.000002 4e-8)"/>
|
||||
<path style="fill:#fff;fill-opacity:.16568047" d="M818.22607 548.55277c-41.18143-55.89508-50.72685-100.94481-53.14467-111.70015 21.96737 50.6686 21.81733 51.28995 53.14467 111.70015z" transform="matrix(1.34737 0 0 1.34737 -902.40019 -586.944907)"/>
|
||||
<path style="fill:url(#d);fill-opacity:1" d="M765.09805 436.43495c-1.05641 2.59705-2.08559 5.1172-3.06152 7.51465-1.08115 2.65585-2.10928 5.19128-3.13111 7.677-1.02174 2.48575-2.03439 4.91156-3.03833 7.30591-1.00398 2.39446-2.01068 4.76169-3.03833 7.14355-1.02758 2.38177-2.06156 4.78845-3.15429 7.23633-1.09273 2.44796-2.23335 4.94504-3.43262 7.53784-1.19937 2.59282-2.45641 5.27815-3.80371 8.09448-.18662.39008-.41312.83402-.60303 1.22925 5.75521 6.09563 12.84133 13.14976 24.28345 21.15234-12.34021-5.07792-20.76511-10.15751-27.06665-15.44677-.32717.66791-.61387 1.26431-.95093 1.94824-.44365.90024-.97632 1.92315-1.43799 2.85278-.80967 1.66032-1.65574 3.36576-2.52807 5.12574-.33524.66652-.62948 1.24283-.97413 1.92504-5.50733 11.05265-12.33962 24.28304-21.12915 40.72754 24.09557-13.57581 50.08533-33.16242 97.29615-16.30493-2.36708-4.48319-4.54319-8.68756-6.58692-12.64038-2.0437-3.95294-3.94246-7.6555-5.70556-11.15601-1.76297-3.50043-3.39212-6.80069-4.917-9.92675-1.52486-3.12599-2.93832-6.0765-4.26757-8.90625-1.32934-2.8297-2.58106-5.55264-3.75733-8.16407-1.17634-2.6114-2.29708-5.11315-3.36304-7.58422-1.06607-2.4712-2.08657-4.89718-3.08471-7.30591-.99823-2.4088-1.97267-4.81178-2.94556-7.23633-.34772-.86638-.69553-1.7689-1.0437-2.64404-2.66339-6.25269-5.3982-12.73163-8.55835-20.15503z" transform="matrix(1.34737 0 0 1.34737 -902.40019 -586.944907)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
86
AppImage/public/icons/debian.svg
Normal file
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 10.0, SVG Export Plug-In . SVG Version: 3.0.0 Build 77) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [
|
||||
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
|
||||
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
|
||||
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
|
||||
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
|
||||
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
|
||||
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
|
||||
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
|
||||
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
|
||||
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
|
||||
<!ENTITY ns_svg "http://www.w3.org/2000/svg">
|
||||
<!ENTITY ns_xlink "http://www.w3.org/1999/xlink">
|
||||
]>
|
||||
<svg
|
||||
xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" i:viewOrigin="262 450" i:rulerOrigin="0 0" i:pageBounds="0 792 612 0"
|
||||
xmlns="&ns_svg;" xmlns:xlink="&ns_xlink;" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
|
||||
width="87.041" height="108.445" viewBox="0 0 87.041 108.445" overflow="visible" enable-background="new 0 0 87.041 108.445"
|
||||
xml:space="preserve">
|
||||
<metadata>
|
||||
<variableSets xmlns="&ns_vars;">
|
||||
<variableSet varSetName="binding1" locked="none">
|
||||
<variables></variables>
|
||||
<v:sampleDataSets xmlns="&ns_custom;" xmlns:v="&ns_vars;"></v:sampleDataSets>
|
||||
</variableSet>
|
||||
</variableSets>
|
||||
<sfw xmlns="&ns_sfw;">
|
||||
<slices></slices>
|
||||
<sliceSourceBounds y="341.555" x="262" width="87.041" height="108.445" bottomLeftOrigin="true"></sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g id="Layer_1" i:layer="yes" i:dimmedPercent="50" i:rgbTrio="#4F008000FFFF">
|
||||
<g>
|
||||
<path i:knockout="Off" fill="#A80030" d="M51.986,57.297c-1.797,0.025,0.34,0.926,2.686,1.287
|
||||
c0.648-0.506,1.236-1.018,1.76-1.516C54.971,57.426,53.484,57.434,51.986,57.297"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M61.631,54.893c1.07-1.477,1.85-3.094,2.125-4.766c-0.24,1.192-0.887,2.221-1.496,3.307
|
||||
c-3.359,2.115-0.316-1.256-0.002-2.537C58.646,55.443,61.762,53.623,61.631,54.893"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M65.191,45.629c0.217-3.236-0.637-2.213-0.924-0.978
|
||||
C64.602,44.825,64.867,46.932,65.191,45.629"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M45.172,1.399c0.959,0.172,2.072,0.304,1.916,0.533
|
||||
C48.137,1.702,48.375,1.49,45.172,1.399"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M47.088,1.932l-0.678,0.14l0.631-0.056L47.088,1.932"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M76.992,46.856c0.107,2.906-0.85,4.316-1.713,6.812l-1.553,0.776
|
||||
c-1.271,2.468,0.123,1.567-0.787,3.53c-1.984,1.764-6.021,5.52-7.313,5.863c-0.943-0.021,0.639-1.113,0.846-1.541
|
||||
c-2.656,1.824-2.131,2.738-6.193,3.846l-0.119-0.264c-10.018,4.713-23.934-4.627-23.751-17.371
|
||||
c-0.107,0.809-0.304,0.607-0.526,0.934c-0.517-6.557,3.028-13.143,9.007-15.832c5.848-2.895,12.704-1.707,16.893,2.197
|
||||
c-2.301-3.014-6.881-6.209-12.309-5.91c-5.317,0.084-10.291,3.463-11.951,7.131c-2.724,1.715-3.04,6.611-4.227,7.507
|
||||
C31.699,56.271,36.3,61.342,44.083,67.307c1.225,0.826,0.345,0.951,0.511,1.58c-2.586-1.211-4.954-3.039-6.901-5.277
|
||||
c1.033,1.512,2.148,2.982,3.589,4.137c-2.438-0.826-5.695-5.908-6.646-6.115c4.203,7.525,17.052,13.197,23.78,10.383
|
||||
c-3.113,0.115-7.068,0.064-10.566-1.229c-1.469-0.756-3.467-2.322-3.11-2.615c9.182,3.43,18.667,2.598,26.612-3.771
|
||||
c2.021-1.574,4.229-4.252,4.867-4.289c-0.961,1.445,0.164,0.695-0.574,1.971c2.014-3.248-0.875-1.322,2.082-5.609l1.092,1.504
|
||||
c-0.406-2.696,3.348-5.97,2.967-10.234c0.861-1.304,0.961,1.403,0.047,4.403c1.268-3.328,0.334-3.863,0.66-6.609
|
||||
c0.352,0.923,0.814,1.904,1.051,2.878c-0.826-3.216,0.848-5.416,1.262-7.285c-0.408-0.181-1.275,1.422-1.473-2.377
|
||||
c0.029-1.65,0.459-0.865,0.625-1.271c-0.324-0.186-1.174-1.451-1.691-3.877c0.375-0.57,1.002,1.478,1.512,1.562
|
||||
c-0.328-1.929-0.893-3.4-0.916-4.88c-1.49-3.114-0.527,0.415-1.736-1.337c-1.586-4.947,1.316-1.148,1.512-3.396
|
||||
c2.404,3.483,3.775,8.881,4.404,11.117c-0.48-2.726-1.256-5.367-2.203-7.922c0.73,0.307-1.176-5.609,0.949-1.691
|
||||
c-2.27-8.352-9.715-16.156-16.564-19.818c0.838,0.767,1.896,1.73,1.516,1.881c-3.406-2.028-2.807-2.186-3.295-3.043
|
||||
c-2.775-1.129-2.957,0.091-4.795,0.002c-5.23-2.774-6.238-2.479-11.051-4.217l0.219,1.023c-3.465-1.154-4.037,0.438-7.782,0.004
|
||||
c-0.228-0.178,1.2-0.644,2.375-0.815c-3.35,0.442-3.193-0.66-6.471,0.122c0.808-0.567,1.662-0.942,2.524-1.424
|
||||
c-2.732,0.166-6.522,1.59-5.352,0.295c-4.456,1.988-12.37,4.779-16.811,8.943l-0.14-0.933c-2.035,2.443-8.874,7.296-9.419,10.46
|
||||
l-0.544,0.127c-1.059,1.793-1.744,3.825-2.584,5.67c-1.385,2.36-2.03,0.908-1.833,1.278c-2.724,5.523-4.077,10.164-5.246,13.97
|
||||
c0.833,1.245,0.02,7.495,0.335,12.497c-1.368,24.704,17.338,48.69,37.785,54.228c2.997,1.072,7.454,1.031,11.245,1.141
|
||||
c-4.473-1.279-5.051-0.678-9.408-2.197c-3.143-1.48-3.832-3.17-6.058-5.102l0.881,1.557c-4.366-1.545-2.539-1.912-6.091-3.037
|
||||
l0.941-1.229c-1.415-0.107-3.748-2.385-4.386-3.646l-1.548,0.061c-1.86-2.295-2.851-3.949-2.779-5.23l-0.5,0.891
|
||||
c-0.567-0.973-6.843-8.607-3.587-6.83c-0.605-0.553-1.409-0.9-2.281-2.484l0.663-0.758c-1.567-2.016-2.884-4.6-2.784-5.461
|
||||
c0.836,1.129,1.416,1.34,1.99,1.533c-3.957-9.818-4.179-0.541-7.176-9.994l0.634-0.051c-0.486-0.732-0.781-1.527-1.172-2.307
|
||||
l0.276-2.75C4.667,58.121,6.719,47.409,7.13,41.534c0.285-2.389,2.378-4.932,3.97-8.92l-0.97-0.167
|
||||
c1.854-3.234,10.586-12.988,14.63-12.486c1.959-2.461-0.389-0.009-0.772-0.629c4.303-4.453,5.656-3.146,8.56-3.947
|
||||
c3.132-1.859-2.688,0.725-1.203-0.709c5.414-1.383,3.837-3.144,10.9-3.846c0.745,0.424-1.729,0.655-2.35,1.205
|
||||
c4.511-2.207,14.275-1.705,20.617,1.225c7.359,3.439,15.627,13.605,15.953,23.17l0.371,0.1
|
||||
c-0.188,3.802,0.582,8.199-0.752,12.238L76.992,46.856"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M32.372,59.764l-0.252,1.26c1.181,1.604,2.118,3.342,3.626,4.596
|
||||
C34.661,63.502,33.855,62.627,32.372,59.764"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M35.164,59.654c-0.625-0.691-0.995-1.523-1.409-2.352
|
||||
c0.396,1.457,1.207,2.709,1.962,3.982L35.164,59.654"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M84.568,48.916l-0.264,0.662c-0.484,3.438-1.529,6.84-3.131,9.994
|
||||
C82.943,56.244,84.088,52.604,84.568,48.916"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M45.527,0.537C46.742,0.092,48.514,0.293,49.803,0c-1.68,0.141-3.352,0.225-5.003,0.438
|
||||
L45.527,0.537"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M2.872,23.219c0.28,2.592-1.95,3.598,0.494,1.889
|
||||
C4.676,22.157,2.854,24.293,2.872,23.219"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M0,35.215c0.563-1.728,0.665-2.766,0.88-3.766C-0.676,33.438,0.164,33.862,0,35.215"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
1
AppImage/public/icons/ubuntu.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" preserveAspectRatio="xMidYMid"><path d="M32 16c0 8.836-7.164 16-16 16S0 24.836 0 16 7.164 0 16 0s16 7.164 16 16z" fill="#dd4814"/><path d="M5.12 13.864c-1.18 0-2.137.956-2.137 2.137s.956 2.136 2.137 2.136S7.257 17.18 7.257 16 6.3 13.864 5.12 13.864zm15.252 9.71c-1.022.6-1.372 1.896-.782 2.917s1.895 1.372 2.917.782 1.372-1.895.782-2.917-1.896-1.37-2.917-.782zM9.76 16a6.23 6.23 0 0 1 2.653-5.105L10.852 8.28a9.3 9.3 0 0 0-3.838 5.394C7.69 14.224 8.12 15.06 8.12 16s-.432 1.776-1.106 2.326c.577 2.237 1.968 4.146 3.838 5.395l1.562-2.616A6.23 6.23 0 0 1 9.761 16zM16 9.76a6.24 6.24 0 0 1 6.215 5.687l3.044-.045a9.25 9.25 0 0 0-2.757-6.019c-.812.307-1.75.26-2.56-.208a2.99 2.99 0 0 1-1.461-2.118C17.7 6.84 16.86 6.72 16 6.72c-1.477 0-2.873.347-4.113.96l1.484 2.66c.8-.372 1.69-.58 2.628-.58zm0 12.48c-.94 0-1.83-.21-2.628-.58l-1.484 2.66c1.24.614 2.636.96 4.113.96a9.28 9.28 0 0 0 2.479-.338c.14-.858.65-1.648 1.46-2.118s1.75-.514 2.56-.207a9.25 9.25 0 0 0 2.757-6.019l-3.045-.045A6.24 6.24 0 0 1 16 22.24zm4.372-13.813c1.022.6 2.328.24 2.917-.78s.24-2.328-.78-2.918-2.328-.24-2.918.783-.24 2.327.782 2.917z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
23
AppImage/public/images/onboarding/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Onboarding Images
|
||||
|
||||
Place your screenshot images here with the following names:
|
||||
|
||||
- `imagen1.png` - Overview section screenshot
|
||||
- `imagen2.png` - Storage section screenshot
|
||||
- `imagen3.png` - Network section screenshot
|
||||
- `imagen4.png` - VMs & LXCs section screenshot
|
||||
- `imagen5.png` - Hardware section screenshot
|
||||
- `imagen6.png` - System Logs section screenshot
|
||||
|
||||
## Image Guidelines
|
||||
|
||||
- **Format**: PNG or JPG
|
||||
- **Recommended size**: 1200x800px or similar aspect ratio
|
||||
- **Quality**: High quality screenshots showing the main features of each section
|
||||
- **Content**: Capture the full section with representative data
|
||||
|
||||
## Notes
|
||||
|
||||
- The last slide (Future Updates) doesn't need an image as it uses an icon
|
||||
- If an image fails to load, the component will show a fallback icon
|
||||
- Images should be optimized for web (compressed but still high quality)
|
||||
BIN
AppImage/public/images/onboarding/imagen1.png
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
AppImage/public/images/onboarding/imagen2.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
AppImage/public/images/onboarding/imagen3.png
Normal file
|
After Width: | Height: | Size: 380 KiB |
BIN
AppImage/public/images/onboarding/imagen4.png
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
AppImage/public/images/onboarding/imagen5.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
AppImage/public/images/onboarding/imagen6.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
AppImage/public/images/proxmenux-logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
AppImage/public/images/proxmenux_update-logo.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
16
AppImage/public/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "ProxMenux Monitor",
|
||||
"short_name": "ProxMenux",
|
||||
"description": "Proxmox System Dashboard and Monitor",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#2b2f36",
|
||||
"theme_color": "#2b2f36",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/proxmenux-logo.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
41
AppImage/scripts/AppRun
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ProxMenux Monitor AppImage Entry Point
|
||||
# This script is executed when the AppImage is run
|
||||
|
||||
# Get the directory where this AppImage is mounted
|
||||
APPDIR="$(dirname "$(readlink -f "${0}")")"
|
||||
|
||||
export PATH="${APPDIR}/usr/bin:${PATH}"
|
||||
export LD_LIBRARY_PATH="${APPDIR}/usr/lib/x86_64-linux-gnu:${APPDIR}/usr/lib:${APPDIR}/lib/x86_64-linux-gnu:${APPDIR}/lib:${LD_LIBRARY_PATH}"
|
||||
export PYTHONPATH="${APPDIR}/usr/lib/python3/dist-packages:${APPDIR}/usr/lib/python3/site-packages:${PYTHONPATH}"
|
||||
|
||||
# Change to the AppImage directory
|
||||
cd "${APPDIR}"
|
||||
|
||||
# Check for translation argument
|
||||
if [[ "$1" == "--translate" ]]; then
|
||||
echo "🌐 Starting ProxMenux Translation Service..."
|
||||
exec python3 "${APPDIR}/usr/bin/translate_cli.py" "${@:2}"
|
||||
else
|
||||
echo "🚀 Starting ProxMenux Monitor Dashboard..."
|
||||
echo ""
|
||||
|
||||
echo "🔧 Hardware monitoring tools:"
|
||||
[ -x "${APPDIR}/usr/bin/ipmitool" ] && echo " ✅ ipmitool available" || echo " ⚠️ ipmitool not available"
|
||||
[ -x "${APPDIR}/usr/bin/sensors" ] && echo " ✅ sensors available" || echo " ⚠️ sensors not available"
|
||||
[ -x "${APPDIR}/usr/bin/upsc" ] && echo " ✅ upsc available" || echo " ⚠️ upsc not available"
|
||||
|
||||
if [ -x "${APPDIR}/usr/bin/ipmitool" ]; then
|
||||
if ldd "${APPDIR}/usr/bin/ipmitool" 2>/dev/null | grep -q "libfreeipmi.so.17 => not found"; then
|
||||
echo " ⚠️ libfreeipmi.so.17 not found - ipmitool may not work"
|
||||
elif ldd "${APPDIR}/usr/bin/ipmitool" 2>/dev/null | grep -q "libfreeipmi.so.17"; then
|
||||
echo " ✅ libfreeipmi.so.17 loaded successfully"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Start the Flask server
|
||||
exec python3 "${APPDIR}/usr/bin/flask_server.py"
|
||||
fi
|
||||
390
AppImage/scripts/ai_context_enrichment.py
Normal file
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Context Enrichment Module
|
||||
|
||||
Enriches notification context with additional information to help AI provide
|
||||
more accurate and helpful responses:
|
||||
|
||||
1. Event frequency - how often this error has occurred
|
||||
2. System uptime - helps distinguish startup issues from runtime failures
|
||||
3. SMART disk data - for disk-related errors
|
||||
4. Known error matching - from proxmox_known_errors database
|
||||
|
||||
Author: MacRimi
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
# Import known errors database
|
||||
try:
|
||||
from proxmox_known_errors import get_error_context, find_matching_error
|
||||
except ImportError:
|
||||
def get_error_context(*args, **kwargs):
|
||||
return None
|
||||
def find_matching_error(*args, **kwargs):
|
||||
return None
|
||||
|
||||
DB_PATH = Path('/usr/local/share/proxmenux/health_monitor.db')
|
||||
|
||||
|
||||
def get_system_uptime() -> str:
|
||||
"""Get system uptime in human-readable format.
|
||||
|
||||
Returns:
|
||||
String like "2 minutes (recently booted)" or "89 days, 4 hours (stable system)"
|
||||
"""
|
||||
try:
|
||||
with open('/proc/uptime', 'r') as f:
|
||||
uptime_seconds = float(f.readline().split()[0])
|
||||
|
||||
days = int(uptime_seconds // 86400)
|
||||
hours = int((uptime_seconds % 86400) // 3600)
|
||||
minutes = int((uptime_seconds % 3600) // 60)
|
||||
|
||||
# Build human-readable string
|
||||
parts = []
|
||||
if days > 0:
|
||||
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||
if hours > 0:
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
if not parts: # Less than an hour
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
|
||||
uptime_str = ", ".join(parts)
|
||||
|
||||
# Add context hint
|
||||
if uptime_seconds < 600: # Less than 10 minutes
|
||||
return f"{uptime_str} (just booted - likely startup issue)"
|
||||
elif uptime_seconds < 3600: # Less than 1 hour
|
||||
return f"{uptime_str} (recently booted)"
|
||||
elif days >= 30:
|
||||
return f"{uptime_str} (stable system)"
|
||||
else:
|
||||
return uptime_str
|
||||
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_event_frequency(error_id: str = None, error_key: str = None,
|
||||
category: str = None, hours: int = 24) -> Optional[Dict[str, Any]]:
|
||||
"""Get frequency information for an error from the database.
|
||||
|
||||
Args:
|
||||
error_id: Specific error ID to look up
|
||||
error_key: Alternative error key
|
||||
category: Error category
|
||||
hours: Time window to check (default 24h)
|
||||
|
||||
Returns:
|
||||
Dict with frequency info or None
|
||||
"""
|
||||
if not DB_PATH.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=5)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try to find the error
|
||||
if error_id:
|
||||
cursor.execute('''
|
||||
SELECT first_seen, last_seen, occurrences, category
|
||||
FROM errors WHERE error_key = ? OR error_id = ?
|
||||
ORDER BY last_seen DESC LIMIT 1
|
||||
''', (error_id, error_id))
|
||||
elif error_key:
|
||||
cursor.execute('''
|
||||
SELECT first_seen, last_seen, occurrences, category
|
||||
FROM errors WHERE error_key = ?
|
||||
ORDER BY last_seen DESC LIMIT 1
|
||||
''', (error_key,))
|
||||
elif category:
|
||||
cursor.execute('''
|
||||
SELECT first_seen, last_seen, occurrences, category
|
||||
FROM errors WHERE category = ? AND resolved_at IS NULL
|
||||
ORDER BY last_seen DESC LIMIT 1
|
||||
''', (category,))
|
||||
else:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
first_seen, last_seen, occurrences, cat = row
|
||||
|
||||
# Calculate age
|
||||
try:
|
||||
first_dt = datetime.fromisoformat(first_seen) if first_seen else None
|
||||
last_dt = datetime.fromisoformat(last_seen) if last_seen else None
|
||||
now = datetime.now()
|
||||
|
||||
result = {
|
||||
'occurrences': occurrences or 1,
|
||||
'category': cat
|
||||
}
|
||||
|
||||
if first_dt:
|
||||
age = now - first_dt
|
||||
if age.total_seconds() < 3600:
|
||||
result['first_seen_ago'] = f"{int(age.total_seconds() / 60)} minutes ago"
|
||||
elif age.total_seconds() < 86400:
|
||||
result['first_seen_ago'] = f"{int(age.total_seconds() / 3600)} hours ago"
|
||||
else:
|
||||
result['first_seen_ago'] = f"{age.days} days ago"
|
||||
|
||||
if last_dt and first_dt and occurrences and occurrences > 1:
|
||||
# Calculate average interval
|
||||
span = (last_dt - first_dt).total_seconds()
|
||||
if span > 0 and occurrences > 1:
|
||||
avg_interval = span / (occurrences - 1)
|
||||
if avg_interval < 60:
|
||||
result['pattern'] = f"recurring every ~{int(avg_interval)} seconds"
|
||||
elif avg_interval < 3600:
|
||||
result['pattern'] = f"recurring every ~{int(avg_interval / 60)} minutes"
|
||||
else:
|
||||
result['pattern'] = f"recurring every ~{int(avg_interval / 3600)} hours"
|
||||
|
||||
return result
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return {'occurrences': occurrences or 1, 'category': cat}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AIContext] Error getting frequency: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_smart_data(disk_device: str) -> Optional[str]:
|
||||
"""Get SMART health data for a disk.
|
||||
|
||||
Args:
|
||||
disk_device: Device path like /dev/sda or just sda
|
||||
|
||||
Returns:
|
||||
Formatted SMART summary or None
|
||||
"""
|
||||
if not disk_device:
|
||||
return None
|
||||
|
||||
# Normalize device path
|
||||
if not disk_device.startswith('/dev/'):
|
||||
disk_device = f'/dev/{disk_device}'
|
||||
|
||||
# Check device exists
|
||||
if not os.path.exists(disk_device):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get health status
|
||||
result = subprocess.run(
|
||||
['smartctl', '-H', disk_device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
|
||||
health_status = "UNKNOWN"
|
||||
if "PASSED" in result.stdout:
|
||||
health_status = "PASSED"
|
||||
elif "FAILED" in result.stdout:
|
||||
health_status = "FAILED"
|
||||
|
||||
# Get key attributes
|
||||
result = subprocess.run(
|
||||
['smartctl', '-A', disk_device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
|
||||
attributes = {}
|
||||
critical_attrs = [
|
||||
'Reallocated_Sector_Ct', 'Current_Pending_Sector',
|
||||
'Offline_Uncorrectable', 'UDMA_CRC_Error_Count',
|
||||
'Reallocated_Event_Count', 'Reported_Uncorrect'
|
||||
]
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
for attr in critical_attrs:
|
||||
if attr in line:
|
||||
parts = line.split()
|
||||
# Typical format: ID ATTRIBUTE_NAME FLAGS VALUE WORST THRESH TYPE UPDATED RAW_VALUE
|
||||
if len(parts) >= 10:
|
||||
raw_value = parts[-1]
|
||||
attributes[attr] = raw_value
|
||||
|
||||
# Build summary
|
||||
lines = [f"SMART Health: {health_status}"]
|
||||
|
||||
# Add critical attributes if non-zero
|
||||
for attr, value in attributes.items():
|
||||
try:
|
||||
if int(value) > 0:
|
||||
lines.append(f" {attr}: {value}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return "\n".join(lines) if len(lines) > 1 or health_status == "FAILED" else f"SMART Health: {health_status}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
# smartctl not installed
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract_disk_device(text: str) -> Optional[str]:
|
||||
"""Extract disk device name from error text.
|
||||
|
||||
Args:
|
||||
text: Error message or log content
|
||||
|
||||
Returns:
|
||||
Device name like 'sda' or None
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# Common patterns for disk devices in errors
|
||||
patterns = [
|
||||
r'/dev/(sd[a-z]\d*)',
|
||||
r'/dev/(nvme\d+n\d+(?:p\d+)?)',
|
||||
r'/dev/(hd[a-z]\d*)',
|
||||
r'/dev/(vd[a-z]\d*)',
|
||||
r'\b(sd[a-z])\b',
|
||||
r'disk[_\s]+(sd[a-z])',
|
||||
r'ata\d+\.\d+: (sd[a-z])',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def enrich_context_for_ai(
|
||||
title: str,
|
||||
body: str,
|
||||
event_type: str,
|
||||
data: Dict[str, Any],
|
||||
journal_context: str = '',
|
||||
detail_level: str = 'standard'
|
||||
) -> str:
|
||||
"""Build enriched context string for AI processing.
|
||||
|
||||
Combines:
|
||||
- Original journal context
|
||||
- Event frequency information
|
||||
- System uptime
|
||||
- SMART data (for disk errors)
|
||||
- Known error matching
|
||||
|
||||
Args:
|
||||
title: Notification title
|
||||
body: Notification body
|
||||
event_type: Type of event
|
||||
data: Event data dict
|
||||
journal_context: Original journal log context
|
||||
detail_level: Level of detail (minimal, standard, detailed)
|
||||
|
||||
Returns:
|
||||
Enriched context string
|
||||
"""
|
||||
context_parts = []
|
||||
combined_text = f"{title} {body} {journal_context}"
|
||||
|
||||
# 1. System uptime - ONLY for critical system-level failures
|
||||
# Uptime helps distinguish startup issues from runtime failures
|
||||
# BUT it's noise for disk errors, warnings, or routine operations
|
||||
# Only include for: system crash, kernel panic, OOM, cluster failures
|
||||
uptime_critical_types = [
|
||||
'crash', 'panic', 'oom', 'kernel',
|
||||
'split_brain', 'quorum_lost', 'node_offline', 'node_fail',
|
||||
'system_fail', 'boot_fail'
|
||||
]
|
||||
|
||||
# Check if this is a critical system-level event (not disk/service/hardware)
|
||||
event_lower = event_type.lower()
|
||||
is_critical_system_event = any(t in event_lower for t in uptime_critical_types)
|
||||
|
||||
# Only add uptime for critical system failures, nothing else
|
||||
if is_critical_system_event:
|
||||
uptime = get_system_uptime()
|
||||
if uptime and uptime != "unknown":
|
||||
context_parts.append(f"System uptime: {uptime}")
|
||||
|
||||
# 2. Event frequency
|
||||
error_key = data.get('error_key') or data.get('error_id')
|
||||
category = data.get('category')
|
||||
|
||||
freq = get_event_frequency(error_id=error_key, category=category)
|
||||
if freq:
|
||||
freq_line = f"Event frequency: {freq.get('occurrences', 1)} occurrence(s)"
|
||||
if freq.get('first_seen_ago'):
|
||||
freq_line += f", first seen {freq['first_seen_ago']}"
|
||||
if freq.get('pattern'):
|
||||
freq_line += f", {freq['pattern']}"
|
||||
context_parts.append(freq_line)
|
||||
|
||||
# 3. SMART data for disk-related events
|
||||
disk_related = any(x in event_type.lower() for x in ['disk', 'smart', 'storage', 'io_error'])
|
||||
if not disk_related:
|
||||
disk_related = any(x in combined_text.lower() for x in ['disk', 'smart', '/dev/sd', 'ata', 'i/o error'])
|
||||
|
||||
if disk_related:
|
||||
disk_device = extract_disk_device(combined_text)
|
||||
if disk_device:
|
||||
smart_data = get_smart_data(disk_device)
|
||||
if smart_data:
|
||||
context_parts.append(smart_data)
|
||||
|
||||
# 4. Known error matching
|
||||
known_error_ctx = get_error_context(combined_text, category=category, detail_level=detail_level)
|
||||
if known_error_ctx:
|
||||
context_parts.append(known_error_ctx)
|
||||
|
||||
# 5. Add original journal context
|
||||
if journal_context:
|
||||
context_parts.append(f"Journal logs:\n{journal_context}")
|
||||
|
||||
# Combine all parts
|
||||
if context_parts:
|
||||
return "\n\n".join(context_parts)
|
||||
|
||||
return journal_context or ""
|
||||
|
||||
|
||||
def get_enriched_context(
|
||||
event: 'NotificationEvent',
|
||||
detail_level: str = 'standard'
|
||||
) -> str:
|
||||
"""Convenience function to enrich context from a NotificationEvent.
|
||||
|
||||
Args:
|
||||
event: NotificationEvent object
|
||||
detail_level: Level of detail
|
||||
|
||||
Returns:
|
||||
Enriched context string
|
||||
"""
|
||||
journal_context = event.data.get('_journal_context', '')
|
||||
|
||||
return enrich_context_for_ai(
|
||||
title=event.data.get('title', ''),
|
||||
body=event.data.get('body', event.data.get('message', '')),
|
||||
event_type=event.event_type,
|
||||
data=event.data,
|
||||
journal_context=journal_context,
|
||||
detail_level=detail_level
|
||||
)
|
||||
106
AppImage/scripts/ai_providers/__init__.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""AI Providers for ProxMenux notification enhancement.
|
||||
|
||||
This module provides a pluggable architecture for different AI providers
|
||||
to enhance and translate notification messages.
|
||||
|
||||
Supported providers:
|
||||
- Groq: Fast inference, generous free tier (30 req/min)
|
||||
- OpenAI: Industry standard, widely used
|
||||
- Anthropic: Excellent for text generation, Claude Haiku is fast and affordable
|
||||
- Gemini: Google's model, free tier available, good quality/price ratio
|
||||
- Ollama: 100% local execution, no costs, complete privacy
|
||||
- OpenRouter: Aggregator with access to 100+ models using a single API key
|
||||
"""
|
||||
from .base import AIProvider, AIProviderError
|
||||
from .groq_provider import GroqProvider
|
||||
from .openai_provider import OpenAIProvider
|
||||
from .anthropic_provider import AnthropicProvider
|
||||
from .gemini_provider import GeminiProvider
|
||||
from .ollama_provider import OllamaProvider
|
||||
from .openrouter_provider import OpenRouterProvider
|
||||
|
||||
PROVIDERS = {
|
||||
'groq': GroqProvider,
|
||||
'openai': OpenAIProvider,
|
||||
'anthropic': AnthropicProvider,
|
||||
'gemini': GeminiProvider,
|
||||
'ollama': OllamaProvider,
|
||||
'openrouter': OpenRouterProvider,
|
||||
}
|
||||
|
||||
# Provider metadata for UI display
|
||||
# Note: No hardcoded models - users load models dynamically from each provider
|
||||
PROVIDER_INFO = {
|
||||
'groq': {
|
||||
'name': 'Groq',
|
||||
'description': 'Fast inference, generous free tier (30 req/min). Ideal to get started.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'openai': {
|
||||
'name': 'OpenAI',
|
||||
'description': 'Industry standard. Very accurate and widely used.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'anthropic': {
|
||||
'name': 'Anthropic (Claude)',
|
||||
'description': 'Excellent for writing and translation. Fast and affordable.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'gemini': {
|
||||
'name': 'Google Gemini',
|
||||
'description': 'Free tier available, very good quality/price ratio.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'ollama': {
|
||||
'name': 'Ollama (Local)',
|
||||
'description': '100% local execution. No costs, complete privacy, no internet required.',
|
||||
'requires_api_key': False,
|
||||
},
|
||||
'openrouter': {
|
||||
'name': 'OpenRouter',
|
||||
'description': 'Aggregator with access to 100+ models using a single API key. Maximum flexibility.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str, **kwargs) -> AIProvider:
|
||||
"""Factory function to get provider instance.
|
||||
|
||||
Args:
|
||||
name: Provider name (groq, openai, anthropic, gemini, ollama, openrouter)
|
||||
**kwargs: Provider-specific arguments (api_key, model, base_url)
|
||||
|
||||
Returns:
|
||||
AIProvider instance
|
||||
|
||||
Raises:
|
||||
AIProviderError: If provider name is unknown
|
||||
"""
|
||||
if name not in PROVIDERS:
|
||||
raise AIProviderError(f"Unknown provider: {name}. Available: {list(PROVIDERS.keys())}")
|
||||
return PROVIDERS[name](**kwargs)
|
||||
|
||||
|
||||
def get_provider_info(name: str = None) -> dict:
|
||||
"""Get provider metadata for UI display.
|
||||
|
||||
Args:
|
||||
name: Optional provider name. If None, returns all providers info.
|
||||
|
||||
Returns:
|
||||
Provider info dict or dict of all providers
|
||||
"""
|
||||
if name:
|
||||
return PROVIDER_INFO.get(name, {})
|
||||
return PROVIDER_INFO
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AIProvider',
|
||||
'AIProviderError',
|
||||
'PROVIDERS',
|
||||
'PROVIDER_INFO',
|
||||
'get_provider',
|
||||
'get_provider_info',
|
||||
]
|
||||
80
AppImage/scripts/ai_providers/anthropic_provider.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Anthropic (Claude) provider implementation.
|
||||
|
||||
Anthropic's Claude models are excellent for text generation and translation.
|
||||
Models use "-latest" aliases that auto-update to newest versions.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class AnthropicProvider(AIProvider):
|
||||
"""Anthropic provider using their Messages API."""
|
||||
|
||||
NAME = "anthropic"
|
||||
REQUIRES_API_KEY = True
|
||||
API_URL = "https://api.anthropic.com/v1/messages"
|
||||
API_VERSION = "2023-06-01"
|
||||
|
||||
# Known stable model aliases (Anthropic doesn't have a public models list API)
|
||||
# These use "-latest" which auto-updates to the newest version
|
||||
KNOWN_MODELS = [
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-opus-latest",
|
||||
]
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Return known Anthropic model aliases.
|
||||
|
||||
Anthropic doesn't have a public models list API, but their "-latest"
|
||||
aliases auto-update to the newest versions, making them reliable choices.
|
||||
"""
|
||||
return self.KNOWN_MODELS
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using Anthropic's API.
|
||||
|
||||
Note: Anthropic uses a different API format than OpenAI.
|
||||
The system prompt goes in a separate field, not in messages.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for Anthropic")
|
||||
|
||||
# Anthropic uses a different format - system is a top-level field
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'system': system_prompt,
|
||||
'messages': [
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': self.api_key,
|
||||
'anthropic-version': self.API_VERSION,
|
||||
}
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
# Anthropic returns content as array of content blocks
|
||||
content = result['content']
|
||||
if isinstance(content, list) and len(content) > 0:
|
||||
return content[0].get('text', '').strip()
|
||||
return str(content).strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||