mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-04 20:02:07 +00:00
Compare commits
900
Commits
v1.2.1
...
v1.2.6.1-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a655a57a | ||
|
|
93900b872e | ||
|
|
ac4be7031f | ||
|
|
102ac40226 | ||
|
|
7f90154410 | ||
|
|
013def197a | ||
|
|
baaff2725b | ||
|
|
8e7588f372 | ||
|
|
8507097a9a | ||
|
|
ed5f0e8393 | ||
|
|
49efa18f97 | ||
|
|
a4537be773 | ||
|
|
8f62859971 | ||
|
|
fb1517807c | ||
|
|
685b37bc43 | ||
|
|
5867d467f3 | ||
|
|
dd2a9f611d | ||
|
|
30e6e64d4f | ||
|
|
f9373f8777 | ||
|
|
cad825ae7e | ||
|
|
5b4366fa82 | ||
|
|
1df63be887 | ||
|
|
b599d1cf26 | ||
|
|
1919e62646 | ||
|
|
38d445a379 | ||
|
|
150494bce1 | ||
|
|
2424545532 | ||
|
|
e9c5a12702 | ||
|
|
3dcf6cd661 | ||
|
|
d91599d354 | ||
|
|
97bf9e24e5 | ||
|
|
9a1642b53c | ||
|
|
c90e365d1f | ||
|
|
9ae3a54e8b | ||
|
|
cc66939095 | ||
|
|
56da2ac880 | ||
|
|
2630e4f016 | ||
|
|
966e9799f2 | ||
|
|
ae3b93a3c1 | ||
|
|
459ea9c6db | ||
|
|
636983b697 | ||
|
|
986301d152 | ||
|
|
cd40abc359 | ||
|
|
ea8c29ecbd | ||
|
|
eecd93bf1a | ||
|
|
d1fed2fb45 | ||
|
|
5b41cbfbe8 | ||
|
|
17a72753bb | ||
|
|
e7b69dae91 | ||
|
|
d10acbc895 | ||
|
|
91b4200179 | ||
|
|
45cc2ba36e | ||
|
|
588d8f629d | ||
|
|
686c9456f4 | ||
|
|
333d03a51e | ||
|
|
14823809d3 | ||
|
|
1bef36fb20 | ||
|
|
c820685241 | ||
|
|
f59323455b | ||
|
|
20cf0a4a7f | ||
|
|
287ad44ffd | ||
|
|
ac30502b67 | ||
|
|
580fab6c78 | ||
|
|
84a3bbe84f | ||
|
|
1309654b4a | ||
|
|
2f7541040f | ||
|
|
3cc020a31b | ||
|
|
4baf7ccf7e | ||
|
|
674e9dba98 | ||
|
|
b7c5960d9a | ||
|
|
220a670b46 | ||
|
|
3c39f84290 | ||
|
|
bdab88c2e8 | ||
|
|
7cdf562efb | ||
|
|
ebfb842dc3 | ||
|
|
2c8fce3df6 | ||
|
|
b88fd353a2 | ||
|
|
55603990b9 | ||
|
|
976c6b46b0 | ||
|
|
8a94fdc66d | ||
|
|
4763897272 | ||
|
|
9946dcfd08 | ||
|
|
1262dafba8 | ||
|
|
f439757fe5 | ||
|
|
315259f5ec | ||
|
|
bc8a95263a | ||
|
|
a5a2b6f8e1 | ||
|
|
7380956975 | ||
|
|
ee382f2e48 | ||
|
|
59080873cc | ||
|
|
c9d9638d67 | ||
|
|
f8e65cc4c4 | ||
|
|
4cf5fad46c | ||
|
|
a3c07c1d23 | ||
|
|
182c1e019f | ||
|
|
2fbbca0944 | ||
|
|
abf0f7fbf8 | ||
|
|
d13f5ed900 | ||
|
|
3fbfb5233e | ||
|
|
44a446b309 | ||
|
|
cecf550a43 | ||
|
|
039988b506 | ||
|
|
0646f6fd96 | ||
|
|
cce714f873 | ||
|
|
eed9c3e597 | ||
|
|
71058685ef | ||
|
|
f80c3cb3f1 | ||
|
|
49b2d6d2c8 | ||
|
|
dcb94acee4 | ||
|
|
4b14dfa47e | ||
|
|
f08ec94521 | ||
|
|
c2a6db38a8 | ||
|
|
0fe32d2842 | ||
|
|
fcfe8da765 | ||
|
|
81b163ae4d | ||
|
|
fc261b35f5 | ||
|
|
82e8cd220c | ||
|
|
b71dd65898 | ||
|
|
46158209f1 | ||
|
|
a1f466e561 | ||
|
|
942384f0dd | ||
|
|
0207cdf4af | ||
|
|
0473430f7a | ||
|
|
3c08f8b943 | ||
|
|
2302b0967b | ||
|
|
d62f8c6bfd | ||
|
|
0251f77331 | ||
|
|
5971626bc9 | ||
|
|
f90f72dcac | ||
|
|
e22699602c | ||
|
|
0b932df450 | ||
|
|
15db6c282b | ||
|
|
d98b39540f | ||
|
|
69a7b07a8d | ||
|
|
f7b29a9bbf | ||
|
|
1353a1cd8d | ||
|
|
8604e66d60 | ||
|
|
78c0508b5a | ||
|
|
7244201810 | ||
|
|
812ca1ba7d | ||
|
|
9bf0ff9848 | ||
|
|
f8605b7583 | ||
|
|
e7bb511821 | ||
|
|
9c33adc77c | ||
|
|
bce139f9d1 | ||
|
|
e6de8b6df9 | ||
|
|
c5050ae022 | ||
|
|
44d2dc7b43 | ||
|
|
7d81724e14 | ||
|
|
c3d2b0f181 | ||
|
|
dd4584c774 | ||
|
|
29cd269e08 | ||
|
|
668035895b | ||
|
|
b2b1b5e3a1 | ||
|
|
56de7322f7 | ||
|
|
14793f8787 | ||
|
|
d8ba3b06ae | ||
|
|
9d8a9fb378 | ||
|
|
d88c36f6ca | ||
|
|
6166486ede | ||
|
|
1dac6166ab | ||
|
|
513a9359fd | ||
|
|
9f66c004a2 | ||
|
|
b5476c75fa | ||
|
|
b047c0b876 | ||
|
|
8158082fa5 | ||
|
|
ec01d4abb6 | ||
|
|
11e030b9de | ||
|
|
c5d717c85a | ||
|
|
e73ec8e24c | ||
|
|
b577c154ee | ||
|
|
ec129b8262 | ||
|
|
698c89fd3b | ||
|
|
8915f8692a | ||
|
|
14da6d9e23 | ||
|
|
7bbccef81e | ||
|
|
b45799d319 | ||
|
|
af40ee5447 | ||
|
|
17d00f2db7 | ||
|
|
58a28dd81e | ||
|
|
42b4aa2489 | ||
|
|
0beeb7a68b | ||
|
|
6b5d45fd6b | ||
|
|
ff575776f4 | ||
|
|
34b8c47415 | ||
|
|
c25f72febe | ||
|
|
f0c8e73446 | ||
|
|
c83382806d | ||
|
|
157ab50f9b | ||
|
|
9eb576f8f3 | ||
|
|
f6da553aeb | ||
|
|
cf0c21eff3 | ||
|
|
99ad69e8cf | ||
|
|
d1adba65a0 | ||
|
|
452902bd9a | ||
|
|
e1fb203e46 | ||
|
|
ac2c28ad2c | ||
|
|
8481e588b2 | ||
|
|
d9944edf5e | ||
|
|
037421d762 | ||
|
|
92df8b59a9 | ||
|
|
5a2ed51af3 | ||
|
|
0ea8797a81 | ||
|
|
3fe9a49ebc | ||
|
|
dbc60585f9 | ||
|
|
805003d811 | ||
|
|
bc88a34390 | ||
|
|
b6e18a5a37 | ||
|
|
153839285b | ||
|
|
ef859d5ec3 | ||
|
|
55a95a2346 | ||
|
|
36e27d6154 | ||
|
|
c81f552ed0 | ||
|
|
25986f062c | ||
|
|
d2c7be9a32 | ||
|
|
bf6c580877 | ||
|
|
3dc263f9fb | ||
|
|
05e5277ae5 | ||
|
|
c2ba648ebe | ||
|
|
2bfa84d5e0 | ||
|
|
d6162af020 | ||
|
|
c4b8ad65da | ||
|
|
a056d7e0dc | ||
|
|
87bda3bbb6 | ||
|
|
c1bd4b3403 | ||
|
|
f2420d9978 | ||
|
|
2901b22be6 | ||
|
|
7b2d9a3268 | ||
|
|
9dae948a7c | ||
|
|
1ed65d502b | ||
|
|
1ddb460b46 | ||
|
|
8e8883cf21 | ||
|
|
f4fac11cd3 | ||
|
|
66a1781298 | ||
|
|
504d34a3ee | ||
|
|
3d83d9d561 | ||
|
|
5c33852a65 | ||
|
|
a2740b6873 | ||
|
|
547942cac8 | ||
|
|
8a5f7a4cb8 | ||
|
|
78caf6598e | ||
|
|
03e034b326 | ||
|
|
d2a3720ec4 | ||
|
|
732f1e8ef7 | ||
|
|
e05b4e94dc | ||
|
|
05476b99c6 | ||
|
|
06f41f5792 | ||
|
|
fb8b41b445 | ||
|
|
eeb39e6e0f | ||
|
|
508a4eb22c | ||
|
|
ba5ed26339 | ||
|
|
2d49b6b4da | ||
|
|
fda41ae8d5 | ||
|
|
5d5aa97814 | ||
|
|
976c61805a | ||
|
|
450940c77f | ||
|
|
4e2deaeba2 | ||
|
|
ab64fb962d | ||
|
|
9effec62cb | ||
|
|
c127be6868 | ||
|
|
3a7e450b55 | ||
|
|
7d781d570c | ||
|
|
e581aa0121 | ||
|
|
f168440892 | ||
|
|
94aa3709f3 | ||
|
|
8dfb747c42 | ||
|
|
ef53fa0827 | ||
|
|
cbb84e0284 | ||
|
|
d2ff21b12d | ||
|
|
76d7ceb647 | ||
|
|
86517860ff | ||
|
|
d62fc29581 | ||
|
|
39e7a7d2db | ||
|
|
88f1d09cb8 | ||
|
|
2109cf2508 | ||
|
|
bf7e049e44 | ||
|
|
dc14bc55ff | ||
|
|
0cdb089890 | ||
|
|
7128fa8a96 | ||
|
|
04a028245b | ||
|
|
526ad3f363 | ||
|
|
03045c9d48 | ||
|
|
4255531db5 | ||
|
|
f10ff18abc | ||
|
|
359a5c269c | ||
|
|
2a23cc9079 | ||
|
|
99c977445b | ||
|
|
7fcbc467df | ||
|
|
b89259840b | ||
|
|
de8738ef32 | ||
|
|
f2d9c9fb8e | ||
|
|
2c05c800d9 | ||
|
|
c6a90361d0 | ||
|
|
89fa4e73a5 | ||
|
|
c4a574422b | ||
|
|
9c6940200e | ||
|
|
e7a9e3a5b6 | ||
|
|
7eeb9fefb1 | ||
|
|
42b07b5ba9 | ||
|
|
b9b6939eff | ||
|
|
aba17c82e7 | ||
|
|
9a952bdb38 | ||
|
|
185d94183a | ||
|
|
e880872612 | ||
|
|
8d9a913b95 | ||
|
|
8976e517d3 | ||
|
|
b9e655c8cf | ||
|
|
cbb46cb70a | ||
|
|
37189ed67e | ||
|
|
ebd41b8a46 | ||
|
|
42ca256bac | ||
|
|
c67c24c210 | ||
|
|
f1dbf0ed42 | ||
|
|
f876b7cd20 | ||
|
|
5929e69793 | ||
|
|
10c7b335fb | ||
|
|
3d0ded5435 | ||
|
|
4498bd8981 | ||
|
|
e438e283f7 | ||
|
|
3c7f4bffd3 | ||
|
|
04f1bbe761 | ||
|
|
6935932dd0 | ||
|
|
082f6ec0e8 | ||
|
|
594e266d3f | ||
|
|
3d3b1fc902 | ||
|
|
1f10d8908b | ||
|
|
e168107a78 | ||
|
|
f5aa0db9ef | ||
|
|
03a7f58dfe | ||
|
|
61c778cc80 | ||
|
|
fb06ed4f8a | ||
|
|
b4a4bb3ddd | ||
|
|
497934f854 | ||
|
|
20f1372816 | ||
|
|
34ec56324f | ||
|
|
031084decd | ||
|
|
91d503ea67 | ||
|
|
057136b1e0 | ||
|
|
1eeb44bae6 | ||
|
|
763ed5dcc6 | ||
|
|
7bf709a679 | ||
|
|
1e6cb58a7c | ||
|
|
61b45a5331 | ||
|
|
b9c9a8bb13 | ||
|
|
06b546427b | ||
|
|
37f06b9e88 | ||
|
|
807edc5700 | ||
|
|
1c0910563b | ||
|
|
abf5ec7405 | ||
|
|
d9aea2ed1d | ||
|
|
047217dc93 | ||
|
|
46cc91e52b | ||
|
|
d94554302f | ||
|
|
af5cf30e34 | ||
|
|
fd3986ccfd | ||
|
|
91355372b6 | ||
|
|
70ae8326f2 | ||
|
|
d24d4372a7 | ||
|
|
2b7c498570 | ||
|
|
ad90f6c99a | ||
|
|
98d8badb52 | ||
|
|
dda0492152 | ||
|
|
005f668d7e | ||
|
|
76ddc8b408 | ||
|
|
8645ee0744 | ||
|
|
78c5765330 | ||
|
|
4f5ccc4933 | ||
|
|
acebb1755d | ||
|
|
b461b85dec | ||
|
|
05dc2cbd6a | ||
|
|
c8613fc864 | ||
|
|
827ccbab57 | ||
|
|
c53289753d | ||
|
|
f2516010c7 | ||
|
|
96f88d3eb5 | ||
|
|
2330179036 | ||
|
|
fd951f134c | ||
|
|
ff0f8f1133 | ||
|
|
b2c6a6c536 | ||
|
|
c81230b0f3 | ||
|
|
cf5871981d | ||
|
|
2e0746e850 | ||
|
|
1fb57c77c6 | ||
|
|
dcf682af76 | ||
|
|
9f014a9784 | ||
|
|
c2442565bd | ||
|
|
6b02c13e23 | ||
|
|
016681caba | ||
|
|
4630be1921 | ||
|
|
451f541342 | ||
|
|
ab4120a81e | ||
|
|
c21441a56e | ||
|
|
8108c42eae | ||
|
|
3c5cad6ab5 | ||
|
|
52d7e20979 | ||
|
|
db79470d32 | ||
|
|
9dc077feec | ||
|
|
1f3702b700 | ||
|
|
21c8b7a62e | ||
|
|
2770c8172b | ||
|
|
f4cd661480 | ||
|
|
3f61bca07c | ||
|
|
af7bb9aa3f | ||
|
|
bcb8ec0f81 | ||
|
|
e0fd85faac | ||
|
|
0b01bad764 | ||
|
|
032ec197d6 | ||
|
|
2fdf94f322 | ||
|
|
ed9b027c19 | ||
|
|
dee9d4ae40 | ||
|
|
4ac112ff39 | ||
|
|
3915b219cc | ||
|
|
ddd9e35c83 | ||
|
|
695dd1da7d | ||
|
|
2a17561477 | ||
|
|
892a90fa3c | ||
|
|
bbebef6929 | ||
|
|
1b992988eb | ||
|
|
fe6e70fa61 | ||
|
|
4fe335224d | ||
|
|
b4dddebd6c | ||
|
|
8060f59d69 | ||
|
|
ac531ff42f | ||
|
|
1872a309ec | ||
|
|
1b623cd275 | ||
|
|
362409e654 | ||
|
|
e771a11441 | ||
|
|
c43bf19bae | ||
|
|
aaab644e47 | ||
|
|
a1167dad4b | ||
|
|
63a6378f2d | ||
|
|
fffeb2c06c | ||
|
|
4d4eabf922 | ||
|
|
627cd80b5e | ||
|
|
ebc668077f | ||
|
|
f5c72f19b8 | ||
|
|
2f85ca47c3 | ||
|
|
cf59b3c621 | ||
|
|
6ad92a908c | ||
|
|
07da709ffa | ||
|
|
b3cfdd28be | ||
|
|
1242cb8ae8 | ||
|
|
89d658ab2e | ||
|
|
aa3714c2ed | ||
|
|
837d5cd95a | ||
|
|
5783b6e0bc | ||
|
|
17246ebdde | ||
|
|
2d3b1fd823 | ||
|
|
3b96837fba | ||
|
|
87454b5b2d | ||
|
|
20f85001d5 | ||
|
|
71780fccf4 | ||
|
|
c0e7a96406 | ||
|
|
1f5fc9ad88 | ||
|
|
65a5755120 | ||
|
|
5e368cd8b3 | ||
|
|
77ae299512 | ||
|
|
1b6776a53f | ||
|
|
d57924e25a | ||
|
|
cc71d14dfa | ||
|
|
197998d231 | ||
|
|
8b6fdcf9e1 | ||
|
|
04a1cffe7f | ||
|
|
a2a0fb4705 | ||
|
|
13435a8b41 | ||
|
|
341681a3dc | ||
|
|
0722469fb5 | ||
|
|
f4406c8770 | ||
|
|
2586819a47 | ||
|
|
f64c7269c7 | ||
|
|
4789371f4d | ||
|
|
6f0fc68c3d | ||
|
|
201849e691 | ||
|
|
63f82d971d | ||
|
|
a037cd15af | ||
|
|
6ae7494a45 | ||
|
|
64c8f4fbb3 | ||
|
|
9c90722ef9 | ||
|
|
9f94d72b69 | ||
|
|
7156af1965 | ||
|
|
20d10fc268 | ||
|
|
8df8a77bf1 | ||
|
|
e07c83f4a9 | ||
|
|
487ab04a14 | ||
|
|
fd1aeb1ead | ||
|
|
e35ef38fea | ||
|
|
26b47e63d9 | ||
|
|
a4f74929fa | ||
|
|
4b022d05f2 | ||
|
|
118d271ea4 | ||
|
|
def007d743 | ||
|
|
7acf18508d | ||
|
|
e7b11c6498 | ||
|
|
b833ca23ba | ||
|
|
380a25a546 | ||
|
|
f92af374c7 | ||
|
|
5b22039600 | ||
|
|
6560d0ced7 | ||
|
|
8bcfcd6059 | ||
|
|
6c1c317921 | ||
|
|
7fc7125c71 | ||
|
|
9f03164258 | ||
|
|
29bca610a0 | ||
|
|
790c8d2fd4 | ||
|
|
877f7a3d81 | ||
|
|
d59b1af8a9 | ||
|
|
85184e174d | ||
|
|
720f3fdbd2 | ||
|
|
0d6c7290e5 | ||
|
|
f768d9eff8 | ||
|
|
66dd3ec014 | ||
|
|
29ebfdc324 | ||
|
|
17de0a5a77 | ||
|
|
efc07056aa | ||
|
|
9a81c631fa | ||
|
|
2f01950d45 | ||
|
|
c25441cca2 | ||
|
|
35cb10ff44 | ||
|
|
eb67fb9bd9 | ||
|
|
c455d66b91 | ||
|
|
153aae659c | ||
|
|
a00a6e5f2b | ||
|
|
2eef425f41 | ||
|
|
ce3ad61f8f | ||
|
|
0ad84572a7 | ||
|
|
c6fb7182e9 | ||
|
|
003ae3d31a | ||
|
|
f17a83a1b3 | ||
|
|
827c88d24f | ||
|
|
872f79a9ea | ||
|
|
0b2879ead0 | ||
|
|
357b2e8ac0 | ||
|
|
efa84b0fa1 | ||
|
|
8705f638d5 | ||
|
|
bc3c771137 | ||
|
|
a92420d654 | ||
|
|
c0b950e637 | ||
|
|
f0e79e93b6 | ||
|
|
f2b0b1b039 | ||
|
|
8235549e4f | ||
|
|
6b173c42b6 | ||
|
|
f5d25f735c | ||
|
|
ac4370f6f3 | ||
|
|
d522b9a337 | ||
|
|
33a8f4baa5 | ||
|
|
cbb58d0363 | ||
|
|
35ebc90fa2 | ||
|
|
2501b08c02 | ||
|
|
24fd2d6e38 | ||
|
|
e79b1065d9 | ||
|
|
2494f9a773 | ||
|
|
9d3c2dd0d3 | ||
|
|
b2753be204 | ||
|
|
a3d282f33e | ||
|
|
51b9285980 | ||
|
|
0167e46eb7 | ||
|
|
ecfdcf1bac | ||
|
|
63b9d69f3f | ||
|
|
a56afecccf | ||
|
|
484f0ce897 | ||
|
|
4efd5ae2d3 | ||
|
|
2052308be0 | ||
|
|
cdb4522e5f | ||
|
|
4f2494e135 | ||
|
|
202068124b | ||
|
|
87a29f324b | ||
|
|
61b9fd12bb | ||
|
|
b7380fd582 | ||
|
|
cd2a075fab | ||
|
|
9f23c6e852 | ||
|
|
93553574b3 | ||
|
|
5807159e5f | ||
|
|
6ab9d4ca27 | ||
|
|
b5882cee04 | ||
|
|
cfdd78244d | ||
|
|
194523c13a | ||
|
|
84b53fe64c | ||
|
|
99e0227fec | ||
|
|
00ecf0497c | ||
|
|
beb3e7e0c4 | ||
|
|
14a865dff4 | ||
|
|
3b365f8ad5 | ||
|
|
b8038f4d41 | ||
|
|
68c8c03642 | ||
|
|
be4c31be92 | ||
|
|
c4cab77319 | ||
|
|
9d099ba358 | ||
|
|
62fd69b147 | ||
|
|
f50265a212 | ||
|
|
e5669dd982 | ||
|
|
7e6022da59 | ||
|
|
d807c9f085 | ||
|
|
d9a56ae0f1 | ||
|
|
a9661a71ff | ||
|
|
7080570b43 | ||
|
|
a47198b37f | ||
|
|
cab9c81c63 | ||
|
|
25612b2252 | ||
|
|
09a67bf4e6 | ||
|
|
3bfb7fa796 | ||
|
|
de118ac259 | ||
|
|
6c7520b75e | ||
|
|
5d36b41c1e | ||
|
|
8e92df5bd7 | ||
|
|
57e936785d | ||
|
|
d7b67ba4a8 | ||
|
|
7192b7b2eb | ||
|
|
3fd7f4b2a4 | ||
|
|
320dbe1948 | ||
|
|
73d4d141bc | ||
|
|
273966e77f | ||
|
|
1c0c4df3e3 | ||
|
|
fe79d35cc0 | ||
|
|
132a52c7b2 | ||
|
|
bf1f68d5e6 | ||
|
|
22a8cbc402 | ||
|
|
c4447eae5e | ||
|
|
d88d0f765e | ||
|
|
7ea9f10d6f | ||
|
|
024ca83afd | ||
|
|
2f01c815e8 | ||
|
|
4c65d5a07a | ||
|
|
905fa4afce | ||
|
|
217d9735a6 | ||
|
|
e3e9d740ff | ||
|
|
0920659693 | ||
|
|
ae68d59a20 | ||
|
|
8b5d7c65d9 | ||
|
|
b45f032f89 | ||
|
|
ee58c21fef | ||
|
|
2a198db593 | ||
|
|
f8be7b06d7 | ||
|
|
70ed3c6f1f | ||
|
|
64356965fb | ||
|
|
75dcb8935d | ||
|
|
d66dc07ae1 | ||
|
|
f066eb0a60 | ||
|
|
d9fee64c35 | ||
|
|
9039064652 | ||
|
|
e3ce042be4 | ||
|
|
c37d3fa34e | ||
|
|
a592eb896f | ||
|
|
2d3c8f5713 | ||
|
|
c6d93278cd | ||
|
|
03b6f25e14 | ||
|
|
6ccb54e64a | ||
|
|
6e1e47d9fd | ||
|
|
761357b737 | ||
|
|
57f1ebc358 | ||
|
|
345d66e0fd | ||
|
|
b5cdf1d2a6 | ||
|
|
fe81ae8f7d | ||
|
|
382493ca84 | ||
|
|
450fceec62 | ||
|
|
41de70a1b9 | ||
|
|
9bedb4f311 | ||
|
|
7166a10ac2 | ||
|
|
ed924b67fe | ||
|
|
0b8c0f57f8 | ||
|
|
a4d1e9fbb9 | ||
|
|
9afbf0ea5e | ||
|
|
8e9c853a81 | ||
|
|
3d9ade0f37 | ||
|
|
6094ab8e1c | ||
|
|
1f13a35c51 | ||
|
|
38845197d8 | ||
|
|
f9cf931828 | ||
|
|
7a88971114 | ||
|
|
827aa7154f | ||
|
|
1ebf4d0a28 | ||
|
|
df95b50f8c | ||
|
|
7ad5508623 | ||
|
|
4dc8be7387 | ||
|
|
d9d63b8f1c | ||
|
|
165e8c9636 | ||
|
|
cff2ca3c95 | ||
|
|
d41871cc53 | ||
|
|
6b3c42e0ed | ||
|
|
d41eaef8a2 | ||
|
|
f54118843e | ||
|
|
f0b8474350 | ||
|
|
61ff665cec | ||
|
|
dac5ff72a3 | ||
|
|
6844406cf7 | ||
|
|
083f8e5fd7 | ||
|
|
218ab2aa89 | ||
|
|
61ff98e830 | ||
|
|
9a938d129b | ||
|
|
66419777d8 | ||
|
|
09abef2d15 | ||
|
|
0a09fa4987 | ||
|
|
d401e5f7de | ||
|
|
3191f5250d | ||
|
|
9656b04a3e | ||
|
|
3629fe8848 | ||
|
|
e6fe598e7a | ||
|
|
e855fca0b3 | ||
|
|
9b0e498c6d | ||
|
|
371f61fa08 | ||
|
|
fa6ff43c6f | ||
|
|
bde36dd241 | ||
|
|
ae91fc4cdd | ||
|
|
6c7a8cae92 | ||
|
|
7cea5563a7 | ||
|
|
755c289894 | ||
|
|
17c5b89cc8 | ||
|
|
32a3e20c76 | ||
|
|
5e795a654d | ||
|
|
3103b87249 | ||
|
|
98e9fb3a14 | ||
|
|
43e9d5db67 | ||
|
|
bcf13d71b2 | ||
|
|
0ac84dc3e4 | ||
|
|
a5fdf43b70 | ||
|
|
bbfe3adc54 | ||
|
|
a921aac51e | ||
|
|
15ed0f84e7 | ||
|
|
2a376d2c9b | ||
|
|
f27b3d480a | ||
|
|
5a116e77b9 | ||
|
|
1d66011b08 | ||
|
|
72b613a00e | ||
|
|
17cae5d3a4 | ||
|
|
642bd8ecae | ||
|
|
92385f44b0 | ||
|
|
4cd1cb4e39 | ||
|
|
7a1fe0b0fc | ||
|
|
3c5beb0286 | ||
|
|
9677c5cb19 | ||
|
|
d25faedc2b | ||
|
|
2f24de2592 | ||
|
|
751a0ef6f4 | ||
|
|
d47f66ebba | ||
|
|
3b2665c4ac | ||
|
|
e83950c0c2 | ||
|
|
857f1cecb7 | ||
|
|
d022c4fe81 | ||
|
|
66262fddc8 | ||
|
|
fcc2c3d542 | ||
|
|
e07bba7dbd | ||
|
|
7f55bd6038 | ||
|
|
e9e10e4ffa | ||
|
|
260aed3838 | ||
|
|
11884799b7 | ||
|
|
a0058857b9 | ||
|
|
b24daf41e2 | ||
|
|
91eea93a58 | ||
|
|
9b6be615df | ||
|
|
2567f802df | ||
|
|
eef6eebffa | ||
|
|
2d971d229c | ||
|
|
d82be76b05 | ||
|
|
01579e99fb | ||
|
|
f4f9b37510 | ||
|
|
21ad85c870 | ||
|
|
65a1220758 | ||
|
|
4798133374 | ||
|
|
ab2db176de | ||
|
|
4f3750a8ab | ||
|
|
1a39e59a6e | ||
|
|
964f2083b6 | ||
|
|
9e8434a16b | ||
|
|
3eebbfb9d7 | ||
|
|
5ca3463bf6 | ||
|
|
875910b4d7 | ||
|
|
853bcbde35 | ||
|
|
2442ca63be | ||
|
|
f2f48376d7 | ||
|
|
91ded0125e | ||
|
|
4bf49675d2 | ||
|
|
66058cea97 | ||
|
|
d2ef8f0899 | ||
|
|
17248eeedf | ||
|
|
9bce6b0a6b | ||
|
|
53ba7b3b2f | ||
|
|
48fd8579c5 | ||
|
|
62bf372e1d | ||
|
|
fe1297936f | ||
|
|
e22ff85dc8 | ||
|
|
3a4654141f | ||
|
|
967dcfcb37 | ||
|
|
d7faf186f8 | ||
|
|
3143fedb7a | ||
|
|
f819aa68d5 | ||
|
|
2dc3a2b93c | ||
|
|
a3aa5d9c1a | ||
|
|
975ad20d5a | ||
|
|
a94106ae61 | ||
|
|
b299227da2 | ||
|
|
3286fc315c | ||
|
|
105576cf17 | ||
|
|
777e830aec | ||
|
|
4b934db7db | ||
|
|
bbf2dc903a | ||
|
|
9d2685d4a8 | ||
|
|
4507eacf1a | ||
|
|
f2a40b993a | ||
|
|
69956a46d0 | ||
|
|
840385272c | ||
|
|
8fb05858c8 | ||
|
|
95d0667077 | ||
|
|
56fac4c34b | ||
|
|
2d523b030f | ||
|
|
f5b7a0a74b | ||
|
|
d0d0f30d64 | ||
|
|
3e9dd599a6 | ||
|
|
0651f57e86 | ||
|
|
7eccc3119b | ||
|
|
9545587b67 | ||
|
|
ef22c88861 | ||
|
|
04185f9a31 | ||
|
|
3723888b0c | ||
|
|
bb982629b5 | ||
|
|
48300d7f01 | ||
|
|
2ae838b4a4 | ||
|
|
ceb563cd60 | ||
|
|
298cd2c6d4 | ||
|
|
4112323961 | ||
|
|
1087a87ea2 | ||
|
|
5227a1370c | ||
|
|
c33f306944 | ||
|
|
3306780507 | ||
|
|
73389d842a | ||
|
|
4e26c5942f | ||
|
|
06e6ae417e | ||
|
|
6eb1312c61 | ||
|
|
84d7b60cd2 | ||
|
|
81844fa456 | ||
|
|
4bedeb9fcd | ||
|
|
0d435a5d4a | ||
|
|
4994543d56 | ||
|
|
026ceab2ef | ||
|
|
c92bf504fb | ||
|
|
a7eac42c9d | ||
|
|
f20550d5f6 | ||
|
|
c13601cd2d | ||
|
|
c0dd7eacb6 | ||
|
|
f2d5eac330 | ||
|
|
092b548d20 | ||
|
|
70ab072c79 | ||
|
|
f8a8c43d0d | ||
|
|
fcd431b421 | ||
|
|
2a9ba5b526 | ||
|
|
aba9402830 | ||
|
|
8877f9871f | ||
|
|
742b08c86d | ||
|
|
a85ddcd6c1 | ||
|
|
758ac4ab62 | ||
|
|
56e5d7ff54 | ||
|
|
596a8ba21d | ||
|
|
19b67b3c8a | ||
|
|
eaa97ce301 | ||
|
|
f569826b78 | ||
|
|
0daab74a58 | ||
|
|
a05d16a789 | ||
|
|
16c97e94cc | ||
|
|
bd9af49412 | ||
|
|
ab5c7093eb | ||
|
|
b4e8c5101a | ||
|
|
911886b90c | ||
|
|
c14b72456f | ||
|
|
6d7e06a0d2 | ||
|
|
0288c14a29 | ||
|
|
748334eed6 | ||
|
|
07301ea599 | ||
|
|
2f919de9e3 | ||
|
|
5ed1fc44fd | ||
|
|
f11aa77e3a | ||
|
|
1bbcf60e58 | ||
|
|
32bbf5bb27 | ||
|
|
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 | ||
|
|
b8b49da99e | ||
|
|
1119a45e93 |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: MacRimi # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: macrimi # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||
against the English source (AppImage/messages/en/common.json).
|
||||
|
||||
Guardrails:
|
||||
- Keys already translated in a target locale are PRESERVED. A key is
|
||||
considered "already translated" when the target value is non-empty
|
||||
AND differs from the English source. This protects human-curated
|
||||
locales (Vaso73's sk) from being overwritten.
|
||||
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
|
||||
are extracted before translation and restored afterwards, so the
|
||||
interpolation contract stays intact regardless of what the
|
||||
translation provider does with the surrounding text.
|
||||
- `sk` IS translated by default too. Guardrail #1 protects every key
|
||||
Vaso73 has curated by hand; auto-translation only fills the keys
|
||||
that are still on the English fallback in sk.
|
||||
|
||||
Reuses the same translation providers as build_translation_cache.py so
|
||||
the CI environment (googletrans pinning, AppImage provider) stays
|
||||
identical.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Reuse providers + cleaner from the CLI translation script.
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from build_translation_cache import ( # noqa: E402
|
||||
clean_translation,
|
||||
protect_technical_terms,
|
||||
restore_technical_terms,
|
||||
translate_appimage,
|
||||
translate_google_web,
|
||||
translate_googletrans,
|
||||
)
|
||||
|
||||
|
||||
# sk IS included in the default. Guardrail #1 (never overwrite a key
|
||||
# whose target value differs from EN) protects every string Vaso73 has
|
||||
# already curated by hand — auto-translation only ever touches keys
|
||||
# that are still on the English fallback in sk. Trade-off accepted:
|
||||
# users on sk see a decent auto-translation for new keys instead of raw
|
||||
# English while the human maintainer catches up, and Vaso73 keeps full
|
||||
# ownership of the wording via follow-up PRs.
|
||||
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
|
||||
DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard. Translate:"
|
||||
|
||||
# next-intl / ICU-style placeholders: {name}, {vmid}, {count}, {app_name}.
|
||||
# We deliberately do NOT match `{{ escaped }}` or nested braces — the
|
||||
# codebase uses only the simple form.
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
||||
|
||||
|
||||
def flatten(node: dict, prefix: str = "") -> dict[str, str]:
|
||||
"""Depth-first flatten of a nested dict into ``{"a.b.c": "value"}``.
|
||||
Non-string leaves are coerced to str (should not happen in messages
|
||||
catalogs, but keeps the function total)."""
|
||||
out: dict[str, str] = {}
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
|
||||
if isinstance(value, dict):
|
||||
out.update(flatten(value, path))
|
||||
elif value is None:
|
||||
out[path] = ""
|
||||
else:
|
||||
out[path] = str(value)
|
||||
return out
|
||||
|
||||
|
||||
def unflatten(flat: dict[str, str]) -> dict:
|
||||
"""Inverse of ``flatten``: rebuild nested structure from dotted keys."""
|
||||
out: dict = {}
|
||||
for path, value in flat.items():
|
||||
parts = path.split(".")
|
||||
cursor = out
|
||||
for part in parts[:-1]:
|
||||
existing = cursor.get(part)
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
cursor[part] = existing
|
||||
cursor = existing
|
||||
cursor[parts[-1]] = value
|
||||
return out
|
||||
|
||||
|
||||
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
||||
"""Swap each ``{xxx}`` for an opaque token that machine translators
|
||||
tend to leave alone. Order is preserved so restore_placeholders can
|
||||
walk it linearly."""
|
||||
placeholders: list[str] = []
|
||||
|
||||
def _swap(match: re.Match) -> str:
|
||||
placeholders.append(match.group(0))
|
||||
return f"__PMX_PH_{len(placeholders) - 1}__"
|
||||
|
||||
return PLACEHOLDER_RE.sub(_swap, text), placeholders
|
||||
|
||||
|
||||
def restore_placeholders(text: str, placeholders: list[str]) -> str:
|
||||
"""Reverse of ``protect_placeholders``. If the provider mangled a
|
||||
token beyond recognition we leave the mangled form in place — the
|
||||
fallback assignment (target = existing or EN) upstream catches
|
||||
the worst case."""
|
||||
for i, original in enumerate(placeholders):
|
||||
text = text.replace(f"__PMX_PH_{i}__", original)
|
||||
return text
|
||||
|
||||
|
||||
def translate_one(
|
||||
text: str,
|
||||
lang: str,
|
||||
provider: str,
|
||||
context: str,
|
||||
timeout: int,
|
||||
appimage_path: Path,
|
||||
) -> str:
|
||||
"""Dispatch to the correct provider. Reuses the same three
|
||||
implementations as build_translation_cache.py so there is exactly
|
||||
one place to fix if a provider changes upstream."""
|
||||
if provider == "googletrans":
|
||||
raw = translate_googletrans(text, lang, context)
|
||||
elif provider == "google-web":
|
||||
raw = translate_google_web(text, lang, context, timeout)
|
||||
elif provider == "appimage":
|
||||
raw = translate_appimage(text, lang, context, timeout, appimage_path)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
return clean_translation(raw) or text
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SystemExit(f"Invalid JSON at {path}: {exc}") from exc
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> None:
|
||||
"""Write with indent=2, no sort_keys — we want to keep the same
|
||||
top-level ordering the maintainer uses in en/common.json so diffs
|
||||
stay readable side-by-side."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=Path,
|
||||
default=Path("AppImage/messages/en/common.json"),
|
||||
help="Path to the English source catalog.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--messages-dir",
|
||||
type=Path,
|
||||
default=Path("AppImage/messages"),
|
||||
help="Directory that contains per-locale subdirectories.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--languages",
|
||||
default=",".join(DEFAULT_LANGUAGES),
|
||||
help=(
|
||||
"Comma-separated target locales. Includes sk by default; "
|
||||
"guardrail #1 never overwrites keys whose sk value differs "
|
||||
"from EN, so Vaso73's curated translations are safe."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("appimage", "googletrans", "google-web"),
|
||||
default="googletrans",
|
||||
help="Translation provider. Default matches build_translation_cache.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--appimage-path",
|
||||
type=Path,
|
||||
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
|
||||
)
|
||||
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||
parser.add_argument("--timeout", type=int, default=30)
|
||||
parser.add_argument("--sleep", type=float, default=0.15)
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Re-translate EVERY key, ignoring existing translations. "
|
||||
"Dangerous: this DOES overwrite human-curated strings. "
|
||||
"Use only when you know what you are doing."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Only translate the first N missing keys per locale (test runs).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-every",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Write the locale JSON every N translated keys so a crash mid-run leaves partial progress on disk.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_arg_parser().parse_args()
|
||||
|
||||
source = args.source.resolve()
|
||||
messages_dir = args.messages_dir.resolve()
|
||||
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
|
||||
|
||||
if not source.is_file():
|
||||
print(f"Source not found: {source}", file=sys.stderr)
|
||||
return 1
|
||||
if not languages:
|
||||
print("No destination languages selected.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
en_nested = read_json(source)
|
||||
en_flat = flatten(en_nested)
|
||||
print(f"Source: {source}", flush=True)
|
||||
print(f"EN keys: {len(en_flat)}", flush=True)
|
||||
print(f"Target locales: {', '.join(languages)}", flush=True)
|
||||
print(f"Provider: {args.provider}", flush=True)
|
||||
print(f"Sleep between calls: {args.sleep}s", flush=True)
|
||||
|
||||
total_failures: list[tuple[str, str, str]] = []
|
||||
|
||||
for lang in languages:
|
||||
locale_path = messages_dir / lang / "common.json"
|
||||
target_flat = flatten(read_json(locale_path))
|
||||
|
||||
# Decide what needs translating. Same rule as
|
||||
# build_translation_cache.py: only touch keys whose target
|
||||
# value is empty. Never overwrite an existing value — that
|
||||
# covers three legitimate cases in one line:
|
||||
# 1. Human-curated translations (protected trivially).
|
||||
# 2. Universal tokens the maintainer left equal to EN on
|
||||
# purpose (Hardware, Terminal, Normal, SMART, OK, CPU %,
|
||||
# {count}h, product names, etc.). Around 120 keys in
|
||||
# es/common.json — the old `existing == en_value` rule
|
||||
# kept resending these to Google every run, and the
|
||||
# provider sometimes mangled them (`Terminal` →
|
||||
# `terminal`, `CPU %` → `% de CPU`, ...).
|
||||
# 3. Auto-fills from prior runs whose output happened to
|
||||
# match EN — leaving them alone is the intended
|
||||
# steady-state, not a bug.
|
||||
# When someone genuinely wants to redo everything, --refresh
|
||||
# is still available (and is destructive by design).
|
||||
missing: list[str] = []
|
||||
for key, en_value in en_flat.items():
|
||||
if not en_value:
|
||||
continue
|
||||
existing = target_flat.get(key, "")
|
||||
if args.refresh or not existing:
|
||||
missing.append(key)
|
||||
|
||||
if args.limit > 0:
|
||||
missing = missing[: args.limit]
|
||||
|
||||
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
|
||||
if not missing:
|
||||
print(f" {lang}: nothing to do", flush=True)
|
||||
continue
|
||||
|
||||
failures_for_lang: list[tuple[str, str, str]] = []
|
||||
|
||||
for index, key in enumerate(missing, start=1):
|
||||
en_value = en_flat[key]
|
||||
protected, placeholders = protect_placeholders(en_value)
|
||||
protected, technical_terms = protect_technical_terms(protected)
|
||||
|
||||
try:
|
||||
translated = translate_one(
|
||||
protected,
|
||||
lang,
|
||||
args.provider,
|
||||
args.context,
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
translated = restore_technical_terms(translated, technical_terms)
|
||||
target_flat[key] = restore_placeholders(translated, placeholders)
|
||||
print(
|
||||
f" [{lang} {index}/{len(missing)}] {key}: "
|
||||
f"{en_value[:60]!r} → {target_flat[key][:60]!r}",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Fall back to whatever we already had (or EN) so the
|
||||
# runtime fallback still kicks in for this key.
|
||||
target_flat[key] = target_flat.get(key) or en_value
|
||||
failures_for_lang.append((lang, key, str(exc)))
|
||||
print(f" [{lang}] {key}: FAILED — {exc}", file=sys.stderr, flush=True)
|
||||
|
||||
if args.save_every > 0 and index % args.save_every == 0:
|
||||
# Preserve keys already in target_flat + write partial progress.
|
||||
write_json(locale_path, unflatten(target_flat))
|
||||
time.sleep(args.sleep)
|
||||
|
||||
write_json(locale_path, unflatten(target_flat))
|
||||
print(f" wrote {locale_path}", flush=True)
|
||||
total_failures.extend(failures_for_lang)
|
||||
|
||||
if total_failures:
|
||||
print(
|
||||
f"\nCompleted with {len(total_failures)} translation failures "
|
||||
f"(partial progress persisted, next run will retry).",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
for lang, key, error in total_failures[:20]:
|
||||
print(f" - {lang}: {key} → {error}", file=sys.stderr, flush=True)
|
||||
if len(total_failures) > 20:
|
||||
print(f" ... and {len(total_failures) - 20} more.", file=sys.stderr, flush=True)
|
||||
# Exit 0 on partial failure so the workflow's Commit + push
|
||||
# step still runs and the keys that DID translate reach
|
||||
# develop. The un-translated keys stay empty and the next
|
||||
# workflow tick (or a manual dispatch) retries them.
|
||||
# Previously we returned 2, which failed the whole run and
|
||||
# discarded 36 out of 38 successful translations because
|
||||
# 2 googletrans timeouts hit sv at the start of the burst.
|
||||
return 0
|
||||
|
||||
print("\ni18n messages generated successfully.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build the ProxMenux translation cache from translate calls in scripts/.
|
||||
|
||||
The generated JSON keeps the same shape used by scripts/utils.sh:
|
||||
|
||||
{
|
||||
"Original English text": {
|
||||
"es": "Translated text",
|
||||
"fr": "Translated text"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk", "sv")
|
||||
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
|
||||
# googletrans and the public Google endpoint used by this workflow do not
|
||||
# support Cloud Translation glossaries. Protect product names, package names
|
||||
# and command identifiers with opaque tokens before sending text to any
|
||||
# provider, then restore the exact source spelling afterwards. Keep longer
|
||||
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
|
||||
PROTECTED_TECHNICAL_TERMS = (
|
||||
"google/gasket-driver",
|
||||
"feranick/gasket-driver",
|
||||
"libedgetpu1-std",
|
||||
"Proxmox VE Helper-Scripts",
|
||||
"Docker Compose",
|
||||
"gasket-driver",
|
||||
"gasket-dkms",
|
||||
"libedgetpu1",
|
||||
"libedgetpu",
|
||||
"Google Coral",
|
||||
"Edge TPU",
|
||||
"ProxMenux",
|
||||
"Proxmox",
|
||||
"AppImage",
|
||||
"smartctl",
|
||||
"systemctl",
|
||||
"pveproxy",
|
||||
"apt-get",
|
||||
"Frigate",
|
||||
"Docker",
|
||||
"Coral",
|
||||
"gasket",
|
||||
"apex",
|
||||
"lspci",
|
||||
"dpkg",
|
||||
"DKMS",
|
||||
"QEMU",
|
||||
"LXC",
|
||||
"ZFS",
|
||||
"SSH",
|
||||
"fork",
|
||||
)
|
||||
TECHNICAL_TERM_RE = re.compile(
|
||||
"|".join(
|
||||
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
|
||||
for term in sorted(PROTECTED_TECHNICAL_TERMS, key=len, reverse=True)
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TRANSLATE_CALL_RE = re.compile(
|
||||
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
|
||||
re.VERBOSE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
|
||||
"""Replace glossary terms with stable tokens before translation."""
|
||||
protected: list[str] = []
|
||||
|
||||
def _swap(match: re.Match[str]) -> str:
|
||||
protected.append(match.group(0))
|
||||
return f"__PMX_TERM_{len(protected) - 1}__"
|
||||
|
||||
return TECHNICAL_TERM_RE.sub(_swap, text), protected
|
||||
|
||||
|
||||
def restore_technical_terms(text: str, protected: list[str]) -> str:
|
||||
"""Restore glossary terms exactly as they appeared in the source."""
|
||||
for index, original in enumerate(protected):
|
||||
text = text.replace(f"__PMX_TERM_{index}__", original)
|
||||
return text
|
||||
|
||||
|
||||
def iter_script_files(
|
||||
scripts_dir: Path, extra_files: Iterable[Path] = ()
|
||||
) -> Iterable[Path]:
|
||||
# Walk the main scripts tree.
|
||||
for path in sorted(scripts_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name == "utils.sh":
|
||||
continue
|
||||
if path.suffix not in {".sh", ".func"}:
|
||||
continue
|
||||
yield path
|
||||
# Yield additional files passed explicitly (e.g. the root-level `menu`
|
||||
# entry point or install_proxmenux*.sh). These live outside scripts/
|
||||
# but still contain translate "..." calls we want in the cache.
|
||||
# No extension filter and no utils.sh skip — the caller decided
|
||||
# they belong, we just check the file actually exists.
|
||||
for extra in extra_files:
|
||||
if extra.is_file():
|
||||
yield extra
|
||||
|
||||
|
||||
def decode_shell_string(raw: str, quote_char: str) -> str:
|
||||
if quote_char == "'":
|
||||
return raw
|
||||
try:
|
||||
return ast.literal_eval(f'"{raw}"')
|
||||
except Exception:
|
||||
return raw.replace(r"\"", '"').replace(r"\\", "\\")
|
||||
|
||||
|
||||
def extract_translate_texts(
|
||||
scripts_dir: Path, extra_files: Iterable[Path] = ()
|
||||
) -> list[str]:
|
||||
found: dict[str, None] = {}
|
||||
for path in iter_script_files(scripts_dir, extra_files):
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
for match in TRANSLATE_CALL_RE.finditer(content):
|
||||
text = decode_shell_string(match.group("text"), match.group("quote"))
|
||||
text = text.strip()
|
||||
if text and "$" not in text and "`" not in text:
|
||||
found.setdefault(text, None)
|
||||
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
|
||||
try:
|
||||
from googletrans import Translator # type: ignore
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"googletrans is not installed. Install googletrans==4.0.0-rc1 "
|
||||
"or run with --provider google-web."
|
||||
) from exc
|
||||
|
||||
translator = Translator()
|
||||
full_text = f"{context} {text}".strip()
|
||||
result = translator.translate(full_text, dest=dest_lang)
|
||||
if inspect.isawaitable(result):
|
||||
result = asyncio.run(result)
|
||||
return result.text
|
||||
|
||||
|
||||
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
|
||||
# The public Google endpoint is not prompt-aware: if we prepend context,
|
||||
# it often translates and returns that context as part of the result.
|
||||
full_text = text
|
||||
url = (
|
||||
"https://translate.googleapis.com/translate_a/single"
|
||||
f"?client=gtx&sl=en&tl={quote(dest_lang)}&dt=t&q={quote(full_text)}"
|
||||
)
|
||||
req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"})
|
||||
with urlopen(req, timeout=timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
return "".join(part[0] for part in payload[0] if part and part[0])
|
||||
|
||||
|
||||
def translate_appimage(
|
||||
text: str,
|
||||
dest_lang: str,
|
||||
context: str,
|
||||
timeout: int,
|
||||
appimage_path: Path,
|
||||
) -> str:
|
||||
if not appimage_path.exists():
|
||||
prev_path = appimage_path.with_name(appimage_path.name + ".prev")
|
||||
if prev_path.exists():
|
||||
appimage_path = prev_path
|
||||
else:
|
||||
raise FileNotFoundError(f"AppImage not found: {appimage_path}")
|
||||
|
||||
req = {
|
||||
"text": text,
|
||||
"dest_lang": dest_lang,
|
||||
"context": context,
|
||||
"cache_file": "",
|
||||
}
|
||||
env = os.environ.copy()
|
||||
env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||
completed = subprocess.run(
|
||||
[str(appimage_path), "--translate"],
|
||||
input=json.dumps(req, ensure_ascii=False),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError((completed.stderr or completed.stdout).strip())
|
||||
|
||||
# AppRun may print a startup line before translate_cli.py emits JSON.
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
payload = json.loads(line)
|
||||
if payload.get("success"):
|
||||
return str(payload.get("text", text))
|
||||
raise RuntimeError(str(payload.get("error", "unknown AppImage translation error")))
|
||||
|
||||
raise RuntimeError(f"AppImage did not return JSON: {completed.stdout.strip()}")
|
||||
|
||||
|
||||
def clean_translation(value: str) -> str:
|
||||
separator = r"[\s\u00a0]*[::]"
|
||||
# `Translate` pivot in every locale currently supported. Without
|
||||
# the target-language variant here, the cleaner can't locate the
|
||||
# boundary between the context prompt and the real translation,
|
||||
# and the whole payload leaks through as the translated context.
|
||||
# Caught on the 2026-08-14 workflow run — every sk / sv key ended
|
||||
# up as "Technický text používateľského rozhrania…" or
|
||||
# "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Övers"
|
||||
# because Preložiť / Översätt / Översätta were missing.
|
||||
translate_labels = (
|
||||
"Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
|
||||
"|Preložiť|Prelož|Preloz" # sk
|
||||
"|Översätta|Översätt" # sv
|
||||
)
|
||||
context_labels = (
|
||||
"Context|Contexto|Contexte|Kontext|Contesto"
|
||||
"|Sammanhang" # sv alternate
|
||||
)
|
||||
value = re.sub(
|
||||
rf"^.*?({translate_labels}){separator}",
|
||||
"",
|
||||
value,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
value = re.sub(
|
||||
rf"^.*?({context_labels}){separator}.*?({translate_labels}){separator}",
|
||||
"",
|
||||
value,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
value = re.sub(
|
||||
rf"^.*?({context_labels}){separator}",
|
||||
"",
|
||||
value,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def translate_text(
|
||||
text: str,
|
||||
dest_lang: str,
|
||||
provider: str,
|
||||
context: str,
|
||||
timeout: int,
|
||||
appimage_path: Path,
|
||||
) -> str:
|
||||
protected_text, protected_terms = protect_technical_terms(text)
|
||||
if provider == "googletrans":
|
||||
translated = translate_googletrans(protected_text, dest_lang, context)
|
||||
elif provider == "google-web":
|
||||
translated = translate_google_web(protected_text, dest_lang, context, timeout)
|
||||
elif provider == "appimage":
|
||||
translated = translate_appimage(
|
||||
protected_text, dest_lang, context, timeout, appimage_path
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
translated = restore_technical_terms(clean_translation(translated), protected_terms)
|
||||
return translated or text
|
||||
|
||||
|
||||
def load_language_cache(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {str(text): str(value) for text, value in data.items()}
|
||||
|
||||
|
||||
def write_language_cache(path: Path, cache: dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp_path.write_text(
|
||||
json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract translate calls from scripts/ and build json/cache.json."
|
||||
)
|
||||
parser.add_argument("--scripts-dir", default="scripts", type=Path)
|
||||
parser.add_argument(
|
||||
"--extra-file",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Extra individual files to scan for translate calls in addition "
|
||||
"to --scripts-dir. Useful for the root-level `menu` entry point "
|
||||
"and install_proxmenux*.sh, which sit outside scripts/. "
|
||||
"Pass multiple times to add more than one file."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=Path("lang"),
|
||||
type=Path,
|
||||
help="Directory where per-language JSON files are written. Default: lang",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default=None,
|
||||
type=Path,
|
||||
help="Deprecated combined cache path. If used, per-language files are written next to it under its parent directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--languages",
|
||||
default=",".join(DEFAULT_LANGUAGES),
|
||||
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("appimage", "googletrans", "google-web"),
|
||||
default="appimage",
|
||||
help="Translation provider to use. Default: appimage",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--appimage-path",
|
||||
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
|
||||
type=Path,
|
||||
help="Path to the ProxMenux AppImage when using --provider appimage.",
|
||||
)
|
||||
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||
parser.add_argument("--timeout", default=30, type=int)
|
||||
parser.add_argument("--sleep", default=0.15, type=float)
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="Translate all entries again instead of reusing existing cache values.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extract-only",
|
||||
action="store_true",
|
||||
help="Only update the cache keys; missing translations are left empty.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Only process the first N extracted strings. Useful for test runs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-every",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Write the output JSON every N translated items. Default: 1",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_arg_parser().parse_args()
|
||||
scripts_dir = args.scripts_dir.resolve()
|
||||
if args.output is not None:
|
||||
output_dir = args.output.resolve().parent / "lang"
|
||||
else:
|
||||
output_dir = args.output_dir.resolve()
|
||||
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
|
||||
|
||||
if not scripts_dir.is_dir():
|
||||
print(f"Scripts directory not found: {scripts_dir}", file=sys.stderr)
|
||||
return 1
|
||||
if not languages:
|
||||
print("No destination languages selected.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
texts = extract_translate_texts(scripts_dir, args.extra_file)
|
||||
if args.limit > 0:
|
||||
texts = texts[: args.limit]
|
||||
existing_by_lang = {
|
||||
lang: load_language_cache(output_dir / f"{lang}.json")
|
||||
for lang in languages
|
||||
}
|
||||
next_by_lang: dict[str, dict[str, str]] = {lang: {} for lang in languages}
|
||||
print(f"Found {len(texts)} unique translate strings.", flush=True)
|
||||
print(f"Output directory: {output_dir}", flush=True)
|
||||
print(f"Languages: {', '.join(languages)}", flush=True)
|
||||
|
||||
failures: list[tuple[str, str, str]] = []
|
||||
total = len(texts) * len(languages)
|
||||
done = 0
|
||||
|
||||
for lang in languages:
|
||||
existing = existing_by_lang.get(lang, {})
|
||||
print(f"Starting language: {lang}", flush=True)
|
||||
|
||||
for index, text in enumerate(texts, start=1):
|
||||
done += 1
|
||||
if not args.refresh and existing.get(text):
|
||||
next_by_lang[lang][text] = existing[text]
|
||||
continue
|
||||
if args.extract_only:
|
||||
next_by_lang[lang][text] = existing.get(text, "")
|
||||
continue
|
||||
|
||||
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
|
||||
try:
|
||||
next_by_lang[lang][text] = translate_text(
|
||||
text,
|
||||
lang,
|
||||
args.provider,
|
||||
args.context,
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
print(f" => {next_by_lang[lang][text][:100]}", flush=True)
|
||||
except Exception as exc:
|
||||
next_by_lang[lang][text] = existing.get(text, text)
|
||||
failures.append((text, lang, str(exc)))
|
||||
print(f" failed: {exc}", file=sys.stderr, flush=True)
|
||||
if args.save_every > 0 and index % args.save_every == 0:
|
||||
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
|
||||
time.sleep(args.sleep)
|
||||
|
||||
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
|
||||
print(f"Completed language: {lang}", flush=True)
|
||||
|
||||
for lang, cache in next_by_lang.items():
|
||||
write_language_cache(output_dir / f"{lang}.json", cache)
|
||||
|
||||
if failures:
|
||||
print(
|
||||
f"Completed with {len(failures)} translation failures "
|
||||
f"(partial progress persisted, next run will retry).",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
for text, lang, error in failures[:20]:
|
||||
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
|
||||
if len(failures) > 20:
|
||||
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
|
||||
# Exit 0 on partial failure so the workflow's Commit + push
|
||||
# step still runs. Otherwise a couple of transient googletrans
|
||||
# timeouts would fail the whole workflow and discard every
|
||||
# successful translation from the same batch.
|
||||
return 0
|
||||
|
||||
print("Translation cache generated successfully.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,853 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build missing translations for the ProxMenux documentation catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from build_translation_cache import ( # noqa: E402
|
||||
clean_translation,
|
||||
translate_appimage,
|
||||
translate_google_web,
|
||||
translate_googletrans,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
|
||||
DEFAULT_CONTEXT = (
|
||||
"Context: ProxMenux technical documentation for Proxmox VE users. "
|
||||
"Preserve product names, commands, paths, variables and placeholders. Translate:"
|
||||
)
|
||||
|
||||
TECHNICAL_TERMS = (
|
||||
"Proxmox VE Helper-Scripts",
|
||||
"Proxmox Backup Server",
|
||||
"Proxmox Mail Gateway",
|
||||
"Proxmox VE",
|
||||
"ProxMenux Monitor",
|
||||
"ProxMenux Scripts",
|
||||
"Docker Compose",
|
||||
"Docker Engine",
|
||||
"Google Coral",
|
||||
"Edge TPU",
|
||||
"Let's Encrypt",
|
||||
"Cloudflare",
|
||||
"Pushover",
|
||||
"Telegram",
|
||||
"Discord",
|
||||
"Microsoft Teams",
|
||||
"GitHub",
|
||||
"Gotify",
|
||||
"Apprise",
|
||||
"Frigate",
|
||||
"Vaultwarden",
|
||||
"Portainer",
|
||||
"ProxMenux",
|
||||
"AppImage",
|
||||
"systemctl",
|
||||
"journalctl",
|
||||
"smartctl",
|
||||
"pveproxy",
|
||||
"apt-get",
|
||||
"gasket-dkms",
|
||||
"libedgetpu",
|
||||
"QEMU",
|
||||
"LXC",
|
||||
"ZFS",
|
||||
"Ceph",
|
||||
"Docker",
|
||||
"OpenAI",
|
||||
"WebSocket",
|
||||
"OAuth",
|
||||
"DKMS",
|
||||
"SSH",
|
||||
"API",
|
||||
)
|
||||
|
||||
PROTECTED_PATTERNS = (
|
||||
# Keep rich-text tags visible to Google Translate. It preserves their
|
||||
# structure while translating the enclosed prose, whereas replacing
|
||||
# opening/closing tags with adjacent sentinels can make the provider drop
|
||||
# one side of the pair. The contract is validated after translation.
|
||||
re.compile(r"`[^`]+`"),
|
||||
re.compile(r"https?://[^\s<>]+"),
|
||||
re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}"),
|
||||
re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"),
|
||||
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
|
||||
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
|
||||
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
|
||||
)
|
||||
LITERAL_TAG_RE = re.compile(
|
||||
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
TERM_RE = re.compile(
|
||||
"|".join(
|
||||
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
|
||||
for term in sorted(TECHNICAL_TERMS, key=len, reverse=True)
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||
NON_TRANSLATABLE_KEYS = {
|
||||
"command",
|
||||
"code",
|
||||
"href",
|
||||
"icon",
|
||||
"id",
|
||||
"path",
|
||||
"route",
|
||||
"slug",
|
||||
"src",
|
||||
"url",
|
||||
}
|
||||
SOURCE_STATE_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Leaf:
|
||||
path: tuple[str | int, ...]
|
||||
source: str
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def iter_leaves(node: Any, path: tuple[str | int, ...] = ()) -> list[Leaf]:
|
||||
leaves: list[Leaf] = []
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
leaves.extend(iter_leaves(value, path + (key,)))
|
||||
elif isinstance(node, list):
|
||||
for index, value in enumerate(node):
|
||||
leaves.extend(iter_leaves(value, path + (index,)))
|
||||
elif isinstance(node, str):
|
||||
leaves.append(Leaf(path, node))
|
||||
return leaves
|
||||
|
||||
|
||||
def get_at_path(node: Any, path: tuple[str | int, ...]) -> Any:
|
||||
current = node
|
||||
try:
|
||||
for part in path:
|
||||
if isinstance(part, int):
|
||||
if not isinstance(current, list):
|
||||
return None
|
||||
current = current[part]
|
||||
else:
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
current = current[part]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def path_key(path: tuple[str | int, ...]) -> str:
|
||||
return ".".join(str(part) for part in path)
|
||||
|
||||
|
||||
def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
|
||||
if not source.strip() or not re.search(r"[A-Za-z]", source):
|
||||
return True
|
||||
last = str(path[-1]).lower() if path else ""
|
||||
if last in NON_TRANSLATABLE_KEYS or any(
|
||||
last.endswith(suffix)
|
||||
for suffix in ("url", "href", "path", "command", "code", "icon")
|
||||
):
|
||||
return True
|
||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
|
||||
return True
|
||||
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def leaf_token(path: tuple[str | int, ...]) -> str:
|
||||
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def source_fingerprints(source: Any) -> dict[str, str]:
|
||||
return {
|
||||
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
|
||||
for leaf in iter_leaves(source)
|
||||
}
|
||||
|
||||
|
||||
def needs_translation(
|
||||
source: str,
|
||||
target: Any,
|
||||
path: tuple[str | int, ...],
|
||||
refresh: bool,
|
||||
forced_tokens: set[str] | None = None,
|
||||
) -> bool:
|
||||
if should_copy(source, path):
|
||||
return False
|
||||
if refresh:
|
||||
return True
|
||||
if forced_tokens and leaf_token(path) in forced_tokens:
|
||||
return True
|
||||
return not isinstance(target, str) or not target.strip() or target == source
|
||||
|
||||
|
||||
def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
|
||||
"""Give translatable rich-text tags opaque names during translation.
|
||||
|
||||
Google can remove semantic tags such as ``strong`` or ``em`` after
|
||||
translating their contents. Unknown tag names are retained, so rename
|
||||
non-literal tags temporarily and restore them before contract validation.
|
||||
Literal ``code``, ``kbd`` and ``pre`` elements stay untouched because the
|
||||
provider preserves both their markup and their contents.
|
||||
"""
|
||||
|
||||
names: dict[str, str] = {}
|
||||
reverse: dict[str, str] = {}
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
original = match.group(1)
|
||||
if original.lower() in {"code", "kbd", "pre"}:
|
||||
return match.group(0)
|
||||
key = original.lower()
|
||||
internal = names.get(key)
|
||||
if internal is None:
|
||||
internal = f"pmxrich{len(names):04d}"
|
||||
names[key] = internal
|
||||
reverse[internal] = original
|
||||
slash = "/" if match.group(0).startswith("</") else ""
|
||||
return f"<{slash}{internal}>"
|
||||
|
||||
return TAG_RE.sub(replace, text), reverse
|
||||
|
||||
|
||||
def restore_rich_tags(text: str, mapping: dict[str, str]) -> str:
|
||||
for internal, original in mapping.items():
|
||||
text = re.sub(
|
||||
rf"<(/?){re.escape(internal)}>",
|
||||
lambda match: f"<{match.group(1)}{original}>",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
||||
# Google already keeps both the markup and the contents of literal rich-
|
||||
# text blocks. Replacing a filename or path inside one of these blocks can
|
||||
# leave the sentinel as its only child, which the provider may discard.
|
||||
literal_ranges = [(match.start(), match.end()) for match in LITERAL_TAG_RE.finditer(text)]
|
||||
|
||||
def overlaps_literal(start: int, end: int) -> bool:
|
||||
return any(start < literal_end and end > literal_start for literal_start, literal_end in literal_ranges)
|
||||
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for pattern in PROTECTED_PATTERNS:
|
||||
candidates.extend(
|
||||
(match.start(), match.end())
|
||||
for match in pattern.finditer(text)
|
||||
if not overlaps_literal(match.start(), match.end())
|
||||
)
|
||||
candidates.extend(
|
||||
(match.start(), match.end())
|
||||
for match in TERM_RE.finditer(text)
|
||||
if not overlaps_literal(match.start(), match.end())
|
||||
)
|
||||
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
|
||||
|
||||
selected: list[tuple[int, int]] = []
|
||||
cursor = -1
|
||||
for start, end in candidates:
|
||||
if start >= cursor:
|
||||
selected.append((start, end))
|
||||
cursor = end
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
chunks: list[str] = []
|
||||
cursor = 0
|
||||
for index, (start, end) in enumerate(selected):
|
||||
# Google Translate can drop underscore-delimited sentinels when they
|
||||
# sit directly beside inline markup (for example an opening <em>
|
||||
# token followed by translated prose). Triple brackets remain opaque
|
||||
# in that position and preserve the complete rich-text contract.
|
||||
token = f"[[[PMXDOC{index:04d}]]]"
|
||||
chunks.append(text[cursor:start])
|
||||
chunks.append(token)
|
||||
mapping[token] = text[start:end]
|
||||
cursor = end
|
||||
chunks.append(text[cursor:])
|
||||
return "".join(chunks), mapping
|
||||
|
||||
|
||||
def restore_text(text: str, mapping: dict[str, str]) -> str:
|
||||
missing = [token for token in mapping if token not in text]
|
||||
if missing:
|
||||
raise ValueError(f"translation provider changed protected token {missing[0]}")
|
||||
for token, original in mapping.items():
|
||||
text = text.replace(token, original)
|
||||
return text
|
||||
|
||||
|
||||
def validate_contract(source: str, target: str) -> None:
|
||||
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
|
||||
raise ValueError("rich-text tag contract changed")
|
||||
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||
raise ValueError("placeholder contract changed")
|
||||
|
||||
|
||||
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||
def translate(text: str, language: str) -> str:
|
||||
if args.provider == "googletrans":
|
||||
raw = translate_googletrans(text, language, args.context)
|
||||
elif args.provider == "google-web":
|
||||
raw = translate_google_web(text, language, args.context, args.timeout)
|
||||
else:
|
||||
raw = translate_appimage(
|
||||
text,
|
||||
language,
|
||||
args.context,
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
return clean_translation(raw).strip()
|
||||
|
||||
return translate
|
||||
|
||||
|
||||
def translate_with_retry(
|
||||
source: str,
|
||||
language: str,
|
||||
translate: Callable[[str, str], str],
|
||||
retries: int,
|
||||
delay: float,
|
||||
) -> str:
|
||||
rich_text, rich_mapping = protect_rich_tags(source)
|
||||
protected, mapping = protect_text(rich_text)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
translated = translate(protected, language)
|
||||
if not translated:
|
||||
raise ValueError("translation provider returned an empty value")
|
||||
translated = restore_text(translated, mapping)
|
||||
translated = restore_rich_tags(translated, rich_mapping)
|
||||
validate_contract(source, translated)
|
||||
return translated
|
||||
except Exception as exc: # network/provider errors are retried together
|
||||
last_error = exc
|
||||
if attempt < retries:
|
||||
time.sleep(delay * (attempt + 1))
|
||||
raise RuntimeError(str(last_error)) from last_error
|
||||
|
||||
|
||||
def merge_tree(
|
||||
source: Any,
|
||||
target: Any,
|
||||
translations: dict[tuple[str | int, ...], str],
|
||||
path: tuple[str | int, ...] = (),
|
||||
) -> Any:
|
||||
if isinstance(source, dict):
|
||||
target_dict = target if isinstance(target, dict) else {}
|
||||
return {
|
||||
key: merge_tree(value, target_dict.get(key), translations, path + (key,))
|
||||
for key, value in source.items()
|
||||
}
|
||||
if isinstance(source, list):
|
||||
target_list = target if isinstance(target, list) else []
|
||||
return [
|
||||
merge_tree(
|
||||
value,
|
||||
target_list[index] if index < len(target_list) else None,
|
||||
translations,
|
||||
path + (index,),
|
||||
)
|
||||
for index, value in enumerate(source)
|
||||
]
|
||||
if isinstance(source, str):
|
||||
if path in translations:
|
||||
return translations[path]
|
||||
if should_copy(source, path):
|
||||
return source
|
||||
if isinstance(target, str) and target.strip():
|
||||
return target
|
||||
return source
|
||||
return source
|
||||
|
||||
|
||||
def source_files(source_root: Path, section: str) -> list[Path]:
|
||||
scope = (source_root / section).resolve()
|
||||
root = source_root.resolve()
|
||||
if scope != root and root not in scope.parents:
|
||||
raise ValueError("section must stay inside the English messages directory")
|
||||
if scope.is_file():
|
||||
if scope.suffix != ".json":
|
||||
raise ValueError("section file must be JSON")
|
||||
return [scope]
|
||||
if not scope.is_dir():
|
||||
raise ValueError(f"section does not exist: {scope}")
|
||||
return sorted(
|
||||
path
|
||||
for path in scope.rglob("*.json")
|
||||
if not any(part.startswith(".") for part in path.relative_to(root).parts)
|
||||
)
|
||||
|
||||
|
||||
def collect_memory(source_root: Path, messages_root: Path, language: str) -> dict[str, str]:
|
||||
memory: dict[str, str] = {}
|
||||
conflicts: set[str] = set()
|
||||
for source_path in sorted(source_root.rglob("*.json")):
|
||||
if any(part.startswith(".") for part in source_path.relative_to(source_root).parts):
|
||||
continue
|
||||
target_path = messages_root / language / source_path.relative_to(source_root)
|
||||
source = read_json(source_path)
|
||||
target = read_json(target_path)
|
||||
if target is None:
|
||||
continue
|
||||
for leaf in iter_leaves(source):
|
||||
translated = get_at_path(target, leaf.path)
|
||||
if not isinstance(translated, str) or not translated.strip() or translated == leaf.source:
|
||||
continue
|
||||
previous = memory.get(leaf.source)
|
||||
if previous is not None and previous != translated:
|
||||
conflicts.add(leaf.source)
|
||||
else:
|
||||
memory[leaf.source] = translated
|
||||
for source in conflicts:
|
||||
memory.pop(source, None)
|
||||
return memory
|
||||
|
||||
|
||||
def pending_leaves(
|
||||
source: Any,
|
||||
target: Any,
|
||||
refresh: bool,
|
||||
forced_tokens: set[str] | None = None,
|
||||
) -> list[Leaf]:
|
||||
return [
|
||||
leaf
|
||||
for leaf in iter_leaves(source)
|
||||
if needs_translation(
|
||||
leaf.source,
|
||||
get_at_path(target, leaf.path),
|
||||
leaf.path,
|
||||
refresh,
|
||||
forced_tokens,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def schema_matches(source: Any, target: Any) -> bool:
|
||||
if type(source) is not type(target):
|
||||
return False
|
||||
if isinstance(source, dict):
|
||||
return list(source) == list(target) and all(
|
||||
schema_matches(source[key], target[key]) for key in source
|
||||
)
|
||||
if isinstance(source, list):
|
||||
return len(source) == len(target) and all(
|
||||
schema_matches(left, right) for left, right in zip(source, target)
|
||||
)
|
||||
if isinstance(source, str):
|
||||
# Localized strings intentionally differ from the English source.
|
||||
return True
|
||||
# Booleans, numbers and null values are structural data and must stay in
|
||||
# sync with the English catalog instead of retaining an obsolete value.
|
||||
return source == target
|
||||
|
||||
|
||||
def empty_source_state() -> dict[str, Any]:
|
||||
return {
|
||||
"version": SOURCE_STATE_VERSION,
|
||||
"initialized": False,
|
||||
"source": {},
|
||||
"pending": {},
|
||||
}
|
||||
|
||||
|
||||
def load_source_state(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return empty_source_state()
|
||||
try:
|
||||
value = read_json(path)
|
||||
except ValueError:
|
||||
return empty_source_state()
|
||||
if not isinstance(value, dict) or value.get("version") != SOURCE_STATE_VERSION:
|
||||
return empty_source_state()
|
||||
if not isinstance(value.get("source"), dict) or not isinstance(value.get("pending"), dict):
|
||||
return empty_source_state()
|
||||
value.setdefault("initialized", True)
|
||||
return value
|
||||
|
||||
|
||||
def state_pending_tokens(state: dict[str, Any], language: str, relative: str) -> set[str]:
|
||||
language_state = state.setdefault("pending", {}).setdefault(language, {})
|
||||
values = language_state.get(relative, [])
|
||||
if not isinstance(values, list):
|
||||
return set()
|
||||
return {str(value) for value in values}
|
||||
|
||||
|
||||
def set_state_pending_tokens(
|
||||
state: dict[str, Any], language: str, relative: str, tokens: set[str]
|
||||
) -> None:
|
||||
language_state = state.setdefault("pending", {}).setdefault(language, {})
|
||||
if tokens:
|
||||
language_state[relative] = sorted(tokens)
|
||||
else:
|
||||
language_state.pop(relative, None)
|
||||
if not language_state:
|
||||
state["pending"].pop(language, None)
|
||||
|
||||
|
||||
def translate_file(
|
||||
source: Any,
|
||||
target: Any,
|
||||
leaves: list[Leaf],
|
||||
language: str,
|
||||
memory: dict[str, str],
|
||||
translate: Callable[[str, str], str],
|
||||
args: argparse.Namespace,
|
||||
) -> tuple[Any | None, list[str], int]:
|
||||
resolved: dict[tuple[str | int, ...], str] = {}
|
||||
failures: list[str] = []
|
||||
jobs: dict[str, list[tuple[str | int, ...]]] = {}
|
||||
|
||||
for leaf in leaves:
|
||||
if not args.refresh and leaf.source in memory:
|
||||
resolved[leaf.path] = memory[leaf.source]
|
||||
continue
|
||||
jobs.setdefault(leaf.source, []).append(leaf.path)
|
||||
|
||||
if jobs:
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
translate_with_retry,
|
||||
text,
|
||||
language,
|
||||
translate,
|
||||
args.retries,
|
||||
args.retry_delay,
|
||||
): text
|
||||
for text in jobs
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
text = futures[future]
|
||||
try:
|
||||
translated = future.result()
|
||||
memory[text] = translated
|
||||
for path in jobs[text]:
|
||||
resolved[path] = translated
|
||||
except Exception as exc:
|
||||
failures.append(f"{text[:90]}: {exc}")
|
||||
if args.sleep:
|
||||
time.sleep(args.sleep)
|
||||
|
||||
if failures:
|
||||
return None, failures, len(jobs)
|
||||
return merge_tree(source, target, resolved), [], len(jobs)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-dir", type=Path, default=Path("web/messages/en"))
|
||||
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
|
||||
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument(
|
||||
"--section",
|
||||
default=".",
|
||||
help="Relative file or directory below the English messages directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("google-web", "googletrans", "appimage"),
|
||||
default="googletrans",
|
||||
)
|
||||
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
|
||||
parser.add_argument(
|
||||
"--source-state",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"Source fingerprint state used to detect changed English strings. "
|
||||
"Defaults to <messages-dir>/.docs-i18n-source-state.json."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||
parser.add_argument("--timeout", type=int, default=30)
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--retries", type=int, default=3)
|
||||
parser.add_argument("--retry-delay", type=float, default=2.0)
|
||||
parser.add_argument("--sleep", type=float, default=0.0)
|
||||
parser.add_argument(
|
||||
"--max-files",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Maximum pending files per locale; zero processes all files.",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--check", action="store_true")
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="Overwrite existing translations in the selected scope.",
|
||||
)
|
||||
parser.add_argument("--strict", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
|
||||
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
source_root = args.source_dir.resolve()
|
||||
messages_root = args.messages_dir.resolve()
|
||||
source_state_path = (
|
||||
args.source_state.resolve()
|
||||
if args.source_state is not None
|
||||
else messages_root / ".docs-i18n-source-state.json"
|
||||
)
|
||||
languages = [item.strip() for item in args.languages.split(",") if item.strip()]
|
||||
try:
|
||||
files = source_files(source_root, args.section)
|
||||
except ValueError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not languages:
|
||||
print("No target languages selected.", file=sys.stderr)
|
||||
return 2
|
||||
if args.refresh:
|
||||
print("WARNING: --refresh overwrites existing translations in the selected scope.")
|
||||
|
||||
translate = provider_function(args)
|
||||
state = load_source_state(source_state_path)
|
||||
state_was_initialized = bool(state.get("initialized"))
|
||||
previous_source_state = state.setdefault("source", {})
|
||||
|
||||
file_info: dict[
|
||||
str, tuple[Path, Any, dict[str, str], set[str], set[str]]
|
||||
] = {}
|
||||
selected_relatives: set[str] = set()
|
||||
for source_path in files:
|
||||
relative = source_path.relative_to(source_root).as_posix()
|
||||
selected_relatives.add(relative)
|
||||
source = read_json(source_path)
|
||||
current_fingerprints = source_fingerprints(source)
|
||||
translatable_tokens = {
|
||||
leaf_token(leaf.path)
|
||||
for leaf in iter_leaves(source)
|
||||
if not should_copy(leaf.source, leaf.path)
|
||||
}
|
||||
previous_fingerprints = previous_source_state.get(relative, {})
|
||||
if not isinstance(previous_fingerprints, dict):
|
||||
previous_fingerprints = {}
|
||||
if state_was_initialized:
|
||||
changed_tokens = {
|
||||
token
|
||||
for token, fingerprint in current_fingerprints.items()
|
||||
if token in translatable_tokens
|
||||
# Existing English leaves whose text changed must be sent to
|
||||
# the provider even when the target still contains the old,
|
||||
# non-empty translation. New leaves are handled by the usual
|
||||
# missing-target detection, preserving a translation supplied
|
||||
# manually in the same commit.
|
||||
and token in previous_fingerprints
|
||||
and previous_fingerprints[token] != fingerprint
|
||||
}
|
||||
else:
|
||||
# The first run establishes the baseline without replacing
|
||||
# existing human/Codex translations. Missing target values are
|
||||
# still discovered separately for every locale below.
|
||||
changed_tokens = set()
|
||||
file_info[relative] = (
|
||||
source_path,
|
||||
source,
|
||||
current_fingerprints,
|
||||
changed_tokens,
|
||||
translatable_tokens,
|
||||
)
|
||||
|
||||
# Materialize every locale's pending queue before advancing the shared
|
||||
# English baseline. If the runner stops halfway through, unprocessed
|
||||
# locales retain the exact changed leaf tokens for the next run.
|
||||
for language in languages:
|
||||
for relative, (
|
||||
_,
|
||||
source,
|
||||
_,
|
||||
changed_tokens,
|
||||
translatable_tokens,
|
||||
) in file_info.items():
|
||||
target_path = messages_root / language / relative
|
||||
target = read_json(target_path)
|
||||
pending_tokens = (
|
||||
state_pending_tokens(state, language, relative)
|
||||
& translatable_tokens
|
||||
)
|
||||
pending_tokens.update(changed_tokens)
|
||||
pending_tokens.update(
|
||||
leaf_token(leaf.path)
|
||||
for leaf in pending_leaves(source, target, args.refresh)
|
||||
)
|
||||
set_state_pending_tokens(state, language, relative, pending_tokens)
|
||||
|
||||
for relative, (_, _, current_fingerprints, _, _) in file_info.items():
|
||||
previous_source_state[relative] = current_fingerprints
|
||||
state["initialized"] = True
|
||||
|
||||
# A complete run also mirrors deletion of an English catalog. Partial
|
||||
# --section runs deliberately leave unrelated paths untouched.
|
||||
if args.section in (".", ""):
|
||||
removed_files = set(previous_source_state) - selected_relatives
|
||||
for relative in sorted(removed_files):
|
||||
for language in languages:
|
||||
target_path = messages_root / language / relative
|
||||
if target_path.exists() and not (args.check or args.dry_run):
|
||||
target_path.unlink()
|
||||
print(f"[{language}] removed obsolete catalog {target_path}")
|
||||
set_state_pending_tokens(state, language, relative, set())
|
||||
previous_source_state.pop(relative, None)
|
||||
|
||||
if not (args.check or args.dry_run):
|
||||
write_json(source_state_path, state)
|
||||
|
||||
total_failures = 0
|
||||
total_written = 0
|
||||
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
|
||||
|
||||
for language in languages:
|
||||
memory = collect_memory(source_root, messages_root, language)
|
||||
# A target value paired with a newly changed English source is the old
|
||||
# translation, not valid translation memory for the new sentence.
|
||||
# Remove every queued source text before provider reuse; successful
|
||||
# translations repopulate memory normally for later files.
|
||||
queued_source_texts: set[str] = set()
|
||||
for relative_key, (_, source, _, _, _) in file_info.items():
|
||||
queued_tokens = state_pending_tokens(state, language, relative_key)
|
||||
queued_source_texts.update(
|
||||
leaf.source
|
||||
for leaf in iter_leaves(source)
|
||||
if leaf_token(leaf.path) in queued_tokens
|
||||
)
|
||||
for source_text in queued_source_texts:
|
||||
memory.pop(source_text, None)
|
||||
pending: list[
|
||||
tuple[Path, Path, Any, Any, list[Leaf], set[str], bool]
|
||||
] = []
|
||||
total_strings = 0
|
||||
missing_strings = 0
|
||||
|
||||
for source_path in files:
|
||||
relative = source_path.relative_to(source_root)
|
||||
relative_key = relative.as_posix()
|
||||
target_path = messages_root / language / relative
|
||||
source = file_info[relative_key][1]
|
||||
target = read_json(target_path)
|
||||
queued_tokens = state_pending_tokens(state, language, relative_key)
|
||||
leaves = pending_leaves(source, target, args.refresh, queued_tokens)
|
||||
total_strings += len(iter_leaves(source))
|
||||
missing_strings += len(leaves)
|
||||
schema_changed = not schema_matches(source, target)
|
||||
if leaves or schema_changed:
|
||||
pending.append(
|
||||
(
|
||||
source_path,
|
||||
target_path,
|
||||
source,
|
||||
target,
|
||||
leaves,
|
||||
queued_tokens,
|
||||
schema_changed,
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
f"[{language}] {missing_strings}/{total_strings} strings pending "
|
||||
f"across {len(pending)} files; reusable translations: {len(memory)}"
|
||||
)
|
||||
if args.check or args.dry_run:
|
||||
continue
|
||||
if args.max_files:
|
||||
pending = pending[: args.max_files]
|
||||
|
||||
for index, (
|
||||
source_path,
|
||||
target_path,
|
||||
source,
|
||||
target,
|
||||
leaves,
|
||||
queued_tokens,
|
||||
schema_changed,
|
||||
) in enumerate(pending, 1):
|
||||
relative = source_path.relative_to(source_root)
|
||||
relative_key = relative.as_posix()
|
||||
suffix = " + schema sync" if schema_changed else ""
|
||||
print(
|
||||
f"[{language} {index}/{len(pending)}] {relative} "
|
||||
f"({len(leaves)} strings{suffix})",
|
||||
flush=True,
|
||||
)
|
||||
built, failures, calls = translate_file(
|
||||
source,
|
||||
target,
|
||||
leaves,
|
||||
language,
|
||||
memory,
|
||||
translate,
|
||||
args,
|
||||
)
|
||||
if failures:
|
||||
total_failures += len(failures)
|
||||
set_state_pending_tokens(state, language, relative_key, queued_tokens)
|
||||
write_json(source_state_path, state)
|
||||
print(
|
||||
f" skipped atomically after {len(failures)} failures "
|
||||
f"({calls} calls)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for failure in failures[:5]:
|
||||
print(f" - {failure}", file=sys.stderr)
|
||||
continue
|
||||
write_json(target_path, built)
|
||||
set_state_pending_tokens(state, language, relative_key, set())
|
||||
write_json(source_state_path, state)
|
||||
total_written += 1
|
||||
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
|
||||
|
||||
if args.check or args.dry_run:
|
||||
return 0
|
||||
print(f"Completed: {total_written} files written; {total_failures} failed strings.")
|
||||
return 1 if args.strict and total_failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,9 +67,9 @@ def fetch_all_records(url: str, *, expand: str | None = None, per_page: int = 50
|
||||
return items
|
||||
|
||||
|
||||
def normalize_os_variants(install_methods_json: list[dict[str, Any]]) -> list[str]:
|
||||
def normalize_os_variants(install_methods: list[dict[str, Any]]) -> list[str]:
|
||||
os_values: list[str] = []
|
||||
for item in install_methods_json:
|
||||
for item in install_methods:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
resources = item.get("resources", {})
|
||||
@@ -83,6 +83,31 @@ def normalize_os_variants(install_methods_json: list[dict[str, Any]]) -> list[st
|
||||
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()
|
||||
@@ -138,19 +163,19 @@ def main() -> int:
|
||||
full_script_url = f"{SCRIPT_BASE}/{script_path}"
|
||||
script_url_mirror = to_mirror_url(full_script_url)
|
||||
|
||||
install_methods_json = raw.get("install_methods_json", [])
|
||||
if not isinstance(install_methods_json, list):
|
||||
install_methods_json = []
|
||||
# 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_json = raw.get("notes_json", [])
|
||||
if not isinstance(notes_json, list):
|
||||
notes_json = []
|
||||
notes_raw = raw.get("notes", [])
|
||||
if not isinstance(notes_raw, list):
|
||||
notes_raw = []
|
||||
|
||||
notes = [
|
||||
note.get("text", "")
|
||||
for note in notes_json
|
||||
if isinstance(note, dict) and isinstance(note.get("text"), str) and note.get("text", "").strip()
|
||||
]
|
||||
notes, warnings = split_notes(notes_raw)
|
||||
|
||||
category_ids = raw.get("categories", [])
|
||||
if not isinstance(category_ids, list):
|
||||
@@ -193,6 +218,7 @@ def main() -> int:
|
||||
"categories": category_ids,
|
||||
"category_names": category_names,
|
||||
"notes": notes,
|
||||
"warnings": warnings,
|
||||
"port": raw.get("port", 0),
|
||||
"website": raw.get("website", ""),
|
||||
"documentation": raw.get("documentation", ""),
|
||||
@@ -210,7 +236,7 @@ def main() -> int:
|
||||
# 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_json)
|
||||
os_variants = normalize_os_variants(install_methods)
|
||||
|
||||
if len(os_variants) > 1:
|
||||
for os_name in os_variants:
|
||||
@@ -228,11 +254,12 @@ def main() -> int:
|
||||
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)}")
|
||||
print(f" Guardados: {len(cache)} entries, {total_notes} notes, {total_warns} warnings")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "build_web_docs_i18n.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_web_docs_i18n", SCRIPT)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC and SPEC.loader
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class WebDocsI18nTests(unittest.TestCase):
|
||||
def run_generator(self, root, provider):
|
||||
source_root = root / "messages" / "en"
|
||||
messages_root = root / "messages"
|
||||
state_path = messages_root / ".docs-i18n-source-state.json"
|
||||
argv = [
|
||||
str(SCRIPT),
|
||||
"--source-dir",
|
||||
str(source_root),
|
||||
"--messages-dir",
|
||||
str(messages_root),
|
||||
"--languages",
|
||||
"de",
|
||||
"--source-state",
|
||||
str(state_path),
|
||||
"--workers",
|
||||
"1",
|
||||
]
|
||||
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||
MODULE, "provider_function", return_value=provider
|
||||
):
|
||||
return MODULE.main()
|
||||
|
||||
def test_protected_contract_round_trip(self):
|
||||
source = (
|
||||
"Run <code>systemctl restart pveproxy</code> in Proxmox VE, "
|
||||
"then open <link>Settings</link> at {host}."
|
||||
)
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
|
||||
self.assertNotIn("Proxmox VE", protected)
|
||||
self.assertIn("<code>", protected)
|
||||
self.assertIn("</code>", protected)
|
||||
self.assertIn("<link>", protected)
|
||||
self.assertIn("</link>", protected)
|
||||
self.assertNotIn("<code>", mapping.values())
|
||||
self.assertNotIn("</code>", mapping.values())
|
||||
self.assertNotIn("systemctl", mapping.values())
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_human_translation_is_not_pending(self):
|
||||
source = {"title": "Updates", "url": "https://example.com"}
|
||||
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
||||
self.assertEqual(MODULE.pending_leaves(source, target, refresh=False), [])
|
||||
|
||||
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
||||
source = {"title": "Updated installation guidance"}
|
||||
target = {"title": "Vorherige Installationsanleitung"}
|
||||
token = MODULE.leaf_token(("title",))
|
||||
leaves = MODULE.pending_leaves(
|
||||
source,
|
||||
target,
|
||||
refresh=False,
|
||||
forced_tokens={token},
|
||||
)
|
||||
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
|
||||
|
||||
def test_source_fingerprints_change_per_leaf(self):
|
||||
before = MODULE.source_fingerprints({"title": "One", "body": "Same"})
|
||||
after = MODULE.source_fingerprints({"title": "Two", "body": "Same"})
|
||||
self.assertNotEqual(
|
||||
before[MODULE.leaf_token(("title",))],
|
||||
after[MODULE.leaf_token(("title",))],
|
||||
)
|
||||
self.assertEqual(
|
||||
before[MODULE.leaf_token(("body",))],
|
||||
after[MODULE.leaf_token(("body",))],
|
||||
)
|
||||
|
||||
def test_schema_mismatch_detects_removed_keys(self):
|
||||
self.assertFalse(
|
||||
MODULE.schema_matches(
|
||||
{"title": "Title"},
|
||||
{"title": "Título", "removed": "Old"},
|
||||
)
|
||||
)
|
||||
|
||||
def test_schema_mismatch_detects_changed_structural_values(self):
|
||||
self.assertFalse(
|
||||
MODULE.schema_matches(
|
||||
{"enabled": True, "retries": 2},
|
||||
{"enabled": False, "retries": 2},
|
||||
)
|
||||
)
|
||||
|
||||
def test_schema_allows_localized_string_values(self):
|
||||
self.assertTrue(
|
||||
MODULE.schema_matches(
|
||||
{"title": "Updates"},
|
||||
{"title": "Aktualisierungen"},
|
||||
)
|
||||
)
|
||||
|
||||
def test_translatable_rich_tags_use_opaque_names(self):
|
||||
source = (
|
||||
"Keep <strong>this</strong>, <em>that</em> and "
|
||||
"<code>systemctl restart pveproxy</code>."
|
||||
)
|
||||
protected, mapping = MODULE.protect_rich_tags(source)
|
||||
self.assertIn("<pmxrich0000>this</pmxrich0000>", protected)
|
||||
self.assertIn("<pmxrich0001>that</pmxrich0001>", protected)
|
||||
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
|
||||
self.assertEqual(MODULE.restore_rich_tags(protected, mapping), source)
|
||||
|
||||
def test_source_schema_drives_output(self):
|
||||
source = {"title": "Title", "items": ["One", "Two"]}
|
||||
target = {
|
||||
"title": "Título",
|
||||
"items": ["Uno", "Dos", "Obsoleto"],
|
||||
"removed": "Old",
|
||||
}
|
||||
translated = {
|
||||
("items", 0): "Uno",
|
||||
("items", 1): "Dos",
|
||||
}
|
||||
self.assertEqual(
|
||||
MODULE.merge_tree(source, target, translated),
|
||||
{"title": "Título", "items": ["Uno", "Dos"]},
|
||||
)
|
||||
|
||||
def test_translation_memory_ignores_conflicts(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source_root = root / "messages" / "en"
|
||||
target_root = root / "messages" / "es"
|
||||
source_root.mkdir(parents=True)
|
||||
target_root.mkdir(parents=True)
|
||||
(source_root / "one.json").write_text(
|
||||
json.dumps({"label": "Settings"}), encoding="utf-8"
|
||||
)
|
||||
(target_root / "one.json").write_text(
|
||||
json.dumps({"label": "Ajustes"}), encoding="utf-8"
|
||||
)
|
||||
(source_root / "two.json").write_text(
|
||||
json.dumps({"label": "Settings"}), encoding="utf-8"
|
||||
)
|
||||
(target_root / "two.json").write_text(
|
||||
json.dumps({"label": "Configuración"}), encoding="utf-8"
|
||||
)
|
||||
memory = MODULE.collect_memory(source_root, root / "messages", "es")
|
||||
self.assertNotIn("Settings", memory)
|
||||
|
||||
def test_source_state_retranslates_an_existing_changed_leaf(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source_path = root / "messages" / "en" / "page.json"
|
||||
target_path = root / "messages" / "de" / "page.json"
|
||||
source_path.parent.mkdir(parents=True)
|
||||
target_path.parent.mkdir(parents=True)
|
||||
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
|
||||
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
|
||||
|
||||
self.assertEqual(
|
||||
self.run_generator(root, lambda *_: self.fail("provider called during baseline")),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(read_json_file(target_path), {"title": "Ursprünglich"})
|
||||
|
||||
source_path.write_text(json.dumps({"title": "Changed"}), encoding="utf-8")
|
||||
calls = []
|
||||
|
||||
def provider(text, language):
|
||||
calls.append((text, language))
|
||||
return "Geändert"
|
||||
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(calls, [("Changed", "de")])
|
||||
self.assertEqual(read_json_file(target_path), {"title": "Geändert"})
|
||||
|
||||
def test_new_leaf_preserves_translation_supplied_with_source_change(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source_path = root / "messages" / "en" / "page.json"
|
||||
target_path = root / "messages" / "de" / "page.json"
|
||||
source_path.parent.mkdir(parents=True)
|
||||
target_path.parent.mkdir(parents=True)
|
||||
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
|
||||
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
|
||||
self.assertEqual(self.run_generator(root, lambda *_: "unused"), 0)
|
||||
|
||||
source_path.write_text(
|
||||
json.dumps({"title": "Original", "new": "New text"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
target_path.write_text(
|
||||
json.dumps({"title": "Ursprünglich", "new": "Neuer Text"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.run_generator(root, lambda *_: self.fail("manual translation overwritten")),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
read_json_file(target_path),
|
||||
{"title": "Ursprünglich", "new": "Neuer Text"},
|
||||
)
|
||||
|
||||
|
||||
def read_json_file(path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -59,7 +56,7 @@ jobs:
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: |
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -59,7 +56,7 @@ jobs:
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-beta-AppImage
|
||||
path: |
|
||||
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
|
||||
@@ -9,9 +9,6 @@ on:
|
||||
paths: [ 'AppImage/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -52,7 +49,7 @@ jobs:
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
name: Build i18n messages
|
||||
|
||||
# Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||
# against the English source whenever the source changes.
|
||||
#
|
||||
# The Monitor's i18n layer (AppImage/lib/i18n/provider.tsx) does its own
|
||||
# runtime fallback (locale → en → key), so this workflow doesn't break
|
||||
# anything if it misses a key: it just eliminates the visible-English
|
||||
# blocks in non-en locales.
|
||||
#
|
||||
# Guardrails baked into build_i18n_messages.py:
|
||||
# - Never overwrites a key whose target value differs from EN (i.e.
|
||||
# already translated by a human). This is what makes it safe to
|
||||
# include sk in the default set: Vaso73's curated strings are
|
||||
# protected end-to-end; auto only fills keys still on the EN
|
||||
# fallback.
|
||||
# - `{placeholder}` tokens are protected end-to-end.
|
||||
#
|
||||
# Triggers:
|
||||
# - push to develop touching AppImage/messages/en/common.json
|
||||
# - manual via workflow_dispatch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
# Only en/ triggers the run — it's the source of truth. Other
|
||||
# locales are destinations, and the bot auto-commits them at
|
||||
# the end of every run; if they were in the trigger too, each
|
||||
# auto-commit would fire another (empty) run.
|
||||
# Manual edits to a curated locale that empty a key for the
|
||||
# workflow to refill are the exception — dispatch this
|
||||
# workflow manually from Actions in that case, or piggy-back
|
||||
# a trivial en/ change onto the commit.
|
||||
- 'AppImage/messages/en/common.json'
|
||||
- '.github/scripts/build_i18n_messages.py'
|
||||
- '.github/workflows/build-i18n-messages.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
refresh:
|
||||
description: 'Re-translate every key (overwrites human translations!)'
|
||||
type: boolean
|
||||
default: false
|
||||
languages:
|
||||
description: 'Comma-separated locales. Default: es,de,fr,it,pt,sk,sv (guardrail protects Vaso73 sk).'
|
||||
default: 'es,de,fr,it,pt,sk,sv'
|
||||
|
||||
# Prevent two runs from racing on the same branch and fighting over the
|
||||
# auto-commit. cancel-in-progress:false because a full first-run may take
|
||||
# ~30 min for the initial bootstrap and interrupting mid-flight would
|
||||
# waste the calls already made.
|
||||
concurrency:
|
||||
group: build-i18n-messages-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # auto-commit AppImage/messages/*/common.json to develop
|
||||
# First-run bootstrap of ~5 locales × 3.8k keys can take a while at
|
||||
# 0.15s/call with rate-limit backoffs. 90 min headroom.
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
# Full history so the auto-commit doesn't drift when another
|
||||
# push landed between trigger and this job start.
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install googletrans
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Same pinning as build-translation-cache.yml so the two
|
||||
# workflows share behavior. Bump both in lockstep.
|
||||
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||
|
||||
- name: Translate missing keys
|
||||
run: |
|
||||
REFRESH_FLAG=""
|
||||
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||
REFRESH_FLAG="--refresh"
|
||||
fi
|
||||
LANGS="${{ github.event.inputs.languages }}"
|
||||
# Keep this fallback in sync with the workflow_dispatch input
|
||||
# default above AND with DEFAULT_LANGUAGES in the Python script —
|
||||
# `push` triggers hit this branch (inputs are empty on push).
|
||||
LANGS="${LANGS:-es,de,fr,it,pt,sk,sv}"
|
||||
python .github/scripts/build_i18n_messages.py \
|
||||
--source AppImage/messages/en/common.json \
|
||||
--messages-dir AppImage/messages \
|
||||
--languages "$LANGS" \
|
||||
--provider googletrans \
|
||||
$REFRESH_FLAG
|
||||
|
||||
- name: Commit + push if changed
|
||||
run: |
|
||||
if git diff --quiet -- AppImage/messages/; then
|
||||
echo "No translation changes — skipping commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add AppImage/messages/
|
||||
git commit -m "chore(i18n): auto-fill missing translations in messages/{locale}/common.json
|
||||
|
||||
Source: ${GITHUB_SHA::7}
|
||||
Triggered by: ${{ github.event_name }}"
|
||||
# Rebase-and-retry against develop: between the initial
|
||||
# checkout and this push another workflow (e.g.
|
||||
# build-translation-cache auto-commit) or a manual push can
|
||||
# land on develop first, and a naked push fails with
|
||||
# non-fast-forward — losing the freshly generated translation
|
||||
# commit. Rebasing the local i18n commit on top of the newer
|
||||
# HEAD is safe: paths don't overlap with cache/script
|
||||
# workflows, and the same-branch collisions between two i18n
|
||||
# runs are already blocked by the concurrency group above.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin develop; then
|
||||
echo "push succeeded on attempt ${attempt}"
|
||||
exit 0
|
||||
fi
|
||||
echo "push attempt ${attempt} failed, retrying..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $(( attempt * 3 ))
|
||||
done
|
||||
echo "push failed after 5 attempts"
|
||||
exit 1
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Build translation cache
|
||||
|
||||
# Regenerates lang/*.json whenever a bash script under scripts/ changes.
|
||||
# The runtime translate() in scripts/utils.sh reads these JSON files and
|
||||
# falls back to the English source on miss, so keeping them up-to-date is
|
||||
# what makes ProxMenux multilingual without any runtime googletrans
|
||||
# dependency on the user's host.
|
||||
#
|
||||
# Triggers:
|
||||
# - push to develop touching scripts/**/*.sh
|
||||
# - manual via workflow_dispatch (force --refresh)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'scripts/**/*.sh'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- '.github/scripts/build_translation_cache.py'
|
||||
- '.github/workflows/build-translation-cache.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
refresh:
|
||||
description: 'Re-translate every entry (ignore cached values)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Avoid two cache rebuilds from racing each other on the same branch and
|
||||
# fighting over the auto-commit.
|
||||
concurrency:
|
||||
group: build-translation-cache-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rebuild-cache:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # auto-commit lang/*.json back to develop
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
# Need full history so the auto-commit doesn't fail when the
|
||||
# cache job runs minutes after the trigger push (GH default
|
||||
# fetch-depth=1 sometimes diverges from origin/develop after a
|
||||
# quick follow-up push).
|
||||
fetch-depth: 0
|
||||
# Use a PAT (or default GITHUB_TOKEN if branch protections allow
|
||||
# it) so the push back to develop actually fires later steps
|
||||
# (workflow runs from auto-commits) if you ever need them.
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install googletrans
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# 4.0.0-rc1 is the same pin that build_translation_cache.py
|
||||
# was written against. Bump in lockstep with the script.
|
||||
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||
|
||||
- name: Regenerate lang/*.json
|
||||
run: |
|
||||
REFRESH_FLAG=""
|
||||
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||
REFRESH_FLAG="--refresh"
|
||||
fi
|
||||
# Extra files outside scripts/ that also contain translate "..."
|
||||
# calls. Keep this list in sync with the `paths` trigger above.
|
||||
python .github/scripts/build_translation_cache.py \
|
||||
--scripts-dir scripts \
|
||||
--extra-file menu \
|
||||
--extra-file install_proxmenux.sh \
|
||||
--extra-file install_proxmenux_beta.sh \
|
||||
--output-dir lang \
|
||||
--provider googletrans \
|
||||
$REFRESH_FLAG
|
||||
|
||||
- name: Commit + push if changed
|
||||
run: |
|
||||
if git diff --quiet -- lang/; then
|
||||
echo "No translation changes — skipping commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add lang/
|
||||
git commit -m "chore(lang): auto-rebuild translation cache
|
||||
|
||||
Source: ${GITHUB_SHA::7}
|
||||
Triggered by: ${{ github.event_name }}"
|
||||
# Rebase-and-retry against develop: between the initial
|
||||
# checkout and this push another workflow (e.g.
|
||||
# build-i18n-messages auto-commit) or a manual push can land
|
||||
# on develop first, and a naked push fails with
|
||||
# non-fast-forward — losing the freshly built cache commit.
|
||||
# Paths don't overlap with the other workflows, and
|
||||
# same-branch collisions between two cache runs are already
|
||||
# blocked by the concurrency group.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin develop; then
|
||||
echo "push succeeded on attempt ${attempt}"
|
||||
exit 0
|
||||
fi
|
||||
echo "push attempt ${attempt} failed, retrying..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $(( attempt * 3 ))
|
||||
done
|
||||
echo "push failed after 5 attempts"
|
||||
exit 1
|
||||
@@ -0,0 +1,119 @@
|
||||
name: Build web documentation translations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'web/messages/en/*.json'
|
||||
- 'web/messages/en/**/*.json'
|
||||
- '.github/scripts/build_web_docs_i18n.py'
|
||||
- '.github/scripts/build_translation_cache.py'
|
||||
- '.github/workflows/build-web-docs-i18n.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
languages:
|
||||
description: 'Comma-separated locales'
|
||||
default: 'es,de,fr,it,pt,sk,sv'
|
||||
section:
|
||||
description: 'File or directory below web/messages/en'
|
||||
default: '.'
|
||||
max_files:
|
||||
description: 'Maximum pending files per locale; 0 means all'
|
||||
default: '0'
|
||||
refresh:
|
||||
description: 'Overwrite existing translations in the selected scope'
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: 'Report pending coverage without writing files'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: build-web-docs-i18n-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install translation provider
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||
|
||||
- name: Build missing translations
|
||||
shell: bash
|
||||
run: |
|
||||
LANGUAGES="${{ github.event.inputs.languages }}"
|
||||
LANGUAGES="${LANGUAGES:-es,de,fr,it,pt,sk,sv}"
|
||||
SECTION="${{ github.event.inputs.section }}"
|
||||
SECTION="${SECTION:-.}"
|
||||
MAX_FILES="${{ github.event.inputs.max_files }}"
|
||||
MAX_FILES="${MAX_FILES:-0}"
|
||||
|
||||
EXTRA_ARGS=()
|
||||
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||
EXTRA_ARGS+=(--refresh)
|
||||
fi
|
||||
if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then
|
||||
EXTRA_ARGS+=(--dry-run)
|
||||
fi
|
||||
|
||||
python .github/scripts/build_web_docs_i18n.py \
|
||||
--source-dir web/messages/en \
|
||||
--messages-dir web/messages \
|
||||
--languages "$LANGUAGES" \
|
||||
--section "$SECTION" \
|
||||
--max-files "$MAX_FILES" \
|
||||
--provider googletrans \
|
||||
--workers 4 \
|
||||
"${EXTRA_ARGS[@]}"
|
||||
|
||||
- name: Validate catalogs
|
||||
run: |
|
||||
python .github/scripts/build_web_docs_i18n.py \
|
||||
--source-dir web/messages/en \
|
||||
--messages-dir web/messages \
|
||||
--languages "${{ github.event.inputs.languages || 'es,de,fr,it,pt,sk,sv' }}" \
|
||||
--check
|
||||
|
||||
- name: Commit and push changes
|
||||
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
if git diff --quiet -- web/messages/; then
|
||||
echo "No documentation translations changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add web/messages/
|
||||
git commit -m "docs(i18n): update documentation translations"
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin develop; then
|
||||
exit 0
|
||||
fi
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $((attempt * 3))
|
||||
done
|
||||
exit 1
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- "main"
|
||||
paths:
|
||||
- "web/**"
|
||||
- "guides/**"
|
||||
- "lang/**"
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,22 +24,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'web/package.json'
|
||||
cache-dependency-path: 'web/package-lock.json'
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Install dependencies and generate lock file
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
npm ci
|
||||
|
||||
- name: Build with Next.js
|
||||
run: |
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
npm run build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: web/out
|
||||
|
||||
@@ -60,4 +60,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Update project growth
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: repo-growth
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: MacRimi/repo-growth@v1
|
||||
with:
|
||||
title: ProxMenux growth
|
||||
output: images/project-growth.svg
|
||||
history: .github/repo-growth/history.json
|
||||
metrics: stars,forks,clones
|
||||
traffic-token: ${{ secrets.REPO_GROWTH_TRAFFIC_TOKEN }}
|
||||
layout: dashboard
|
||||
@@ -0,0 +1,164 @@
|
||||
name: Update App Tracking Hints
|
||||
|
||||
on:
|
||||
# Manual trigger from the Actions UI
|
||||
workflow_dispatch:
|
||||
|
||||
# Re-merge whenever the generator, workflow, or the maintainer-
|
||||
# curated runtime overrides change. `runtime_verified_overrides.json`
|
||||
# is the file to edit when a real LXC reveals a canonical path the
|
||||
# community-scripts helper doesn't ship (legacy /app/package.json,
|
||||
# /opt/vaultwarden/bin/vaultwarden, etc.) — the generator folds it
|
||||
# into the operational catalog every run.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/scripts/generate_app_tracking_catalog.py"
|
||||
- ".github/workflows/update-app-tracking-hints.yml"
|
||||
- "json/runtime_verified_overrides.json"
|
||||
|
||||
# Regen every 6h — picks up new community-scripts LXC apps and
|
||||
# detector-relevant script edits without needing a manual trigger.
|
||||
schedule:
|
||||
- cron: "0 */6 * * *"
|
||||
|
||||
jobs:
|
||||
update-hints:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: ⬇️ Checkout the repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 🐍 Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# The generator writes 4 files; only `.generated.json` is
|
||||
# consumed downstream by the merge step. The v2 catalog and
|
||||
# per-app audit are useful for local review but not kept in
|
||||
# the repo — written under /tmp so they never appear as
|
||||
# dirty files here.
|
||||
#
|
||||
# `--runtime-overrides` folds real-CT evidence into the
|
||||
# operational hints (canonical paths, cross-method fallbacks
|
||||
# per app) so the runtime doesn't get fed helper-marker
|
||||
# false-positives. Modern helper markers are included as their
|
||||
# official version contract; runtime keeps them distinguishable from
|
||||
# canonical package/binary/manual detectors for legacy compatibility.
|
||||
run: |
|
||||
python .github/scripts/generate_app_tracking_catalog.py \
|
||||
--helpers-cache json/helpers_cache.json \
|
||||
--existing json/app_tracking_hints.json \
|
||||
--runtime-overrides json/runtime_verified_overrides.json \
|
||||
--include-helper-markers \
|
||||
--output json/app_tracking_hints.generated.json \
|
||||
--v2-output /tmp/app_tracking_catalog.v2.json \
|
||||
--audit-output /tmp/app_tracking_hints.audit.json
|
||||
|
||||
- name: 🧬 Smart-merge generated into app_tracking_hints.json
|
||||
# Single source of truth: `app_tracking_hints.json` is the ONE
|
||||
# file. It contains 3 kinds of entries:
|
||||
# 1. Auto-verified from community-scripts (the generator
|
||||
# manages every "generator-owned" field on these).
|
||||
# 2. User-edited additions to those entries — extra fields
|
||||
# the generator doesn't touch (default_ports,
|
||||
# file_fallbacks, custom logo overrides…).
|
||||
# 3. User-only entries the generator can't verify (Docker,
|
||||
# AdGuard, Pi-hole, WireGuard, …) — left alone.
|
||||
# Merge rule: for slugs the generator produces, refresh only
|
||||
# the whitelisted fields; preserve everything else. For slugs
|
||||
# NOT in the generator's output, keep the existing entry
|
||||
# untouched.
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
GEN = Path("json/app_tracking_hints.generated.json")
|
||||
OUT = Path("json/app_tracking_hints.json")
|
||||
|
||||
# Fields owned by the generator — refreshed on every run.
|
||||
# These are all populated deterministically by the generator
|
||||
# (the audit script folds `runtime_verified_overrides.json`
|
||||
# in as it runs), so a local hand-edit for a generator-known
|
||||
# slug would get overwritten on the next tick. To add a new
|
||||
# canonical path or a cross-method fallback for a slug the
|
||||
# generator already knows, edit `runtime_verified_overrides
|
||||
# .json` — that file IS the maintainer-controlled input.
|
||||
#
|
||||
# For user-only slugs (Docker, WireGuard, Pi-hole and any
|
||||
# other entry not in the generator's output) EVERY field is
|
||||
# preserved verbatim by the merge below — the whitelist only
|
||||
# governs generator-covered slugs.
|
||||
GENERATOR_FIELDS = {
|
||||
"installed_via", "package", "file_path", "file_regex",
|
||||
"binary_path", "binary_args", "python_path", "distribution",
|
||||
"container_name", "label", "command_argv", "installed_version",
|
||||
"repo", "github_source", "tag_regex", "installed_regex",
|
||||
# Upstream source discriminator + per-type fields
|
||||
# (http_json + docker_hub). Kept in the whitelist so a
|
||||
# curated entry in runtime_verified_overrides.json can
|
||||
# supply them and the smart merge won't drop them on the
|
||||
# next regeneration.
|
||||
"upstream_type", "upstream_url", "upstream_json_path",
|
||||
"docker_image",
|
||||
"logo", "website",
|
||||
"default_ports", "file_fallbacks", "alt_detectors",
|
||||
}
|
||||
|
||||
generated = json.loads(GEN.read_text(encoding="utf-8"))
|
||||
existing = {}
|
||||
if OUT.is_file():
|
||||
try:
|
||||
existing = json.loads(OUT.read_text(encoding="utf-8"))
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
except json.JSONDecodeError:
|
||||
existing = {}
|
||||
|
||||
merged = {}
|
||||
for slug, gen_entry in generated.items():
|
||||
base = dict(existing.get(slug) or {})
|
||||
# Refresh generator-owned fields (add/update).
|
||||
for k, v in gen_entry.items():
|
||||
if k in GENERATOR_FIELDS:
|
||||
base[k] = v
|
||||
# Drop generator-owned fields that the generator no
|
||||
# longer emits for this slug (e.g. path renamed away).
|
||||
for k in list(base):
|
||||
if k in GENERATOR_FIELDS and k not in gen_entry:
|
||||
del base[k]
|
||||
merged[slug] = base
|
||||
# Preserve user-only entries the generator can't verify.
|
||||
for slug, entry in existing.items():
|
||||
if slug not in generated and isinstance(entry, dict):
|
||||
merged[slug] = dict(entry)
|
||||
|
||||
OUT.write_text(json.dumps(merged, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
added = sorted(set(generated) - set(existing))
|
||||
removed = sorted(set(existing) - set(generated) - {s for s, e in existing.items() if not (
|
||||
set(e.keys()) - GENERATOR_FIELDS
|
||||
)})
|
||||
print(f"merged: {len(merged)} entries "
|
||||
f"(generated={len(generated)}, existing={len(existing)})")
|
||||
if added:
|
||||
print(f" new from generator: {len(added)}")
|
||||
# Clean up the intermediate file so it doesn't get committed.
|
||||
GEN.unlink()
|
||||
PY
|
||||
|
||||
- name: 📤 Commit + push if changed
|
||||
run: |
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add json/app_tracking_hints.json
|
||||
git diff --cached --quiet || git commit -m "Update app tracking hints"
|
||||
git push
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: ⬇️ Checkout the repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 🐍 Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
|
||||
+17
@@ -6,6 +6,14 @@ web/out/
|
||||
web/node_modules/
|
||||
node_modules/
|
||||
|
||||
# Local-only — accidental pagefind install at project root.
|
||||
# Pagefind is declared and installed from web/package.json; the
|
||||
# CI build (.github/workflows/deploy.yml) only runs
|
||||
# `cd web && npm install`, so a root-level package.json/lock is
|
||||
# never consumed and just adds noise. Keep them ignored.
|
||||
/package.json
|
||||
/package-lock.json
|
||||
|
||||
# Logs
|
||||
web/*.log
|
||||
*.log
|
||||
@@ -33,6 +41,15 @@ Thumbs.db
|
||||
/web/.next
|
||||
/web/out
|
||||
|
||||
# Build artifacts generated by web's prebuild + build scripts.
|
||||
# `prebuild` runs `sync:scripts` which rsyncs ../scripts/ into
|
||||
# public/scripts/. `build` runs pagefind --site out which writes the
|
||||
# search index into public/pagefind/. Both are regenerated fresh by
|
||||
# the GitHub Pages CI on every deploy; committing them would just
|
||||
# bloat the repo and produce constant noise in `git status`.
|
||||
/web/public/pagefind/
|
||||
/web/public/scripts/
|
||||
|
||||
# Cache
|
||||
.cache
|
||||
/web/.cache
|
||||
|
||||
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -1 +1 @@
|
||||
0024ebd5201dc3b504aaa760b18ff6651338e3cae21e26e3cb8f4cc8b613b04a ProxMenux-1.2.0.AppImage
|
||||
fc092901ab67df01316a389e92502c26248fe61a68b705d9eacfd717a40c2391 ProxMenux-1.2.6.AppImage
|
||||
|
||||
+116
-34
@@ -27,19 +27,97 @@ A modern, responsive dashboard for monitoring Proxmox VE systems built with Next
|
||||
|
||||
## 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.
|
||||
**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, backups, health status and system logs — all from a single browser tab.
|
||||
|
||||
The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network.
|
||||
|
||||
**Full documentation:** [proxmenux.com/docs/monitor](https://proxmenux.com/docs/monitor) — per-feature walkthroughs, API reference and integration guides.
|
||||
|
||||
|
||||
## Screenshots
|
||||
|
||||
Get a quick overview of ProxMenux Monitor's main features:
|
||||
A tour of the Monitor's main areas. See the [public documentation](https://proxmenux.com/docs/monitor) for the full walkthrough.
|
||||
|
||||
### Dashboard overview
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen1.png" alt="Overview Dashboard" width="800"/>
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/dashboard-home.png" alt="Dashboard home" width="900"/>
|
||||
<br/>
|
||||
<em>System Overview - Monitor CPU, memory, temperature, and uptime in real-time</em>
|
||||
<em>Real-time CPU, memory, temperature, storage and network activity on one glance-optimised page.</em>
|
||||
</p>
|
||||
|
||||
### Backup & Restore
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/images/docs/backup-restore/scheduled-backup-monitor.png" alt="Scheduled backup jobs" width="900"/>
|
||||
<br/>
|
||||
<em>Integrated host backup & restore — Local, PBS or Borg destinations; own timer or attached to a PVE vzdump job with live-inherited retention; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore.</em>
|
||||
</p>
|
||||
|
||||
### Storage & SMART
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/storage-top-row.png" alt="Storage top row" width="900"/>
|
||||
<br/>
|
||||
<em>Per-disk cards with capacity palette shared across Storage and Backups. USB-NVMe enclosures (ASMedia / JMicron / Realtek) show the drive's real identity and temperature, not the bridge.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/disk-modal-smart.png" alt="Disk SMART modal" width="900"/>
|
||||
<br/>
|
||||
<em>Per-disk detail modal — SMART attributes, temperature history, observations and downloadable PDF report.</em>
|
||||
</p>
|
||||
|
||||
### Network — live topology
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/network-flow-overview.png" alt="Network Flow diagram" width="900"/>
|
||||
<br/>
|
||||
<em>Network Flow — live view of NICs → host → bridges → guests with animated rx / tx pulses on every link. Bridges without active guests are hidden.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/network-latency-historical.png" alt="Network latency historical" width="900"/>
|
||||
<br/>
|
||||
<em>Latency modal — historical view and real-time ping test against Gateway / Cloudflare / Google, with a downloadable PDF report.</em>
|
||||
</p>
|
||||
|
||||
### Virtual machines & LXCs
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/vms-top-row.png" alt="VMs & LXCs overview" width="900"/>
|
||||
<br/>
|
||||
<em>Inventory of running VMs and containers with resource usage and per-guest controls.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/vms-modal-status.png" alt="VM detail modal — status" width="900"/>
|
||||
<br/>
|
||||
<em>Per-guest modal with status, backups, mounts and (for LXC) apt / apk / community-scripts update inventory.</em>
|
||||
</p>
|
||||
|
||||
### Health Monitor
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/health-monitor.png" alt="Health Monitor" width="900"/>
|
||||
<br/>
|
||||
<em>Ten categories of proactive health checks with hysteresis, per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke.</em>
|
||||
</p>
|
||||
|
||||
### System overview — top processes
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/system-overview-top-processes.png" alt="Top processes" width="900"/>
|
||||
<br/>
|
||||
<em>Per-process CPU / memory / I/O sorted by consumer, with a click-through detail modal.</em>
|
||||
</p>
|
||||
|
||||
### Mobile
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/mobile-home.png" alt="Mobile responsive layout" width="360"/>
|
||||
<br/>
|
||||
<em>Fully responsive — every panel adapts to phone and tablet layouts.</em>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -47,18 +125,21 @@ Get a quick overview of ProxMenux Monitor's main features:
|
||||
|
||||
## 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
|
||||
- **Host Backup & Restore** — integrated section covering Local, PBS and Borg destinations; schedule with a systemd timer or attach to an existing PVE vzdump job; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore with kernel-agnostic hydration (IOMMU / VFIO / GRUB), cascade-safe package replay, and NIC auto-remap by MAC after a motherboard swap
|
||||
- **System Overview** — real-time CPU / memory / temperature / uptime with a per-process drill-down (Top Processes view) and click-through detail modal
|
||||
- **Storage & SMART** — per-disk cards in a responsive grid; temperature history, SMART attributes and a downloadable PDF SMART report per drive. USB-NVMe bridges (ASMedia / JMicron / Realtek) show the real drive's identity and temperature instead of the enclosure's
|
||||
- **Network** — live topology (Network Flow) with animated rx / tx pulses, per-interface RRD charts, latency modal against Gateway / Cloudflare / Google with a downloadable PDF report
|
||||
- **Virtual Machines & LXCs** — inventory, per-guest metrics and controls, per-LXC update inventory (apt / apk / community-scripts), backup state, mount inspection
|
||||
- **Hardware** — CPU / GPU / PCIe / disks / NICs, with correct SSD vs HDD classification even behind USB-SATA bridges
|
||||
- **System Logs** — journalctl with severity / since / free-text filters and a download-as-text action
|
||||
- **Health Monitor** — ten categories of proactive checks with hysteresis and configurable thresholds; per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke
|
||||
- **Notifications** — five channels (Telegram, Discord, Gotify, Email, Apprise for ~80 endpoints), per-event toggles, Quiet Hours, Daily Digest, optional AI enrichment (Groq / OpenAI / Ollama / Gemini / Anthropic / OpenRouter)
|
||||
- **Authentication & 2FA** — optional password protection with TOTP-based two-factor authentication and long-lived API tokens (365 days) for integrations
|
||||
- **RESTful API** — complete access with JWT auth; ready-made recipes for Homepage, Home Assistant and custom dashboards; Prometheus scrape endpoint
|
||||
- **Reports (PDF)** — SMART report per-disk and Network Latency report per-target, ready to send to a vendor or an ISP
|
||||
- **Dark / Light Theme** — Proxmox-inspired palette with live switch
|
||||
- **Responsive Design** — desktop, tablet and mobile layouts
|
||||
- **Release Notes** — automatic notification of new features on each Monitor upgrade
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@@ -106,7 +187,7 @@ On first launch, you'll be presented with three options:
|
||||
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)
|
||||
|
||||
@@ -118,7 +199,21 @@ After setting up your password, you can enable 2FA using any TOTP authenticator
|
||||
4. Enter the 6-digit code to verify
|
||||
5. Save your backup codes in a secure location
|
||||
|
||||

|
||||

|
||||
|
||||
### Embedding in Trusted Iframes
|
||||
|
||||
By default, ProxMenux Monitor blocks embedding in iframes with `frame-ancestors 'none'` and `X-Frame-Options: DENY`.
|
||||
|
||||
If you run a trusted local portal or monitoring page and need to embed the Monitor, set `PROXMENUX_ALLOWED_FRAME_ANCESTORS` to the exact parent origins that may frame it:
|
||||
|
||||
```bash
|
||||
PROXMENUX_ALLOWED_FRAME_ANCESTORS="https://portal.example.com http://raspberrypi.local:8080"
|
||||
```
|
||||
|
||||
Only exact `http://` or `https://` origins are accepted. Paths, wildcards, broad schemes, credentials, and malformed values are ignored. When this setting is present, the Monitor sends a matching CSP `frame-ancestors` allowlist and omits `X-Frame-Options`, because that legacy header cannot express multiple allowed parents.
|
||||
|
||||
`ALLOWED_FRAME_ANCESTORS` is also accepted as a compatibility alias when `PROXMENUX_ALLOWED_FRAME_ANCESTORS` is not set.
|
||||
|
||||
### Security Best Practices for API Tokens
|
||||
|
||||
@@ -254,7 +349,7 @@ The easiest way to generate an API token is through the ProxMenux Monitor web in
|
||||
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.
|
||||
|
||||
@@ -643,8 +738,6 @@ Finally, reference the secret in your `services.yaml`:
|
||||
format: bytes
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform.
|
||||
@@ -727,24 +820,13 @@ entities:
|
||||
icon: mdi:clock-outline
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**.
|
||||
This project is licensed under the **GNU General Public License, version 3 (GPL-3.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/).
|
||||
You are free to use, study, share and modify the software under the terms of the licence. Any distributed derivative work must be licensed under the same terms and include the full source code — see the [full licence text](https://www.gnu.org/licenses/gpl-3.0.html) or the [`LICENSE`](https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) file at the repository root.
|
||||
|
||||
|
||||
|
||||
|
||||
+11
-4
@@ -3,6 +3,9 @@ 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 { PwaRegister } from "../components/pwa-register"
|
||||
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
|
||||
import { I18nProvider } from "../lib/i18n/provider"
|
||||
import { Suspense } from "react"
|
||||
import "./globals.css"
|
||||
|
||||
@@ -41,11 +44,15 @@ export default function RootLayout({
|
||||
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 fallback={null}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<PwaInstallPrompt />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
<PwaRegister />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
+43
-5
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
|
||||
import { Login } from "../components/login"
|
||||
import { AuthSetup } from "../components/auth-setup"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export default function Home() {
|
||||
const t = useT()
|
||||
const [authStatus, setAuthStatus] = useState<{
|
||||
loading: boolean
|
||||
authEnabled: boolean
|
||||
@@ -29,21 +31,57 @@ export default function Home() {
|
||||
const response = await fetch(getApiUrl("/api/auth/status"), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
|
||||
|
||||
// 401 here means the token is present but invalid — typically signed
|
||||
// under a previous jwt_secret (rotated on AppImage upgrade or fresh
|
||||
// install). If we let this fall into the catch below, the dashboard
|
||||
// would render and every authenticated component would fire its own
|
||||
// 401 in parallel, flooding the backend logs and looping reloads.
|
||||
// Drop the dead token and force the Login screen instead.
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
} catch {
|
||||
// private browsing — best-effort
|
||||
}
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: true,
|
||||
authConfigured: true,
|
||||
authenticated: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Clear the 401 cascade-prevention flag when we successfully end
|
||||
// up in the authenticated state. The flag is meant to dedupe a
|
||||
// burst of 401s during a single page load; once we've confirmed
|
||||
// the user is in, a future 401 (token rotation, restart, etc.)
|
||||
// should be allowed to reload again. Without this, a stale flag
|
||||
// can prevent the post-2FA dashboard from recovering from any
|
||||
// transient 401 and leaves the UI blocked.
|
||||
if (authenticated) {
|
||||
try {
|
||||
sessionStorage.removeItem("proxmenux-auth-401-handled")
|
||||
} catch {
|
||||
// private browsing — best-effort
|
||||
}
|
||||
}
|
||||
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: data.auth_enabled,
|
||||
@@ -77,8 +115,8 @@ export default function Home() {
|
||||
<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 className="text-sm font-medium text-foreground">{t("app.loading")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import {
|
||||
Github,
|
||||
Heart,
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
Bug,
|
||||
Sparkles,
|
||||
Scale,
|
||||
ExternalLink,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { APP_VERSION } from "./release-notes-modal"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Issue #191: a dedicated About tab. Centralises project metadata
|
||||
// (version, license, author) and every external link the project
|
||||
// already exposes — GitHub, docs, donation. Replaces the lone
|
||||
// "Support and contribute to the project" footer link with a proper
|
||||
// information surface that's easy to extend with new social channels
|
||||
// without re-cluttering the dashboard footer.
|
||||
|
||||
interface LinkRow {
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
href: string
|
||||
Icon: React.ComponentType<{ className?: string }>
|
||||
accent?: keyof typeof ACCENT_CLASSES
|
||||
}
|
||||
|
||||
// Tailwind only emits classes that appear as literal strings in the
|
||||
// source. A dynamic `bg-${accent}/10` template does not survive the
|
||||
// purge step, so each accent maps to a fully-spelled class pair below.
|
||||
const ACCENT_CLASSES = {
|
||||
gray: "bg-gray-500/10 text-gray-400",
|
||||
blue: "bg-blue-500/10 text-blue-500",
|
||||
purple: "bg-purple-500/10 text-purple-400",
|
||||
red: "bg-red-500/10 text-red-500",
|
||||
pink: "bg-pink-500/10 text-pink-500",
|
||||
} as const
|
||||
|
||||
const PROJECT_LINKS: LinkRow[] = [
|
||||
{
|
||||
labelKey: "about.links.repository.label",
|
||||
descriptionKey: "about.links.repository.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux",
|
||||
Icon: Github,
|
||||
accent: "gray",
|
||||
},
|
||||
{
|
||||
labelKey: "about.links.documentation.label",
|
||||
descriptionKey: "about.links.documentation.description",
|
||||
href: "https://proxmenux.com",
|
||||
Icon: BookOpen,
|
||||
accent: "blue",
|
||||
},
|
||||
{
|
||||
labelKey: "about.links.discussions.label",
|
||||
descriptionKey: "about.links.discussions.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux/discussions",
|
||||
Icon: MessageSquare,
|
||||
accent: "purple",
|
||||
},
|
||||
{
|
||||
labelKey: "about.links.issues.label",
|
||||
descriptionKey: "about.links.issues.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux/issues",
|
||||
Icon: Bug,
|
||||
accent: "red",
|
||||
},
|
||||
]
|
||||
|
||||
const SUPPORT_LINKS: LinkRow[] = [
|
||||
{
|
||||
labelKey: "about.links.support.label",
|
||||
descriptionKey: "about.links.support.description",
|
||||
href: "https://ko-fi.com/macrimi",
|
||||
Icon: Heart,
|
||||
accent: "pink",
|
||||
},
|
||||
]
|
||||
|
||||
function LinkCard({ row }: { row: LinkRow }) {
|
||||
const t = useT()
|
||||
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
|
||||
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
|
||||
// translucent background by default, slightly lighter on hover, no
|
||||
// accent-coloured borders or text colour changes — keeps the look
|
||||
// consistent with the rest of the project.
|
||||
return (
|
||||
<a
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer flex items-start gap-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md ${accentClass}`}
|
||||
>
|
||||
<row.Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{t(row.labelKey)}
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{t(row.descriptionKey)}</p>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function About() {
|
||||
const t = useT()
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Hero — logo, name, version, one-line description. */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 pb-6">
|
||||
<div className="flex flex-col md:flex-row items-center md:items-start gap-4 md:gap-6">
|
||||
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
|
||||
<Image
|
||||
src="/images/proxmenux-logo.png"
|
||||
alt={t("about.logoAlt")}
|
||||
fill
|
||||
priority
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center md:text-left flex-1 min-w-0">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold text-foreground">
|
||||
ProxMenux Monitor
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("about.heroDescription")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
v{APP_VERSION}
|
||||
</span>
|
||||
{/* Beta versions surface their pre-release notes on the
|
||||
GitHub Releases page (where each beta is tagged + signed);
|
||||
stable versions point at the canonical web changelog
|
||||
which only carries shipped releases. Detection: the
|
||||
APP_VERSION string carries a "-beta" / "-rc" /
|
||||
"-alpha" suffix for any non-stable build. */}
|
||||
{(() => {
|
||||
const isPrerelease = /-(beta|rc|alpha)/i.test(APP_VERSION)
|
||||
const href = isPrerelease
|
||||
? "https://github.com/MacRimi/ProxMenux/releases"
|
||||
: "https://proxmenux.com/en/changelog"
|
||||
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-muted hover:bg-muted/70 transition-colors text-foreground border border-border px-2.5 py-1 text-xs"
|
||||
>
|
||||
{label}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Project links — GitHub, docs, discussions, bug tracker. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Github className="h-4 w-4 text-muted-foreground" />
|
||||
{t("about.project.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("about.project.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{PROJECT_LINKS.map(row => (
|
||||
<LinkCard key={row.href} row={row} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Support + License combined — donation link and licensing
|
||||
info in one card. The previous layout had a separate "Author"
|
||||
block that has been removed by request. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Heart className="h-4 w-4 text-pink-500" />
|
||||
{t("about.support.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("about.support.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{SUPPORT_LINKS.map(row => (
|
||||
<LinkCard key={row.href} row={row} />
|
||||
))}
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer flex items-start gap-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||||
>
|
||||
<span className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md bg-gray-500/10 text-gray-400">
|
||||
<Scale className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{t("about.license.label")}
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||
{t("about.license.description")}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { ThemeAwareLogo } from "./lxc-app-panel"
|
||||
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
|
||||
import { Button } from "./ui/button"
|
||||
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
||||
|
||||
// ─── Local subset of /api/vms shape ─────────────────────────────
|
||||
// Kept narrow on purpose — this component only needs what feeds a
|
||||
// launcher card. Full VMData / LxcAppWatch types live in
|
||||
// virtual-machines.tsx.
|
||||
|
||||
interface AppPort {
|
||||
port: number
|
||||
description?: string
|
||||
scheme?: "http" | "https"
|
||||
web_path?: string
|
||||
logo_url?: string | null
|
||||
category?: string
|
||||
custom_url?: string
|
||||
}
|
||||
|
||||
interface AppWatch {
|
||||
id: string
|
||||
name: string | null
|
||||
logo_url?: string | null
|
||||
ports?: AppPort[]
|
||||
installed_version: string | null
|
||||
latest_version: string | null
|
||||
update_available: boolean | null
|
||||
managed_oci_app_id?: string | null
|
||||
helper_slug?: string
|
||||
}
|
||||
|
||||
interface DockerImageUpdate {
|
||||
reference: string
|
||||
display_name?: string | null
|
||||
used_by?: string[]
|
||||
update_available: boolean | null
|
||||
}
|
||||
|
||||
// Locate the docker_inventory image whose lifecycle matches a given
|
||||
// Web Link. The port's `description` is a user-typed label (e.g.
|
||||
// "Paperless") so exact match on `used_by` (real container names
|
||||
// like "paperless-webserver-1") almost never hits. Fall back through:
|
||||
// 1. exact match in `used_by`
|
||||
// 2. case-insensitive substring either way in `used_by`
|
||||
// 3. substring in `display_name`
|
||||
// 4. substring in `reference` (the full image path)
|
||||
// Returns undefined when nothing matches — the caller treats that as
|
||||
// "no upstream update signal for this port".
|
||||
function findDockerImageForPort(
|
||||
port: AppPort,
|
||||
images: DockerImageUpdate[],
|
||||
): DockerImageUpdate | undefined {
|
||||
const desc = (port.description || "").trim().toLowerCase()
|
||||
if (!desc || !images.length) return undefined
|
||||
const exact = images.find((i) =>
|
||||
(i.used_by || []).some((c) => c.toLowerCase() === desc),
|
||||
)
|
||||
if (exact) return exact
|
||||
const inclUsedBy = images.find((i) =>
|
||||
(i.used_by || []).some((c) => {
|
||||
const cl = c.toLowerCase()
|
||||
return cl.includes(desc) || desc.includes(cl)
|
||||
}),
|
||||
)
|
||||
if (inclUsedBy) return inclUsedBy
|
||||
const byDisplay = images.find((i) => {
|
||||
const d = (i.display_name || "").toLowerCase()
|
||||
return !!d && (d.includes(desc) || desc.includes(d))
|
||||
})
|
||||
if (byDisplay) return byDisplay
|
||||
return images.find((i) => (i.reference || "").toLowerCase().includes(desc))
|
||||
}
|
||||
|
||||
interface VM {
|
||||
vmid: number
|
||||
name: string
|
||||
ip?: string
|
||||
type: string
|
||||
app_watches?: AppWatch[]
|
||||
docker_inventory?: { images?: DockerImageUpdate[] }
|
||||
}
|
||||
|
||||
interface LaunchLink {
|
||||
key: string
|
||||
// Present for LXC-registered apps and for custom links with a
|
||||
// guest binding. Absent when the link is an unbound custom entry
|
||||
// (e.g. an external service).
|
||||
vmid: number | null
|
||||
guestType: "lxc" | "qemu" | null
|
||||
ctName: string
|
||||
appName: string
|
||||
logoUrl: string | null
|
||||
weblink: string
|
||||
category: string
|
||||
updateAvailable: boolean
|
||||
// Set for user-defined custom links so the card can offer edit
|
||||
// and delete actions in edit mode.
|
||||
isCustom: boolean
|
||||
customId?: string
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
// Same URL construction as the Web Link row in the App tab.
|
||||
// Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx
|
||||
// is scoped to that module, and copying keeps this component free of
|
||||
// hidden cross-file dependencies. A per-port `custom_url` overrides
|
||||
// the ip:port composition entirely — used for apps served behind a
|
||||
// reverse-proxy domain.
|
||||
function buildWebUrl(ip: string | undefined, port: AppPort): string | null {
|
||||
const custom = (port.custom_url || "").trim()
|
||||
if (custom) return custom
|
||||
const raw = (ip || "").trim().split("/")[0]
|
||||
if (!raw || raw === "DHCP" || !port?.port) return null
|
||||
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
|
||||
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
|
||||
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
|
||||
return `${scheme}://${host}:${port.port}${path}`
|
||||
}
|
||||
|
||||
type SortMode = "name" | "ct" | "category"
|
||||
const SORT_STORAGE_KEY = "proxmenux-apps-sort"
|
||||
const ALL_CATEGORIES = "__all__"
|
||||
|
||||
const fetcher = async (url: string) => fetchApi(url)
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────
|
||||
|
||||
export function AppsDashboard() {
|
||||
const t = useT()
|
||||
const { data: vms } = useSWR<VM[]>("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false })
|
||||
// Custom links persisted in /etc/proxmenux/custom_links.json. Small
|
||||
// and rarely changes, so we don't poll on an interval — mutate() is
|
||||
// called explicitly after create / update / delete.
|
||||
const { data: customLinks, mutate: mutateCustomLinks } = useSWR<CustomLink[]>(
|
||||
"/api/apps/custom-links", fetcher, { revalidateOnFocus: false },
|
||||
)
|
||||
// Category presets for the "+ Add link" modal.
|
||||
const { data: categoryPresets } = useSWR<string[]>(
|
||||
"/api/apps/categories", fetcher, { revalidateOnFocus: false },
|
||||
)
|
||||
|
||||
const isLightTheme = useIsLightTheme()
|
||||
|
||||
// Flatten VMs → LaunchLinks. One card per (app × port with weblink)
|
||||
// for LXC-registered apps, plus one card per user-defined custom
|
||||
// link. A custom link with a binding resolves its ctName from the
|
||||
// matching guest in `vms` so renames stay in sync automatically.
|
||||
const links = useMemo<LaunchLink[]>(() => {
|
||||
const out: LaunchLink[] = []
|
||||
const vmsList = Array.isArray(vms) ? vms : []
|
||||
for (const vm of vmsList) {
|
||||
const apps = vm.app_watches || []
|
||||
if (!apps.length) continue
|
||||
for (const app of apps) {
|
||||
// Skip the synthetic entry ProxMenux inserts for managed
|
||||
// OCI apps (Secure Gateway) — it has no user-assigned
|
||||
// Web Link and doesn't belong in a launcher.
|
||||
if (app.managed_oci_app_id) continue
|
||||
// `app.update_available` refers to the app itself. For a
|
||||
// Docker registration that app is the Docker engine, and its
|
||||
// ports are containers running INSIDE Docker (Portainer,
|
||||
// Frigate…) — each with an independent image update
|
||||
// lifecycle in `vm.docker_inventory.images[]`. Propagating
|
||||
// the engine-level flag to every container card would falsely
|
||||
// mark Portainer/Frigate as updatable when only the engine
|
||||
// needs bumping; missing the per-image flag would hide real
|
||||
// Portainer/Frigate updates that ARE tracked in the App tab
|
||||
// and fire notifications. Resolution: for each Docker port,
|
||||
// find the image entry whose `used_by` includes the port's
|
||||
// container name (== `port.description`) and use THAT image's
|
||||
// update_available. Engine update stays out of the port cards
|
||||
// — it belongs in the Updates tab.
|
||||
const isDockerApp = app.helper_slug === "docker"
|
||||
const dockerImages = vm.docker_inventory?.images || []
|
||||
for (const port of app.ports || []) {
|
||||
const url = buildWebUrl(vm.ip, port)
|
||||
if (!url) continue
|
||||
let updateAvailable = false
|
||||
if (isDockerApp) {
|
||||
const img = findDockerImageForPort(port, dockerImages)
|
||||
updateAvailable = img?.update_available === true
|
||||
} else {
|
||||
updateAvailable = app.update_available === true
|
||||
}
|
||||
out.push({
|
||||
key: `lxc-${vm.vmid}-${app.id}-${port.port}`,
|
||||
vmid: vm.vmid,
|
||||
guestType: "lxc",
|
||||
ctName: vm.name,
|
||||
appName: (port.description || app.name || vm.name || "").trim(),
|
||||
logoUrl: port.logo_url || app.logo_url || null,
|
||||
weblink: url,
|
||||
category: (port.category || "").trim(),
|
||||
updateAvailable,
|
||||
isCustom: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Merge user-defined custom links. Their `binding` decides how the
|
||||
// CT/VM reference renders and where clicking it navigates.
|
||||
const guestByVmid = new Map<number, { name: string; type: string }>()
|
||||
for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type })
|
||||
for (const link of customLinks || []) {
|
||||
let ctName = ""
|
||||
let vmid: number | null = null
|
||||
let guestType: "lxc" | "qemu" | null = null
|
||||
if (link.binding) {
|
||||
const guest = guestByVmid.get(link.binding.vmid)
|
||||
vmid = link.binding.vmid
|
||||
guestType = link.binding.guest_type
|
||||
ctName = guest?.name || ""
|
||||
}
|
||||
out.push({
|
||||
key: `custom-${link.id}`,
|
||||
vmid,
|
||||
guestType,
|
||||
ctName,
|
||||
appName: link.name,
|
||||
logoUrl: link.logo_url || null,
|
||||
weblink: link.url,
|
||||
category: (link.category || "").trim(),
|
||||
updateAvailable: false,
|
||||
isCustom: true,
|
||||
customId: link.id,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}, [vms, customLinks])
|
||||
|
||||
// Category list for the filter dropdown — built from the data so
|
||||
// it always reflects reality (presets and custom-entered names).
|
||||
const categoryCounts = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const l of links) {
|
||||
const key = l.category || t("apps.uncategorized")
|
||||
map.set(key, (map.get(key) || 0) + 1)
|
||||
}
|
||||
return map
|
||||
}, [links, t])
|
||||
const sortedCategoryEntries = useMemo(
|
||||
() => Array.from(categoryCounts.entries()).sort((a, b) => a[0].localeCompare(b[0])),
|
||||
[categoryCounts],
|
||||
)
|
||||
|
||||
// ─── Controls state ────────────────────────────────────────────
|
||||
|
||||
const [query, setQuery] = useState("")
|
||||
const [currentCat, setCurrentCat] = useState<string>(ALL_CATEGORIES)
|
||||
const [sortMode, setSortMode] = useState<SortMode>("name")
|
||||
const [searchExpanded, setSearchExpanded] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// Restore sort from localStorage on mount.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(SORT_STORAGE_KEY)
|
||||
if (saved === "name" || saved === "ct" || saved === "category") {
|
||||
setSortMode(saved)
|
||||
}
|
||||
} catch (_) { /* private mode / storage disabled — silent */ }
|
||||
}, [])
|
||||
|
||||
// Persist sort choice — only this one preference survives reload;
|
||||
// category filter and search reset each visit so the dashboard
|
||||
// always opens showing every app.
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {}
|
||||
}, [sortMode])
|
||||
|
||||
// Filter category resets if the user removes/renames the currently
|
||||
// selected one and it disappears from the list.
|
||||
useEffect(() => {
|
||||
if (currentCat === ALL_CATEGORIES) return
|
||||
if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES)
|
||||
}, [currentCat, categoryCounts])
|
||||
|
||||
// ─── Custom link editor state ──────────────────────────────────
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingLink, setEditingLink] = useState<CustomLink | null>(null)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
|
||||
// Guest list feeds the binding dropdown in the editor modal.
|
||||
const guestOptions = useMemo<GuestOption[]>(() => {
|
||||
if (!Array.isArray(vms)) return []
|
||||
return vms
|
||||
.filter((v) => v.type === "lxc" || v.type === "qemu")
|
||||
.map((v) => ({
|
||||
vmid: v.vmid,
|
||||
name: v.name,
|
||||
type: v.type as "lxc" | "qemu",
|
||||
}))
|
||||
}, [vms])
|
||||
|
||||
const openNewLink = () => {
|
||||
setEditingLink(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
const openEditForLink = (customId: string) => {
|
||||
const found = (customLinks || []).find((l) => l.id === customId)
|
||||
if (!found) return
|
||||
setEditingLink(found)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
// ─── Filter + sort ─────────────────────────────────────────────
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const uncatKey = t("apps.uncategorized")
|
||||
let filtered = links
|
||||
if (currentCat !== ALL_CATEGORIES) {
|
||||
filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat)
|
||||
}
|
||||
if (q) {
|
||||
filtered = filtered.filter((l) =>
|
||||
l.appName.toLowerCase().includes(q) ||
|
||||
(l.ctName || "").toLowerCase().includes(q) ||
|
||||
(l.vmid != null && String(l.vmid).includes(q)) ||
|
||||
(l.category || "").toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
const sorted = [...filtered]
|
||||
sorted.sort((a, b) => {
|
||||
if (sortMode === "name") return a.appName.localeCompare(b.appName)
|
||||
if (sortMode === "ct") {
|
||||
// Unbound custom links have no vmid; sort them after every
|
||||
// bound entry, ordered alphabetically by app name.
|
||||
if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName)
|
||||
if (a.vmid == null) return 1
|
||||
if (b.vmid == null) return -1
|
||||
return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName)
|
||||
}
|
||||
// category — grouped alphabetically, then by app name inside
|
||||
const catA = a.category || uncatKey
|
||||
const catB = b.category || uncatKey
|
||||
const c = catA.localeCompare(catB)
|
||||
return c !== 0 ? c : a.appName.localeCompare(b.appName)
|
||||
})
|
||||
return sorted
|
||||
}, [links, query, currentCat, sortMode, t])
|
||||
|
||||
// ─── Empty state ───────────────────────────────────────────────
|
||||
|
||||
const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0)
|
||||
if (vms && !hasAnyData) {
|
||||
return (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground py-16">
|
||||
<div className="text-sm">{t("apps.emptyTitle")}</div>
|
||||
<div className="text-xs mt-1 opacity-80">{t("apps.emptyHint")}</div>
|
||||
<Button
|
||||
onClick={openNewLink}
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
{t("apps.customLinkAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
<CustomLinkEditor
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
editing={editingLink}
|
||||
guests={guestOptions}
|
||||
categoryPresets={categoryPresets || []}
|
||||
onSaved={() => mutateCustomLinks()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────
|
||||
|
||||
const countLabel = shown.length === 1
|
||||
? t("apps.countOne")
|
||||
: t("apps.countMany", { n: shown.length })
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Search — icon-only until tapped on narrow screens */}
|
||||
<div className={`relative ${searchExpanded ? "flex-1 min-w-full sm:min-w-0 sm:flex-none sm:w-72" : "sm:flex-1 sm:min-w-40 sm:max-w-xs"}`}>
|
||||
{!searchExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchExpanded(true)
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||
}}
|
||||
className="sm:hidden inline-flex items-center justify-center w-9 h-9 rounded-md border border-border bg-card text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
|
||||
aria-label={t("apps.searchAriaLabel")}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className={`relative ${searchExpanded ? "block" : "hidden sm:block"}`}>
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onBlur={() => { if (!query.trim()) setSearchExpanded(false) }}
|
||||
placeholder={t("apps.searchPlaceholder")}
|
||||
aria-label={t("apps.searchAriaLabel")}
|
||||
className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<select
|
||||
value={currentCat}
|
||||
onChange={(e) => setCurrentCat(e.target.value)}
|
||||
aria-label={t("apps.filterAriaLabel")}
|
||||
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[9.5rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
|
||||
style={{
|
||||
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
|
||||
}}
|
||||
>
|
||||
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
|
||||
{sortedCategoryEntries.map(([cat, n]) => (
|
||||
<option key={cat} value={cat}>{`${cat} · ${n}`}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sortMode}
|
||||
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||
aria-label={t("apps.sortAriaLabel")}
|
||||
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[8rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
|
||||
style={{
|
||||
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
|
||||
}}
|
||||
>
|
||||
<option value="name">{t("apps.sortName")}</option>
|
||||
<option value="ct">{t("apps.sortId")}</option>
|
||||
<option value="category">{t("apps.sortCategory")}</option>
|
||||
</select>
|
||||
|
||||
{/* Count — desktop only. Mobile gives the horizontal room to
|
||||
the + button instead so everything stays on one line. */}
|
||||
<span className="ml-auto hidden sm:inline-flex items-center h-9 px-3 text-xs text-muted-foreground rounded-md bg-card border border-border font-mono tabular-nums">
|
||||
{countLabel}
|
||||
</span>
|
||||
|
||||
{/* + Add custom link. Icon-only on mobile (mirrors the search
|
||||
icon-toggle pattern) so the toolbar fits in one line even
|
||||
in the narrowest viewport. On desktop shows label + icon. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={openNewLink}
|
||||
className="sm:ml-2 ml-auto h-9 px-2.5 sm:px-3 flex-shrink-0"
|
||||
aria-label={t("apps.customLinkAdd")}
|
||||
>
|
||||
<Plus className="h-4 w-4 sm:mr-1.5" />
|
||||
<span className="hidden sm:inline">{t("apps.customLinkAdd")}</span>
|
||||
</Button>
|
||||
|
||||
{/* Edit mode toggle — only shown when at least one custom link
|
||||
exists, since it's the only card type that carries per-card
|
||||
edit/delete actions. LXC-registered apps are edited in the
|
||||
LXC App tab of their guest modal. */}
|
||||
{(customLinks && customLinks.length > 0) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
className={`h-9 px-2.5 sm:px-3 flex-shrink-0 ${editMode ? "border-blue-500/60 text-blue-400" : ""}`}
|
||||
aria-pressed={editMode}
|
||||
aria-label={t("apps.editModeToggle")}
|
||||
>
|
||||
{editMode ? <Check className="h-4 w-4 sm:mr-1.5" /> : <Pencil className="h-4 w-4 sm:mr-1.5" />}
|
||||
<span className="hidden sm:inline">{editMode ? t("apps.editModeDone") : t("apps.editModeToggle")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid — grouped headers when sorted by category */}
|
||||
<CardsGrid
|
||||
links={shown}
|
||||
grouped={sortMode === "category"}
|
||||
uncategorizedLabel={t("apps.uncategorized")}
|
||||
openLabel={t("apps.openAriaLabel")}
|
||||
isLightTheme={isLightTheme}
|
||||
editMode={editMode}
|
||||
onEditCustom={openEditForLink}
|
||||
/>
|
||||
|
||||
<CustomLinkEditor
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
editing={editingLink}
|
||||
guests={guestOptions}
|
||||
categoryPresets={categoryPresets || []}
|
||||
onSaved={() => mutateCustomLinks()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Cards grid + card ───────────────────────────────────────────
|
||||
|
||||
function CardsGrid({
|
||||
links,
|
||||
grouped,
|
||||
uncategorizedLabel,
|
||||
openLabel,
|
||||
isLightTheme,
|
||||
editMode,
|
||||
onEditCustom,
|
||||
}: {
|
||||
links: LaunchLink[]
|
||||
grouped: boolean
|
||||
uncategorizedLabel: string
|
||||
openLabel: string
|
||||
isLightTheme: boolean
|
||||
editMode: boolean
|
||||
onEditCustom: (customId: string) => void
|
||||
}) {
|
||||
if (!links.length) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
|
||||
{/* No results after filter/search */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!grouped) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{links.map((link) => (
|
||||
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group by category, insert header rows spanning the full grid width.
|
||||
const groups: Array<[string, LaunchLink[]]> = []
|
||||
let currentCat: string | null = null
|
||||
let bucket: LaunchLink[] = []
|
||||
for (const link of links) {
|
||||
const cat = link.category || uncategorizedLabel
|
||||
if (cat !== currentCat) {
|
||||
if (bucket.length) groups.push([currentCat!, bucket])
|
||||
currentCat = cat
|
||||
bucket = []
|
||||
}
|
||||
bucket.push(link)
|
||||
}
|
||||
if (bucket.length) groups.push([currentCat!, bucket])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{groups.map(([cat, items]) => (
|
||||
<div key={`grp-${cat}`} className="contents">
|
||||
<h3 className="col-span-full uppercase text-xs tracking-wider text-muted-foreground font-semibold pt-3 pb-1.5 border-b border-border/60 flex items-center gap-2">
|
||||
<span>{cat}</span>
|
||||
<span className="font-mono tabular-nums text-[10px] px-1.5 py-0.5 rounded bg-card border border-border/60 font-normal">{items.length}</span>
|
||||
</h3>
|
||||
{items.map((link) => (
|
||||
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// hueForCategory / categoryChipStyle / readIsLightTheme moved to
|
||||
// lib/category-color.ts so the LXC App tab can render the same chip.
|
||||
|
||||
// Dispatch the pair of events that jumps from the Apps dashboard to
|
||||
// the VMs modal on the App tab for a given CT. Two events by design:
|
||||
// `changeTab` switches the outer tab (dashboard-level) and
|
||||
// `openLxcAppModal` tells VirtualMachines which guest to open and on
|
||||
// which inner tab to land. Both fire in the same tick.
|
||||
function openLxcModalOnAppTab(vmid: number) {
|
||||
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
||||
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } }))
|
||||
}
|
||||
|
||||
// Same pattern for a QEMU guest: land on the modal's Status tab
|
||||
// (QEMU guests don't have the App tab). Used by custom links whose
|
||||
// binding is a VM instead of an LXC.
|
||||
function openVmModalOnStatusTab(vmid: number) {
|
||||
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
||||
window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } }))
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
link,
|
||||
openLabel,
|
||||
isLightTheme,
|
||||
editMode,
|
||||
onEditCustom,
|
||||
}: {
|
||||
link: LaunchLink
|
||||
openLabel: string
|
||||
isLightTheme: boolean
|
||||
editMode: boolean
|
||||
onEditCustom: (customId: string) => void
|
||||
}) {
|
||||
const t = useT()
|
||||
// Navigate to the bound guest's modal on the appropriate inner tab:
|
||||
// LXC → App tab (where the weblink was registered), VM → Status tab
|
||||
// (VMs don't have an App tab). Unbound custom links have no CT ref
|
||||
// to click, so this handler is only wired when `link.vmid` exists.
|
||||
const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (link.vmid == null) return
|
||||
if (link.guestType === "qemu") {
|
||||
openVmModalOnStatusTab(link.vmid)
|
||||
} else {
|
||||
openLxcModalOnAppTab(link.vmid)
|
||||
}
|
||||
}
|
||||
|
||||
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (link.customId) onEditCustom(link.customId)
|
||||
}
|
||||
|
||||
const guestPrefix = link.guestType === "qemu" ? "VM" : "CT"
|
||||
const hasBinding = link.vmid != null
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.weblink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={openLabel.replace("{name}", link.appName)}
|
||||
className="group relative flex flex-col gap-2 p-3.5 bg-card border border-border rounded-xl no-underline text-foreground hover:bg-white/5 hover:border-border/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:translate-y-px"
|
||||
>
|
||||
{/* Head: logo + name (+ update icon) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-md bg-muted/40 grid place-items-center flex-shrink-0 overflow-hidden">
|
||||
{link.logoUrl ? (
|
||||
<ThemeAwareLogo src={link.logoUrl} className="w-9 h-9 object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] font-mono text-muted-foreground uppercase">{link.appName.slice(0, 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold text-foreground truncate leading-tight">{link.appName}</div>
|
||||
</div>
|
||||
{/* Edit mode on a custom card takes over the update-icon slot
|
||||
with a proper edit button — custom links never carry the
|
||||
update signal, so nothing is displaced. Falls back to the
|
||||
update icon in every other case. */}
|
||||
{editMode && link.isCustom ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToEditor}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToEditor(e) }}
|
||||
className="h-8 w-8 rounded-md border border-border bg-background hover:bg-muted flex items-center justify-center flex-shrink-0 self-start text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t("apps.customLinkEditAria", { name: link.appName })}
|
||||
title={t("actions.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
) : link.updateAvailable && (
|
||||
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Foot: weblink + CT ref + category chip */}
|
||||
<div className="flex flex-col gap-1.5 mt-auto pt-2 border-t border-dashed border-border/60">
|
||||
<div className="flex items-center gap-1.5 text-blue-400 text-sm font-mono truncate">
|
||||
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 opacity-80" />
|
||||
<span className="truncate">{link.weblink}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{/* Guest ref → click opens that guest's modal (LXC → App
|
||||
tab, VM → Status tab). stopPropagation keeps the outer
|
||||
anchor from firing at the same time. Unbound custom
|
||||
links: in edit mode show the edit button here, otherwise
|
||||
show nothing. */}
|
||||
{hasBinding && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToBoundGuest}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToBoundGuest(e) }}
|
||||
className="inline-flex items-center gap-1.5 min-w-0 rounded hover:text-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
|
||||
aria-label={t("apps.openGuestAriaLabel", {
|
||||
name: link.ctName || String(link.vmid),
|
||||
type: guestPrefix,
|
||||
id: link.vmid!,
|
||||
})}
|
||||
>
|
||||
<span className="font-mono px-1.5 py-0.5 rounded bg-muted/40 text-muted-foreground/90 flex-shrink-0">{link.vmid}</span>
|
||||
<span className="truncate min-w-0">{link.ctName || `${guestPrefix} ${link.vmid}`}</span>
|
||||
</button>
|
||||
)}
|
||||
{link.category && (
|
||||
<span
|
||||
style={categoryChipStyle(link.category, isLightTheme)}
|
||||
className="ml-auto px-1.5 py-0.5 border rounded text-[10px] font-medium flex-shrink-0 truncate max-w-[45%]"
|
||||
title={link.category}
|
||||
>
|
||||
{link.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } 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 { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface AuthSetupProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<"choice" | "setup">("choice")
|
||||
const [username, setUsername] = useState("")
|
||||
@@ -22,6 +24,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
// Profile (Fase 2 — v1.2.2). Both optional decorations on top of the
|
||||
// mandatory username + password. Persisted via PUT /api/auth/profile
|
||||
// and POST /api/auth/profile/avatar after the user lands a successful
|
||||
// /api/auth/setup so we don't change the setup endpoint's contract.
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const checkOnboardingStatus = async () => {
|
||||
@@ -58,58 +68,65 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
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")
|
||||
throw new Error(data.error || t("authSetup.skipFailed"))
|
||||
}
|
||||
|
||||
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")
|
||||
console.error("Auth skip error:", err)
|
||||
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarPick = () => fileInputRef.current?.click()
|
||||
|
||||
const handleAvatarChange = (file: File | null) => {
|
||||
// Revoke the previous local preview so we don't leak blob URLs while
|
||||
// the user picks another file before submitting.
|
||||
if (avatarPreviewUrl) {
|
||||
URL.revokeObjectURL(avatarPreviewUrl)
|
||||
}
|
||||
setAvatarFile(file)
|
||||
setAvatarPreviewUrl(file ? URL.createObjectURL(file) : null)
|
||||
}
|
||||
|
||||
const handleSetupAuth = async () => {
|
||||
setError("")
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please fill in all fields")
|
||||
setError(t("authSetup.fillFields"))
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match")
|
||||
setError(t("authSetup.passwordMismatch"))
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters")
|
||||
setError(t("authSetup.passwordTooShort"))
|
||||
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" },
|
||||
@@ -120,23 +137,76 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
console.log("[v0] Auth setup response:", data)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to setup authentication")
|
||||
throw new Error(data.error || t("authSetup.setupFailed"))
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||
localStorage.removeItem("proxmenux-auth-declined")
|
||||
console.log("[v0] Authentication setup successful")
|
||||
}
|
||||
|
||||
// Profile decorations (Fase 2). Sent as a follow-up to the setup
|
||||
// call so the /api/auth/setup endpoint stays minimal (username +
|
||||
// password only) — these calls reuse the existing profile
|
||||
// endpoints and the JWT we just received. Failures here are
|
||||
// non-fatal: the user is already authenticated and can finish
|
||||
// configuring the profile from the /profile page.
|
||||
const token = data.token
|
||||
if (token) {
|
||||
const trimmedDisplayName = displayName.trim()
|
||||
if (trimmedDisplayName) {
|
||||
try {
|
||||
await fetch(getApiUrl("/api/auth/profile"), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ display_name: trimmedDisplayName }),
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn("[auth-setup] failed to save display_name:", e)
|
||||
}
|
||||
}
|
||||
if (avatarFile) {
|
||||
try {
|
||||
await fetch(getApiUrl("/api/auth/profile/avatar"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": avatarFile.type,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: avatarFile,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn("[auth-setup] failed to upload avatar:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release the local preview blob now that the file has been
|
||||
// uploaded (or skipped). The header avatar pulls a fresh copy
|
||||
// from the backend.
|
||||
if (avatarPreviewUrl) {
|
||||
URL.revokeObjectURL(avatarPreviewUrl)
|
||||
setAvatarPreviewUrl(null)
|
||||
}
|
||||
|
||||
// Notify the header AvatarMenu (mounted on dashboard load with
|
||||
// auth_enabled=false) to re-fetch its status + profile so the
|
||||
// avatar appears immediately after first-time setup instead of
|
||||
// requiring a page refresh.
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("proxmenux:profile-changed"))
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("[v0] Auth setup error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
||||
console.error("Auth setup error:", err)
|
||||
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -146,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<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"}
|
||||
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
|
||||
</DialogTitle>
|
||||
{step === "choice" ? (
|
||||
<div className="space-y-6 py-2">
|
||||
@@ -154,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</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.
|
||||
{t("authSetup.protectDescription")}
|
||||
</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
|
||||
{t("authSetup.setupPassword")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSkipAuth}
|
||||
@@ -172,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
No, Continue Without Protection
|
||||
{t("authSetup.skipProtection")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
|
||||
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 py-2">
|
||||
@@ -184,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -198,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-sm">
|
||||
Username
|
||||
{t("authSetup.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"
|
||||
placeholder={t("authSetup.usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -217,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm">
|
||||
Password
|
||||
{t("authSetup.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"
|
||||
placeholder={t("authSetup.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -244,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" className="text-sm">
|
||||
Confirm Password
|
||||
{t("authSetup.confirmPassword")}
|
||||
</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"
|
||||
placeholder={t("authSetup.confirmPasswordPlaceholder")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -268,14 +338,108 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional profile decorations (Fase 2). Visually
|
||||
separated from the mandatory credential fields by a
|
||||
divider + a small heading so the operator understands
|
||||
they can skip everything below and still complete the
|
||||
setup. Both are saved with follow-up calls after the
|
||||
setup endpoint returns the JWT. */}
|
||||
<div className="pt-3 border-t border-border/60 space-y-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
{t("authSetup.profileOptional")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="display-name" className="text-sm">
|
||||
{t("authSetup.displayName")}
|
||||
</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="display-name"
|
||||
type="text"
|
||||
placeholder={t("authSetup.displayNamePlaceholder")}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
maxLength={64}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("authSetup.displayNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">{t("authSetup.avatar")}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
{avatarPreviewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={avatarPreviewUrl}
|
||||
alt=""
|
||||
className="w-14 h-14 rounded-full object-cover border border-border bg-cyan-500/5 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-14 h-14 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-xl font-semibold border border-border shrink-0">
|
||||
{(displayName || username || "U").trim().charAt(0).toUpperCase() || "U"}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
handleAvatarChange(file)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAvatarPick}
|
||||
disabled={loading}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Upload className="h-3 w-3 mr-1.5" />
|
||||
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
|
||||
</Button>
|
||||
{avatarFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAvatarChange(null)}
|
||||
disabled={loading}
|
||||
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1.5" />
|
||||
{t("authSetup.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("authSetup.avatarHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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"}
|
||||
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
|
||||
</Button>
|
||||
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
||||
Back
|
||||
{t("authSetup.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { User, Shield, LogOut } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface AuthStatus {
|
||||
auth_enabled?: boolean
|
||||
username?: string | null
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
success: boolean
|
||||
username?: string | null
|
||||
display_name?: string | null
|
||||
has_avatar?: boolean
|
||||
avatar_mtime?: number | null
|
||||
}
|
||||
|
||||
interface AvatarMenuProps {
|
||||
/** Size of the avatar circle in the header trigger. */
|
||||
size?: "md" | "lg"
|
||||
/**
|
||||
* Callback used by the Security menu item. The Monitor renders its
|
||||
* Settings/Security panels inside the same dashboard route, not on
|
||||
* a separate URL, so navigation is handled by the parent that knows
|
||||
* how to switch tabs. Optional — when omitted the menu item is hidden.
|
||||
*/
|
||||
onOpenSecurity?: () => void
|
||||
/**
|
||||
* Callback for "View profile". Same rationale: the parent decides how
|
||||
* to route there (modal, page, tab switch). Until Fase 2 lands the
|
||||
* caller typically passes an alert/toast that the page is coming.
|
||||
*/
|
||||
onOpenProfile?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AvatarMenu — user/account dropdown for the header.
|
||||
*
|
||||
* Self-fetches the current auth status to derive the username and the
|
||||
* initial that fills the avatar circle. Stays silent (renders nothing)
|
||||
* when authentication is disabled on this install — no point showing
|
||||
* an account menu for a "Sign out" that doesn't apply.
|
||||
*
|
||||
* Sign out clears the token from localStorage and reloads, mirroring
|
||||
* the existing `handleLogout` in `security.tsx`. That keeps a single
|
||||
* source of truth for the logout flow until Fase 2 introduces a
|
||||
* proper /api/auth/logout that revokes the JWT server-side too.
|
||||
*/
|
||||
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
|
||||
const t = useT()
|
||||
|
||||
// IMPORTANT — all hooks must run unconditionally on every render. The
|
||||
// previous version short-circuited with `if (!auth_enabled) return null`
|
||||
// BEFORE the avatar blob hooks, so the hook count changed between
|
||||
// renders the moment auth status loaded → React error #310 ("rendered
|
||||
// more hooks than during the previous render"). All `useState` and
|
||||
// `useEffect` calls now live above any early return; the null branch
|
||||
// is at the very end after the hooks.
|
||||
const [status, setStatus] = useState<AuthStatus | null>(null)
|
||||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
|
||||
|
||||
// Load both auth_status (to decide whether to render at all) and the
|
||||
// profile (to render display_name + avatar). Profile is fetched only
|
||||
// when auth is enabled — saves one roundtrip on installs without
|
||||
// auth where the menu won't show anyway.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchApi<AuthStatus>("/api/auth/status")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setStatus(data)
|
||||
if (data?.auth_enabled && data?.username) {
|
||||
fetchApi<ProfileData>("/api/auth/profile")
|
||||
.then(p => {
|
||||
if (!cancelled) setProfile(p)
|
||||
})
|
||||
.catch(() => {
|
||||
// Profile fetch is best-effort. Falls back to username + initials.
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus(null)
|
||||
})
|
||||
// Reload status + profile when the user updates the profile from
|
||||
// the /profile page OR completes first-time auth setup. Refreshing
|
||||
// status is what flips the menu visible after setup (when the
|
||||
// initial mount saw auth_enabled=false); refreshing profile is
|
||||
// what makes a new avatar/display name appear without a full
|
||||
// browser refresh.
|
||||
const handler = () => {
|
||||
fetchApi<AuthStatus>("/api/auth/status")
|
||||
.then(s => {
|
||||
if (cancelled) return
|
||||
setStatus(s)
|
||||
if (s?.auth_enabled && s?.username) {
|
||||
fetchApi<ProfileData>("/api/auth/profile")
|
||||
.then(p => {
|
||||
if (!cancelled) setProfile(p)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("proxmenux:profile-changed", handler)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("proxmenux:profile-changed", handler)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Avatar fetch — the endpoint requires the Bearer header, which
|
||||
// <img src=…> can't send, so we fetch as a blob and convert it to a
|
||||
// local object URL for rendering. The blob URL is revoked on cleanup
|
||||
// and on every refetch to avoid leaking memory.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let currentBlobUrl: string | null = null
|
||||
if (profile?.has_avatar) {
|
||||
const token = getAuthToken()
|
||||
const url = `${getApiUrl("/api/auth/profile/avatar")}?v=${profile.avatar_mtime || ""}`
|
||||
fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
.then(r => (r.ok ? r.blob() : null))
|
||||
.then(blob => {
|
||||
if (cancelled || !blob) return
|
||||
currentBlobUrl = URL.createObjectURL(blob)
|
||||
setAvatarBlobUrl(currentBlobUrl)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAvatarBlobUrl(null)
|
||||
})
|
||||
} else {
|
||||
setAvatarBlobUrl(null)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl)
|
||||
}
|
||||
}, [profile?.has_avatar, profile?.avatar_mtime])
|
||||
|
||||
// ── Hooks finished. Safe to early-return now. ──
|
||||
// Hide the avatar entirely when auth isn't enabled on this install —
|
||||
// there's no user identity to surface and no Sign out to offer.
|
||||
if (!status?.auth_enabled || !status?.username) return null
|
||||
|
||||
const username = status.username
|
||||
const displayName = profile?.display_name || username
|
||||
const initial = displayName.trim().charAt(0).toUpperCase() || "U"
|
||||
|
||||
const handleSignOut = () => {
|
||||
try {
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
localStorage.removeItem("proxmenux-auth-setup-complete")
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode); fall through.
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// Avatar size in the header trigger. The trigger has no chevron now —
|
||||
// removing it freed enough horizontal space to bump the avatar a
|
||||
// notch up (40 → 44 / 32 → 36) without nudging the Refresh / Theme
|
||||
// buttons sitting to its left.
|
||||
const avatarSize = size === "lg" ? "w-11 h-11 text-lg" : "w-9 h-9 text-sm"
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay — dim only (no blur). Mounted while the
|
||||
dropdown is open. `bg-black/40` dims the page enough to focus
|
||||
attention on the dropdown without distorting the content
|
||||
behind, which testers found annoying when full backdrop blur
|
||||
was used (especially on wider desktop viewports). `z-40`
|
||||
places it above the dashboard content but below the dropdown
|
||||
portal (`DropdownMenuContent` lands on z-[60]) and below the
|
||||
header (which stays on z-50 so the avatar trigger remains
|
||||
clickable). Clicking the backdrop closes the menu — the
|
||||
explicit `onClick` mirrors Radix's outside-click handler. */}
|
||||
{open && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-black/40 animate-in fade-in-0 duration-150"
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
|
||||
aria-label={t("actions.openUserMenu")}
|
||||
// WebKit ignores `outline` for the tap-highlight overlay
|
||||
// shown on iOS / Android Chrome after a touch. That overlay
|
||||
// was the white border that lingered on the avatar after
|
||||
// dismissing the dropdown without picking anything. Setting
|
||||
// `-webkit-tap-highlight-color` to transparent suppresses
|
||||
// it without affecting keyboard focus visibility (handled
|
||||
// separately by `focus-visible:outline-none` above).
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
>
|
||||
{avatarBlobUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={avatarBlobUrl}
|
||||
alt=""
|
||||
className={`${avatarSize} rounded-full object-cover bg-cyan-500/10`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`${avatarSize} rounded-full flex items-center justify-center font-semibold bg-cyan-500/15 text-cyan-600 dark:text-cyan-300`}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72 z-[60]">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
{avatarBlobUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={avatarBlobUrl}
|
||||
alt=""
|
||||
className="w-20 h-20 rounded-full object-cover bg-cyan-500/10 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-20 h-20 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-3xl font-semibold shrink-0">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold truncate">{displayName}</div>
|
||||
{profile?.display_name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{username}</div>
|
||||
)}
|
||||
{!profile?.display_name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{onOpenProfile && (
|
||||
<DropdownMenuItem onClick={onOpenProfile}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
{t("account.viewProfile")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onOpenSecurity && (
|
||||
<DropdownMenuItem onClick={onOpenSecurity}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
{t("account.security")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onClick={handleSignOut}
|
||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{t("account.signOut")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
|
||||
// Minimal shape we need from the /api/vms poll. Kept narrow so this
|
||||
// component stays independent of the fuller VMData type used in
|
||||
// virtual-machines.tsx.
|
||||
export interface GuestOption {
|
||||
vmid: number
|
||||
name: string
|
||||
type: "lxc" | "qemu"
|
||||
}
|
||||
|
||||
export interface CustomLink {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
logo_url: string
|
||||
category: string
|
||||
binding: { vmid: number; guest_type: "lxc" | "qemu" } | null
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
export interface DraftCustomLink {
|
||||
name: string
|
||||
url: string
|
||||
logo_url: string
|
||||
category: string
|
||||
bindingKey: string
|
||||
}
|
||||
|
||||
const UNBOUND_KEY = "__none__"
|
||||
|
||||
function buildKey(binding: CustomLink["binding"]): string {
|
||||
if (!binding) return UNBOUND_KEY
|
||||
return `${binding.guest_type}:${binding.vmid}`
|
||||
}
|
||||
|
||||
function parseKey(key: string): CustomLink["binding"] {
|
||||
if (!key || key === UNBOUND_KEY) return null
|
||||
const [type, vmid] = key.split(":")
|
||||
if (type !== "lxc" && type !== "qemu") return null
|
||||
const n = Number(vmid)
|
||||
if (!Number.isFinite(n)) return null
|
||||
return { guest_type: type, vmid: n }
|
||||
}
|
||||
|
||||
export function CustomLinkEditor({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
guests,
|
||||
categoryPresets,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
/** null = create; existing link = edit */
|
||||
editing: CustomLink | null
|
||||
/** VMs + LXCs from /api/vms so the user can bind a link to a guest */
|
||||
guests: GuestOption[]
|
||||
/** Populated from /api/apps/categories */
|
||||
categoryPresets: string[]
|
||||
/** Called on successful save/delete so the parent can refresh */
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const [draft, setDraft] = useState<DraftCustomLink>({
|
||||
name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY,
|
||||
})
|
||||
const [customCategoryMode, setCustomCategoryMode] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset the draft whenever the modal opens with a new target.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError(null)
|
||||
if (editing) {
|
||||
setDraft({
|
||||
name: editing.name,
|
||||
url: editing.url,
|
||||
logo_url: editing.logo_url || "",
|
||||
category: editing.category || "",
|
||||
bindingKey: buildKey(editing.binding),
|
||||
})
|
||||
setCustomCategoryMode(
|
||||
!!editing.category && !categoryPresets.includes(editing.category),
|
||||
)
|
||||
} else {
|
||||
setDraft({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY })
|
||||
setCustomCategoryMode(false)
|
||||
}
|
||||
}, [open, editing, categoryPresets])
|
||||
|
||||
const canSave = draft.name.trim() && draft.url.trim() && !saving
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: draft.name.trim(),
|
||||
url: draft.url.trim(),
|
||||
logo_url: draft.logo_url.trim(),
|
||||
category: draft.category.trim(),
|
||||
binding: parseKey(draft.bindingKey),
|
||||
}
|
||||
if (editing) {
|
||||
await fetchApi(`/api/apps/custom-links/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
} else {
|
||||
await fetchApi("/api/apps/custom-links", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
onSaved()
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
setError((e && e.message) || t("apps.customLinkSaveError"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!editing) return
|
||||
setError(null)
|
||||
setDeleting(true)
|
||||
try {
|
||||
await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "DELETE" })
|
||||
onSaved()
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
setError((e && e.message) || t("apps.customLinkDeleteError"))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort guests by vmid so the dropdown is easy to scan
|
||||
const sortedGuests = [...guests].sort((a, b) => a.vmid - b.vmid)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px] bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? t("apps.customLinkEditTitle") : t("apps.customLinkNewTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="cl-name" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkName")}</Label>
|
||||
<Input
|
||||
id="cl-name"
|
||||
autoFocus
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
placeholder={t("apps.customLinkNamePlaceholder")}
|
||||
maxLength={80}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="cl-url" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkUrl")}</Label>
|
||||
<Input
|
||||
id="cl-url"
|
||||
type="url"
|
||||
value={draft.url}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
|
||||
placeholder="https://example.com"
|
||||
maxLength={512}
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="cl-logo" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkLogo")}</Label>
|
||||
<Input
|
||||
id="cl-logo"
|
||||
type="url"
|
||||
value={draft.logo_url}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, logo_url: e.target.value }))}
|
||||
placeholder={t("apps.customLinkLogoPlaceholder")}
|
||||
maxLength={512}
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkCategory")}</Label>
|
||||
{customCategoryMode ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft.category}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, category: e.target.value }))}
|
||||
placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")}
|
||||
maxLength={60}
|
||||
className="text-sm"
|
||||
onBlur={() => { if (!draft.category.trim()) setCustomCategoryMode(false) }}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={draft.category || "__none__"}
|
||||
onValueChange={(v) => {
|
||||
if (v === "__add__") {
|
||||
setCustomCategoryMode(true)
|
||||
setDraft((d) => ({ ...d, category: "" }))
|
||||
} else if (v === "__none__") {
|
||||
setDraft((d) => ({ ...d, category: "" }))
|
||||
} else {
|
||||
setDraft((d) => ({ ...d, category: v }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-sm h-9">
|
||||
<SelectValue placeholder={t("vmLxc.appEditor.portCategoryPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
|
||||
{categoryPresets.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkBinding")}</Label>
|
||||
<Select
|
||||
value={draft.bindingKey}
|
||||
onValueChange={(v) => setDraft((d) => ({ ...d, bindingKey: v }))}
|
||||
>
|
||||
<SelectTrigger className="text-sm h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNBOUND_KEY}>{t("apps.customLinkBindingNone")}</SelectItem>
|
||||
{sortedGuests.map((g) => (
|
||||
<SelectItem key={`${g.type}:${g.vmid}`} value={`${g.type}:${g.vmid}`}>
|
||||
{g.type === "qemu" ? "VM" : "CT"} {g.vmid} · {g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("apps.customLinkBindingHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
{editing ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || saving}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||
{t("apps.customLinkDelete")}
|
||||
</Button>
|
||||
) : <div />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("apps.customLinkCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className="bg-blue-500 hover:bg-blue-600 !text-white"
|
||||
>
|
||||
{editing ? t("apps.customLinkSave") : t("apps.customLinkCreate")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Thermometer } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useDiskTempThresholds } from "@/lib/health-thresholds"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface TempPoint {
|
||||
timestamp: number
|
||||
value: number
|
||||
}
|
||||
|
||||
interface DiskTemperatureCardProps {
|
||||
diskName: string
|
||||
liveTemperature: number
|
||||
/** Disk class — "HDD" | "SSD" | "NVMe" | "SAS". Drives the threshold colors. */
|
||||
diskType: string
|
||||
/** Click handler — opens the full timeframe-selector modal as drill-down. */
|
||||
onOpenDetail?: () => void
|
||||
}
|
||||
|
||||
// Disk-temperature thresholds come from the user-configurable backend
|
||||
// (lib/health-thresholds.ts). The classifier here takes the resolved
|
||||
// pair so the consumer can read it from the hook once per render.
|
||||
function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
|
||||
if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
|
||||
if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
|
||||
if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
|
||||
return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
|
||||
}
|
||||
|
||||
const MiniTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const ts = payload[0].payload?.timestamp
|
||||
const date = ts ? new Date(ts * 1000) : null
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-md px-2 py-1 shadow-xl">
|
||||
{date && (
|
||||
<p className="text-[10px] text-gray-300">
|
||||
{date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs font-semibold text-white">{payload[0].value}°C</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function DiskTemperatureCard({
|
||||
diskName,
|
||||
liveTemperature,
|
||||
diskType,
|
||||
onOpenDetail,
|
||||
}: DiskTemperatureCardProps) {
|
||||
const t = useT()
|
||||
const [data, setData] = useState<TempPoint[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const cancelled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
cancelled.current = false
|
||||
const fetchHistory = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 24-h timeframe gives a more useful "is this drive trending
|
||||
// up over a day" view; the 1-h window was too short to spot
|
||||
// anything that mattered.
|
||||
const result = await fetchApi<{ data: TempPoint[] }>(
|
||||
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=day`,
|
||||
)
|
||||
if (cancelled.current) return
|
||||
setData(result?.data || [])
|
||||
} catch {
|
||||
if (!cancelled.current) setData([])
|
||||
} finally {
|
||||
if (!cancelled.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchHistory()
|
||||
// Refresh once a minute so the inline chart tracks the collector
|
||||
// without needing the user to reopen the modal.
|
||||
const id = setInterval(fetchHistory, 60_000)
|
||||
return () => {
|
||||
cancelled.current = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [diskName])
|
||||
|
||||
const allThresholds = useDiskTempThresholds()
|
||||
const dt = (() => {
|
||||
const t = (diskType || "").toUpperCase()
|
||||
if (t === "HDD") return allThresholds.HDD
|
||||
if (t === "NVME") return allThresholds.NVMe
|
||||
if (t === "SAS") return allThresholds.SAS
|
||||
return allThresholds.SSD
|
||||
})()
|
||||
const status = statusFor(liveTemperature, dt)
|
||||
const lineColor = status.color
|
||||
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
|
||||
const samples = data.length
|
||||
|
||||
const interactive = !!onOpenDetail
|
||||
const Wrapper: any = interactive ? "button" : "div"
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
type={interactive ? "button" : undefined}
|
||||
onClick={interactive ? onOpenDetail : undefined}
|
||||
className={[
|
||||
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
|
||||
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
|
||||
].join(" ")}
|
||||
title={interactive ? t("details.temperature.openHistory") : undefined}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("details.temperature.diskTitle")}</p>
|
||||
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
|
||||
{tempDisplay}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
|
||||
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
|
||||
{t(status.labelKey)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[40px] -mx-1">
|
||||
{loading ? (
|
||||
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
|
||||
) : samples < 2 ? (
|
||||
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
|
||||
{t("details.temperature.collectingSamples")}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 2, right: 4, left: 4, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`diskTempCardGrad-${diskName}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={lineColor} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Y domain is computed the same way the detail modal
|
||||
does — floor(min−3) / ceil(max+3), floor at 0 — so
|
||||
the line shape here matches the bigger chart instead
|
||||
of recharts' auto-domain collapsing 24 → 29 °C into
|
||||
a near-flat line that doesn't look like the modal. */}
|
||||
<YAxis
|
||||
hide
|
||||
domain={[
|
||||
(dataMin: number) => Math.max(0, Math.floor(dataMin - 3)),
|
||||
(dataMax: number) => Math.ceil(dataMax + 3),
|
||||
]}
|
||||
/>
|
||||
<Tooltip content={<MiniTooltip />} cursor={{ stroke: lineColor, strokeOpacity: 0.3, strokeWidth: 1 }} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={lineColor}
|
||||
strokeWidth={1.6}
|
||||
fill={`url(#diskTempCardGrad-${diskName})`}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"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 { 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"
|
||||
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", labelKey: "details.temperature.timeframes.hour" },
|
||||
{ value: "day", labelKey: "details.temperature.timeframes.day" },
|
||||
{ value: "week", labelKey: "details.temperature.timeframes.week" },
|
||||
{ value: "month", labelKey: "details.temperature.timeframes.month" },
|
||||
]
|
||||
|
||||
interface TempHistoryPoint {
|
||||
timestamp: number
|
||||
value: number
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
interface TempStats {
|
||||
min: number
|
||||
max: number
|
||||
avg: number
|
||||
current: number
|
||||
}
|
||||
|
||||
interface DiskTemperatureDetailModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
diskName: string
|
||||
diskModel?: string
|
||||
liveTemperature?: number
|
||||
diskType?: "HDD" | "SSD" | "NVMe" | "SAS" | string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Per-disk-class thresholds come from the user-configurable backend
|
||||
// (lib/health-thresholds.ts), so the chart line color stays in sync
|
||||
// with whatever the user sets in Settings → Health Monitor Thresholds.
|
||||
function colorFor(temp: number, t: DiskTempThreshold): string {
|
||||
if (temp >= t.hot) return "#ef4444"
|
||||
if (temp >= t.warn) return "#f59e0b"
|
||||
return "#22c55e"
|
||||
}
|
||||
|
||||
function statusInfoFor(temp: number, t: DiskTempThreshold) {
|
||||
if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
}
|
||||
|
||||
export function DiskTemperatureDetailModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
diskName,
|
||||
diskModel,
|
||||
liveTemperature,
|
||||
diskType,
|
||||
}: DiskTemperatureDetailModalProps) {
|
||||
const t = useT()
|
||||
const [timeframe, setTimeframe] = useState("day")
|
||||
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 && diskName) {
|
||||
fetchHistory()
|
||||
}
|
||||
}, [open, timeframe, diskName])
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await fetchApi<{ data: TempHistoryPoint[]; stats: TempStats }>(
|
||||
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=${timeframe}`,
|
||||
)
|
||||
if (result && result.data) {
|
||||
setData(result.data)
|
||||
setStats(result.stats)
|
||||
} else {
|
||||
setData([])
|
||||
setStats({ min: 0, max: 0, avg: 0, current: 0 })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ProxMenux] Failed to fetch disk temperature history:", err)
|
||||
setData([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (timeframe === "hour" || timeframe === "day") {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const chartData = data.map((d) => ({ ...d, time: formatTime(d.timestamp) }))
|
||||
|
||||
const currentTemp = liveTemperature && liveTemperature > 0 ? Math.round(liveTemperature * 10) / 10 : stats.current
|
||||
const allThresholds = useDiskTempThresholds()
|
||||
const dt: DiskTempThreshold = (() => {
|
||||
const t = (diskType || "").toUpperCase()
|
||||
if (t === "HDD") return allThresholds.HDD
|
||||
if (t === "NVME") return allThresholds.NVMe
|
||||
if (t === "SAS") return allThresholds.SAS
|
||||
return allThresholds.SSD
|
||||
})()
|
||||
const chartColor = colorFor(currentTemp, dt)
|
||||
const currentStatus = statusInfoFor(currentTemp, dt)
|
||||
|
||||
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>
|
||||
{/*
|
||||
Header layout mirrors temperature-detail-modal exactly so the
|
||||
mobile breakpoints behave the same. Earlier we tried to inline
|
||||
the model name in the DialogTitle, but the long WD/Samsung
|
||||
strings broke `truncate` and pushed the dialog past the
|
||||
viewport — clipping the timeframe selector and the right two
|
||||
stat cards. Keeping the title short and parking the model in
|
||||
a second line (DialogDescription) lets the standard mobile
|
||||
grid render correctly.
|
||||
*/}
|
||||
<div className="flex items-center justify-between pr-6">
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Thermometer className="h-5 w-5" />
|
||||
/dev/{diskName}
|
||||
</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}>
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{diskModel && (
|
||||
<p className="text-xs text-muted-foreground truncate pr-6 mt-0.5">{diskModel}</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
|
||||
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
|
||||
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}</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" /> {t("details.temperature.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" /> {t("details.temperature.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" /> {t("details.temperature.max")}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>{t("details.temperature.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`diskTempGradient-${diskName}`} 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={t("details.temperature.seriesName")}
|
||||
stroke={chartColor}
|
||||
strokeWidth={2}
|
||||
fill={`url(#diskTempGradient-${diskName})`}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: chartColor, stroke: "#fff", strokeWidth: 2 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface SriovInfo {
|
||||
role: "vf" | "pf-active" | "pf-idle"
|
||||
@@ -26,6 +27,8 @@ export function GpuSwitchModeIndicator({
|
||||
className,
|
||||
sriovInfo,
|
||||
}: GpuSwitchModeIndicatorProps) {
|
||||
const t = useT()
|
||||
|
||||
// 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"
|
||||
@@ -69,15 +72,21 @@ export function GpuSwitchModeIndicator({
|
||||
// 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"
|
||||
if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf")
|
||||
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) {
|
||||
return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount })
|
||||
}
|
||||
return t("hardware.gpuSwitch.sriov")
|
||||
})()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-6",
|
||||
// On very narrow containers (mobile, narrow modal), stack the SVG
|
||||
// above the status text so the 224px-wide SVG doesn't squeeze the
|
||||
// text into a 2-character-wide column. At sm+ we go back to the
|
||||
// original side-by-side layout.
|
||||
"flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-6",
|
||||
isEditing && !isSriovActive && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
@@ -120,7 +129,7 @@ export function GpuSwitchModeIndicator({
|
||||
className="text-[14px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
GPU
|
||||
{t("hardware.gpuSwitch.gpu")}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
@@ -264,7 +273,7 @@ export function GpuSwitchModeIndicator({
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
{t("hardware.gpuSwitch.lxc")}
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
@@ -275,7 +284,7 @@ export function GpuSwitchModeIndicator({
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
{t("hardware.gpuSwitch.lxc")}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -328,7 +337,7 @@ export function GpuSwitchModeIndicator({
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
{t("hardware.gpuSwitch.vm")}
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
@@ -339,7 +348,7 @@ export function GpuSwitchModeIndicator({
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
{t("hardware.gpuSwitch.vm")}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
@@ -359,34 +368,47 @@ export function GpuSwitchModeIndicator({
|
||||
)}
|
||||
>
|
||||
{isSriovActive
|
||||
? "SR-IOV active"
|
||||
? t("hardware.gpuSwitch.sriovActive")
|
||||
: isLxcActive
|
||||
? "Ready for LXC containers"
|
||||
? t("hardware.gpuSwitch.readyForLxc")
|
||||
: isVmActive
|
||||
? "Ready for VM passthrough"
|
||||
: "Mode unknown"}
|
||||
? t("hardware.gpuSwitch.readyForVm")
|
||||
: t("hardware.gpuSwitch.modeUnknown")}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isSriovActive
|
||||
? "Virtual Functions managed externally"
|
||||
? t("hardware.gpuSwitch.virtualFunctionsExternal")
|
||||
: isLxcActive
|
||||
? "Native driver active"
|
||||
? t("hardware.gpuSwitch.nativeDriverActive")
|
||||
: isVmActive
|
||||
? "VFIO-PCI driver active"
|
||||
: "No driver detected"}
|
||||
? t("hardware.gpuSwitch.vfioDriverActive")
|
||||
: t("hardware.gpuSwitch.noDriverDetected")}
|
||||
</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}` : ""}`
|
||||
? t(
|
||||
sriovInfo.physfn
|
||||
? "hardware.gpuSwitch.virtualFunctionWithParent"
|
||||
: "hardware.gpuSwitch.virtualFunction",
|
||||
{ parent: sriovInfo.physfn || "" },
|
||||
)
|
||||
: sriovInfo.vfCount !== undefined
|
||||
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
|
||||
? t(
|
||||
sriovInfo.totalvfs
|
||||
? "hardware.gpuSwitch.physicalFunctionWithMax"
|
||||
: "hardware.gpuSwitch.physicalFunction",
|
||||
{
|
||||
count: sriovInfo.vfCount,
|
||||
max: sriovInfo.totalvfs || "",
|
||||
},
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
)}
|
||||
{hasChanged && (
|
||||
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
||||
Change pending...
|
||||
{t("hardware.gpuSwitch.changePending")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+499
-361
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,14 @@ 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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
ArrowUpCircle,
|
||||
X,
|
||||
Clock,
|
||||
BellOff,
|
||||
@@ -31,6 +40,8 @@ import {
|
||||
Settings2,
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
@@ -94,19 +105,20 @@ interface HealthStatusModalProps {
|
||||
}
|
||||
|
||||
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 },
|
||||
{ key: "cpu", category: "temperature", Icon: Cpu },
|
||||
{ key: "memory", category: "memory", Icon: MemoryStick },
|
||||
{ key: "storage", category: "storage", Icon: HardDrive },
|
||||
{ key: "disks", category: "disks", Icon: Disc },
|
||||
{ key: "network", category: "network", Icon: Network },
|
||||
{ key: "vms", category: "vms", Icon: Box },
|
||||
{ key: "services", category: "pve_services", Icon: Settings },
|
||||
{ key: "logs", category: "logs", Icon: FileText },
|
||||
{ key: "updates", category: "updates", Icon: RefreshCw },
|
||||
{ key: "security", category: "security", Icon: Shield },
|
||||
]
|
||||
|
||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||
const t = useT()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
@@ -114,14 +126,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
||||
|
||||
const fetchHealthDetails = useCallback(async () => {
|
||||
const fetchHealthDetails = useCallback(async (force = false) => {
|
||||
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> = {}
|
||||
@@ -129,13 +142,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
authHeaders["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl("/api/health/full"), { headers: authHeaders })
|
||||
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/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")
|
||||
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||
const data = await legacyResponse.json()
|
||||
setHealthData(data)
|
||||
setDismissedItems([])
|
||||
@@ -192,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error")
|
||||
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [getApiUrl])
|
||||
}, [getApiUrl, t])
|
||||
|
||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||
const [, setTick] = useState(0)
|
||||
@@ -211,7 +224,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
if (open) {
|
||||
fetchHealthDetails()
|
||||
// Auto-refresh every 5 minutes while modal is open
|
||||
const refreshInterval = setInterval(fetchHealthDetails, 300000)
|
||||
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
|
||||
return () => clearInterval(refreshInterval)
|
||||
}
|
||||
}, [open, fetchHealthDetails])
|
||||
@@ -266,21 +279,96 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
|
||||
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>
|
||||
const s = status?.toUpperCase()
|
||||
const label =
|
||||
s === "OK" ? t("healthStatus.status.ok") :
|
||||
s === "INFO" ? t("healthStatus.status.info") :
|
||||
s === "WARNING" ? t("healthStatus.status.warning") :
|
||||
s === "CRITICAL" ? t("healthStatus.status.critical") :
|
||||
t("healthStatus.status.unknown")
|
||||
return <Badge variant="outline" className={getOutlineBadgeStyle(status)}>{label}</Badge>
|
||||
}
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
const key = status?.toLowerCase()
|
||||
return ["ok", "info", "warning", "critical", "unknown"].includes(key)
|
||||
? t(`healthStatus.status.${key}`)
|
||||
: status
|
||||
}
|
||||
|
||||
const translateHealthText = (value?: string): string => {
|
||||
if (!value) return ""
|
||||
const exact: Record<string, string> = {
|
||||
"All systems operational": t("healthStatus.details.allOperational"),
|
||||
"Normal": t("healthStatus.details.normal"),
|
||||
"No I/O errors in dmesg": t("healthStatus.details.noIoErrors"),
|
||||
"Mounted read-write, space OK": t("healthStatus.details.rootFilesystemOk"),
|
||||
"No SMART warnings in journal": t("healthStatus.details.noSmartWarnings"),
|
||||
"No critical errors": t("healthStatus.details.noCriticalErrors"),
|
||||
"No cascading errors": t("healthStatus.details.noCascadingErrors"),
|
||||
"No error spikes": t("healthStatus.details.noErrorSpikes"),
|
||||
"No persistent patterns": t("healthStatus.details.noPersistentPatterns"),
|
||||
"Certificate valid": t("healthStatus.details.certificateValid"),
|
||||
"Cluster detected (corosync.conf present)": t("healthStatus.details.clusterDetected"),
|
||||
"Active": t("healthStatus.details.active"),
|
||||
"UP": t("healthStatus.details.up"),
|
||||
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
|
||||
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
|
||||
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
|
||||
"No container startup errors": t("healthStatus.details.noContainerErrors"),
|
||||
"No OOM events detected": t("healthStatus.details.noOomEvents"),
|
||||
"No QMP timeouts detected": t("healthStatus.details.noQmpTimeouts"),
|
||||
"No VM startup failures": t("healthStatus.details.noVmFailures"),
|
||||
"Dismissed by user": t("healthStatus.details.dismissedByUser"),
|
||||
}
|
||||
if (exact[value]) return exact[value]
|
||||
|
||||
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
|
||||
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
|
||||
match = value.match(/^(\d+) failed login attempts in 24h$/)
|
||||
if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
|
||||
match = value.match(/^(\d+) IP\(s\) currently banned by Fail2Ban \(jails: (.+)\)$/)
|
||||
if (match) return t("healthStatus.details.fail2banBannedIps", { count: match[1], jails: match[2] })
|
||||
match = value.match(/^Uptime (\d+) days?$/)
|
||||
if (match) return t("healthStatus.details.uptimeDays", { count: match[1] })
|
||||
match = value.match(/^(\d+) package\(s\) pending$/)
|
||||
if (match) return t("healthStatus.details.pendingPackages", { count: match[1] })
|
||||
match = value.match(/^Last updated (\d+) day\(s\) ago$/)
|
||||
if (match) return t("healthStatus.details.updatedDaysAgo", { count: match[1] })
|
||||
match = value.match(/^Storage: \d+ Proxmox storages unavailable: (.+) \(startup\)$/)
|
||||
if (match) return t("healthStatus.details.startupStoragesChecking", { storages: match[1] })
|
||||
match = value.match(/^Storage: (.+) not yet available \(startup\)$/)
|
||||
if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] })
|
||||
match = value.match(/^(.+) not yet available \(startup\)$/)
|
||||
if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] })
|
||||
match = value.match(/^\[Startup\] Storage '(.+)' is configured but not found on the server\. \(checking\.\.\.\)$/)
|
||||
if (match) return t("healthStatus.details.startupStorageNotFound", { storage: match[1] })
|
||||
match = value.match(/^\[Startup\] Storage '(.+)' is not available \(connection error or backend issue\)\. \(checking\.\.\.\)$/)
|
||||
if (match) return t("healthStatus.details.startupStorageUnavailable", { storage: match[1] })
|
||||
match = value.match(/^\[Startup\] Storage '(.+)' has status: (.+)\. \(checking\.\.\.\)$/)
|
||||
if (match) return t("healthStatus.details.startupStorageStatus", { storage: match[1], status: match[2] })
|
||||
match = value.match(/^(.+) storage available$/)
|
||||
if (match) return t("healthStatus.details.storageAvailable", { type: match[1] })
|
||||
match = value.match(/^(.+) mount reachable$/)
|
||||
if (match) return t("healthStatus.details.mountReachable", { type: match[1] })
|
||||
match = value.match(/^rootfs ([\d.]+)% used \((.+)\)$/)
|
||||
if (match) return t("healthStatus.details.rootfsUsed", { percent: match[1], size: match[2] })
|
||||
match = value.match(/^(\d+) running CT\(s\) within safe rootfs usage$/)
|
||||
if (match) return t("healthStatus.details.runningCtsSafe", { count: match[1] })
|
||||
match = value.match(/^(\d+) PVE block storage\(s\) within safe usage$/)
|
||||
if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] })
|
||||
match = value.match(/^(\d+) remote mount\(s\) healthy$/)
|
||||
if (match) return t("healthStatus.details.remoteMountsHealthy", { count: match[1] })
|
||||
return value
|
||||
}
|
||||
|
||||
const formatDuration = (hours: number) => {
|
||||
if (hours === -1) return t("healthStatus.permanent")
|
||||
if (hours >= 8760) return t("healthStatus.duration.years", { count: Math.floor(hours / 8760) })
|
||||
if (hours >= 720) return t("healthStatus.duration.months", { count: Math.floor(hours / 720) })
|
||||
if (hours >= 168) return t("healthStatus.duration.weeks", { count: Math.floor(hours / 168) })
|
||||
if (hours >= 24) return t("healthStatus.duration.days", { count: Math.floor(hours / 24) })
|
||||
return t("healthStatus.duration.hours", { count: Math.round(hours) })
|
||||
}
|
||||
|
||||
// Get categories that have dismissed items (to show as INFO)
|
||||
@@ -357,8 +445,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// `suppressionHours` overrides the category default for this dismiss:
|
||||
// - undefined → backend uses the category's configured suppression
|
||||
// - 24, 168 (7 days) → silence for that many hours
|
||||
// - -1 → permanent dismiss; only revertible from
|
||||
// Settings → Active Suppressions
|
||||
const handleAcknowledge = async (
|
||||
errorKey: string,
|
||||
suppressionHours?: number,
|
||||
) => {
|
||||
setDismissingKey(errorKey)
|
||||
|
||||
try {
|
||||
@@ -369,10 +464,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { error_key: errorKey }
|
||||
if (suppressionHours !== undefined) {
|
||||
body.suppression_hours = suppressionHours
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ error_key: errorKey }),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const responseData = await response.json().catch(() => ({}))
|
||||
@@ -390,13 +490,24 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
severity: responseData.result?.original_severity || 'WARNING',
|
||||
reason: 'Dismissed by user',
|
||||
dismissed: true,
|
||||
acknowledged_at: new Date().toISOString()
|
||||
// Surface the chosen duration so the row shows the right badge
|
||||
// (countdown vs. "Permanent") without waiting for the refetch.
|
||||
permanent: suppressionHours === -1,
|
||||
suppression_remaining_hours: suppressionHours === -1 ? -1 : undefined,
|
||||
suppression_hours: suppressionHours,
|
||||
acknowledged_at: new Date().toISOString(),
|
||||
}
|
||||
setDismissedItems(prev => [...prev, dismissedItem])
|
||||
}
|
||||
|
||||
|
||||
// Fetch fresh data in background (non-blocking)
|
||||
fetchHealthDetails().catch(() => {})
|
||||
|
||||
// Notify other mounted views (e.g. Settings → Active Suppressions
|
||||
// panel) that the suppression set has changed so they can refresh.
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("health-suppression-changed"))
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.error("Error dismissing:", err)
|
||||
} finally {
|
||||
@@ -410,11 +521,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
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`
|
||||
if (diffMin < 1) return t("healthStatus.time.justNow")
|
||||
if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo")
|
||||
if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin })
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
return `${diffHours}h ${diffMin % 60}m ago`
|
||||
return t("healthStatus.time.hoursMinutesAgo", { hours: diffHours, minutes: diffMin % 60 })
|
||||
}
|
||||
|
||||
const getCategoryRowStyle = (status: string) => {
|
||||
@@ -437,49 +548,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
|
||||
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]
|
||||
const knownKeys = new Set([
|
||||
"cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem",
|
||||
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
|
||||
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
||||
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
|
||||
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
|
||||
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
|
||||
])
|
||||
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
|
||||
// Convert snake_case or camelCase to Title Case
|
||||
return key
|
||||
.replace(/_/g, " ")
|
||||
@@ -509,34 +586,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<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>
|
||||
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{translateHealthText(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>
|
||||
checkData.permanent ? (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
|
||||
{t("healthStatus.permanent")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
||||
{t("healthStatus.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>
|
||||
<DismissDropdown
|
||||
onSelect={(hours) =>
|
||||
handleAcknowledge(checkData.error_key || checkKey, hours)
|
||||
}
|
||||
busy={dismissingKey === (checkData.error_key || checkKey)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -555,12 +626,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<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>
|
||||
<span className="truncate text-base sm:text-lg">{t("healthStatus.title")}</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>
|
||||
<span>{t("healthStatus.description")}</span>
|
||||
{getTimeSinceCheck() && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
@@ -578,7 +649,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{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="font-medium">{t("healthStatus.errors.loading")}</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -589,47 +660,47 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<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 className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.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 className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.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 className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.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 className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.warning")}</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 className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.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 className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.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>
|
||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category List */}
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map(({ key, label, Icon }) => {
|
||||
{CATEGORIES.map(({ key, 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 reason = translateHealthText(categoryData?.reason)
|
||||
const checks = categoryData?.checks
|
||||
const isExpanded = expandedCategories.has(key)
|
||||
const hasChecks = checks && Object.keys(checks).length > 0
|
||||
@@ -650,7 +721,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</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>
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
|
||||
{hasChecks && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||
@@ -663,7 +734,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</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}
|
||||
{formatStatus(status)}
|
||||
</Badge>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
|
||||
@@ -681,25 +752,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<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>
|
||||
<DismissDropdown
|
||||
onSelect={(hours) =>
|
||||
handleAcknowledge(`category_${key}_unknown`, hours)
|
||||
}
|
||||
busy={dismissingKey === `category_${key}_unknown`}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -708,7 +767,24 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
) : (
|
||||
<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
|
||||
{t("healthStatus.noIssues")}
|
||||
</div>
|
||||
)}
|
||||
{/* Only offer "Update Now" when the category is not
|
||||
already OK — hiding it when there's nothing
|
||||
pending prevents the operator from spawning a
|
||||
terminal that would only report "System is
|
||||
already up to date". */}
|
||||
{key === "updates" && status?.toUpperCase() !== "OK" && (
|
||||
<div className="flex justify-end px-3 py-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowUpdateTerminal(true)}
|
||||
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
{t("healthStatus.updateNow")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -727,12 +803,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<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})
|
||||
{t("healthStatus.dismissedItems", { count: 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 catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
|
||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||
|
||||
return (
|
||||
@@ -747,34 +823,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<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>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{translateHealthText(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
|
||||
{t("healthStatus.permanent")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
|
||||
Dismissed
|
||||
{t("healthStatus.dismissed")}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
|
||||
was {item.severity}
|
||||
{t("healthStatus.wasStatus", { status: formatStatus(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`
|
||||
? t("healthStatus.permanentlySuppressed")
|
||||
: t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -790,30 +860,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<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
|
||||
{t("healthStatus.customSuppressionSettings")}
|
||||
</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 catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category)
|
||||
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`
|
||||
const durationLabel = formatDuration(cs.hours)
|
||||
|
||||
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>
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : 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}
|
||||
@@ -823,7 +883,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
})}
|
||||
</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.
|
||||
{t("healthStatus.autoSuppressedHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -831,12 +891,84 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{healthData.timestamp && (
|
||||
<div className="text-xs text-muted-foreground text-center pt-2">
|
||||
Last updated: {new Date(healthData.timestamp).toLocaleString()}
|
||||
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
<ScriptTerminalModal
|
||||
open={showUpdateTerminal}
|
||||
onClose={() => {
|
||||
setShowUpdateTerminal(false)
|
||||
// Force a fresh read (cache-busting via ?refresh=1) so the
|
||||
// "System Updates" row reflects the state right after the
|
||||
// update finished, instead of the pre-update cached value.
|
||||
fetchHealthDetails(true).catch(() => {})
|
||||
}}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/utilities/proxmox_update.sh"
|
||||
scriptName="proxmox_update"
|
||||
params={{
|
||||
EXECUTION_MODE: "web",
|
||||
}}
|
||||
title={t("healthStatus.updateTerminalTitle")}
|
||||
description={t("healthStatus.updateTerminalDescription")}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Small split button: the visible click opens a 3-option menu so the user
|
||||
// chooses how long this specific alert stays silenced. ``-1`` is the
|
||||
// permanent sentinel — backend stores it as `suppression_hours = -1` and
|
||||
// the alert can only be brought back from Settings → Active Suppressions.
|
||||
function DismissDropdown({
|
||||
onSelect,
|
||||
busy,
|
||||
t,
|
||||
}: {
|
||||
onSelect: (suppressionHours: number) => void
|
||||
busy: boolean
|
||||
t: ReturnType<typeof useT>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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={busy}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 sm:mr-0.5" />
|
||||
<span className="hidden sm:inline">{t("healthStatus.dismiss")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("healthStatus.silenceFor")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
|
||||
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.24hours")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs">
|
||||
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.7days")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSelect(-1)}
|
||||
className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10"
|
||||
>
|
||||
<BellOff className="h-3 w-3 mr-2" /> {t("healthStatus.permanently")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,871 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Input } from "./ui/input"
|
||||
import {
|
||||
SlidersHorizontal,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Server,
|
||||
Thermometer,
|
||||
Settings2,
|
||||
Check,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
AlertCircle,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Waves,
|
||||
} from "lucide-react"
|
||||
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Local fetch wrapper that *preserves* the JSON body on non-2xx
|
||||
// responses so we can surface backend validation messages
|
||||
// (e.g. "critical must be >= warning") to the user. The shared
|
||||
// `fetchApi` throws a generic "API request failed: 400" on any
|
||||
// non-OK response, eating the body.
|
||||
async function fetchJson<T>(endpoint: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...((init?.headers as Record<string, string>) || {}),
|
||||
}
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`
|
||||
const res = await fetch(getApiUrl(endpoint), {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
})
|
||||
let data: any = null
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch {
|
||||
// empty body — fall through with raw status
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && typeof window !== "undefined") {
|
||||
try {
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
} catch {}
|
||||
const path = window.location.pathname
|
||||
if (!path.startsWith("/auth") && !path.startsWith("/login")) {
|
||||
window.location.assign("/")
|
||||
}
|
||||
}
|
||||
const msg =
|
||||
(data && (data.message || data.error)) ||
|
||||
`${res.status} ${res.statusText}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The backend returns a tree of leaves. Each leaf carries the metadata
|
||||
// the UI needs to render an input + the recommended/customised flags.
|
||||
// We mirror the shape rather than hand-coding it to keep the contract
|
||||
// in one place — the backend is the source of truth.
|
||||
interface ThresholdLeaf {
|
||||
value: number
|
||||
recommended: number
|
||||
customised: boolean
|
||||
unit: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
}
|
||||
|
||||
interface ThresholdsTree {
|
||||
cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
|
||||
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
disk_temperature: {
|
||||
hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
ssd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
nvme: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
sas: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
}
|
||||
// Phase 3 additions
|
||||
lxc_mount: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
pve_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
zfs_pool: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
}
|
||||
|
||||
// Pending edits: { "section/key" : "76" } — kept as raw strings while
|
||||
// the user types so partial input ("8" mid-type) doesn't fail the
|
||||
// numeric coercion. Coerced + validated on Save.
|
||||
type PendingEdits = Record<string, string>
|
||||
|
||||
// ─── Section descriptors ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Drives both the render order and the labels. Keeping it data-only
|
||||
// means adding a new section later (Phase 4) is one entry, not a JSX
|
||||
// surgery.
|
||||
interface SectionField {
|
||||
// Path in the thresholds tree, e.g. ["cpu", "warning"] or
|
||||
// ["disk_temperature", "nvme", "critical"].
|
||||
path: string[]
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SectionDef {
|
||||
id: string // Backend section key — used by the reset endpoint
|
||||
title: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
description?: string
|
||||
fields: SectionField[]
|
||||
// For tabular sections (disk temperature) we group by sub-key. When
|
||||
// present, fields are rendered in a 2-column grid (warning, critical)
|
||||
// labelled by sub-key (HDD / SSD / NVMe / SAS).
|
||||
rowGroups?: Array<{ subKey: string; label: string }>
|
||||
}
|
||||
|
||||
// Order: compute → heat → storage capacity. Reading top-to-bottom
|
||||
// flows naturally with no domain jumps:
|
||||
// • Compute (CPU usage, RAM/Swap)
|
||||
// • Heat (CPU temp, then disk temp — both °C)
|
||||
// • Storage capacity (host → LXC rootfs → LXC mounts → PVE → ZFS,
|
||||
// i.e. concrete to abstract)
|
||||
const SECTIONS: SectionDef[] = [
|
||||
// ── Compute ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: "cpu",
|
||||
title: "CPU usage",
|
||||
icon: Cpu,
|
||||
fields: [
|
||||
{ path: ["cpu", "warning"], label: "Warning" },
|
||||
{ path: ["cpu", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
title: "Memory & Swap",
|
||||
icon: MemoryStick,
|
||||
fields: [
|
||||
{ path: ["memory", "warning"], label: "Memory warning" },
|
||||
{ path: ["memory", "critical"], label: "Memory critical" },
|
||||
{ path: ["memory", "swap_high"], label: "Swap high" },
|
||||
{ path: ["memory", "available_min"], label: "Memory available minimum" },
|
||||
],
|
||||
},
|
||||
// ── Heat ────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "cpu_temperature",
|
||||
title: "CPU temperature",
|
||||
icon: Thermometer,
|
||||
fields: [
|
||||
{ path: ["cpu_temperature", "warning"], label: "Warning" },
|
||||
{ path: ["cpu_temperature", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "disk_temperature",
|
||||
title: "Disk temperature",
|
||||
icon: Thermometer,
|
||||
description:
|
||||
"Per-class thresholds. Same units (°C) — different defaults because each class tolerates a different envelope.",
|
||||
rowGroups: [
|
||||
{ subKey: "hdd", label: "HDD" },
|
||||
{ subKey: "ssd", label: "SSD" },
|
||||
{ subKey: "nvme", label: "NVMe" },
|
||||
{ subKey: "sas", label: "SAS" },
|
||||
],
|
||||
// For row-group sections, `fields` is unused — we generate per-row
|
||||
// path lookups from the rowGroups + a hardcoded ["warning","critical"].
|
||||
fields: [],
|
||||
},
|
||||
// ── Storage capacity ────────────────────────────────────────────
|
||||
{
|
||||
id: "host_storage",
|
||||
title: "Disk space — host",
|
||||
icon: HardDrive,
|
||||
description: "Applies to / and every mountpoint under /var/lib/vz, /mnt/* etc.",
|
||||
fields: [
|
||||
{ path: ["host_storage", "warning"], label: "Warning" },
|
||||
{ path: ["host_storage", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lxc_rootfs",
|
||||
title: "Disk space — LXC rootfs",
|
||||
icon: Server,
|
||||
description: "Per-container root disk, evaluated against the rootfs size from PVE.",
|
||||
fields: [
|
||||
{ path: ["lxc_rootfs", "warning"], label: "Warning" },
|
||||
{ path: ["lxc_rootfs", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lxc_mount",
|
||||
title: "LXC mount points",
|
||||
icon: FolderOpen,
|
||||
description:
|
||||
"Capacity of mountpoints inside running CTs (mp0, mp1, NFS, bind mounts). Excludes the rootfs — that's covered above.",
|
||||
fields: [
|
||||
{ path: ["lxc_mount", "warning"], label: "Warning" },
|
||||
{ path: ["lxc_mount", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pve_storage",
|
||||
title: "PVE storage capacity",
|
||||
icon: Database,
|
||||
description:
|
||||
"Block-style PVE storages: LVM, LVM-thin, ZFS-pool, RBD/Ceph, PBS. Filesystem-style (dir/nfs/cifs) is already covered by host disk thresholds.",
|
||||
fields: [
|
||||
{ path: ["pve_storage", "warning"], label: "Warning" },
|
||||
{ path: ["pve_storage", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "zfs_pool",
|
||||
title: "ZFS pool capacity",
|
||||
icon: Waves,
|
||||
description:
|
||||
"ZFS pools at the host level — independent of PVE registration so rpool and dedicated backup pools are also monitored.",
|
||||
fields: [
|
||||
{ path: ["zfs_pool", "warning"], label: "Warning" },
|
||||
{ path: ["zfs_pool", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getLeaf(tree: ThresholdsTree | null, path: string[]): ThresholdLeaf | null {
|
||||
if (!tree) return null
|
||||
let node: any = tree
|
||||
for (const p of path) {
|
||||
if (node == null || typeof node !== "object") return null
|
||||
node = node[p]
|
||||
}
|
||||
return node as ThresholdLeaf | null
|
||||
}
|
||||
|
||||
function pathKey(path: string[]): string {
|
||||
return path.join("/")
|
||||
}
|
||||
|
||||
// Trim the visible slider range to a window around the saved +
|
||||
// recommended values so the track has usable resolution (e.g. CPU
|
||||
// 60–100 instead of the backend's 0–100). Derived from stable inputs
|
||||
// so the range does NOT shift under an active drag.
|
||||
function computeVisualRange(
|
||||
values: number[],
|
||||
backendMin: number,
|
||||
backendMax: number,
|
||||
step: number,
|
||||
): { min: number; max: number } {
|
||||
const totalRange = Math.max(1, backendMax - backendMin)
|
||||
// Margin ≈ 25% of total range, clamped to at least 5 steps so tiny
|
||||
// step sizes (e.g. step=1 on 0–100) still get a usable window.
|
||||
const rawMargin = Math.max(step * 5, Math.round(totalRange * 0.25))
|
||||
const lo = Math.min(...values)
|
||||
const hi = Math.max(...values)
|
||||
const snap = (n: number) => Math.round(n / step) * step
|
||||
let visMin = Math.max(backendMin, snap(lo - rawMargin))
|
||||
let visMax = Math.min(backendMax, snap(hi + rawMargin))
|
||||
// Ensure the window is at least 4 steps wide so the slider has
|
||||
// room to move even if all inputs collapse to one value.
|
||||
if (visMax - visMin < step * 4) {
|
||||
const mid = (visMax + visMin) / 2
|
||||
visMin = Math.max(backendMin, snap(mid - step * 2))
|
||||
visMax = Math.min(backendMax, snap(mid + step * 2))
|
||||
}
|
||||
return { min: visMin, max: visMax }
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function HealthThresholds() {
|
||||
const t = useT()
|
||||
const tFallback = (key: string, fallback: string) => {
|
||||
const translated = t(key)
|
||||
return translated === key ? fallback : translated
|
||||
}
|
||||
const [tree, setTree] = useState<ThresholdsTree | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedFlash, setSavedFlash] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<PendingEdits>({})
|
||||
|
||||
// Load on mount + auto-refresh after each save
|
||||
const fetchTree = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetchJson<{ success: boolean; thresholds: ThresholdsTree }>(
|
||||
"/api/health/thresholds",
|
||||
)
|
||||
if (res?.success && res.thresholds) setTree(res.thresholds)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTree()
|
||||
}, [])
|
||||
|
||||
const hasPendingChanges = Object.keys(pending).length > 0
|
||||
|
||||
// Build the partial payload from pending. Any blank or unparseable
|
||||
// entry is skipped — the backend will reject anything malformed
|
||||
// anyway, but we want to fail fast on the UI side too.
|
||||
const buildPayload = (): Record<string, any> | null => {
|
||||
const payload: Record<string, any> = {}
|
||||
for (const [key, raw] of Object.entries(pending)) {
|
||||
const parts = key.split("/")
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === "") continue
|
||||
const num = Number(trimmed)
|
||||
if (!isFinite(num)) {
|
||||
setError(t("settings.healthThresholds.invalidValue", { key }))
|
||||
return null
|
||||
}
|
||||
// Walk into payload mirroring the path
|
||||
let cur: any = payload
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
cur[parts[i]] = cur[parts[i]] || {}
|
||||
cur = cur[parts[i]]
|
||||
}
|
||||
cur[parts[parts.length - 1]] = num
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditMode(true)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditMode(false)
|
||||
setPending({})
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = buildPayload()
|
||||
if (payload === null) return
|
||||
if (Object.keys(payload).length === 0) {
|
||||
setEditMode(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||
"/api/health/thresholds",
|
||||
{ method: "PUT", body: JSON.stringify(payload) },
|
||||
)
|
||||
if (!data.success || !data.thresholds) {
|
||||
setError(data.message || t("status.saveFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
setPending({})
|
||||
setEditMode(false)
|
||||
setSavedFlash(true)
|
||||
setTimeout(() => setSavedFlash(false), 2000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetSection = async (sectionId: string) => {
|
||||
const section = SECTIONS.find((s) => s.id === sectionId)
|
||||
const sectionTitle = section ? tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title) : sectionId
|
||||
if (!confirm(t("settings.healthThresholds.resetSectionConfirm", { section: sectionTitle })))
|
||||
return
|
||||
try {
|
||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||
`/api/health/thresholds/reset?section=${encodeURIComponent(sectionId)}`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
if (!data.success || !data.thresholds) {
|
||||
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
// Drop any pending edits within this section so the UI stays
|
||||
// consistent — the values were just reset on the server.
|
||||
setPending((p) => {
|
||||
const next: PendingEdits = {}
|
||||
for (const [k, v] of Object.entries(p)) {
|
||||
if (!k.startsWith(sectionId + "/")) next[k] = v
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetAll = async () => {
|
||||
if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return
|
||||
try {
|
||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||
"/api/health/thresholds/reset",
|
||||
{ method: "POST" },
|
||||
)
|
||||
if (!data.success || !data.thresholds) {
|
||||
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
setPending({})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
|
||||
}
|
||||
}
|
||||
|
||||
const renderField = (path: string[], label: string) => {
|
||||
// Kept for single-value leaves that don't have a warn/crit pair
|
||||
// (e.g. Memory's swap_critical). The pair-cases route to
|
||||
// renderThresholdRange below.
|
||||
const leaf = getLeaf(tree, path)
|
||||
if (!leaf) return null
|
||||
const key = pathKey(path)
|
||||
const editingValue = pending[key] ?? String(leaf.value)
|
||||
const last = path[path.length - 1] || ""
|
||||
const isCritical = last.toLowerCase().includes("critical")
|
||||
const isWarning = last.toLowerCase().includes("warning")
|
||||
const severityClass = isCritical
|
||||
? "border-red-500/70 bg-red-500/10 focus-visible:border-red-500"
|
||||
: isWarning
|
||||
? "border-amber-500/70 bg-amber-500/10 focus-visible:border-amber-500"
|
||||
: "border-input"
|
||||
const isCustomised = leaf.customised && !(key in pending)
|
||||
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
|
||||
const fieldClass = isCustomised ? customisedClass : severityClass
|
||||
const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}`
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
|
||||
<span className="text-xs sm:text-sm text-foreground/90 min-w-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
min={leaf.min}
|
||||
max={leaf.max}
|
||||
step={leaf.step}
|
||||
disabled={!editMode}
|
||||
value={editingValue}
|
||||
title={recommendedTooltip}
|
||||
onChange={(e) =>
|
||||
setPending((p) => ({ ...p, [key]: e.target.value }))
|
||||
}
|
||||
className={`w-20 h-7 text-xs text-right tabular-nums border ${fieldClass} ${
|
||||
!editMode ? "disabled:opacity-100 disabled:cursor-default" : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Single-handle slider for thresholds that don't have a warn/crit
|
||||
// pair (only Memory's swap_critical today). Same visual language as
|
||||
// the dual-handle: red handle, value above, OK / CRIT zones below
|
||||
// — so the operator doesn't read it as a different control.
|
||||
const renderSingleThresholdSlider = (path: string[], severity: "warning" | "critical" = "critical") => {
|
||||
const leaf = getLeaf(tree, path)
|
||||
if (!leaf) return null
|
||||
const key = pathKey(path)
|
||||
const val = Number(pending[key] ?? leaf.value)
|
||||
const step = leaf.step || 1
|
||||
const unit = leaf.unit || ""
|
||||
const { min, max } = computeVisualRange(
|
||||
[leaf.value, leaf.recommended],
|
||||
leaf.min,
|
||||
leaf.max,
|
||||
step,
|
||||
)
|
||||
const pct = ((Math.max(min, Math.min(max, val)) - min) / (max - min)) * 100
|
||||
const custom = leaf.customised && !(key in pending)
|
||||
const color = severity === "critical" ? "red" : "amber"
|
||||
const handleClass = severity === "critical"
|
||||
? "[&::-webkit-slider-thumb]:bg-red-500 [&::-moz-range-thumb]:bg-red-500"
|
||||
: "[&::-webkit-slider-thumb]:bg-amber-500 [&::-moz-range-thumb]:bg-amber-500"
|
||||
const numberColor = custom
|
||||
? "text-blue-400"
|
||||
: severity === "critical"
|
||||
? "text-red-500"
|
||||
: "text-amber-500"
|
||||
const fillColor = severity === "critical" ? "bg-red-500/30" : "bg-amber-500/30"
|
||||
|
||||
return (
|
||||
<div className="px-1 py-3">
|
||||
<div className="relative h-6 sm:h-5 mb-1 select-none">
|
||||
<span
|
||||
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${numberColor}`}
|
||||
style={{ left: `${pct}%` }}
|
||||
>
|
||||
{val}{unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-9 sm:h-6">
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full ${fillColor}`}
|
||||
style={{ left: `${pct}%`, right: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={!editMode}
|
||||
value={val}
|
||||
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
|
||||
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
|
||||
title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span>{t("settings.healthThresholds.ok")} < {val}{unit}</span>
|
||||
<span className="text-right">{severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} > {val}{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Dual-handle range slider replacing the two stacked number inputs
|
||||
// for warn/crit pairs. Visual model: a horizontal track split into
|
||||
// three zones — OK (left of warning, muted), WARN→CRIT (between
|
||||
// handles, amber-to-red gradient), and OVER-CRIT (right of critical,
|
||||
// dark red overlay). The handles themselves stay coloured (amber for
|
||||
// warning, red for critical) so the operator reads the same severity
|
||||
// mapping they're used to from the old inputs. Numbers ride above
|
||||
// each handle and double as a click-to-edit affordance — clicking a
|
||||
// number swaps it for a tight `<Input type="number">` so precise
|
||||
// values are still keyboard-friendly. Customised values surface as a
|
||||
// small blue dot on the affected handle (same signal as the old blue
|
||||
// ring, less visual weight).
|
||||
const renderThresholdRange = (
|
||||
basePath: string[],
|
||||
options?: { hideLabels?: boolean }
|
||||
) => {
|
||||
const wPath = [...basePath, "warning"]
|
||||
const cPath = [...basePath, "critical"]
|
||||
const wLeaf = getLeaf(tree, wPath)
|
||||
const cLeaf = getLeaf(tree, cPath)
|
||||
if (!wLeaf || !cLeaf) return null
|
||||
|
||||
const wKey = pathKey(wPath)
|
||||
const cKey = pathKey(cPath)
|
||||
const wVal = Number(pending[wKey] ?? wLeaf.value)
|
||||
const cVal = Number(pending[cKey] ?? cLeaf.value)
|
||||
|
||||
// Backend validates warning <= critical on save.
|
||||
const step = Math.max(wLeaf.step, cLeaf.step) || 1
|
||||
const backendMin = Math.min(wLeaf.min, cLeaf.min)
|
||||
const backendMax = Math.max(wLeaf.max, cLeaf.max)
|
||||
const { min, max } = computeVisualRange(
|
||||
[wLeaf.value, cLeaf.value, wLeaf.recommended, cLeaf.recommended],
|
||||
backendMin,
|
||||
backendMax,
|
||||
step,
|
||||
)
|
||||
const pct = (v: number) => ((Math.max(min, Math.min(max, v)) - min) / (max - min)) * 100
|
||||
const wPct = pct(wVal)
|
||||
const cPct = pct(cVal)
|
||||
const unit = wLeaf.unit || cLeaf.unit || ""
|
||||
|
||||
const wCustom = wLeaf.customised && !(wKey in pending)
|
||||
const cCustom = cLeaf.customised && !(cKey in pending)
|
||||
|
||||
const setVal = (key: string, value: number, peer: number, isWarn: boolean) => {
|
||||
// Clamp on the fly: warning can't cross critical and vice-versa,
|
||||
// matching the backend invariant so the user can't drag into an
|
||||
// invalid state.
|
||||
let v = value
|
||||
if (isWarn && v >= peer) v = peer - step
|
||||
if (!isWarn && v <= peer) v = peer + step
|
||||
setPending((p) => ({ ...p, [key]: String(v) }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-1 py-3">
|
||||
{/* Numeric labels above each handle, positioned absolutely so
|
||||
they ride above the corresponding thumb regardless of the
|
||||
slider width. Pointer events disabled so they don't steal
|
||||
clicks from the underlying range inputs. */}
|
||||
<div className="relative h-6 sm:h-5 mb-1 select-none">
|
||||
<span
|
||||
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${wCustom ? "text-blue-400" : "text-amber-500"}`}
|
||||
style={{ left: `${wPct}%` }}
|
||||
>
|
||||
{wVal}{unit}
|
||||
</span>
|
||||
<span
|
||||
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${cCustom ? "text-blue-400" : "text-red-500"}`}
|
||||
style={{ left: `${cPct}%` }}
|
||||
>
|
||||
{cVal}{unit}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Two range inputs stacked. Mobile track box is taller so the
|
||||
enlarged thumbs (h-7) have room to sit without clipping. */}
|
||||
<div className="relative h-9 sm:h-6">
|
||||
{/* Background track: OK zone (muted) running the full width */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
|
||||
{/* Warn-to-Crit gradient between the two handles */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
|
||||
style={{
|
||||
left: `${wPct}%`,
|
||||
width: `${Math.max(0, cPct - wPct)}%`,
|
||||
background: "linear-gradient(90deg, rgb(245 158 11), rgb(239 68 68))",
|
||||
}}
|
||||
/>
|
||||
{/* OVER-CRIT zone (right of critical) — solid red dim */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full bg-red-500/30"
|
||||
style={{ left: `${cPct}%`, right: 0 }}
|
||||
/>
|
||||
{/* Two superposed range inputs. Pointer-events on the thumb
|
||||
only, so the inert track bar above stays visible. */}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={!editMode}
|
||||
value={wVal}
|
||||
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)}
|
||||
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
|
||||
title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={!editMode}
|
||||
value={cVal}
|
||||
onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)}
|
||||
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
|
||||
title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zone labels — explicit ranges so the operator knows where
|
||||
"warn" starts and ends without having to read the handles. */}
|
||||
{!options?.hideLabels && (
|
||||
<div className="grid grid-cols-3 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span>{t("settings.healthThresholds.ok")} < {wVal}{unit}</span>
|
||||
<span className="text-center">{t("settings.healthThresholds.warn")} {wVal}–{cVal}{unit}</span>
|
||||
<span className="text-right">{t("settings.healthThresholds.crit")} > {cVal}{unit}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
|
||||
<CardTitle>{t("settings.healthThresholds.title")}</CardTitle>
|
||||
</div>
|
||||
{!loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedFlash && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasPendingChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5"
|
||||
onClick={handleResetAll}
|
||||
title={t("settings.healthThresholds.resetAllTitle")}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
{t("actions.resetAll")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t("settings.healthThresholds.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{/* Intentional exception to the global "edit mode contrast"
|
||||
rule: the numeric inputs already carry semantic colored
|
||||
backgrounds (red critical, amber warning, blue customised)
|
||||
which are meaningful and more graphical than a plain form.
|
||||
A sunken selector would either erase those tints or force
|
||||
us to `!important` every one — cleaner to keep this card
|
||||
untouched in edit mode. */}
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !tree ? (
|
||||
<div className="text-sm text-muted-foreground">{t("settings.healthThresholds.loadFailed")}</div>
|
||||
) : (
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 p-2.5 rounded-md bg-red-500/10 border border-red-500/30 text-red-500 text-xs">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Masonry-style flow via CSS columns: cards keep their natural
|
||||
height (CPU = 2 rows, Disk temperature = 8 rows) and the
|
||||
browser packs them top-to-bottom into 1/2/3 columns based on
|
||||
viewport. `break-inside-avoid` keeps each card whole.
|
||||
Mobile (<md) stays single-column as today.
|
||||
*/}
|
||||
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
|
||||
{SECTIONS.map((section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<h4 className="text-sm font-medium">{tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title)}</h4>
|
||||
</div>
|
||||
{editMode && (
|
||||
<button
|
||||
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
|
||||
onClick={() => handleResetSection(section.id)}
|
||||
title={t("settings.healthThresholds.resetSectionTitle")}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{section.description && (
|
||||
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
|
||||
{tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
{section.rowGroups ? (
|
||||
// Per-class disk temperature: one slider per row
|
||||
// (HDD / SSD / NVMe / SAS). Group label sits on
|
||||
// top of each slider so the operator scans the
|
||||
// column from top down without losing context.
|
||||
section.rowGroups.map((group) => (
|
||||
<div key={group.subKey} className="py-1.5 border-b border-border/40 last:border-b-0">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||
{group.label}
|
||||
</div>
|
||||
{renderThresholdRange([section.id, group.subKey])}
|
||||
</div>
|
||||
))
|
||||
) : section.id === "memory" ? (
|
||||
// Memory & Swap is special: warn/crit pair for
|
||||
// RAM, plus the swap-pressure pair. Swap
|
||||
// CRITICAL fires only when both conditions hold
|
||||
// — swap file above `swap_high` AND available
|
||||
// RAM below `available_min`. Rendering the two
|
||||
// sliders one under the other under a shared
|
||||
// header reads as "the two knobs of one signal".
|
||||
<>
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||
{t("settings.healthThresholds.ram")}
|
||||
</div>
|
||||
{renderThresholdRange(["memory"])}
|
||||
<div className="border-t border-border/40 pt-1.5 mt-1.5">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||
{t("settings.healthThresholds.swapPressure")}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground px-1 pt-1 pb-1 leading-snug">
|
||||
{t("settings.healthThresholds.swapPressureHint")}
|
||||
</p>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1 pt-1">
|
||||
{t("settings.healthThresholds.swapHighLabel")}
|
||||
</div>
|
||||
{renderSingleThresholdSlider(["memory", "swap_high"], "critical")}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1">
|
||||
{t("settings.healthThresholds.availableMinLabel")}
|
||||
</div>
|
||||
{renderSingleThresholdSlider(["memory", "available_min"], "warning")}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : section.fields.length === 2 &&
|
||||
section.fields[0].path[section.fields[0].path.length - 1] === "warning" &&
|
||||
section.fields[1].path[section.fields[1].path.length - 1] === "critical" ? (
|
||||
// Generic warn+crit pair (CPU, CPU temp, storage
|
||||
// capacities …) → single slider.
|
||||
renderThresholdRange([section.id])
|
||||
) : (
|
||||
// Fallback for any future section shape — keep
|
||||
// the original per-field number inputs.
|
||||
<div className="divide-y divide-border/40">
|
||||
{section.fields.map((f) => renderField(f.path, f.label))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,19 +9,22 @@ import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText, S
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts"
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "6hour", label: "6 Hours" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "3day", label: "3 Days" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "hour", labelKey: "network.latency.timeframes.hour" },
|
||||
{ value: "6hour", labelKey: "network.latency.timeframes.sixHours" },
|
||||
{ value: "day", labelKey: "network.latency.timeframes.day" },
|
||||
{ value: "3day", labelKey: "network.latency.timeframes.threeDays" },
|
||||
{ value: "week", labelKey: "network.latency.timeframes.week" },
|
||||
]
|
||||
|
||||
const TARGET_OPTIONS = [
|
||||
{ value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false },
|
||||
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true },
|
||||
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true },
|
||||
{ value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false },
|
||||
{ value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true },
|
||||
{ value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", realtime: true },
|
||||
]
|
||||
|
||||
// Realtime test configuration
|
||||
@@ -60,7 +63,22 @@ interface LatencyDetailModalProps {
|
||||
currentLatency?: number
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
const getLatencyTimeframeLabel = (value: string, t: TFunction): string =>
|
||||
TIMEFRAME_OPTIONS.find((option) => option.value === value)
|
||||
? t(TIMEFRAME_OPTIONS.find((option) => option.value === value)!.labelKey)
|
||||
: value
|
||||
|
||||
const getLatencyTargetLabel = (value: string, t: TFunction): string =>
|
||||
TARGET_OPTIONS.find((option) => option.value === value)
|
||||
? t(TARGET_OPTIONS.find((option) => option.value === value)!.labelKey)
|
||||
: value
|
||||
|
||||
const getLatencyTargetShortLabel = (value: string, t: TFunction): string =>
|
||||
TARGET_OPTIONS.find((option) => option.value === value)
|
||||
? t(TARGET_OPTIONS.find((option) => option.value === value)!.shortLabelKey)
|
||||
: value
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, t }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const entry = payload[0]
|
||||
const data = entry?.payload
|
||||
@@ -76,17 +94,17 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">Min:</span>
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.min")}:</span>
|
||||
<span className="text-sm font-semibold text-green-400">{data.min} ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">Avg:</span>
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.avg")}:</span>
|
||||
<span className="text-sm font-semibold text-white">{data.value} ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">Max:</span>
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.max")}:</span>
|
||||
<span className="text-sm font-semibold text-red-400">{data.max} ms</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -94,14 +112,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
// Simple latency display for single data points
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">Latency:</span>
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.latency")}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
|
||||
</div>
|
||||
)}
|
||||
{packetLoss !== undefined && packetLoss > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">Pkt Loss:</span>
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.packetLossShort")}:</span>
|
||||
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -118,20 +136,38 @@ const getStatusColor = (latency: number) => {
|
||||
return "#22c55e"
|
||||
}
|
||||
|
||||
const getStatusInfo = (latency: number | null) => {
|
||||
if (latency === null || latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
const getStatusInfo = (latency: number | null, t: TFunction) => {
|
||||
if (latency === null || latency === 0) return { status: t("common.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (latency < 50) return { status: t("network.latency.status.excellent"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (latency < 100) return { status: t("network.latency.status.good"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (latency < 200) return { status: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: t("network.latency.status.poor"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
|
||||
const getStatusText = (latency: number | null): string => {
|
||||
if (latency === null || latency === 0) return "N/A"
|
||||
if (latency < 50) return "Excellent"
|
||||
if (latency < 100) return "Good"
|
||||
if (latency < 200) return "Fair"
|
||||
return "Poor"
|
||||
const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => {
|
||||
if (latency === null || latency === 0) return "na"
|
||||
if (latency < 50) return "excellent"
|
||||
if (latency < 100) return "good"
|
||||
if (latency < 200) return "fair"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
const getStatusText = (latency: number | null, t: TFunction): string => {
|
||||
const key = getStatusKey(latency)
|
||||
return key === "na" ? t("common.notAvailable") : t(`network.latency.status.${key}`)
|
||||
}
|
||||
|
||||
const formatReportDuration = (seconds: number | undefined, t: TFunction, compact = false): string => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return compact ? t("network.latency.report.realTime") : t("network.latency.report.testPeriod")
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return t(compact ? "network.latency.report.secondsShort" : "network.latency.report.seconds", { count: seconds })
|
||||
}
|
||||
|
||||
const minutes = Math.max(1, Math.round(seconds / 60))
|
||||
return t(compact ? "network.latency.report.minutesShort" : "network.latency.report.minutes", { count: minutes })
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
@@ -145,9 +181,10 @@ interface ReportData {
|
||||
testDuration?: number
|
||||
}
|
||||
|
||||
const generateLatencyReport = (report: ReportData) => {
|
||||
const generateLatencyReport = (report: ReportData, t: TFunction) => {
|
||||
const now = new Date().toLocaleString()
|
||||
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
|
||||
const htmlLang = document.documentElement.lang || "en"
|
||||
|
||||
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg
|
||||
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
||||
@@ -160,29 +197,57 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
} : null
|
||||
|
||||
const statusText = report.isRealtime
|
||||
? getStatusText(realtimeStats?.current ?? null)
|
||||
: getStatusText(report.stats.current)
|
||||
? getStatusText(realtimeStats?.current ?? null, t)
|
||||
: getStatusText(report.stats.current, t)
|
||||
|
||||
// Colors matching Lynis report
|
||||
const statusColorMap: Record<string, string> = {
|
||||
"Excellent": "#16a34a",
|
||||
"Good": "#16a34a",
|
||||
"Fair": "#ca8a04",
|
||||
"Poor": "#dc2626",
|
||||
"N/A": "#64748b"
|
||||
excellent: "#16a34a",
|
||||
good: "#16a34a",
|
||||
fair: "#ca8a04",
|
||||
poor: "#dc2626",
|
||||
na: "#64748b",
|
||||
}
|
||||
const statusColor = statusColorMap[statusText] || "#64748b"
|
||||
const statusKey = report.isRealtime
|
||||
? getStatusKey(realtimeStats?.current ?? null)
|
||||
: getStatusKey(report.stats.current)
|
||||
const statusColor = statusColorMap[statusKey] || "#64748b"
|
||||
|
||||
const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe
|
||||
const timeframeLabel = getLatencyTimeframeLabel(report.timeframe, t)
|
||||
const reportId = `PMXL-${Date.now().toString(36).toUpperCase()}`
|
||||
const notAvailable = t("common.notAvailable")
|
||||
const modeLabel = report.isRealtime
|
||||
? t("network.latency.report.realTimeTest")
|
||||
: t("network.latency.report.historicalAnalysis")
|
||||
const realtimeDurationText = formatReportDuration(report.testDuration, t)
|
||||
const realtimePacketLossText =
|
||||
realtimeStats && realtimeStats.avgPacketLoss > 0
|
||||
? `<span style="color:#dc2626">${t("network.latency.report.averagePacketLoss", {
|
||||
value: realtimeStats.avgPacketLoss.toFixed(1),
|
||||
})}</span>`
|
||||
: `<span style="color:#16a34a">${t("network.latency.report.noPacketLoss")}</span>`
|
||||
const testPeriodValue = report.isRealtime
|
||||
? formatReportDuration(report.testDuration, t, true)
|
||||
: timeframeLabel
|
||||
const targetIpLabel =
|
||||
report.target === "gateway"
|
||||
? t("network.latency.report.defaultGateway")
|
||||
: report.target === "cloudflare"
|
||||
? "1.1.1.1"
|
||||
: "8.8.8.8"
|
||||
const detailSectionNumber =
|
||||
(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0)
|
||||
? "6"
|
||||
: "5"
|
||||
|
||||
// Build test results table for realtime mode - each row is now an individual ping measurement
|
||||
const realtimeTableRows = report.realtimeResults.map((r, i) => `
|
||||
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
|
||||
<td>${i + 1}</td>
|
||||
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
|
||||
<td style="font-weight:600;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : 'Failed'}</td>
|
||||
<td style="font-weight:600;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
|
||||
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
|
||||
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg)}</span></td>
|
||||
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg, t)}</span></td>
|
||||
</tr>
|
||||
`).join('')
|
||||
|
||||
@@ -199,9 +264,9 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
|
||||
<td>${i + 1}</td>
|
||||
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
||||
<td style="font-weight:600;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : 'Failed'}</td>
|
||||
<td style="font-weight:600;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
|
||||
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
|
||||
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${getStatusText(d.value)}</span></td>
|
||||
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${getStatusText(d.value, t)}</span></td>
|
||||
</tr>
|
||||
`).join('')
|
||||
|
||||
@@ -210,7 +275,7 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
||||
: report.data.map(d => d.value || 0)
|
||||
|
||||
let chartSvg = '<p style="text-align:center;color:#64748b;padding:20px;">Not enough data points for chart</p>'
|
||||
let chartSvg = `<p style="text-align:center;color:#64748b;padding:20px;">${t("network.latency.report.notEnoughData")}</p>`
|
||||
if (chartData.length >= 2) {
|
||||
const rawMin = Math.min(...chartData)
|
||||
const rawMax = Math.max(...chartData)
|
||||
@@ -253,17 +318,17 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
|
||||
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
|
||||
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
|
||||
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${chartData.length} samples</text>
|
||||
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${t("network.latency.report.samples", { count: chartData.length })}</text>
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="${htmlLang}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Network Latency Report - ${report.targetLabel}</title>
|
||||
<title>${t("network.latency.report.title")} - ${report.targetLabel}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
|
||||
@@ -463,11 +528,11 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
<div class="top-bar no-print">
|
||||
<div class="top-bar-left">
|
||||
<div>
|
||||
<div class="top-bar-title">ProxMenux Network Latency Report</div>
|
||||
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div>
|
||||
<div class="top-bar-title">${t("network.latency.report.topBarTitle")}</div>
|
||||
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="window.print()">Print / Save as PDF</button>
|
||||
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</button>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -475,21 +540,21 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
<div class="rpt-header-left">
|
||||
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
|
||||
<div>
|
||||
<h1>Network Latency Report</h1>
|
||||
<p>ProxMenux Monitor - Network Performance Analysis</p>
|
||||
<h1>${t("network.latency.report.title")}</h1>
|
||||
<p>${t("network.latency.report.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpt-header-right">
|
||||
<div><strong>Date:</strong> ${now}</div>
|
||||
<div><strong>Target:</strong> ${report.targetLabel}</div>
|
||||
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div>
|
||||
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
|
||||
<div><strong>${t("network.latency.report.date")}:</strong> ${now}</div>
|
||||
<div><strong>${t("network.latency.report.target")}:</strong> ${report.targetLabel}</div>
|
||||
<div><strong>${t("network.latency.report.mode")}:</strong> ${modeLabel}</div>
|
||||
<div class="rid">ID: ${reportId}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. Executive Summary -->
|
||||
<div class="section">
|
||||
<div class="section-title">1. Executive Summary</div>
|
||||
<div class="section-title">1. ${t("network.latency.report.executiveSummary")}</div>
|
||||
<div class="exec-box">
|
||||
<div class="latency-gauge">
|
||||
<svg viewBox="0 0 120 90" width="160" height="120">
|
||||
@@ -508,35 +573,41 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
|
||||
</svg>
|
||||
<div class="gauge-value" style="color:${statusColor};">
|
||||
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? 'N/A') : report.stats.avg}</span>
|
||||
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? notAvailable) : report.stats.avg}</span>
|
||||
<span class="gauge-unit">ms</span>
|
||||
</div>
|
||||
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
|
||||
</div>
|
||||
<div class="exec-text">
|
||||
<h3>Network Latency Assessment${report.isRealtime ? ' (Real-time)' : ''}</h3>
|
||||
<h3>${t("network.latency.report.assessmentTitle")}${report.isRealtime ? ` (${t("network.latency.report.realTime")})` : ""}</h3>
|
||||
<p>
|
||||
${report.isRealtime
|
||||
? `Real-time latency test to <strong>${report.targetLabel}</strong> with <strong>${report.realtimeResults.length} samples</strong> collected over ${report.testDuration ? Math.round(report.testDuration / 60) + ' minute(s)' : 'the test period'}.
|
||||
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</strong>.
|
||||
${realtimeStats && realtimeStats.avgPacketLoss > 0 ? `<span style="color:#dc2626">Average packet loss: ${realtimeStats.avgPacketLoss.toFixed(1)}%.</span>` : '<span style="color:#16a34a">No packet loss detected.</span>'}`
|
||||
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>.
|
||||
<strong>${report.data.length} samples</strong> analyzed.
|
||||
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.`
|
||||
? `${t("network.latency.report.realtimeSummary", {
|
||||
target: `<strong>${report.targetLabel}</strong>`,
|
||||
count: `<strong>${report.realtimeResults.length}</strong>`,
|
||||
duration: realtimeDurationText,
|
||||
avg: `<strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? notAvailable} ms</strong>`,
|
||||
})} ${realtimePacketLossText}`
|
||||
: `${t("network.latency.report.historicalSummary", {
|
||||
target: t("network.latency.targets.gatewayShort"),
|
||||
timeframe: timeframeLabel.toLowerCase(),
|
||||
count: report.data.length,
|
||||
avg: report.stats.avg,
|
||||
})}`
|
||||
}
|
||||
</p>
|
||||
<div class="latency-range">
|
||||
<div class="range-item">
|
||||
<span class="range-label">Minimum</span>
|
||||
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms</span>
|
||||
<span class="range-label">${t("network.labels.minimum")}</span>
|
||||
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms</span>
|
||||
</div>
|
||||
<div class="range-item">
|
||||
<span class="range-label">Average</span>
|
||||
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms</span>
|
||||
<span class="range-label">${t("network.labels.average")}</span>
|
||||
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms</span>
|
||||
</div>
|
||||
<div class="range-item">
|
||||
<span class="range-label">Maximum</span>
|
||||
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms</span>
|
||||
<span class="range-label">${t("network.labels.maximum")}</span>
|
||||
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,42 +616,40 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
|
||||
<!-- 2. Statistics -->
|
||||
<div class="section">
|
||||
<div class="section-title">2. Latency Statistics</div>
|
||||
<div class="section-title">2. ${t("network.latency.report.latencyStatistics")}</div>
|
||||
<div class="grid-4">
|
||||
<div class="card card-c">
|
||||
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? 'N/A') : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||
<div class="card-label">Current</div>
|
||||
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? notAvailable) : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||
<div class="card-label">${t("network.labels.current")}</div>
|
||||
</div>
|
||||
<div class="card card-c">
|
||||
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||
<div class="card-label">Minimum</div>
|
||||
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||
<div class="card-label">${t("network.labels.minimum")}</div>
|
||||
</div>
|
||||
<div class="card card-c">
|
||||
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||
<div class="card-label">Average</div>
|
||||
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||
<div class="card-label">${t("network.labels.average")}</div>
|
||||
</div>
|
||||
<div class="card card-c">
|
||||
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||
<div class="card-label">Maximum</div>
|
||||
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||
<div class="card-label">${t("network.labels.maximum")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<div class="card-label">Sample Count</div>
|
||||
<div class="card-label">${t("network.latency.report.sampleCount")}</div>
|
||||
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Packet Loss (Avg)</div>
|
||||
<div class="card-label">${t("network.latency.report.packetLossAvg")}</div>
|
||||
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
|
||||
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Test Period</div>
|
||||
<div class="card-label">${t("network.latency.report.testPeriodLabel")}</div>
|
||||
<div class="card-value" style="font-size:11px;">
|
||||
${report.isRealtime
|
||||
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
|
||||
: timeframeLabel}
|
||||
${testPeriodValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,7 +657,7 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
|
||||
<!-- 3. Latency Graph (always section 3) -->
|
||||
<div class="section">
|
||||
<div class="section-title">3. Latency Graph</div>
|
||||
<div class="section-title">3. ${t("network.latency.report.latencyGraph")}</div>
|
||||
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
|
||||
${chartSvg}
|
||||
</div>
|
||||
@@ -596,37 +665,37 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
|
||||
<!-- 4. Performance Thresholds (always section 4) -->
|
||||
<div class="section">
|
||||
<div class="section-title">4. Performance Thresholds</div>
|
||||
<div class="section-title">4. ${t("network.latency.report.performanceThresholds")}</div>
|
||||
<div class="threshold-item">
|
||||
<div class="threshold-dot" style="background:#16a34a;"></div>
|
||||
<p><strong>Excellent (< 50ms):</strong> Optimal for real-time applications, gaming, and video calls.</p>
|
||||
<p><strong>${t("network.latency.status.excellent")} (< 50ms):</strong> ${t("network.latency.report.thresholdExcellent")}</p>
|
||||
</div>
|
||||
<div class="threshold-item">
|
||||
<div class="threshold-dot" style="background:#16a34a;"></div>
|
||||
<p><strong>Good (50-100ms):</strong> Acceptable for most applications with minimal impact.</p>
|
||||
<p><strong>${t("network.latency.status.good")} (50-100ms):</strong> ${t("network.latency.report.thresholdGood")}</p>
|
||||
</div>
|
||||
<div class="threshold-item">
|
||||
<div class="threshold-dot" style="background:#ca8a04;"></div>
|
||||
<p><strong>Fair (100-200ms):</strong> Noticeable delay. May affect VoIP and interactive applications.</p>
|
||||
<p><strong>${t("network.latency.status.fair")} (100-200ms):</strong> ${t("network.latency.report.thresholdFair")}</p>
|
||||
</div>
|
||||
<div class="threshold-item">
|
||||
<div class="threshold-dot" style="background:#dc2626;"></div>
|
||||
<p><strong>Poor (> 200ms):</strong> Significant latency. Investigation recommended.</p>
|
||||
<p><strong>${t("network.latency.status.poor")} (> 200ms):</strong> ${t("network.latency.report.thresholdPoor")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${report.isRealtime && report.realtimeResults.length > 0 ? `
|
||||
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
|
||||
<div class="section">
|
||||
<div class="section-title">5. Detailed Test Results</div>
|
||||
<div class="section-title">5. ${t("network.latency.report.detailedTestResults")}</div>
|
||||
<table class="chk-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Time</th>
|
||||
<th>Latency</th>
|
||||
<th>Packet Loss</th>
|
||||
<th>Status</th>
|
||||
<th>${t("network.labels.time")}</th>
|
||||
<th>${t("network.labels.latency")}</th>
|
||||
<th>${t("network.labels.packetLoss")}</th>
|
||||
<th>${t("network.labels.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -639,15 +708,15 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
${!report.isRealtime && report.data.length > 0 ? `
|
||||
<!-- 5. Detailed History (for Gateway) -->
|
||||
<div class="section">
|
||||
<div class="section-title">5. Latency History (Last ${Math.min(20, report.data.length)} Records)</div>
|
||||
<div class="section-title">5. ${t("network.latency.report.latencyHistory", { count: Math.min(20, report.data.length) })}</div>
|
||||
<table class="chk-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Time</th>
|
||||
<th>Latency</th>
|
||||
<th>Packet Loss</th>
|
||||
<th>Status</th>
|
||||
<th>${t("network.labels.time")}</th>
|
||||
<th>${t("network.labels.latency")}</th>
|
||||
<th>${t("network.labels.packetLoss")}</th>
|
||||
<th>${t("network.labels.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -659,41 +728,41 @@ ${!report.isRealtime && report.data.length > 0 ? `
|
||||
|
||||
<!-- Methodology -->
|
||||
<div class="section">
|
||||
<div class="section-title">${(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? '6' : '5'}. Methodology</div>
|
||||
<div class="section-title">${detailSectionNumber}. ${t("network.latency.report.methodology")}</div>
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-label">Test Method</div>
|
||||
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div>
|
||||
<div class="card-label">${t("network.latency.report.testMethod")}</div>
|
||||
<div class="card-value" style="font-size:12px;">${t("network.latency.report.icmpEchoRequest")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Samples per Test</div>
|
||||
<div class="card-value" style="font-size:12px;">3 consecutive pings</div>
|
||||
<div class="card-label">${t("network.latency.report.samplesPerTest")}</div>
|
||||
<div class="card-value" style="font-size:12px;">${t("network.latency.report.threeConsecutivePings")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Target</div>
|
||||
<div class="card-label">${t("network.latency.report.target")}</div>
|
||||
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Target IP</div>
|
||||
<div class="card-value" style="font-size:12px;">${report.target === 'gateway' ? 'Default Gateway' : report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'}</div>
|
||||
<div class="card-label">${t("network.latency.report.targetIp")}</div>
|
||||
<div class="card-value" style="font-size:12px;">${targetIpLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h4>Performance Assessment</h4>
|
||||
<h4>${t("network.latency.report.performanceAssessment")}</h4>
|
||||
<p>${
|
||||
statusText === 'Excellent' ? 'Network latency is excellent. No action required.' :
|
||||
statusText === 'Good' ? 'Network latency is within acceptable parameters.' :
|
||||
statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' :
|
||||
statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' :
|
||||
'Unable to determine network status.'
|
||||
statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") :
|
||||
statusKey === 'good' ? t("network.latency.report.assessmentGood") :
|
||||
statusKey === 'fair' ? t("network.latency.report.assessmentFair") :
|
||||
statusKey === 'poor' ? t("network.latency.report.assessmentPoor") :
|
||||
t("network.latency.report.assessmentUnknown")
|
||||
}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="rpt-footer">
|
||||
<div>ProxMenux Monitor - Network Performance Report</div>
|
||||
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
|
||||
<div>${t("network.latency.report.footerTitle")}</div>
|
||||
<div>${t("network.latency.report.generated")}: ${now} | ${t("network.latency.report.reportId")}: ${reportId}</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
@@ -706,6 +775,7 @@ ${!report.isRealtime && report.data.length > 0 ? `
|
||||
}
|
||||
|
||||
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
|
||||
const t = useT()
|
||||
const [timeframe, setTimeframe] = useState("hour")
|
||||
const [target, setTarget] = useState("gateway")
|
||||
const [data, setData] = useState<LatencyHistoryPoint[]>([])
|
||||
@@ -882,7 +952,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
|
||||
} : stats
|
||||
|
||||
const statusInfo = getStatusInfo(displayStats.current)
|
||||
const statusInfo = getStatusInfo(displayStats.current, t)
|
||||
|
||||
// Calculate test duration for report based on first and last result timestamps
|
||||
const testDuration = realtimeResults.length >= 2
|
||||
@@ -897,20 +967,20 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<Wifi className="h-5 w-5 text-blue-500" />
|
||||
Network Latency
|
||||
{t("network.cards.latency")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 mt-1 flex-nowrap">
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
|
||||
<span className="truncate">
|
||||
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target}
|
||||
{getLatencyTargetShortLabel(target, t)}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TARGET_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -923,7 +993,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -938,7 +1008,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
Stop
|
||||
{t("network.latency.actions.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -948,7 +1018,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
className="gap-1.5 shrink-0 h-8 px-3"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Test Again
|
||||
{t("network.latency.actions.testAgain")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -957,19 +1027,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
size="sm"
|
||||
onClick={() => generateLatencyReport({
|
||||
target,
|
||||
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target,
|
||||
targetLabel: getLatencyTargetLabel(target, t),
|
||||
isRealtime,
|
||||
stats,
|
||||
realtimeResults,
|
||||
data,
|
||||
timeframe,
|
||||
testDuration: isRealtime ? testDuration : undefined,
|
||||
})}
|
||||
}, t)}
|
||||
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
|
||||
className="gap-1.5 shrink-0 h-8 px-3"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Report
|
||||
{t("network.latency.actions.report")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -977,8 +1047,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
{isRealtime && realtimeTesting && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>Testing... {Math.round(testProgress)}%</span>
|
||||
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span>
|
||||
<span>{t("network.latency.testingProgress", { percent: Math.round(testProgress) })}</span>
|
||||
<span>{t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -992,7 +1062,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
{/* Stats Cards - Compact single row */}
|
||||
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground">Current</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t("network.labels.current")}</span>
|
||||
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
|
||||
{displayStats.current || '-'}
|
||||
</span>
|
||||
@@ -1000,19 +1070,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground">Min</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t("network.labels.min")}</span>
|
||||
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
|
||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Minus className="h-3 w-3 shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground">Avg</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t("network.labels.avg")}</span>
|
||||
<span className="text-base font-bold">{displayStats.avg || '-'}</span>
|
||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground">Max</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t("network.labels.max")}</span>
|
||||
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
|
||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||
</div>
|
||||
@@ -1025,8 +1095,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
</Badge>
|
||||
{isRealtime && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected
|
||||
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''}
|
||||
{t("network.latency.samplesCollected", { count: realtimeResults.length })}
|
||||
{realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1058,7 +1128,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
domain={['dataMin - 1', 'dataMax + 2']}
|
||||
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Tooltip content={<CustomTooltip t={t} />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
@@ -1075,7 +1145,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{realtimeTesting ? 'Collecting data...' : 'No data yet. Click "Test Again" to start.'}
|
||||
{realtimeTesting ? t("network.latency.collectingData") : t("network.latency.noRealtimeData")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -1107,7 +1177,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
domain={['dataMin - 1', 'dataMax + 2']}
|
||||
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Tooltip content={<CustomTooltip t={t} />} />
|
||||
{/* For longer timeframes (6h+), show max values to preserve spikes.
|
||||
For 1 hour view, show avg values since there's no downsampling */}
|
||||
<Area
|
||||
@@ -1123,8 +1193,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">No latency data available for this period</p>
|
||||
<p className="text-xs mt-1">Data is collected every 60 seconds</p>
|
||||
<p className="text-sm">{t("network.latency.noDataForPeriod")}</p>
|
||||
<p className="text-xs mt-1">{t("network.latency.collectionInterval")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1133,8 +1203,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
{isRealtime && (
|
||||
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<p className="text-xs text-blue-400">
|
||||
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds.
|
||||
Click "Test Again" to add more samples. All data is included in the report.
|
||||
<strong>{t("network.latency.realTimeMode")}:</strong> {t("network.latency.realTimeModeDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } 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 { useT } from "../lib/i18n/provider"
|
||||
import Image from "next/image"
|
||||
|
||||
interface LoginProps {
|
||||
@@ -16,6 +17,7 @@ interface LoginProps {
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: LoginProps) {
|
||||
const t = useT()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [totpCode, setTotpCode] = useState("")
|
||||
@@ -24,8 +26,24 @@ export function Login({ onLogin }: LoginProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const lastAutoSubmittedTotp = useRef("")
|
||||
|
||||
useEffect(() => {
|
||||
// The Login screen is, by construction, the recovery path from any
|
||||
// 401 cascade (the api-config wrapper redirects here when an
|
||||
// expired/invalid JWT is detected). Clear the cascade-prevention
|
||||
// flag on mount so a successful login can subsequently fire a fresh
|
||||
// reload if a NEW 401 ever occurs. Without this clear, any 401 set
|
||||
// earlier in the session sticks around forever and the next 401
|
||||
// (e.g. mid-2FA, or right after a successful login if the token was
|
||||
// briefly stale) is silently swallowed by the de-dup — the user
|
||||
// sees a blank/stuck dashboard.
|
||||
try {
|
||||
sessionStorage.removeItem("proxmenux-auth-401-handled")
|
||||
} catch {
|
||||
// private browsing — best-effort
|
||||
}
|
||||
|
||||
const savedUsername = localStorage.getItem("proxmenux-saved-username")
|
||||
const savedPassword = localStorage.getItem("proxmenux-saved-password")
|
||||
|
||||
@@ -36,17 +54,18 @@ export function Login({ onLogin }: LoginProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const submitLogin = async (totpOverride?: string) => {
|
||||
setError("")
|
||||
|
||||
const token = totpOverride ?? totpCode
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please enter username and password")
|
||||
setError(t("login.missingCredentials"))
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresTotp && !totpCode) {
|
||||
setError("Please enter your 2FA code")
|
||||
if (requiresTotp && !token) {
|
||||
setError(t("login.missingTotp"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,23 +78,34 @@ export function Login({ onLogin }: LoginProps) {
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
totp_token: totpCode || undefined, // Include 2FA code if provided
|
||||
totp_token: token || undefined, // Include 2FA code if provided
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.requires_totp) {
|
||||
if (response.ok && data.requires_totp) {
|
||||
setRequiresTotp(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Login failed")
|
||||
if (response.status === 429) {
|
||||
throw new Error(t("login.tooManyAttempts"))
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error(data.requires_totp ? t("login.invalidTotp") : t("login.invalidCredentials"))
|
||||
}
|
||||
throw new Error(t("login.loginFailed"))
|
||||
}
|
||||
|
||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||
try {
|
||||
sessionStorage.removeItem("proxmenux-auth-401-handled")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("proxmenux-saved-username", username)
|
||||
@@ -87,12 +117,28 @@ export function Login({ onLogin }: LoginProps) {
|
||||
|
||||
onLogin()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed")
|
||||
setError(err instanceof Error ? err.message : t("login.loginFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await submitLogin()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresTotp || loading || !/^\d{6}$/.test(totpCode)) {
|
||||
return
|
||||
}
|
||||
if (lastAutoSubmittedTotp.current === totpCode) {
|
||||
return
|
||||
}
|
||||
lastAutoSubmittedTotp.current = totpCode
|
||||
void submitLogin(totpCode)
|
||||
}, [requiresTotp, totpCode, loading])
|
||||
|
||||
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">
|
||||
@@ -119,8 +165,8 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</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>
|
||||
<h1 className="text-3xl font-bold">{t("app.title")}</h1>
|
||||
<p className="text-muted-foreground mt-2">{t("login.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,14 +183,14 @@ export function Login({ onLogin }: LoginProps) {
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-username" className="text-sm">
|
||||
Username
|
||||
{t("login.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"
|
||||
placeholder={t("login.usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -156,14 +202,14 @@ export function Login({ onLogin }: LoginProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-password" className="text-sm">
|
||||
Password
|
||||
{t("login.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"
|
||||
placeholder={t("login.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 pr-10 text-base"
|
||||
@@ -194,7 +240,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
|
||||
Remember me
|
||||
{t("login.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
@@ -203,29 +249,29 @@ export function Login({ onLogin }: LoginProps) {
|
||||
<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>
|
||||
<p className="text-sm font-medium text-blue-500">{t("login.twoFactorTitle")}</p>
|
||||
<p className="text-xs text-blue-500 mt-1">{t("login.twoFactorDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code" className="text-sm">
|
||||
Authentication Code
|
||||
{t("login.authenticationCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
onChange={(e) => setTotpCode(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 9))}
|
||||
className="text-center text-lg tracking-widest font-mono text-base"
|
||||
maxLength={6}
|
||||
maxLength={9}
|
||||
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)
|
||||
{t("login.backupCodeHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -240,18 +286,18 @@ export function Login({ onLogin }: LoginProps) {
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Back to login
|
||||
{t("login.backToLogin")}
|
||||
</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"}
|
||||
{loading ? t("login.signingIn") : requiresTotp ? t("login.verifyCode") : t("login.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.0</p>
|
||||
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
Copy,
|
||||
Clipboard,
|
||||
} from "lucide-react"
|
||||
import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -33,6 +36,8 @@ 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"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface LxcTerminalModalProps {
|
||||
open: boolean
|
||||
@@ -47,33 +52,35 @@ interface CheatSheetResult {
|
||||
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" },
|
||||
]
|
||||
const LXC_COMMANDS = [
|
||||
{ cmd: "ls -la", descKey: "listFiles" },
|
||||
{ cmd: "cd /path/to/dir", descKey: "changeDirectory" },
|
||||
{ cmd: "cat filename", descKey: "displayFile" },
|
||||
{ cmd: "grep 'pattern' file", descKey: "searchPattern" },
|
||||
{ cmd: "find . -name 'file'", descKey: "findFiles" },
|
||||
{ cmd: "df -h", descKey: "diskUsage" },
|
||||
{ cmd: "du -sh *", descKey: "directorySizes" },
|
||||
{ cmd: "free -h", descKey: "memoryUsage" },
|
||||
{ cmd: "top", descKey: "runningProcesses" },
|
||||
{ cmd: "ps aux | grep process", descKey: "findProcess" },
|
||||
{ cmd: "systemctl status service", descKey: "serviceStatus" },
|
||||
{ cmd: "systemctl restart service", descKey: "restartService" },
|
||||
{ cmd: "apt update && apt upgrade", descKey: "updatePackages" },
|
||||
{ cmd: "apt install package", descKey: "installPackage" },
|
||||
{ cmd: "tail -f /var/log/syslog", descKey: "followLog" },
|
||||
{ cmd: "chmod 755 file", descKey: "changePermissions" },
|
||||
{ cmd: "chown user:group file", descKey: "changeOwner" },
|
||||
{ cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
|
||||
{ cmd: "docker ps", descKey: "listContainers" },
|
||||
{ cmd: "docker images", descKey: "listImages" },
|
||||
{ cmd: "ip addr show", descKey: "showIpAddresses" },
|
||||
{ cmd: "ping host", descKey: "testConnectivity" },
|
||||
{ cmd: "curl -I url", descKey: "httpHeaders" },
|
||||
{ cmd: "history", descKey: "commandHistory" },
|
||||
{ cmd: "clear", descKey: "clearScreen" },
|
||||
] as const
|
||||
|
||||
type LocalCommand = { cmd: string; desc: string }
|
||||
|
||||
function getWebSocketUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -97,6 +104,7 @@ export function LxcTerminalModal({
|
||||
vmid,
|
||||
vmName,
|
||||
}: LxcTerminalModalProps) {
|
||||
const t = useT()
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
@@ -117,12 +125,18 @@ export function LxcTerminalModal({
|
||||
// Search state
|
||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
||||
const localCommands = useMemo<LocalCommand[]>(
|
||||
() => LXC_COMMANDS.map((item) => ({ cmd: item.cmd, desc: t(`lxcTerminal.commands.${item.descKey}`) })),
|
||||
[t],
|
||||
)
|
||||
const [filteredCommands, setFilteredCommands] = useState<LocalCommand[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||
const [useOnline, setUseOnline] = useState(true)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredCommands(localCommands)
|
||||
}, [localCommands])
|
||||
|
||||
// Detect mobile/tablet
|
||||
useEffect(() => {
|
||||
@@ -161,9 +175,16 @@ export function LxcTerminalModal({
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
// `cancelled` short-circuits the async init if the modal closes
|
||||
// before the dynamic xterm import resolves. Without this, we'd
|
||||
// construct a Terminal instance, attach it to a now-stale ref, and
|
||||
// open a WebSocket that nobody listens to. Audit Tier 6 — useEffect
|
||||
// con `import("xterm")` sin cancelación.
|
||||
let cancelled = false
|
||||
|
||||
// Small delay to ensure Dialog content is rendered
|
||||
const initTimeout = setTimeout(() => {
|
||||
if (!terminalContainerRef.current) return
|
||||
if (cancelled || !terminalContainerRef.current) return
|
||||
initTerminal()
|
||||
}, 100)
|
||||
|
||||
@@ -172,12 +193,13 @@ export function LxcTerminalModal({
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
])
|
||||
if (cancelled) return
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
const term = new TerminalClass({
|
||||
rendererType: "dom",
|
||||
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontFamily: '"MesloLGS NF", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "Symbols Nerd Font", "Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: true,
|
||||
@@ -221,9 +243,11 @@ export function LxcTerminalModal({
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
// Connect WebSocket to host terminal
|
||||
// Connect WebSocket to host terminal. We append a single-use ticket
|
||||
// (`?ticket=...`) which the backend consumes on handshake — see
|
||||
// lib/terminal-ws.ts and AppImage/scripts/flask_terminal_routes.py.
|
||||
const wsUrl = getWebSocketUrl()
|
||||
const ws = new WebSocket(wsUrl)
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
wsRef.current = ws
|
||||
|
||||
// Reset state for new connection
|
||||
@@ -252,17 +276,28 @@ export function LxcTerminalModal({
|
||||
rows: term.rows,
|
||||
}))
|
||||
|
||||
// Auto-execute pct enter after connection is ready
|
||||
// Auto-execute pct enter after connection is ready.
|
||||
// The string is sent verbatim to the bash PTY, so a non-numeric
|
||||
// `vmid` would land as shell input (e.g. `pct enter ; rm -rf /`).
|
||||
// The prop is typed `number` but JSON / URL query injections can
|
||||
// sneak strings in; validate as a defensive redundancy. Audit
|
||||
// residual #lxc-terminal-vmid-injection.
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`pct enter ${vmid}\r`)
|
||||
if (ws.readyState !== WebSocket.OPEN) return
|
||||
// Coerce + verify: must be a positive integer that round-trips
|
||||
// through Number without losing fidelity.
|
||||
const id = Number(vmid)
|
||||
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
|
||||
term.writeln(`\r\n\x1b[31m[ERROR] ${t("lxcTerminal.errors.invalidVmid")}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
ws.send(`pct enter ${id}\r`)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||||
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
@@ -270,7 +305,7 @@ export function LxcTerminalModal({
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||||
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
@@ -302,13 +337,17 @@ export function LxcTerminalModal({
|
||||
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/)
|
||||
// Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd").
|
||||
// Charset widened to accept dotted FQDNs (`proxmox.lan`) and unicode
|
||||
// letters/numbers (host names like `próxmox` or non-Latin scripts).
|
||||
// The previous `[a-zA-Z0-9_-]` truncated the hostname and the
|
||||
// "are we inside the LXC?" comparison then misfired.
|
||||
const hostPromptMatch = cleanBuffer.match(/@([\p{L}\p{N}._-]+).*pct enter/u)
|
||||
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*$/)
|
||||
const promptMatch = afterPctEnter.match(/[@\[]([\p{L}\p{N}._-]+)[^\r\n]*[#$]\s*$/u)
|
||||
|
||||
if (promptMatch) {
|
||||
const lxcHostname = promptMatch[1]
|
||||
@@ -354,6 +393,7 @@ export function LxcTerminalModal({
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(initTimeout)
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
@@ -365,7 +405,7 @@ export function LxcTerminalModal({
|
||||
termRef.current.dispose()
|
||||
}
|
||||
}
|
||||
}, [isOpen, vmid])
|
||||
}, [isOpen, vmid, t])
|
||||
|
||||
// Resize handling
|
||||
useEffect(() => {
|
||||
@@ -435,12 +475,20 @@ export function LxcTerminalModal({
|
||||
const sendEnter = useCallback(() => sendKey("\r"), [sendKey])
|
||||
const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C
|
||||
|
||||
// Mobile clipboard helpers — see lib/terminal-clipboard.ts for the rationale.
|
||||
const handleCopy = useCallback(async () => {
|
||||
await copyTerminalSelection(termRef.current)
|
||||
}, [])
|
||||
const handlePaste = useCallback(async () => {
|
||||
await pasteFromClipboard(sendKey)
|
||||
}, [sendKey])
|
||||
|
||||
// Search effect - debounced search with cheat.sh
|
||||
useEffect(() => {
|
||||
const searchCheatSh = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localCommands)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -453,7 +501,7 @@ export function LxcTerminalModal({
|
||||
})
|
||||
|
||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||
throw new Error("No examples found")
|
||||
throw new Error(t("terminal.noExamplesFound"))
|
||||
}
|
||||
|
||||
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
||||
@@ -465,7 +513,7 @@ export function LxcTerminalModal({
|
||||
setUseOnline(true)
|
||||
setSearchResults(formattedResults)
|
||||
} catch (error) {
|
||||
const filtered = proxmoxCommands.filter(
|
||||
const filtered = localCommands.filter(
|
||||
(item) =>
|
||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||
@@ -483,12 +531,12 @@ export function LxcTerminalModal({
|
||||
searchCheatSh(searchQuery)
|
||||
} else {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localCommands)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchQuery])
|
||||
}, [searchQuery, localCommands, t])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (termRef.current) {
|
||||
@@ -527,7 +575,7 @@ export function LxcTerminalModal({
|
||||
{/* 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})
|
||||
{t("lxcTerminal.title", { name: vmName, id: vmid })}
|
||||
</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -538,7 +586,7 @@ export function LxcTerminalModal({
|
||||
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>
|
||||
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClear}
|
||||
@@ -548,7 +596,7 @@ export function LxcTerminalModal({
|
||||
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>
|
||||
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -634,20 +682,30 @@ export function LxcTerminalModal({
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</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>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</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>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</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>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -668,7 +726,7 @@ export function LxcTerminalModal({
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
|
||||
<span className="text-xs text-zinc-400">{t(`scriptTerminal.${connectionStatus}`)}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
@@ -677,7 +735,7 @@ export function LxcTerminalModal({
|
||||
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>
|
||||
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -686,22 +744,22 @@ export function LxcTerminalModal({
|
||||
<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>
|
||||
<SearchDialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</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"}
|
||||
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription>
|
||||
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</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)"
|
||||
placeholder={t("terminal.searchPlaceholder")}
|
||||
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"
|
||||
@@ -715,7 +773,7 @@ export function LxcTerminalModal({
|
||||
{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>
|
||||
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -742,7 +800,7 @@ export function LxcTerminalModal({
|
||||
<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
|
||||
{t("terminal.poweredByCheatSh")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -768,13 +826,13 @@ export function LxcTerminalModal({
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching && !searchQuery && !useOnline ? (
|
||||
proxmoxCommands.map((item, index) => (
|
||||
localCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToTerminal(item.cmd)}
|
||||
@@ -795,7 +853,7 @@ export function LxcTerminalModal({
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -806,17 +864,17 @@ export function LxcTerminalModal({
|
||||
<>
|
||||
<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>
|
||||
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</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>
|
||||
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
|
||||
<div className="text-sm text-zinc-500 space-y-1">
|
||||
<p>Try searching for:</p>
|
||||
<p>{t("terminal.trySearchingFor")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
||||
<code
|
||||
@@ -833,7 +891,7 @@ export function LxcTerminalModal({
|
||||
{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>
|
||||
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -842,13 +900,11 @@ export function LxcTerminalModal({
|
||||
) : 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>
|
||||
{useOnline && searchResults.length > 0 && (
|
||||
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
|
||||
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
|
||||
</div>
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface DetectionResponse {
|
||||
success: boolean
|
||||
enabled?: boolean
|
||||
message?: string
|
||||
purged?: number
|
||||
}
|
||||
|
||||
export function LxcUpdateDetection() {
|
||||
const t = useT()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [enabled, setEnabled] = useState<boolean>(true)
|
||||
const [pending, setPending] = useState<boolean>(true)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [lastPurged, setLastPurged] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchApi<DetectionResponse>("/api/lxc-updates/detection")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
if (data.success && typeof data.enabled === "boolean") {
|
||||
setEnabled(data.enabled)
|
||||
setPending(data.enabled)
|
||||
} else {
|
||||
setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hasChanges = pending !== enabled
|
||||
|
||||
function handleEdit() {
|
||||
setEditMode(true)
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
setLastPurged(null)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setPending(enabled)
|
||||
setEditMode(false)
|
||||
setError(null)
|
||||
setLastPurged(null)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasChanges) {
|
||||
setEditMode(false)
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
setLastPurged(null)
|
||||
try {
|
||||
const data = await fetchApi<DetectionResponse>("/api/lxc-updates/detection", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled: pending }),
|
||||
})
|
||||
if (!data.success) {
|
||||
setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
|
||||
return
|
||||
}
|
||||
setEnabled(pending)
|
||||
setEditMode(false)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
if (!pending && typeof data.purged === "number" && data.purged > 0) {
|
||||
setLastPurged(data.purged)
|
||||
}
|
||||
// Notify the Notifications section so it hides/shows the
|
||||
// lxc_updates_available toggle in real time.
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("proxmenux:lxc-detection-changed", { detail: { enabled: pending } }),
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(t("settings.lxcUpdateDetection.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Title row — flex-wrap so on narrow screens the badge can drop
|
||||
under the title without dragging the icon along with it. The
|
||||
icon stays on the same baseline as the title text on every
|
||||
breakpoint thanks to `items-center` + leading-tight title. */}
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
|
||||
<CardTitle className="leading-tight">{t("settings.lxcUpdateDetection.title")}</CardTitle>
|
||||
{enabled ? (
|
||||
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
||||
{t("status.active")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{error && !editMode && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
|
||||
title={error}
|
||||
>
|
||||
{t("status.saveFailed")}: {error}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={handleEdit}
|
||||
disabled={loading}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t("settings.lxcUpdateDetection.descriptionStart")}{" "}
|
||||
(<code>apt list --upgradable</code> / <code>apk list -u</code>)
|
||||
{t("settings.lxcUpdateDetection.descriptionEnd")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
className={`space-y-5${
|
||||
editMode
|
||||
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* ── Enable/Disable ── single-line label + toggle. The description
|
||||
paragraph was removed because the CardDescription above already
|
||||
covers the behaviour; on mobile that second paragraph forced
|
||||
the icon to top-align and made the toggle wrap awkwardly. */}
|
||||
<div className="flex items-center justify-between gap-3 py-2 px-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Boxes
|
||||
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{t("settings.lxcUpdateDetection.enableLabel")}</span>
|
||||
</div>
|
||||
<button
|
||||
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
|
||||
pending ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
|
||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onClick={() => editMode && setPending(p => !p)}
|
||||
disabled={!editMode || saving}
|
||||
role="switch"
|
||||
aria-checked={pending}
|
||||
aria-label={t("settings.lxcUpdateDetection.enableLabel")}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
pending ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastPurged !== null && lastPurged > 0 && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
|
||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && editMode && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<Info className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-amber-500 leading-relaxed break-all">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { ArrowLeft, Loader2 } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useI18n } from "@/lib/i18n/provider"
|
||||
|
||||
interface MetricsViewProps {
|
||||
vmid: number
|
||||
@@ -15,12 +16,12 @@ interface MetricsViewProps {
|
||||
}
|
||||
|
||||
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" },
|
||||
]
|
||||
{ value: "hour", labelKey: "vmMetrics.timeframes.hour" },
|
||||
{ value: "day", labelKey: "vmMetrics.timeframes.day" },
|
||||
{ value: "week", labelKey: "vmMetrics.timeframes.week" },
|
||||
{ value: "month", labelKey: "vmMetrics.timeframes.month" },
|
||||
{ value: "year", labelKey: "vmMetrics.timeframes.year" },
|
||||
] as const
|
||||
|
||||
const CustomCPUTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
@@ -103,6 +104,7 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
||||
}
|
||||
|
||||
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
|
||||
const { language, t } = useI18n()
|
||||
const [timeframe, setTimeframe] = useState("week")
|
||||
const [data, setData] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -112,7 +114,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics()
|
||||
}, [vmid, timeframe])
|
||||
}, [vmid, timeframe, language])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
setLoading(true)
|
||||
@@ -126,19 +128,19 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
@@ -146,12 +148,12 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "month") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
@@ -173,7 +175,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
setData(transformedData)
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error loading metrics")
|
||||
setError(err.message || t("vmMetrics.errors.loading"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -203,7 +205,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<p className="text-muted-foreground">No data available</p>
|
||||
<p className="text-muted-foreground">{t("vmMetrics.noData")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -214,7 +216,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
<div className="space-y-8">
|
||||
{/* CPU Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">CPU Usage</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.cpu")}</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
@@ -244,7 +246,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="CPU %"
|
||||
name={t("vmMetrics.series.cpu")}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -252,7 +254,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
{/* Memory Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Memory Usage</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.memory")}</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
@@ -282,7 +284,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Memory GB"
|
||||
name={t("vmMetrics.series.memoryGb")}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -290,7 +292,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
{/* Disk I/O Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Disk I/O</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.diskIo")}</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
@@ -321,7 +323,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Read"
|
||||
name={t("vmMetrics.series.read")}
|
||||
hide={hiddenDiskLines.includes("diskread")}
|
||||
/>
|
||||
<Area
|
||||
@@ -331,7 +333,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Write"
|
||||
name={t("vmMetrics.series.write")}
|
||||
hide={hiddenDiskLines.includes("diskwrite")}
|
||||
/>
|
||||
</AreaChart>
|
||||
@@ -340,7 +342,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
{/* Network I/O Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Network I/O</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.networkIo")}</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
@@ -371,7 +373,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Download"
|
||||
name={t("vmMetrics.series.download")}
|
||||
hide={hiddenNetworkLines.includes("netin")}
|
||||
/>
|
||||
<Area
|
||||
@@ -381,7 +383,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Upload"
|
||||
name={t("vmMetrics.series.upload")}
|
||||
hide={hiddenNetworkLines.includes("netout")}
|
||||
/>
|
||||
</AreaChart>
|
||||
@@ -461,9 +463,9 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Metrics - {vmName}</h2>
|
||||
<h2 className="text-xl font-semibold">{t("vmMetrics.title", { name: vmName })}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
VMID: {vmid} • Type: {vmType.toUpperCase()}
|
||||
VMID: {vmid} • {t("vmMetrics.type")}: {vmType.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -474,7 +476,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import {
|
||||
Boxes,
|
||||
Check,
|
||||
ChevronDown,
|
||||
DatabaseBackup,
|
||||
GripVertical,
|
||||
Grid3x3,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Settings2,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import {
|
||||
DEFAULT_TAB_ORDER,
|
||||
useTabOrder,
|
||||
type TabId,
|
||||
} from "../lib/tab-order"
|
||||
|
||||
// Long-press activation on touch — matches the delay platform UIs
|
||||
// use so the user can still scroll a page that happens to start on a
|
||||
// tab handle.
|
||||
const TOUCH_ACTIVATION_MS = 250
|
||||
const TOUCH_TOLERANCE_PX = 5
|
||||
|
||||
type TabMeta = {
|
||||
id: TabId
|
||||
Icon: React.ComponentType<{ className?: string }>
|
||||
labelKey: string
|
||||
hasDropdown: boolean
|
||||
}
|
||||
|
||||
const META: Record<TabId, TabMeta> = {
|
||||
overview: { id: "overview", Icon: LayoutDashboard, labelKey: "navigation.overview", hasDropdown: false },
|
||||
apps: { id: "apps", Icon: Grid3x3, labelKey: "navigation.apps", hasDropdown: false },
|
||||
vms: { id: "vms", Icon: Boxes, labelKey: "navigation.virtualMachines", hasDropdown: false },
|
||||
node: { id: "node", Icon: Server, labelKey: "navigation.node", hasDropdown: true },
|
||||
backup: { id: "backup", Icon: DatabaseBackup, labelKey: "navigation.backup", hasDropdown: false },
|
||||
terminal: { id: "terminal", Icon: Terminal, labelKey: "navigation.terminal", hasDropdown: false },
|
||||
admin: { id: "admin", Icon: Settings2, labelKey: "navigation.admin", hasDropdown: true },
|
||||
}
|
||||
|
||||
// Sortable list built on Pointer Events. Mouse activates on move
|
||||
// (2px threshold to survive accidental clicks); touch activates on
|
||||
// long-press after 250ms unless the finger moves past 5px, which is
|
||||
// treated as a scroll intent and the drag is cancelled.
|
||||
export function NavTabOrderCard() {
|
||||
const t = useT()
|
||||
const { order: savedOrder, setOrder, reset, isCustom } = useTabOrder()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [draft, setDraft] = useState<TabId[]>(savedOrder)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) setDraft(savedOrder)
|
||||
}, [savedOrder, editMode])
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraft(savedOrder)
|
||||
setEditMode(false)
|
||||
}
|
||||
const handleSave = () => {
|
||||
setOrder(draft)
|
||||
setEditMode(false)
|
||||
setSaved(true)
|
||||
window.setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
const handleReset = () => {
|
||||
setDraft([...DEFAULT_TAB_ORDER])
|
||||
}
|
||||
|
||||
const draftIsChanged =
|
||||
draft.length !== savedOrder.length ||
|
||||
draft.some((id, i) => id !== savedOrder[i])
|
||||
const draftIsCustom =
|
||||
draft.length !== DEFAULT_TAB_ORDER.length ||
|
||||
draft.some((id, i) => id !== DEFAULT_TAB_ORDER[i])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>{t("settings.navOrder.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSave}
|
||||
disabled={!draftIsChanged}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>{t("settings.navOrder.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={
|
||||
editMode
|
||||
? "bg-accent"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortableList
|
||||
items={editMode ? draft : savedOrder}
|
||||
onReorder={setDraft}
|
||||
editable={editMode}
|
||||
t={t}
|
||||
/>
|
||||
{editMode && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 text-muted-foreground disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={handleReset}
|
||||
disabled={!draftIsCustom}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
{t("settings.navOrder.reset")}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("settings.navOrder.hint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!editMode && isCustom && (
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">
|
||||
{t("settings.navOrder.customActive")}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// SortableList
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
type DragState = {
|
||||
fromIdx: number
|
||||
pointerY: number
|
||||
offsetY: number
|
||||
itemHeight: number
|
||||
} | null
|
||||
|
||||
function SortableList({
|
||||
items,
|
||||
onReorder,
|
||||
editable,
|
||||
t,
|
||||
}: {
|
||||
items: TabId[]
|
||||
onReorder: (next: TabId[]) => void
|
||||
editable: boolean
|
||||
t: (k: string) => string
|
||||
}) {
|
||||
const [drag, setDrag] = useState<DragState>(null)
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
|
||||
const listRef = useRef<HTMLUListElement | null>(null)
|
||||
|
||||
const commit = (from: number, to: number) => {
|
||||
if (from === to || to < 0 || to >= items.length) return
|
||||
const next = items.slice()
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved)
|
||||
onReorder(next)
|
||||
}
|
||||
|
||||
const handleDragMove = (clientY: number) => {
|
||||
if (!drag || !listRef.current) return
|
||||
const rows = Array.from(listRef.current.querySelectorAll<HTMLLIElement>("li[data-row]"))
|
||||
let target = drag.fromIdx
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect()
|
||||
const mid = rect.top + rect.height / 2
|
||||
if (clientY < mid) { target = i; break }
|
||||
target = i
|
||||
}
|
||||
setHoverIdx(target)
|
||||
setDrag((d) => (d ? { ...d, pointerY: clientY } : d))
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (drag && hoverIdx !== null) commit(drag.fromIdx, hoverIdx)
|
||||
setDrag(null)
|
||||
setHoverIdx(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="flex flex-col gap-1.5 select-none"
|
||||
onPointerMove={(e) => { if (drag) handleDragMove(e.clientY) }}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
>
|
||||
{items.map((id, idx) => {
|
||||
const isDragging = drag?.fromIdx === idx
|
||||
const meta = META[id]
|
||||
return (
|
||||
<SortableRow
|
||||
key={id}
|
||||
id={id}
|
||||
idx={idx}
|
||||
meta={meta}
|
||||
label={t(meta.labelKey)}
|
||||
editable={editable}
|
||||
isDragging={!!isDragging}
|
||||
hoverIdx={hoverIdx}
|
||||
drag={drag}
|
||||
onDragStart={(fromIdx, pointerY, offsetY, itemHeight) => {
|
||||
setDrag({ fromIdx, pointerY, offsetY, itemHeight })
|
||||
setHoverIdx(fromIdx)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableRow({
|
||||
id,
|
||||
idx,
|
||||
meta,
|
||||
label,
|
||||
editable,
|
||||
isDragging,
|
||||
hoverIdx,
|
||||
drag,
|
||||
onDragStart,
|
||||
}: {
|
||||
id: TabId
|
||||
idx: number
|
||||
meta: TabMeta
|
||||
label: string
|
||||
editable: boolean
|
||||
isDragging: boolean
|
||||
hoverIdx: number | null
|
||||
drag: DragState
|
||||
onDragStart: (fromIdx: number, pointerY: number, offsetY: number, itemHeight: number) => void
|
||||
}) {
|
||||
const rowRef = useRef<HTMLLIElement | null>(null)
|
||||
const longPressTimer = useRef<number | null>(null)
|
||||
const pointerStart = useRef<{ x: number; y: number } | null>(null)
|
||||
const activatedRef = useRef(false)
|
||||
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimer.current !== null) {
|
||||
window.clearTimeout(longPressTimer.current)
|
||||
longPressTimer.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const beginDrag = (clientY: number) => {
|
||||
if (!rowRef.current) return
|
||||
const rect = rowRef.current.getBoundingClientRect()
|
||||
activatedRef.current = true
|
||||
onDragStart(idx, clientY, clientY - rect.top, rect.height)
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLLIElement>) => {
|
||||
if (!editable) return
|
||||
if (e.button !== undefined && e.button !== 0) return
|
||||
pointerStart.current = { x: e.clientX, y: e.clientY }
|
||||
activatedRef.current = false
|
||||
if (e.pointerType === "touch") {
|
||||
longPressTimer.current = window.setTimeout(() => {
|
||||
longPressTimer.current = null
|
||||
beginDrag(e.clientY)
|
||||
}, TOUCH_ACTIVATION_MS)
|
||||
} else {
|
||||
// Mouse/pen: activate immediately on press.
|
||||
beginDrag(e.clientY)
|
||||
}
|
||||
;(e.currentTarget as HTMLLIElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLLIElement>) => {
|
||||
if (!editable) return
|
||||
if (!activatedRef.current && pointerStart.current) {
|
||||
const dx = e.clientX - pointerStart.current.x
|
||||
const dy = e.clientY - pointerStart.current.y
|
||||
if (Math.hypot(dx, dy) > TOUCH_TOLERANCE_PX) {
|
||||
// Movement before activation → scroll intent on touch. Cancel
|
||||
// the pending long-press so the page can scroll normally.
|
||||
cancelLongPress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
cancelLongPress()
|
||||
pointerStart.current = null
|
||||
}
|
||||
|
||||
// Simple drag visual: ghost the row being dragged, show a blue
|
||||
// drop-line above or below the row the pointer is currently over.
|
||||
// No item swap animation — commit on release.
|
||||
const isTarget = drag && hoverIdx === idx && drag.fromIdx !== idx
|
||||
const dropAbove = isTarget && drag!.fromIdx > idx
|
||||
const dropBelow = isTarget && drag!.fromIdx < idx
|
||||
const RowIcon = meta.Icon
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
data-row
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
className={[
|
||||
"relative rounded-md border transition-colors",
|
||||
editable
|
||||
? "bg-background border-border cursor-grab active:cursor-grabbing touch-none"
|
||||
: "bg-card border-border/60",
|
||||
isDragging ? "opacity-40" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{dropAbove && <div className="absolute -top-[3px] left-2 right-2 h-[2px] bg-blue-500 rounded-full pointer-events-none" />}
|
||||
{dropBelow && <div className="absolute -bottom-[3px] left-2 right-2 h-[2px] bg-blue-500 rounded-full pointer-events-none" />}
|
||||
<div className="flex items-center gap-3 p-2.5">
|
||||
<GripVertical
|
||||
className={
|
||||
"h-4 w-4 flex-shrink-0 " +
|
||||
(editable ? "text-muted-foreground" : "text-muted-foreground/40")
|
||||
}
|
||||
/>
|
||||
<RowIcon className="h-4 w-4 flex-shrink-0 text-blue-500" />
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||
{meta.hasDropdown && (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground/70" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { Wifi, Zap } from 'lucide-react'
|
||||
import { useState, useEffect } from "react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
interface NetworkCardProps {
|
||||
interface_: {
|
||||
@@ -32,43 +35,51 @@ interface NetworkCardProps {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const getInterfaceTypeBadge = (type: string) => {
|
||||
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
|
||||
switch (type) {
|
||||
case "physical":
|
||||
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" }
|
||||
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") }
|
||||
case "bridge":
|
||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" }
|
||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") }
|
||||
case "bond":
|
||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" }
|
||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") }
|
||||
case "vlan":
|
||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" }
|
||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") }
|
||||
case "vm_lxc":
|
||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
|
||||
case "virtual":
|
||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
|
||||
default:
|
||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
|
||||
}
|
||||
}
|
||||
|
||||
const getVMTypeBadge = (vmType: string | undefined) => {
|
||||
const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => {
|
||||
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" }
|
||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
|
||||
}
|
||||
|
||||
const formatSpeed = (speed: number): string => {
|
||||
if (speed === 0) return "N/A"
|
||||
const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => {
|
||||
const normalized = (status || "").toLowerCase()
|
||||
if (normalized === "up") return t("network.status.up")
|
||||
if (normalized === "down") return t("network.status.down")
|
||||
return status || t("common.unknown")
|
||||
}
|
||||
|
||||
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
|
||||
if (speed === 0) return unavailable
|
||||
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 t = useT()
|
||||
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
|
||||
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type, t) : null
|
||||
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
|
||||
|
||||
@@ -109,7 +120,7 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch traffic data for card:", error)
|
||||
console.error("Failed to fetch traffic data for card:", error)
|
||||
setTrafficData({ received: 0, sent: 0 })
|
||||
}
|
||||
}
|
||||
@@ -125,17 +136,17 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
||||
const getTimeframeLabel = () => {
|
||||
switch (timeframe) {
|
||||
case "hour":
|
||||
return "Last Hour"
|
||||
return t("network.timeframes.last.hour")
|
||||
case "day":
|
||||
return "Last 24 Hours"
|
||||
return t("network.timeframes.last.day")
|
||||
case "week":
|
||||
return "Last 7 Days"
|
||||
return t("network.timeframes.last.week")
|
||||
case "month":
|
||||
return "Last 30 Days"
|
||||
return t("network.timeframes.last.month")
|
||||
case "year":
|
||||
return "Last Year"
|
||||
return t("network.timeframes.last.year")
|
||||
default:
|
||||
return "Last 24 Hours"
|
||||
return t("network.timeframes.last.day")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +185,7 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
>
|
||||
{interface_.status.toUpperCase()}
|
||||
{formatInterfaceStatus(interface_.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -182,22 +193,22 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
||||
<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"}
|
||||
{interface_.type === "vm_lxc" ? "VMID" : t("network.labels.ipAddress")}
|
||||
</div>
|
||||
<div className="font-medium text-foreground font-mono text-sm truncate">
|
||||
{interface_.type === "vm_lxc"
|
||||
? (interface_.vmid ?? "N/A")
|
||||
? (interface_.vmid ?? t("common.notAvailable"))
|
||||
: interface_.addresses.length > 0
|
||||
? interface_.addresses[0].ip
|
||||
: "N/A"}
|
||||
: t("common.notAvailable")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Speed</div>
|
||||
<div className="text-muted-foreground text-xs">{t("network.labels.speed")}</div>
|
||||
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
|
||||
<Zap className="h-3 w-3" />
|
||||
{formatSpeed(interface_.speed)}
|
||||
{formatSpeed(interface_.speed, t("common.notAvailable"))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface NetworkMetricsData {
|
||||
time: string
|
||||
@@ -50,6 +51,7 @@ export function NetworkTrafficChart({
|
||||
refreshInterval = 60000,
|
||||
networkUnit: networkUnitProp, // Rename prop to avoid conflict
|
||||
}: NetworkTrafficChartProps) {
|
||||
const t = useT()
|
||||
const [data, setData] = useState<NetworkMetricsData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -110,12 +112,11 @@ export function NetworkTrafficChart({
|
||||
? `/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")
|
||||
throw new Error(t("network.chart.invalidDataFormat"))
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
@@ -207,8 +208,8 @@ export function NetworkTrafficChart({
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[v0] Error fetching network metrics:", err)
|
||||
setError(err.message || "Error loading metrics")
|
||||
console.error("Error fetching network metrics:", err)
|
||||
setError(err.message || t("network.chart.loadError"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -256,7 +257,7 @@ export function NetworkTrafficChart({
|
||||
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-muted-foreground text-sm">{t("overview.networkMetricsUnavailable")}</p>
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -265,7 +266,7 @@ export function NetworkTrafficChart({
|
||||
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>
|
||||
<p className="text-muted-foreground text-sm">{t("overview.noNetworkMetrics")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -296,7 +297,7 @@ export function NetworkTrafficChart({
|
||||
}}
|
||||
domain={[0, "auto"]}
|
||||
/>
|
||||
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
|
||||
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} />
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -305,7 +306,7 @@ export function NetworkTrafficChart({
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Received"
|
||||
name={t("overview.receivedShort")}
|
||||
hide={!visibleLines.netIn}
|
||||
isAnimationActive={true}
|
||||
animationDuration={300}
|
||||
@@ -318,7 +319,7 @@ export function NetworkTrafficChart({
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="Sent"
|
||||
name={t("overview.sentShort")}
|
||||
hide={!visibleLines.netOut}
|
||||
isAnimationActive={true}
|
||||
animationDuration={300}
|
||||
|
||||
@@ -7,12 +7,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useI18n } from "../lib/i18n/provider"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||
{ value: "day", labelKey: "overview.timeframes.day" },
|
||||
{ value: "week", labelKey: "overview.timeframes.week" },
|
||||
{ value: "month", labelKey: "overview.timeframes.month" },
|
||||
]
|
||||
|
||||
interface NodeMetricsData {
|
||||
@@ -66,11 +67,71 @@ const CustomMemoryTooltip = ({ active, payload, label }: any) => {
|
||||
return null
|
||||
}
|
||||
|
||||
interface MetricsError {
|
||||
headline: string
|
||||
details?: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
// AVG / MAX / MIN chip row for the chart card headers. Values come
|
||||
// from the backend `period_stats` (calculated over the raw RRD points
|
||||
// BEFORE downsampling), not from the displayed chart points — that's
|
||||
// what makes a 1-minute CPU spike still appear in the 24h MAX even
|
||||
// though the chart shows 5-min bucket averages.
|
||||
//
|
||||
// Colour choice: all three values render in the same foreground tone.
|
||||
// The previous red(max)/green(min) scheme misread as severity (a
|
||||
// healthy 10 % CPU max showed in red and looked like an alert).
|
||||
//
|
||||
// Responsive: on ≥sm the chips sit to the right of the title; on
|
||||
// mobile they wrap below in their own row (the parent CardHeader uses
|
||||
// `flex-col sm:flex-row`). Smaller text + tabular-nums keeps the
|
||||
// chips compact enough that they don't crowd long titles.
|
||||
type PeriodStat = { avg: number; max: number; min: number } | null
|
||||
function ChartStatsHeader({
|
||||
stats,
|
||||
suffix = "",
|
||||
labels,
|
||||
}: {
|
||||
stats: PeriodStat
|
||||
suffix?: string
|
||||
labels: { avg: string; max: string; min: string }
|
||||
}) {
|
||||
if (!stats) return null
|
||||
const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1))
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm tabular-nums">
|
||||
<span>
|
||||
<span className="font-semibold text-foreground">{fmt(stats.avg)}{suffix}</span>
|
||||
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.avg}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold text-foreground">{fmt(stats.max)}{suffix}</span>
|
||||
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.max}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold text-foreground">{fmt(stats.min)}{suffix}</span>
|
||||
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.min}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function NodeMetricsCharts() {
|
||||
const { language, t } = useI18n()
|
||||
const [timeframe, setTimeframe] = useState("day")
|
||||
const [data, setData] = useState<NodeMetricsData[]>([])
|
||||
// period_stats from the backend — computed over the raw RRD points
|
||||
// BEFORE the 5-min downsampling so the chart header's MAX/MIN
|
||||
// captures real per-minute extremes (a 1-min CPU spike still shows
|
||||
// up on the 24h view's MAX).
|
||||
const [periodStats, setPeriodStats] = useState<{
|
||||
cpu?: PeriodStat
|
||||
memory_used?: PeriodStat
|
||||
}>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<MetricsError | null>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const [visibleLines, setVisibleLines] = useState({
|
||||
@@ -83,41 +144,30 @@ export function NodeMetricsCharts() {
|
||||
const hasMemoryFree = data.some(d => d.memoryFree > 0)
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[v0] NodeMetricsCharts component mounted")
|
||||
fetchMetrics()
|
||||
}, [timeframe])
|
||||
}, [timeframe, language])
|
||||
|
||||
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")
|
||||
console.error("Invalid data format - data is not an array:", result)
|
||||
throw new Error(t("overview.invalidMetricsData"))
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
console.warn("[v0] No data points received")
|
||||
console.warn("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) => {
|
||||
@@ -125,26 +175,26 @@ export function NodeMetricsCharts() {
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
@@ -169,13 +219,20 @@ export function NodeMetricsCharts() {
|
||||
})
|
||||
|
||||
setData(transformedData)
|
||||
setPeriodStats(result.period_stats || {})
|
||||
} 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")
|
||||
console.error("Error fetching node metrics:", err)
|
||||
// fetchApi attaches the parsed JSON body to err.body. The metrics
|
||||
// endpoint enriches 503 responses with `details` (Proxmox-side
|
||||
// diagnostic) and `suggestion` (how to fix). Pull them through so
|
||||
// the user sees actionable text instead of a bare "503".
|
||||
const body = err?.body
|
||||
setError({
|
||||
headline: body?.error || err?.message || t("overview.metricsLoadError"),
|
||||
details: body?.details,
|
||||
suggestion: body?.suggestion,
|
||||
})
|
||||
} finally {
|
||||
console.log("[v0] fetchMetrics finally block - setting loading to false")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -220,10 +277,8 @@ export function NodeMetricsCharts() {
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -245,44 +300,54 @@ export function NodeMetricsCharts() {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.log("[v0] Rendering error state:", error)
|
||||
// Both panels carry the same error — render an identical card on
|
||||
// each side. The headline is the short cause, the details block
|
||||
// explains it's a Proxmox-host issue (not a Monitor bug), and the
|
||||
// suggestion is the exact command the operator should run.
|
||||
const errorCard = (
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-start justify-center h-[300px] gap-2 px-2 overflow-auto">
|
||||
<p className="text-sm font-semibold text-red-400">{error.headline}</p>
|
||||
{error.details && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{error.details}</p>
|
||||
)}
|
||||
{error.suggestion && (
|
||||
<div className="w-full mt-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
||||
{t("overview.suggestedFix")}
|
||||
</p>
|
||||
<code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all">
|
||||
{error.suggestion}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
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>
|
||||
{errorCard}
|
||||
{errorCard}
|
||||
</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>
|
||||
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</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>
|
||||
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -290,7 +355,6 @@ export function NodeMetricsCharts() {
|
||||
)
|
||||
}
|
||||
|
||||
console.log("[v0] Rendering charts with", data.length, "data points")
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -303,7 +367,7 @@ export function NodeMetricsCharts() {
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -315,10 +379,21 @@ export function NodeMetricsCharts() {
|
||||
{/* 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>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<TrendingUp className="h-5 w-5 mr-2" />
|
||||
{t("overview.cpuUsageLoadAverage")}
|
||||
</CardTitle>
|
||||
<ChartStatsHeader
|
||||
stats={periodStats.cpu ?? null}
|
||||
suffix="%"
|
||||
labels={{
|
||||
avg: t("overview.stats.avg"),
|
||||
max: t("overview.stats.max"),
|
||||
min: t("overview.stats.min"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
@@ -351,7 +426,7 @@ export function NodeMetricsCharts() {
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }
|
||||
isMobile ? undefined : { value: t("overview.loadAxis"), angle: 90, position: "insideRight", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
@@ -365,7 +440,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="CPU %"
|
||||
name={t("overview.cpuPercent")}
|
||||
hide={!visibleLines.cpu.cpu}
|
||||
/>
|
||||
<Area
|
||||
@@ -376,7 +451,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Load Avg"
|
||||
name={t("overview.loadAverage")}
|
||||
hide={!visibleLines.cpu.load}
|
||||
/>
|
||||
</AreaChart>
|
||||
@@ -387,10 +462,21 @@ export function NodeMetricsCharts() {
|
||||
{/* 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>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<MemoryStick className="h-5 w-5 mr-2" />
|
||||
{t("overview.memoryUsage")}
|
||||
</CardTitle>
|
||||
<ChartStatsHeader
|
||||
stats={periodStats.memory_used ?? null}
|
||||
suffix=" GB"
|
||||
labels={{
|
||||
avg: t("overview.stats.avg"),
|
||||
max: t("overview.stats.max"),
|
||||
min: t("overview.stats.min"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pr-2 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
@@ -424,7 +510,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.1}
|
||||
name="Total"
|
||||
name={t("overview.total")}
|
||||
hide={!visibleLines.memory.memoryTotal}
|
||||
/>
|
||||
<Area
|
||||
@@ -434,7 +520,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Used"
|
||||
name={t("overview.used")}
|
||||
hide={!visibleLines.memory.memoryUsed}
|
||||
/>
|
||||
{/* Only show ZFS ARC if there's data */}
|
||||
@@ -459,7 +545,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#06b6d4"
|
||||
fillOpacity={0.3}
|
||||
name="Free"
|
||||
name={t("overview.free")}
|
||||
hide={!visibleLines.memory.memoryFree}
|
||||
/>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,12 @@ import {
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface OnboardingSlide {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
image?: string
|
||||
icon: React.ReactNode
|
||||
gradient: string
|
||||
@@ -33,77 +34,70 @@ interface OnboardingSlide {
|
||||
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.",
|
||||
titleKey: "onboarding.slides.welcome.title",
|
||||
descriptionKey: "onboarding.slides.welcome.description",
|
||||
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.",
|
||||
titleKey: "onboarding.slides.overview.title",
|
||||
descriptionKey: "onboarding.slides.overview.description",
|
||||
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.",
|
||||
titleKey: "onboarding.slides.storage.title",
|
||||
descriptionKey: "onboarding.slides.storage.description",
|
||||
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.",
|
||||
titleKey: "onboarding.slides.network.title",
|
||||
descriptionKey: "onboarding.slides.network.description",
|
||||
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.",
|
||||
titleKey: "onboarding.slides.virtualMachines.title",
|
||||
descriptionKey: "onboarding.slides.virtualMachines.description",
|
||||
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.",
|
||||
titleKey: "onboarding.slides.hardware.title",
|
||||
descriptionKey: "onboarding.slides.hardware.description",
|
||||
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.",
|
||||
titleKey: "onboarding.slides.logs.title",
|
||||
descriptionKey: "onboarding.slides.logs.description",
|
||||
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.",
|
||||
titleKey: "onboarding.slides.future.title",
|
||||
descriptionKey: "onboarding.slides.future.description",
|
||||
icon: <Rocket className="h-16 w-16" />,
|
||||
gradient: "from-indigo-500 via-purple-500 to-pink-500",
|
||||
},
|
||||
]
|
||||
|
||||
export function OnboardingCarousel() {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [direction, setDirection] = useState<"next" | "prev">("next")
|
||||
@@ -155,11 +149,13 @@ export function OnboardingCarousel() {
|
||||
}
|
||||
|
||||
const slide = slides[currentSlide]
|
||||
const slideTitle = t(slide.titleKey)
|
||||
const slideDescription = t(slide.descriptionKey)
|
||||
|
||||
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>
|
||||
<DialogTitle className="sr-only">{t("onboarding.dialogTitle")}</DialogTitle>
|
||||
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -181,7 +177,7 @@ export function OnboardingCarousel() {
|
||||
<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}
|
||||
alt={slideTitle}
|
||||
width={600}
|
||||
height={400}
|
||||
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
|
||||
@@ -207,9 +203,9 @@ export function OnboardingCarousel() {
|
||||
|
||||
<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>
|
||||
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slideTitle}</h2>
|
||||
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||
{slide.description}
|
||||
{slideDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +219,7 @@ export function OnboardingCarousel() {
|
||||
? "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}`}
|
||||
aria-label={t("onboarding.goToSlide", { number: index + 1 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -236,7 +232,7 @@ export function OnboardingCarousel() {
|
||||
className="gap-2 w-full sm:w-auto text-sm"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
{t("onboarding.previous")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
@@ -247,13 +243,13 @@ export function OnboardingCarousel() {
|
||||
onClick={handleSkip}
|
||||
className="flex-1 sm:flex-none bg-transparent text-sm"
|
||||
>
|
||||
Skip
|
||||
{t("onboarding.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
|
||||
>
|
||||
Next
|
||||
{t("onboarding.next")}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
@@ -262,7 +258,7 @@ export function OnboardingCarousel() {
|
||||
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!
|
||||
{t("onboarding.getStarted")}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -279,7 +275,7 @@ export function OnboardingCarousel() {
|
||||
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
|
||||
{t("onboarding.dontShowAgain")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { ScrollArea } from "./ui/scroll-area"
|
||||
import { Cpu, MemoryStick, Search } from "lucide-react"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { ProcessInfoModal } from "./process-info-modal"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
/** Parent process PID — equal to `pid` for process rows, different
|
||||
* for thread rows (CPU sort enumerates per-thread). The detail modal
|
||||
* always loads the parent, since /proc/<pid>/cmdline etc. only exist
|
||||
* at the process level. */
|
||||
parent_pid?: number
|
||||
user: string
|
||||
cpu: number
|
||||
/** CPU% averaged over the process's whole lifetime. Surfaces
|
||||
* long-running idle processes that consume a steady baseline but
|
||||
* don't spike during the 1-s sample window (an orphan `bash -s`
|
||||
* in a sleep-loop, a polling daemon, etc.). Only meaningful on
|
||||
* the CPU sort response. */
|
||||
cpu_avg?: number
|
||||
mem: number
|
||||
rss_kb: number
|
||||
command: string
|
||||
/** Full command line. Used for filter matching and hover tooltips so
|
||||
* searching e.g. "proxmenux" finds a process whose short name is just
|
||||
* "python3" but whose cmdline is `python3 /.../proxmenux.py`. */
|
||||
cmdline?: string
|
||||
}
|
||||
|
||||
interface ProcessesResponse {
|
||||
processes: ProcessInfo[]
|
||||
sort: "cpu" | "mem"
|
||||
captured_at: number
|
||||
}
|
||||
|
||||
interface ProcessDetailModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Which metric the parent card represents (drives default sort + emphasis) */
|
||||
sort: "cpu" | "mem"
|
||||
}
|
||||
|
||||
const REFRESH_MS = 3000
|
||||
// FETCH_LIMIT is how many rows the server returns. DISPLAY_LIMIT is what
|
||||
// the user actually sees when no filter is set. We over-fetch so the
|
||||
// filter can find processes that aren't in the top-N by metric — e.g.,
|
||||
// searching "proxmenux" in the Memory modal should find it even though
|
||||
// it's nowhere near the top 25 by RSS.
|
||||
const FETCH_LIMIT = 200
|
||||
const DISPLAY_LIMIT = 25
|
||||
|
||||
const formatRss = (kb: number): string => {
|
||||
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB`
|
||||
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
|
||||
return `${kb} KB`
|
||||
}
|
||||
|
||||
export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) {
|
||||
const t = useT()
|
||||
const [data, setData] = useState<ProcessesResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filter, setFilter] = useState("")
|
||||
const [selectedPid, setSelectedPid] = useState<number | null>(null)
|
||||
|
||||
const fetchProcesses = async (silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`)
|
||||
setData(res)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("details.processes.loadFailed"))
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
fetchProcesses()
|
||||
const id = setInterval(() => fetchProcesses(true), REFRESH_MS)
|
||||
return () => clearInterval(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, sort])
|
||||
|
||||
// Reset filter when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) setFilter("")
|
||||
}, [open])
|
||||
|
||||
// When no filter is set, the user just wants the top N by metric
|
||||
// (CPU usage or memory). When they type a query, they want EVERY
|
||||
// match — including processes that aren't in the top N — which is
|
||||
// why we over-fetch on the server.
|
||||
const allMatches = (data?.processes ?? []).filter((p) => {
|
||||
if (!filter) return true
|
||||
const q = filter.toLowerCase()
|
||||
return (
|
||||
p.command.toLowerCase().includes(q) ||
|
||||
(p.cmdline?.toLowerCase().includes(q) ?? false) ||
|
||||
p.user.toLowerCase().includes(q) ||
|
||||
String(p.pid).includes(q)
|
||||
)
|
||||
})
|
||||
const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT)
|
||||
|
||||
const Icon = sort === "cpu" ? Cpu : MemoryStick
|
||||
const title = sort === "cpu" ? t("details.processes.topByCpu") : t("details.processes.topByMemory")
|
||||
const description =
|
||||
sort === "cpu"
|
||||
? t("details.processes.cpuDescription")
|
||||
: t("details.processes.memoryDescription")
|
||||
|
||||
// Accent palette matched to the Overview cards: CPU Usage donut uses
|
||||
// blue (#3b82f6), Memory cached uses rgba(99,102,241,0.55) — we keep
|
||||
// the same hues so the modal feels like a continuation of the card.
|
||||
const accent = sort === "cpu"
|
||||
? { dot: "#3b82f6", bar: "#3b82f6", text: "text-blue-500" }
|
||||
: { dot: "#6366f1", bar: "#6366f1", text: "text-indigo-400" }
|
||||
|
||||
// Scale bars to the largest value in the (filtered) list so the visual
|
||||
// ranking is preserved even when no process is near 100 %. CPU can
|
||||
// exceed 100 % on multi-threaded apps — falling back to max=1 prevents
|
||||
// a divide-by-zero when the list is empty.
|
||||
const maxPrimary = Math.max(
|
||||
1,
|
||||
...filtered.map((p) => (sort === "cpu" ? p.cpu : p.mem))
|
||||
)
|
||||
|
||||
// Mobile drops PID + USER; desktop keeps the full 5-column layout.
|
||||
// CPU and MEM columns are wider on desktop with a real gap between
|
||||
// them so the two metrics don't feel glued together.
|
||||
const gridCols =
|
||||
"grid-cols-[minmax(0,1fr)_70px_90px] sm:grid-cols-[60px_96px_minmax(140px,1fr)_110px_120px]"
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-3xl"
|
||||
/* Prevent Radix from focusing the search Input on open — the
|
||||
auto-focus pops the on-screen keyboard on touch devices and
|
||||
covers half the modal. The user can still tap the field to
|
||||
start filtering. */
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon className={`h-5 w-5 ${accent.text}`} />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("details.processes.filterPlaceholder")}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="text-sm text-red-500 py-4">{error}</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[440px] border border-border rounded-md">
|
||||
<div className="min-w-full">
|
||||
{/* Sticky solid header so scrolled rows don't bleed through */}
|
||||
<div
|
||||
className={`grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground border-b border-border bg-card sticky top-0 z-10 ${gridCols}`}
|
||||
>
|
||||
<div className="hidden sm:block">{t("details.processes.pid")}</div>
|
||||
<div className="hidden sm:block truncate">{t("details.processes.user")}</div>
|
||||
<div>{t("details.processes.command")}</div>
|
||||
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>{t("details.processes.cpuPercent")}</div>
|
||||
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>
|
||||
{sort === "mem" ? t("details.processes.memory") : t("details.processes.memPercent")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && !loading ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
{t("details.processes.noMatches")}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((p) => {
|
||||
const primary = sort === "cpu" ? p.cpu : p.mem
|
||||
const barPct = Math.min(100, (primary / maxPrimary) * 100)
|
||||
return (
|
||||
<button
|
||||
key={p.pid}
|
||||
type="button"
|
||||
onClick={() => setSelectedPid(p.parent_pid ?? p.pid)}
|
||||
className={`w-full text-left grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 border-b border-border/40 hover:bg-white/5 transition-colors ${gridCols}`}
|
||||
>
|
||||
<div className="hidden sm:flex font-mono text-xs items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: accent.dot }}
|
||||
/>
|
||||
<span className="truncate">{p.pid}</span>
|
||||
</div>
|
||||
<div className="hidden sm:block font-mono text-xs truncate" title={p.user}>{p.user}</div>
|
||||
<div className="font-mono text-xs truncate min-w-0 flex items-center gap-1.5" title={p.cmdline || p.command}>
|
||||
{/* Mobile only: keep the accent dot since PID column is gone */}
|
||||
<span
|
||||
className="sm:hidden w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: accent.dot }}
|
||||
/>
|
||||
<span className="truncate">{p.command}</span>
|
||||
</div>
|
||||
|
||||
{/* Primary metric: value + sized progress bar in the accent colour */}
|
||||
{sort === "cpu" ? (
|
||||
<div className="flex flex-col items-end gap-1 min-w-0">
|
||||
<span className={`font-mono text-sm font-semibold ${accent.text}`}>{p.cpu.toFixed(1)}</span>
|
||||
{/* Show lifetime average only when it
|
||||
materially differs from the live
|
||||
sample (process consumes a steady
|
||||
baseline but was idle at sample time
|
||||
— orphaned bash loop, polling daemon).
|
||||
The 0.5 / 1.5x thresholds skip cases
|
||||
where avg and now match within sampler
|
||||
noise. */}
|
||||
{typeof p.cpu_avg === "number" && p.cpu_avg >= 0.5 && p.cpu_avg > p.cpu * 1.5 && (
|
||||
<span className="font-mono text-[10px] text-amber-400" title={t("details.processes.lifetimeAverageTitle")}>
|
||||
{t("details.processes.averageShort")} {p.cpu_avg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: accent.bar }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-xs text-right text-muted-foreground">{p.cpu.toFixed(1)}</div>
|
||||
)}
|
||||
|
||||
{/* Secondary column: mem % when CPU is primary, RSS when memory is primary */}
|
||||
{sort === "cpu" ? (
|
||||
<div className="font-mono text-xs text-right text-muted-foreground">{p.mem.toFixed(1)}</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-1 min-w-0">
|
||||
<span className={`font-mono text-sm font-semibold ${accent.text}`}>{formatRss(p.rss_kb)}</span>
|
||||
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: accent.bar }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{data?.captured_at && (
|
||||
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
||||
{t("details.processes.captured", { time: new Date(data.captured_at * 1000).toLocaleTimeString() })} · {filter
|
||||
? t(allMatches.length === 1 ? "details.processes.matchCount" : "details.processes.matchesCount", {
|
||||
count: allMatches.length,
|
||||
total: data.processes.length,
|
||||
})
|
||||
: t("details.processes.topCount", {
|
||||
shown: filtered.length,
|
||||
total: data.processes.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ProcessInfoModal
|
||||
pid={selectedPid}
|
||||
accent={accent}
|
||||
onClose={() => setSelectedPid(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { ScrollArea } from "./ui/scroll-area"
|
||||
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface ProcessDetail {
|
||||
pid: number
|
||||
comm: string
|
||||
cmdline: string
|
||||
exe: string | null
|
||||
cwd: string | null
|
||||
state: string
|
||||
ppid: number
|
||||
parent_name: string | null
|
||||
threads: number
|
||||
vm_rss_kb: number
|
||||
vm_size_kb: number
|
||||
vm_swap_kb: number
|
||||
user: string
|
||||
group: string
|
||||
uid: number
|
||||
gid: number
|
||||
start_time: string | null
|
||||
elapsed: string | null
|
||||
cpu: number
|
||||
mem: number
|
||||
io_read_bytes: number | null
|
||||
io_write_bytes: number | null
|
||||
fd_count: number | null
|
||||
captured_at: number
|
||||
}
|
||||
|
||||
interface ProcessInfoModalProps {
|
||||
pid: number | null
|
||||
accent: { dot: string; bar: string; text: string }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const REFRESH_MS = 3000
|
||||
|
||||
const formatKb = (kb: number | null | undefined): string => {
|
||||
if (kb == null) return "—"
|
||||
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB`
|
||||
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
|
||||
return `${kb} KB`
|
||||
}
|
||||
|
||||
const formatBytes = (b: number | null | undefined): string => {
|
||||
if (b == null) return "—"
|
||||
if (b >= 1024 * 1024 * 1024) return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`
|
||||
if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||
return `${b} B`
|
||||
}
|
||||
|
||||
// Linux process states from /proc/<pid>/status. The first char of `State:`
|
||||
// is the canonical letter — the rest of the field is a human label like
|
||||
// "(running)". We expand the bare letter to something readable.
|
||||
const stateLabel = (state: string, t: (key: string) => string): string => {
|
||||
const rawLetter = (state || "").trim().charAt(0)
|
||||
const letter = rawLetter.toUpperCase()
|
||||
const map: Record<string, string> = {
|
||||
R: "running",
|
||||
S: "sleeping",
|
||||
D: "diskWait",
|
||||
Z: "zombie",
|
||||
T: rawLetter === "t" ? "tracingStop" : "stopped",
|
||||
X: "dead",
|
||||
I: "idle",
|
||||
}
|
||||
const key = map[letter]
|
||||
return key ? t(`details.processInfo.states.${key}`) : state || "—"
|
||||
}
|
||||
|
||||
export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) {
|
||||
const { language, t } = useI18n()
|
||||
const [data, setData] = useState<ProcessDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exited, setExited] = useState(false)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const open = pid != null
|
||||
|
||||
const stopPolling = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDetail = async (silent = false) => {
|
||||
if (pid == null) return
|
||||
if (!silent) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchApi<ProcessDetail>(`/api/processes/${pid}`)
|
||||
setData(res)
|
||||
} catch (e: any) {
|
||||
// 404 = the process exited while the modal was open. Expected for
|
||||
// short-lived helpers (pct exec, backup subprocesses, the `ps` snapshot
|
||||
// itself). Keep the last good snapshot on screen, stop polling, and
|
||||
// surface an info banner — NOT an error — so it doesn't look like a bug.
|
||||
if (e?.message?.includes("404")) {
|
||||
setExited(true)
|
||||
stopPolling()
|
||||
} else {
|
||||
setError(t("details.processInfo.fetchFailed"))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pid == null) {
|
||||
setData(null)
|
||||
setError(null)
|
||||
setExited(false)
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
setExited(false)
|
||||
fetchDetail()
|
||||
intervalRef.current = setInterval(() => fetchDetail(true), REFRESH_MS)
|
||||
return () => stopPolling()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pid])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ background: accent.dot }}
|
||||
/>
|
||||
<span className="truncate font-mono text-base">{data?.comm || t("details.processInfo.titleFallback")}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{exited
|
||||
? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
|
||||
: t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Info banner when the process has finished. Amber, not red — this is
|
||||
expected behavior for short-lived processes, not an error. */}
|
||||
{exited && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/10 text-xs text-amber-300">
|
||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium text-amber-200">{t("details.processInfo.finishedTitle")}</div>
|
||||
<div className="text-amber-300/80 mt-0.5">
|
||||
{t("details.processInfo.finishedDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !data ? (
|
||||
<div className="text-sm text-red-500 py-4">{error}</div>
|
||||
) : !data ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{loading ? t("details.processInfo.loading") : "—"}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Overview */}
|
||||
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title={t("details.processInfo.sections.overview")}>
|
||||
<Row label={t("details.processInfo.labels.state")} value={exited ? t("details.processInfo.states.exited") : stateLabel(data.state, t)} />
|
||||
<Row label={t("details.processInfo.labels.parent")} value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
|
||||
<Row label={t("details.processInfo.labels.threads")} value={String(data.threads)} mono />
|
||||
<Row label={t("details.processInfo.labels.openFds")} value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
|
||||
<Row label={t("details.processInfo.labels.user")} value={`${data.user} (${data.uid})`} mono />
|
||||
<Row label={t("details.processInfo.labels.group")} value={`${data.group} (${data.gid})`} mono />
|
||||
</Section>
|
||||
|
||||
{/* Resources */}
|
||||
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title={t("details.processInfo.sections.resources")}>
|
||||
<Row label={t("details.processInfo.labels.cpu")} value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
|
||||
<Row label={t("details.processInfo.labels.memory")} value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
|
||||
<Row label={t("details.processInfo.labels.residentRss")} value={formatKb(data.vm_rss_kb)} mono />
|
||||
<Row label={t("details.processInfo.labels.virtualSize")} value={formatKb(data.vm_size_kb)} mono />
|
||||
<Row label={t("details.processInfo.labels.swap")} value={formatKb(data.vm_swap_kb)} mono />
|
||||
<Row label={t("details.processInfo.labels.ioRead")} value={formatBytes(data.io_read_bytes)} mono />
|
||||
<Row label={t("details.processInfo.labels.ioWrite")} value={formatBytes(data.io_write_bytes)} mono />
|
||||
</Section>
|
||||
|
||||
{/* Command */}
|
||||
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title={t("details.processInfo.sections.command")}>
|
||||
<Row label={t("details.processInfo.labels.name")} value={data.comm} mono />
|
||||
<Row label={t("details.processInfo.labels.commandLine")} value={data.cmdline || data.comm} mono wrap />
|
||||
<Row label={t("details.processInfo.labels.executable")} value={data.exe || "—"} mono wrap />
|
||||
<Row label={t("details.processInfo.labels.workingDir")} value={data.cwd || "—"} mono wrap />
|
||||
</Section>
|
||||
|
||||
{/* Times */}
|
||||
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title={t("details.processInfo.sections.lifetime")}>
|
||||
<Row label={t("details.processInfo.labels.started")} value={data.start_time || "—"} mono />
|
||||
<Row label={t("details.processInfo.labels.runningFor")} value={data.elapsed || "—"} mono />
|
||||
</Section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{data?.captured_at && (
|
||||
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
||||
{exited ? t("details.processInfo.lastSeen") : t("details.processInfo.captured")}{" "}
|
||||
{new Date(data.captured_at * 1000).toLocaleTimeString(language)}
|
||||
{error ? ` · ${error}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-card text-xs font-medium uppercase tracking-wider text-muted-foreground border-b border-border">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
wrap,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
mono?: boolean
|
||||
wrap?: boolean
|
||||
valueClass?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[110px_minmax(0,1fr)] gap-2 px-3 py-1.5 text-xs">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<div
|
||||
className={`${mono ? "font-mono" : ""} ${wrap ? "break-all" : "truncate"} ${valueClass || ""}`}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
User as UserIcon,
|
||||
Upload,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
Lock,
|
||||
X,
|
||||
Settings2,
|
||||
CheckCircle2,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface ProfileData {
|
||||
success: boolean
|
||||
username?: string | null
|
||||
display_name?: string | null
|
||||
has_avatar?: boolean
|
||||
avatar_mtime?: number | null
|
||||
avatar_content_type?: string | null
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface ProfileProps {
|
||||
/** Optional navigation hook so the page can link to Security for
|
||||
* password / 2FA changes without redirecting through a URL. */
|
||||
onOpenSecurity?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile page (Fase 2, v1.2.2).
|
||||
*
|
||||
* Lets the operator edit their **display name** and upload / remove
|
||||
* their **avatar**. Username is read-only (changing it requires
|
||||
* disabling and reconfiguring auth from Security). Password / 2FA
|
||||
* are intentionally not editable from this page — those live in
|
||||
* Security to keep the "account security" surface in one place.
|
||||
*
|
||||
* Layout: centered, two cards (Profile + Account security shortcut).
|
||||
* Display name uses the same Edit / Save / Cancel pattern as the
|
||||
* Health Thresholds / Notifications panels — read-only by default,
|
||||
* the operator hits Edit to start typing.
|
||||
*/
|
||||
export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
const t = useT()
|
||||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Display name: read-only by default, editable after pressing Edit.
|
||||
// Mirrors the editMode pattern used in HealthThresholds / Notifications
|
||||
// so the operator never types into a field that isn't ready to be saved.
|
||||
const [displayEditMode, setDisplayEditMode] = useState(false)
|
||||
const [displayDraft, setDisplayDraft] = useState("")
|
||||
const [savingDisplay, setSavingDisplay] = useState(false)
|
||||
const [savedDisplay, setSavedDisplay] = useState(false)
|
||||
|
||||
// Avatar state.
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null)
|
||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await fetchApi<ProfileData>("/api/auth/profile")
|
||||
setProfile(data)
|
||||
setDisplayDraft(data.display_name || "")
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile()
|
||||
}, [])
|
||||
|
||||
// Avatar fetch. Same blob-URL pattern as in AvatarMenu — the endpoint
|
||||
// requires the Bearer header, which <img src=…> can't send. Plain
|
||||
// `<img>` would render a broken image icon (the bug the user reported).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let currentBlobUrl: string | null = null
|
||||
if (profile?.has_avatar) {
|
||||
const token = getAuthToken()
|
||||
const url = `${getApiUrl("/api/auth/profile/avatar")}?v=${profile.avatar_mtime || ""}`
|
||||
fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
.then(r => (r.ok ? r.blob() : null))
|
||||
.then(blob => {
|
||||
if (cancelled || !blob) return
|
||||
currentBlobUrl = URL.createObjectURL(blob)
|
||||
setAvatarBlobUrl(currentBlobUrl)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAvatarBlobUrl(null)
|
||||
})
|
||||
} else {
|
||||
setAvatarBlobUrl(null)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl)
|
||||
}
|
||||
}, [profile?.has_avatar, profile?.avatar_mtime])
|
||||
|
||||
const initial = (profile?.display_name || profile?.username || "U")
|
||||
.trim()
|
||||
.charAt(0)
|
||||
.toUpperCase()
|
||||
|
||||
const hasDisplayChanges = displayDraft !== (profile?.display_name || "")
|
||||
|
||||
const handleEditDisplay = () => {
|
||||
setDisplayEditMode(true)
|
||||
setSavedDisplay(false)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleCancelDisplay = () => {
|
||||
setDisplayDraft(profile?.display_name || "")
|
||||
setDisplayEditMode(false)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleSaveDisplayName = async () => {
|
||||
if (!hasDisplayChanges) {
|
||||
setDisplayEditMode(false)
|
||||
return
|
||||
}
|
||||
setSavingDisplay(true)
|
||||
setError(null)
|
||||
setSavedDisplay(false)
|
||||
try {
|
||||
const data = await fetchApi<ProfileData>("/api/auth/profile", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ display_name: displayDraft }),
|
||||
})
|
||||
if (!data.success) {
|
||||
setError(data.message || t("profilePage.errors.saveDisplayNameFailed"))
|
||||
return
|
||||
}
|
||||
setProfile(data)
|
||||
setDisplayEditMode(false)
|
||||
setSavedDisplay(true)
|
||||
setTimeout(() => setSavedDisplay(false), 2500)
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("proxmenux:profile-changed"))
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSavingDisplay(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarPick = () => fileInputRef.current?.click()
|
||||
|
||||
const handleAvatarFile = async (file: File) => {
|
||||
setUploadingAvatar(true)
|
||||
setAvatarError(null)
|
||||
try {
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`
|
||||
// Raw upload (Content-Type = the image's own MIME) — simpler than
|
||||
// multipart and the backend handles both.
|
||||
headers["Content-Type"] = file.type
|
||||
const r = await fetch(getApiUrl("/api/auth/profile/avatar"), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: file,
|
||||
})
|
||||
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
||||
if (!r.ok || !data.success) {
|
||||
setAvatarError(data.message || t("profilePage.errors.uploadFailed", { status: r.status }))
|
||||
return
|
||||
}
|
||||
setProfile(data)
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("proxmenux:profile-changed"))
|
||||
}
|
||||
} catch (e) {
|
||||
setAvatarError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setUploadingAvatar(false)
|
||||
// Reset the input so picking the same file twice in a row still
|
||||
// fires the change event.
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
setUploadingAvatar(true)
|
||||
setAvatarError(null)
|
||||
try {
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`
|
||||
const r = await fetch(getApiUrl("/api/auth/profile/avatar"), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
})
|
||||
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
||||
if (!r.ok || !data.success) {
|
||||
setAvatarError(data.message || t("profilePage.errors.deleteFailed", { status: r.status }))
|
||||
return
|
||||
}
|
||||
setProfile(data)
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("proxmenux:profile-changed"))
|
||||
}
|
||||
} catch (e) {
|
||||
setAvatarError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setUploadingAvatar(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
{t("profilePage.loading")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !profile) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-2 text-red-500">
|
||||
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium">{t("profilePage.loadFailed")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 break-all">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{/* Edit / Save / Cancel sit in the card header — same pattern
|
||||
as Health Thresholds and Notifications. Avatar actions
|
||||
(upload / remove) stay independent of editMode because
|
||||
they're explicit one-shot actions, not field edits. */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="h-5 w-5 text-cyan-500" />
|
||||
<CardTitle>{t("profilePage.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{savedDisplay && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{displayEditMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelDisplay}
|
||||
disabled={savingDisplay}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDisplayName}
|
||||
disabled={savingDisplay || !hasDisplayChanges}
|
||||
className="h-7 text-xs bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{savingDisplay ? (
|
||||
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3 w-3 mr-1.5" />
|
||||
)}
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditDisplay}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Settings2 className="h-3 w-3 mr-1.5" />
|
||||
{t("actions.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t("profilePage.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-8">
|
||||
{/* ─── Avatar section ──────────────────────────────────────
|
||||
Big preview (160×160) so the operator can see the actual
|
||||
image they uploaded. `object-cover` keeps the aspect
|
||||
ratio and crops to fit the circle. */}
|
||||
<div>
|
||||
<Label className="text-sm">{t("profilePage.avatar.label")}</Label>
|
||||
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
|
||||
<div className="relative shrink-0">
|
||||
{avatarBlobUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={avatarBlobUrl}
|
||||
alt=""
|
||||
className="w-40 h-40 rounded-full object-cover border border-border bg-cyan-500/5"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-40 h-40 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-6xl font-semibold border border-border">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
{uploadingAvatar && (
|
||||
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleAvatarFile(file)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAvatarPick}
|
||||
disabled={uploadingAvatar}
|
||||
className="justify-start"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 mr-2" />
|
||||
{profile?.has_avatar ? t("profilePage.avatar.replace") : t("profilePage.avatar.upload")}
|
||||
</Button>
|
||||
{profile?.has_avatar && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAvatarDelete}
|
||||
disabled={uploadingAvatar}
|
||||
className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
{t("profilePage.avatar.remove")}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
|
||||
{t("profilePage.avatar.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{avatarError && (
|
||||
<div className="mt-3 text-xs text-red-500 flex items-start gap-1.5">
|
||||
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||
<span className="break-all">{avatarError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Username (read-only) ─── */}
|
||||
<div>
|
||||
<Label className="text-sm" htmlFor="profile-username">{t("profilePage.username.label")}</Label>
|
||||
<Input
|
||||
id="profile-username"
|
||||
value={profile?.username || ""}
|
||||
disabled
|
||||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
{t("profilePage.username.help")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ─── Display name (Edit controls live in the card header) ─── */}
|
||||
<div>
|
||||
<Label className="text-sm" htmlFor="profile-display">
|
||||
{t("profilePage.displayName.label")} <span className="text-muted-foreground font-normal">{t("profilePage.displayName.optional")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-display"
|
||||
value={displayDraft}
|
||||
onChange={(e) => setDisplayDraft(e.target.value)}
|
||||
placeholder={profile?.username || t("profilePage.displayName.placeholder")}
|
||||
maxLength={64}
|
||||
disabled={!displayEditMode || savingDisplay}
|
||||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
{t("profilePage.displayName.help")}
|
||||
</p>
|
||||
{error && displayEditMode && (
|
||||
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
|
||||
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||
<span className="break-all">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Account security shortcut ─── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-orange-500" />
|
||||
<CardTitle>{t("profilePage.accountSecurity.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t("profilePage.accountSecurity.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{onOpenSecurity ? (
|
||||
<Button variant="outline" onClick={onOpenSecurity}>
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
{t("profilePage.accountSecurity.openSecurity")}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profilePage.accountSecurity.fallback")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
||||
@@ -8,15 +9,20 @@ import { SystemOverview } from "./system-overview"
|
||||
import { StorageOverview } from "./storage-overview"
|
||||
import { NetworkMetrics } from "./network-metrics"
|
||||
import { VirtualMachines } from "./virtual-machines"
|
||||
import { AppsDashboard } from "./apps-dashboard"
|
||||
import Hardware from "./hardware"
|
||||
import { SystemLogs } from "./system-logs"
|
||||
import { Settings } from "./settings"
|
||||
import { Security } from "./security"
|
||||
import { Profile } from "./profile"
|
||||
import { About } from "./about"
|
||||
import { HostBackup } from "./host-backup"
|
||||
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 { AvatarMenu } from "./avatar-menu"
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
@@ -27,17 +33,30 @@ import {
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
NetworkIcon,
|
||||
Box,
|
||||
Boxes,
|
||||
Grid3x3,
|
||||
Cpu,
|
||||
FileText,
|
||||
ScrollText,
|
||||
SettingsIcon,
|
||||
Settings2,
|
||||
Terminal,
|
||||
ShieldCheck,
|
||||
Info,
|
||||
DatabaseBackup,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { APP_VERSION } from "../lib/version"
|
||||
import { useTabOrder, firstActualTab, type TabId } from "../lib/tab-order"
|
||||
|
||||
interface SystemStatus {
|
||||
status: "healthy" | "warning" | "critical"
|
||||
@@ -66,7 +85,19 @@ interface FlaskSystemInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Prefetch on dashboard mount: SWR caches by key across all
|
||||
// `useSWR` calls, so firing these here means the Apps tab finds the
|
||||
// data already resolved when it opens. Without this, the tab pays a
|
||||
// visible roundtrip on first render because VirtualMachines has been
|
||||
// warming /api/vms since page load but nobody was warming the custom
|
||||
// links endpoint.
|
||||
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
||||
|
||||
export function ProxmoxDashboard() {
|
||||
const t = useT()
|
||||
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
const { order: tabOrder } = useTabOrder()
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||
status: "healthy",
|
||||
uptime: "Loading...",
|
||||
@@ -79,12 +110,21 @@ export function ProxmoxDashboard() {
|
||||
const [componentKey, setComponentKey] = useState(0)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
// On first mount, land on whatever the user's custom order says is
|
||||
// the leading tab. localStorage isn't available during SSR so this
|
||||
// runs post-hydration; the tiny flash is acceptable and matches the
|
||||
// pattern next-themes uses for the same reason.
|
||||
useEffect(() => {
|
||||
setActiveTab(firstActualTab())
|
||||
}, [])
|
||||
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()
|
||||
const displayServerName = systemStatus.serverName === "Loading..." ? t("app.loading") : systemStatus.serverName
|
||||
const displayUptime = systemStatus.uptime === "Loading..." ? t("app.loading") : systemStatus.uptime || t("app.notAvailable")
|
||||
|
||||
// Category keys for health info count calculation
|
||||
const HEALTH_CATEGORY_KEYS = [
|
||||
@@ -155,7 +195,7 @@ export function ProxmoxDashboard() {
|
||||
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
||||
|
||||
const uptimeValue =
|
||||
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
|
||||
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : t("app.notAvailable")
|
||||
|
||||
const backendStatus = data.health?.status?.toUpperCase() || "OK"
|
||||
let healthStatus: "healthy" | "warning" | "critical"
|
||||
@@ -172,8 +212,8 @@ export function ProxmoxDashboard() {
|
||||
status: healthStatus,
|
||||
uptime: uptimeValue,
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
serverName: data.hostname || "Unknown",
|
||||
nodeId: data.node_id || "Unknown",
|
||||
serverName: data.hostname || t("app.unknown"),
|
||||
nodeId: data.node_id || t("app.unknown"),
|
||||
})
|
||||
setIsServerConnected(true)
|
||||
} catch (error) {
|
||||
@@ -183,13 +223,13 @@ export function ProxmoxDashboard() {
|
||||
setSystemStatus((prev) => ({
|
||||
...prev,
|
||||
status: "critical",
|
||||
serverName: "Server Offline",
|
||||
nodeId: "Server Offline",
|
||||
uptime: "N/A",
|
||||
serverName: t("app.serverOffline"),
|
||||
nodeId: t("app.serverOffline"),
|
||||
uptime: t("app.notAvailable"),
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
// Siempre fetch inicial
|
||||
@@ -281,13 +321,13 @@ export function ProxmoxDashboard() {
|
||||
if (
|
||||
systemStatus.serverName &&
|
||||
systemStatus.serverName !== "Loading..." &&
|
||||
systemStatus.serverName !== "Server Offline"
|
||||
systemStatus.serverName !== t("app.serverOffline")
|
||||
) {
|
||||
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
|
||||
} else {
|
||||
document.title = "ProxMenux Monitor"
|
||||
}
|
||||
}, [systemStatus.serverName])
|
||||
}, [systemStatus.serverName, t])
|
||||
|
||||
useEffect(() => {
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -349,26 +389,20 @@ export function ProxmoxDashboard() {
|
||||
|
||||
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"
|
||||
case "overview": return t("navigation.overview")
|
||||
case "apps": return t("navigation.apps")
|
||||
case "vms": return t("navigation.virtualMachines")
|
||||
case "storage": return t("navigation.storage")
|
||||
case "network": return t("navigation.network")
|
||||
case "hardware": return t("navigation.hardware")
|
||||
case "backup": return t("navigation.backup")
|
||||
case "terminal": return t("navigation.terminal")
|
||||
case "logs": return t("navigation.systemLogs")
|
||||
case "security": return t("navigation.security")
|
||||
case "settings": return t("navigation.settings")
|
||||
case "about": return t("navigation.about")
|
||||
case "profile": return t("navigation.profile")
|
||||
default: return t("navigation.menu")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,13 +416,13 @@ export function ProxmoxDashboard() {
|
||||
<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>
|
||||
<span className="font-medium">{t("status.connectionFailed")}</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>• {t("status.checkService")}</p>
|
||||
<p>• {t("status.serverPort")}</p>
|
||||
<p>
|
||||
• Try accessing:{" "}
|
||||
• {t("status.tryAccessing")}{" "}
|
||||
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{getApiUrl("/api/health")}
|
||||
</a>
|
||||
@@ -427,11 +461,11 @@ export function ProxmoxDashboard() {
|
||||
<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>
|
||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</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>
|
||||
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,14 +475,14 @@ export function ProxmoxDashboard() {
|
||||
<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 className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
||||
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
|
||||
</Badge>
|
||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||
@@ -459,7 +493,7 @@ export function ProxmoxDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Uptime: {systemStatus.uptime || "N/A"}
|
||||
{t("status.uptime", { uptime: displayUptime })}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -473,50 +507,80 @@ export function ProxmoxDashboard() {
|
||||
className="border-border/50 bg-transparent hover:bg-secondary"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t("actions.refresh")}
|
||||
</Button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* User account dropdown — Fase 1 (v1.2.2). Self-hides
|
||||
when auth isn't enabled on this install. */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<AvatarMenu
|
||||
size="lg"
|
||||
onOpenProfile={() => setActiveTab("profile")}
|
||||
onOpenSecurity={() => setActiveTab("security")}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Mobile Actions — variant D approved in demo:
|
||||
• Top-right: Refresh + Theme + Avatar (all with border)
|
||||
• Bottom row (under Node line): badges left-aligned with
|
||||
the Node text column, Uptime right-aligned in the same
|
||||
horizontal line. No extra row for Uptime so the
|
||||
header doesn't grow vertically. */}
|
||||
<div className="flex lg:hidden items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 w-8 p-0 -mt-1"
|
||||
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
|
||||
aria-label={t("actions.refresh")}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()} className="-mt-1">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<AvatarMenu
|
||||
size="lg"
|
||||
onOpenProfile={() => setActiveTab("profile")}
|
||||
onOpenSecurity={() => setActiveTab("security")}
|
||||
/>
|
||||
</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>
|
||||
{/* Mobile bottom row — badges (left, aligned with the title
|
||||
column via pl-[3.25rem] = w-16 logo + space-x-2 gap-ish)
|
||||
and Uptime (right). The pl matches the mobile logo width
|
||||
+ the parent flex gap so the badges sit visually under
|
||||
"Node: amd", not flush against the screen edge. */}
|
||||
<div className="lg:hidden mt-2 flex items-center justify-between gap-2 pl-[4.5rem]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||
{statusIcon}
|
||||
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
|
||||
</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-3 w-3" />
|
||||
<span className="ml-1">{infoCount}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t("status.uptime", { uptime: displayUptime })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -530,62 +594,115 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
<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>
|
||||
{/* Sprint 13D nav redesign — 6 top-level slots in usage order:
|
||||
Overview · VMs & LXCs · Node ⌄ · Backup · Terminal · Admin ⌄
|
||||
Node groups Storage / Network / Hardware (3 sub-items).
|
||||
Admin groups System Logs / Security / Settings / About
|
||||
(will split when RBAC arrives in 1.5.0).
|
||||
Backup is direct now (only Host Backup); becomes a dropdown
|
||||
when VM/LXC centralised backup ships. */}
|
||||
{(() => {
|
||||
const triggerActiveClass =
|
||||
"data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
// Each dropdown lists its children in the order they
|
||||
// render. When one of them is the active tab, the dropdown
|
||||
// trigger swaps its label + icon to that child — same
|
||||
// pattern macOS Settings uses inside a category: the
|
||||
// crumb shows where you are, the chevron tells you the
|
||||
// siblings are one click away.
|
||||
const NODE_ITEMS = [
|
||||
{ value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false },
|
||||
{ value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false },
|
||||
{ value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false },
|
||||
]
|
||||
const ADMIN_ITEMS = [
|
||||
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
|
||||
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
|
||||
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
||||
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
||||
]
|
||||
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
||||
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
||||
const isNodeActive = activeNodeItem !== undefined
|
||||
const isAdminActive = activeAdminItem !== undefined
|
||||
// The trigger label + icon shown on the bar. When a child
|
||||
// is active we surface IT; otherwise the group default.
|
||||
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
|
||||
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : t("navigation.node")
|
||||
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
|
||||
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : t("navigation.admin")
|
||||
// Dropdown trigger styling: parity with TabsTrigger so the
|
||||
// parent visibly carries the "I'm the selected section"
|
||||
// signal when any of its children is the active tab —
|
||||
// same blue background + white text + rounded as a direct
|
||||
// tab. Without this the user lands on Storage and the
|
||||
// entire top bar looks idle.
|
||||
const dropdownBtnClass = (active: boolean) =>
|
||||
`inline-flex items-center justify-center whitespace-nowrap 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 ${
|
||||
active
|
||||
? "bg-blue-500 text-white rounded-md"
|
||||
: "text-muted-foreground hover:text-foreground rounded-sm"
|
||||
}`
|
||||
|
||||
// Data-driven TabsList: iterate over the user's saved
|
||||
// top-level order. Each slot is either a direct tab or a
|
||||
// dropdown group (Node/Admin). The internal items of a
|
||||
// dropdown are never reordered by the user — a grouped
|
||||
// slot moves as a unit.
|
||||
const renderDirect = (
|
||||
value: string,
|
||||
Icon: React.ComponentType<{ className?: string }>,
|
||||
label: string,
|
||||
) => (
|
||||
<TabsTrigger key={value} value={value} className={triggerActiveClass}>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
)
|
||||
const renderDropdown = (
|
||||
key: string,
|
||||
items: { value: string; label: string; Icon: React.ComponentType<{ className?: string }> }[],
|
||||
active: boolean,
|
||||
TriggerIcon: React.ComponentType<{ className?: string }>,
|
||||
triggerLabel: string,
|
||||
) => (
|
||||
<DropdownMenu key={key}>
|
||||
<DropdownMenuTrigger className={dropdownBtnClass(active)}>
|
||||
<TriggerIcon className="mr-2 h-4 w-4" />
|
||||
{triggerLabel}
|
||||
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[180px]">
|
||||
{items.map(({ value, label, Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => setActiveTab(value)}
|
||||
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
const renderTop = (id: TabId) => {
|
||||
switch (id) {
|
||||
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
|
||||
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
|
||||
case "terminal": return renderDirect("terminal", Terminal, t("navigation.terminal"))
|
||||
case "node": return renderDropdown("node", NODE_ITEMS, isNodeActive, NodeTriggerIcon, NodeTriggerLabel)
|
||||
case "admin": return renderDropdown("admin", ADMIN_ITEMS, isAdminActive, AdminTriggerIcon, AdminTriggerLabel)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
|
||||
{tabOrder.map(renderTop)}
|
||||
</TabsList>
|
||||
)
|
||||
})()}
|
||||
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<div className="lg:hidden">
|
||||
@@ -602,143 +719,70 @@ export function ProxmoxDashboard() {
|
||||
</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"
|
||||
{(() => {
|
||||
// Sheet items mirror the desktop layout: 6 sections,
|
||||
// with two of them (Node, Admin) collapsing into a
|
||||
// header + nested items. Direct tabs (Overview, VMs,
|
||||
// Backup, Terminal) sit at the top level.
|
||||
const select = (v: string) => {
|
||||
setActiveTab(v)
|
||||
setMobileMenuOpen(false)
|
||||
}
|
||||
const itemClass = (active: boolean) =>
|
||||
`w-full justify-start gap-3 ${
|
||||
active
|
||||
? "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>
|
||||
}`
|
||||
// Mobile sheet honours the same user-defined
|
||||
// top-level order as the desktop TabsList. Grouped
|
||||
// slots (Node/Admin) expand their child items
|
||||
// inline right after their position — the group
|
||||
// still moves as a unit, but children stay grouped.
|
||||
const btn = (
|
||||
value: string,
|
||||
Icon: React.ComponentType<{ className?: string }>,
|
||||
label: string,
|
||||
) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant="ghost"
|
||||
onClick={() => select(value)}
|
||||
className={itemClass(activeTab === value)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mt-4">
|
||||
{tabOrder.map((id): React.ReactNode => {
|
||||
switch (id) {
|
||||
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
|
||||
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
|
||||
case "terminal": return btn("terminal", Terminal, t("navigation.terminal"))
|
||||
case "node": return (
|
||||
<React.Fragment key="node">
|
||||
{btn("storage", HardDrive, t("navigation.storage"))}
|
||||
{btn("network", NetworkIcon, t("navigation.network"))}
|
||||
{btn("hardware", Cpu, t("navigation.hardware"))}
|
||||
</React.Fragment>
|
||||
)
|
||||
case "admin": return (
|
||||
<React.Fragment key="admin">
|
||||
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
||||
{btn("security", ShieldCheck, t("navigation.security"))}
|
||||
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
||||
{btn("about", Info, t("navigation.about"))}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Tabs>
|
||||
@@ -746,11 +790,30 @@ export function ProxmoxDashboard() {
|
||||
</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">
|
||||
{/* No `space-y-*` here: only one TabsContent is visible at a
|
||||
time, but Overview stays force-mounted (hidden) as the
|
||||
first child, so every OTHER active tab used to inherit an
|
||||
extra top margin from the space-y utility — pushing the
|
||||
page content further from the nav than on Overview.
|
||||
Vertical spacing INSIDE each tab lives on its own
|
||||
TabsContent's `space-y-*`. */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||
{/* forceMount so SystemOverview mounts at dashboard load and
|
||||
never gets torn down when the user visits another tab.
|
||||
Without this, every return to Overview re-fires ~7 fetches
|
||||
(system, vms, storage, proxmox-storage, network, node
|
||||
metrics, network chart) and the user waited for the
|
||||
cascade to complete each time. With forceMount, the
|
||||
5 s / 59 s refresh intervals keep the data fresh in the
|
||||
background — reopening the tab is instant. */}
|
||||
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
|
||||
<SystemOverview key={`overview-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AppsDashboard key={`apps-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0">
|
||||
<StorageOverview key={`storage-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -759,7 +822,13 @@ export function ProxmoxDashboard() {
|
||||
<NetworkMetrics key={`network-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vms" className="space-y-4 md:space-y-6 mt-0">
|
||||
{/* forceMount so the modal-data prefetcher (inside VirtualMachines)
|
||||
starts warming caches from the moment the dashboard loads,
|
||||
not the first time the user clicks the VMs tab. Kept
|
||||
visually hidden with data-attribute selector when the tab
|
||||
is inactive — mount cost is ~zero (no polling loop that
|
||||
other components run). */}
|
||||
<TabsContent value="vms" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
|
||||
<VirtualMachines key={`vms-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -771,6 +840,10 @@ export function ProxmoxDashboard() {
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||
<HostBackup key={`backup-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -779,13 +852,27 @@ export function ProxmoxDashboard() {
|
||||
<Security key={`security-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Profile tab — not surfaced in the top tabs nav. The only
|
||||
entry point is the avatar dropdown in the header (View
|
||||
profile). v1.2.2 Fase 2. */}
|
||||
<TabsContent value="profile" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Profile
|
||||
key={`profile-${componentKey}`}
|
||||
onOpenSecurity={() => setActiveTab("security")}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Settings />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="about" className="space-y-4 md:space-y-6 mt-0">
|
||||
<About />
|
||||
</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 className="font-medium mb-2">ProxMenux Monitor v{APP_VERSION}</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
@@ -793,7 +880,7 @@ export function ProxmoxDashboard() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
|
||||
>
|
||||
Support and contribute to the project
|
||||
{t("app.supportProject")}
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Plus, Share, X } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// ==========================================================
|
||||
// PwaInstallPrompt
|
||||
// ==========================================================
|
||||
// Bottom-sheet shown on mobile when the Monitor is opened in
|
||||
// a browser (not launched as an installed PWA). Two variants:
|
||||
// iOS Safari → manual 3-step instructions
|
||||
// Android → generic "browser menu → Add to Home Screen"
|
||||
//
|
||||
// No `beforeinstallprompt` handling: capturing the event to
|
||||
// drive a custom Install button interacts badly with Chrome's
|
||||
// own "Add to Home Screen" menu — Chromium degrades the manual
|
||||
// path to a plain shortcut when a page has intercepted the
|
||||
// event but hasn't yet called `prompt()`. Installation goes
|
||||
// through the browser's own menu entry and produces a real PWA.
|
||||
//
|
||||
// Never shown on desktop, or when already running standalone.
|
||||
// Dismissal options:
|
||||
// "Not now" → temporary, hidden for 30 days
|
||||
// "Don't show again" → permanent (no expiry)
|
||||
// Backdrop / X → session-only dismiss (reappears on
|
||||
// the next page load)
|
||||
// ==========================================================
|
||||
|
||||
const DISMISSED_FOREVER_KEY = "proxmenux-install-dismissed"
|
||||
const DISMISSED_UNTIL_KEY = "proxmenux-install-dismissed-until"
|
||||
const NOT_NOW_DAYS = 30
|
||||
|
||||
function isMobileDevice(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
// Prefer feature detection (coarse pointer + touch) over UA sniffing,
|
||||
// and fall back to UA for the corner case where a mobile browser
|
||||
// reports fine pointer under a desktop-mode toggle.
|
||||
const coarse = window.matchMedia("(pointer: coarse)").matches
|
||||
const ua = navigator.userAgent
|
||||
const uaMobile = /Android|iPhone|iPad|iPod|Mobile|Opera Mini|BlackBerry|IEMobile/i.test(ua)
|
||||
return coarse || uaMobile
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
const displayModeStandalone = window.matchMedia("(display-mode: standalone)").matches
|
||||
const iosStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
return displayModeStandalone || iosStandalone
|
||||
}
|
||||
|
||||
function isIOS(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
const ua = navigator.userAgent
|
||||
// iPadOS 13+ reports as MacIntel — detect that too when maxTouchPoints > 1.
|
||||
const iPadMasqueradingAsMac =
|
||||
ua.includes("Macintosh") && (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints! > 1
|
||||
return /iPhone|iPad|iPod/i.test(ua) || iPadMasqueradingAsMac
|
||||
}
|
||||
|
||||
export function PwaInstallPrompt() {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (!isMobileDevice() || isStandalone()) return
|
||||
|
||||
try {
|
||||
if (localStorage.getItem(DISMISSED_FOREVER_KEY) === "1") return
|
||||
const untilRaw = localStorage.getItem(DISMISSED_UNTIL_KEY)
|
||||
if (untilRaw) {
|
||||
const until = Number.parseInt(untilRaw, 10)
|
||||
// Corrupt / non-numeric values fall through and the prompt shows,
|
||||
// which is the safe default.
|
||||
if (Number.isFinite(until) && until > Date.now()) return
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (private mode etc.) — treat as not dismissed.
|
||||
}
|
||||
|
||||
setPlatform(isIOS() ? "ios" : "android")
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleNotNow = useCallback(() => {
|
||||
try {
|
||||
const until = Date.now() + NOT_NOW_DAYS * 24 * 60 * 60 * 1000
|
||||
localStorage.setItem(DISMISSED_UNTIL_KEY, String(until))
|
||||
} catch {
|
||||
// Best-effort; if localStorage fails the user will see the prompt
|
||||
// again next visit, which is the safe default.
|
||||
}
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleNeverAgain = useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_FOREVER_KEY, "1")
|
||||
} catch {
|
||||
// Best-effort; if localStorage fails the user will see the prompt
|
||||
// again next visit, which is the safe default.
|
||||
}
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Session-only dismiss: closing via X or backdrop does NOT persist,
|
||||
// so the prompt reappears on the next page load. Users who want to
|
||||
// silence it for longer must use "Not now" (30 d) or "Don't show again".
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (!open || !platform) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pwa-install-title"
|
||||
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) handleClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-t-2xl bg-background text-foreground shadow-2xl border-t border-border animate-in slide-in-from-bottom duration-300"
|
||||
style={{ paddingBottom: "max(1.25rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="relative px-5 pt-5">
|
||||
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-border" aria-hidden="true" />
|
||||
{/* Hardened close button — the X wasn't reliably closing
|
||||
the sheet on iOS/Android. Fixes:
|
||||
* `stopPropagation` so the click never bubbles up to
|
||||
the backdrop handler (which was seeing the target
|
||||
and might have been running its own logic against
|
||||
the same tap on some mobile browsers).
|
||||
* `z-10` puts it above any sibling absolute layers
|
||||
(drag handle, headings) in case one silently ate
|
||||
the tap.
|
||||
* 40 × 40 hit area — comfortable Apple/Google minimum
|
||||
for a touch target; the 32 × 32 we had was fine on
|
||||
desktop but easy to miss with a thumb.
|
||||
* `onPointerDown` as a secondary handler covers the
|
||||
iOS Safari case where a fast tap on a nested
|
||||
`<button>` inside a `role="dialog"` can lose the
|
||||
click event to the parent overlay. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); handleClose() }}
|
||||
onPointerDown={(e) => { e.stopPropagation() }}
|
||||
aria-label={t("actions.close")}
|
||||
className="absolute right-2 top-2 z-10 flex h-10 w-10 items-center justify-center rounded-full text-muted-foreground hover:bg-muted active:bg-muted transition-colors touch-manipulation"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="mb-4 flex items-start gap-3.5">
|
||||
<div className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-xl bg-muted p-1 shadow-md">
|
||||
<img src="/icon.svg" alt="ProxMenux Monitor" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground">
|
||||
{t("pwaInstall.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] leading-snug text-muted-foreground">
|
||||
{platform === "ios"
|
||||
? t("pwaInstall.iosDescription")
|
||||
: t("pwaInstall.androidDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{platform === "ios" ? (
|
||||
<ol className="mb-4 flex flex-col gap-2" role="list">
|
||||
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
{t("pwaInstall.ios.stepShareBefore")}{" "}
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
||||
<Share className="h-4 w-4" aria-hidden="true" />
|
||||
{t("pwaInstall.ios.share")}
|
||||
</span>{" "}
|
||||
{t("pwaInstall.ios.stepShareAfter")}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||
2
|
||||
</span>
|
||||
<span>
|
||||
{t("pwaInstall.ios.stepChooseBefore")}{" "}
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
{t("pwaInstall.addToHomeScreen")}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||
3
|
||||
</span>
|
||||
<span>
|
||||
{t("pwaInstall.ios.stepConfirmBefore")} <b>{t("pwaInstall.ios.add")}</b> {t("pwaInstall.ios.stepConfirmAfter")}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
) : (
|
||||
<div className="mb-4 rounded-lg border border-border bg-muted/50 px-3.5 py-3 text-[13px] leading-relaxed text-muted-foreground">
|
||||
{t("pwaInstall.android.stepOpenMenu")} <b className="text-foreground">⋮</b> →{" "}
|
||||
<b className="text-foreground">{t("pwaInstall.addToHomeScreen")}</b> → {t("pwaInstall.android.stepConfirm")}{" "}
|
||||
<b className="text-foreground">{t("pwaInstall.android.install")}</b>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex flex-col gap-1 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNotNow}
|
||||
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{t("pwaInstall.notNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNeverAgain}
|
||||
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-amber-700 dark:text-amber-500 hover:bg-muted transition-colors"
|
||||
>
|
||||
{t("pwaInstall.neverAgain")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
// Unregister any Service Worker on this origin at mount. A SW here
|
||||
// interacts badly with mobile battery throttling behind reverse
|
||||
// proxies. `sw.js` is kept for a future PWA-offline revisit.
|
||||
export function PwaRegister() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (!("serviceWorker" in navigator)) return
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.then((regs) => {
|
||||
if (regs.length === 0) return
|
||||
return Promise.all(regs.map((r) => r.unregister()))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
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 { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.2.0" // Sync with AppImage/package.json
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { APP_VERSION } from "../lib/version"
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
@@ -18,6 +18,224 @@ interface ReleaseNote {
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.2.6": {
|
||||
date: "September 2, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Borg remote target — the Add Borg destination dialog in the Monitor and the shell TUI (menu → Host Backup → New Borg target) accept a custom SSH port; the default stays at 22 and existing entries created without a port keep working. BORG_RSH, the auto key install flow and the capacity probe all honour the custom port (suggested by @songochain in discussion #236).",
|
||||
"GitHub API — Settings → GitHub API accepts an optional personal access token for release and tag checks when GitHub's anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser (suggested by @SystemIdleProcess in discussion #306).",
|
||||
],
|
||||
changed: [
|
||||
"Notification delivery is atomic — events reserve their deduplication fingerprint before AI processing and channel delivery, so concurrent collectors or parallel Monitor processes cannot send the same event twice. The reservation is shared through SQLite and released when no channel succeeds, preserving retries after temporary transport failures.",
|
||||
"Native Proxmox replication failure notifications now resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job's failures deduplicate independently (reported by Ale R.).",
|
||||
],
|
||||
fixed: [
|
||||
"AI Assistant custom OpenAI endpoint — endpoints reachable on private IPs, loopback or Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies…) are accepted when loading the model catalogue and validating the AI configuration. The dropdown surfaces the reason returned by the server (or the underlying network error) directly under the Load button, in every Monitor language (#325, reported by @jorgeffonte).",
|
||||
"Secure Gateway wizard — Alpine template download and local template selection filter by the host's real architecture (via dpkg --print-architecture, falling back to uname -m); pct create is invoked with an explicit --arch so container metadata matches the host on both x86_64 and arm64 (#324, reported by @N0X4DD0).",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.5": {
|
||||
date: "September 1, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, sort by name/id/category, and one-click deep-links back to the guest modal (LXC cards land on App, VM cards on Status).",
|
||||
"Application detection catalog with over 380 tracked workloads, generated live from the community-scripts source across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
|
||||
"App tab inside every VM & LXC modal — register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships. Cold-start Docker detection now correctly promotes Docker as the parent workload before the daemon finishes booting.",
|
||||
"Reworked LXC Updates tab — apply OS packages and registered-app updates from a single button, schedule a recurring auto-update job, and cover Docker end to end (Engine + per-image on the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison).",
|
||||
"The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Thanks to @vaso73 for building the i18n scaffolding.",
|
||||
"Navigation order — a new Settings card lets each user reorder the seven top-level tabs by drag and drop. Grouped slots (Node, Admin) move as a single unit; the first slot in the saved order becomes the tab the Monitor opens on. Mouse and touch supported.",
|
||||
"Custom Web Links — persistent sidecar at /etc/proxmenux/custom_links.json for URLs that don't live inside a registered LXC app. Editor takes name, URL, optional logo, category and optional binding to a specific VM or LXC.",
|
||||
"NVIDIA — multi-GPU passthrough by exact BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs. Kernel + branch + GPU-aware version picker (#298).",
|
||||
"Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
|
||||
"Host backup/restore continuity — ProxMenux configuration and independent backups survive a full Proxmox restore (#317, reported by @tropicaljoe).",
|
||||
"Hardware temperature sensor identity — storage temperatures now identify the physical drive (NVMe namespace, model, serial) instead of the generic hwmon label. HDD/SSD classification follows the block device rotational flag (#315, suggested by @Dark-Witcher).",
|
||||
"Post-install — precise rollback for every registered flow and Debian 13 readiness.",
|
||||
"First-time visitors on Android and iOS Safari now see an in-app install prompt for adding the Monitor to their home screen as a PWA.",
|
||||
],
|
||||
changed: [
|
||||
"Memory & Swap health check now signals real memory pressure — CRITICAL fires only when swap file is nearly full AND available RAM is genuinely tight (both editable in Settings → Health thresholds). The old swap-only signal was noisy on hosts where Linux proactively swaps out inactive pages.",
|
||||
"Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
|
||||
"Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
|
||||
"VMs running the QEMU Guest Agent now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
|
||||
"App tab cache stays warm across every action. Successful add / edit / check / dismiss / delete operations write the returned sidecar directly into both backend and shared frontend caches; post-update scans revalidate in the background while the last valid content stays visible.",
|
||||
"Docker cards on the Apps dashboard resolve their update state per image, not per Docker Engine. The purple update arrow only lights up when that specific image has an upstream update.",
|
||||
"ZFS ARC sizing under memory pressure and OOM diagnostics reworked (credit: LeidenSpain).",
|
||||
],
|
||||
fixed: [
|
||||
"Fix #309 — Proxmox storage availability: false critical alerts for iSCSI storages with maxdisk=0.",
|
||||
"HID USB device class no longer reads as 'escondido' (past tense of 'to hide') in Spanish, German, French, Italian and Portuguese — the acronym is now preserved.",
|
||||
"The Memory & Swap health card is dismissable like every other category (RAM usage and Swap usage sub-checks carry the dismissable flag).",
|
||||
"Regenerated Proxmox VE Helper-Scripts updaters remain available after an app update (both historical wrappers and the current generated entrypoint are recognised).",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.4.1-beta": {
|
||||
date: "August 17, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||
"New App tab inside the VM & LXC modal — especially for LXCs. Register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships.",
|
||||
"Reworked Updates tab for LXCs: apply OS packages and registered-app updates from a single button, and schedule a recurring auto-update job that checks the container's OS and its tracked app on every run.",
|
||||
"First-time visitors on Android and iOS Safari now see an in-app install prompt with clear steps for adding the Monitor to their home screen as a PWA.",
|
||||
],
|
||||
changed: [
|
||||
"Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
|
||||
"Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
|
||||
"VMs running the QEMU Guest Agent (qemu-guest-agent) now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.3": {
|
||||
date: "July 15, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Backups integrated in the Monitor — a new first-class section to create, schedule and restore host backups against Local, PBS or Borg destinations from the Web dashboard. Jobs run on a proper systemd timer or attach to an existing PVE vzdump job with retention live-inherited from the parent. Encrypted PBS backups store a paired recovery blob next to each snapshot so a fresh install can always get the key back. After a reboot the tab shows a real-time restore progress card with milestones, per-component status (NVIDIA, Intel GPU tools, Coral, AMD tools), boot sanity warnings and a rollback delta listing anything on the host that wasn't in the backup.",
|
||||
"Network Flow diagram — a new live topology view on the Network tab showing NICs → host → bridges → LXCs / VMs with animated rx / tx pulses on every internal link, so the operator can see in real time how traffic distributes inside the host and which guests are pulling or pushing data.",
|
||||
"Physical Disks and Physical Interfaces cards redesigned — clearer per-item presentation on the Storage and Network tabs. USB-NVMe / USB-SATA enclosures reporting removable=0 (ASMedia, JMicron, Realtek, ASM105x) now walk sysfs to detect USB attachment, so the -d snt* pass-through is tried and the drive's real model, serial, temperature, power-on hours and health surface — instead of the bridge's chatter.",
|
||||
"Richer notifications out of the box — for users not running an AI agent, the templated body now identifies the affected object (which storage, which interface, which container), surfaces the top offenders with an \"…and N more\" tail when the list is long, and preserves the same identity in the recovery message. Users with AI enrichment enabled continue to get their tailored rewrite on top of this improved base.",
|
||||
],
|
||||
changed: [
|
||||
"Redesigned cards across Overview, VM / LXC, Storage and Network — layouts reworked for faster reading and denser, more practical information: key numbers surface at a glance, grouped by relevance, and the responsive grid now behaves cleanly from a phone up to an ultrawide.",
|
||||
"Health Monitor Thresholds — the Settings panel that controls per-category Warning and Critical levels (CPU, memory, temperature, storage, disks, ...) was reworked with clearer visual grouping and inline hints, so tuning a threshold now takes a couple of clicks instead of scrolling through a wall of numbers.",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.2": {
|
||||
date: "May 31, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Health Monitor Thresholds - Per-category Warning and Critical levels for CPU, memory, temperature, storage, disks and more, configurable from Settings. The same numbers also feed the colour ranges of every widget (storage bars, CPU/memory rings, temperature chips), so a colour anywhere in the Monitor maps to a definite range relative to the configured pair",
|
||||
"Per-error dismiss duration - The Dismiss button on each Health Monitor alert now opens a small dropdown with three options: 24 hours, 7 days, or Permanently. Permanent dismisses persist with suppression_hours = -1, never re-emit and are marked with a distinct amber Permanent badge so the operator knows the alert is intentionally silenced",
|
||||
"Active Suppressions panel in Settings - New section inside Settings -> Health Monitor (below the per-category suppression durations) that lists every currently-dismissed alert, time-limited and permanent. Each row carries the error_key, category, severity, when it was dismissed, plus a Re-enable button gated by Edit mode. Permanent dismisses can only be reverted from here",
|
||||
"Apprise notification channel - Full feature parity with native channels: the Apprise tab now exposes the same per-event toggles, Quiet Hours and Daily Digest controls as Telegram / Gotify / Discord / Email. One Apprise URL talks to ~80 notification services (Pushover, ntfy, Slack, Matrix, mailto, signal, ...) without ProxMenux needing a dedicated adapter for each",
|
||||
"LXC update detection - New dedicated Settings section with a single toggle that gates the per-CT apt list --upgradable / apk list -u scan end-to-end. The checker now reads the mtime of the CT's package-manager metadata and refreshes it via pct exec if it is older than 24 h, so long-running appliance CTs whose caches were months stale finally surface their real upstream backlog",
|
||||
"Disk I/O severity tiers - Sliding 24 h window classifies dmesg ATA / SCSI errors into silent (0-10), WARNING (11-100) and CRITICAL (100+ or any hard error like UNC / Buffer I/O / Sense Key Hardware Error), so quiet days stay quiet and a single Buffer I/O event still pages immediately",
|
||||
"Quiet Hours buffering - Events suppressed during a channel's quiet window are now persisted to SQLite and released as a grouped summary when the window closes, instead of being silently dropped",
|
||||
"Post-install function update detection - The Monitor tracks installed ProxMenux optimizations (Log2Ram, Memory Settings, System Limits, Logrotate, ...) and notifies when a newer version of any of them is available, with one-click apply from Settings",
|
||||
"NVIDIA driver update notifications - Kernel-aware detection of newer compatible driver versions, surfaced in the Hardware tab and as notifications when an upstream build is published",
|
||||
"Coral TPU installer - Uninstall path mirroring the NVIDIA flow, and registry-driven update notifications for both the PCIe gasket-dkms driver (tracked against feranick/gasket-driver) and the USB libedgetpu1 runtime",
|
||||
"Secure Gateway update flow - One-click Tailscale update from Settings with Last-checked / Installed / Latest indicators and notification when a new version is available",
|
||||
"Helper-Scripts menu - Richer context and useful information for each entry, making it easier to know what every script does before running it",
|
||||
],
|
||||
changed: [
|
||||
"AI Enhancement section in Notifications - Rewritten from a muted uppercase row to a normal-case foreground label with a Sparkles icon and a persistent badge (green Active when AI is enabled, neutral Optional when it isn't) so the feature is discoverable regardless of state",
|
||||
"Disk temperature monitoring - Improved readings, smarter caching across SMART probes and a redesigned history modal that opens at 24 h by default with min / avg / max statistics",
|
||||
"VM and LXC modal - Expanded with additional information so a single panel covers data you previously had to look up across multiple tabs",
|
||||
"Page load - Faster first paint and lighter network usage on the Overview, Storage and Hardware tabs",
|
||||
"Security improvements - Tighter authentication checks across notification, scripts and terminal endpoints, plus a more conservative default policy for new installs",
|
||||
"POST /api/health/acknowledge accepts an optional suppression_hours body field - positive integer in hours, -1 for permanent. Omitting it preserves the previous behaviour (uses the category default). New endpoint POST /api/health/un-acknowledge {error_key} reverses a dismiss",
|
||||
"Burst aggregation wording - Burst summaries now report only the additional events that arrived after the initial individual alert, so the operator no longer sees the first event counted twice",
|
||||
"Known-error classifier - Word-boundary regex on ATA / UNC patterns so kernel messages like nvidia_uvm:FatalError are no longer misclassified as ATA cable issues",
|
||||
"Resolved notifications severity matches the user-visible severity instead of the silently escalated value the DB may carry during the 24 h same-key cooldown",
|
||||
"log2ram apply path - The auto / update flow now restarts log2ram after writing the new size, so a configured 512M actually takes effect on the running tmpfs",
|
||||
"VM / CT control errors - Failed start / stop / restart now surfaces the real pvesh stderr (e.g. \"no space left on device\") in the UI toast and fires a vm_fail / ct_fail notification, instead of a bare 500 INTERNAL SERVER ERROR",
|
||||
"Mobile design of Quiet Hours / Daily Digest - Time inputs are now full-height with inline labels instead of the cramped grid layout that overflowed on narrow screens",
|
||||
"Health Monitor dismissed annotation - When an alert is acknowledged with suppression_hours = -1, the dashboard payload tags the check with permanent: true alongside dismissed: true so the UI can render the Permanent badge separately from the standard time-limited Dismissed badge",
|
||||
],
|
||||
fixed: [
|
||||
"Terminal modals on HTTPS hosts - Every terminal modal (dashboard terminal, LXC terminal, script terminal) used to fail with WebSocket connection error on hosts with HTTPS enabled. Root cause: the gevent + SSL path stacked geventwebsocket's WebSocketHandler on top of flask-sock's protocol implementation, so the server emitted two consecutive HTTP/1.1 101 Switching Protocols headers and the browser closed the connection as a corrupt frame. Dropping handler_class=WebSocketHandler restores a single 101 response",
|
||||
"Health Monitor kernel updates on PVE 9.x (#208) - The System Updates -> Kernel / PVE row used to report \"Kernel/PVE up to date\" even when an update for the running kernel was waiting upstream. Three combined fixes: (a) the kernel-package prefix list now includes proxmox-kernel-* and proxmox-firmware-* (PVE 9.x ships kernels under proxmox-kernel-, not pve-kernel- as in 7.x / 8.x), (b) the dry-run switched from apt-get upgrade --dry-run to apt-get dist-upgrade --dry-run so kernel updates packaged as new installs are visible, (c) the categoriser now reads uname -r and flags an update as a running-kernel update when the package matches the running release",
|
||||
"NVIDIA installer - The version menu now respects the running kernel compatibility window, only offering driver branches that won't fail to compile",
|
||||
"NVIDIA installer on Alpine LXC - Container-side userspace install reworked so it succeeds on Alpine hosts, and free-space detection works reliably across all storage layouts",
|
||||
"NVIDIA installer with NVENC patch - When the host has the NVENC patch applied, the version menu narrows to drivers supported by the patch so reinstalling never silently loses it",
|
||||
"Webhook URL - PVE notification webhook now follows the active SSL state automatically, switching between http and https when you toggle HTTPS in the panel",
|
||||
"ATA disk error not recorded - disk_observations is now written before the SMART gate, so transient errors that don't yet trip SMART still build the per-disk history",
|
||||
"Quiet Hours toggle not persisting - get_settings now returns the per-channel quiet_* / digest_* fields so the toggle state reloads correctly after a refresh",
|
||||
"Frontend 401 cascade - Login screen no longer swallows the 401 forever after a brief stale-token state",
|
||||
"Apprise URL section - Mobile overflow - On narrow viewports the Apprise URL row used to break the layout. The placeholder is now a single concise example (tgram://bottoken/ChatID), the URL input enforces min-w-0 / flex-1 / shrink-0 on its children, and the examples paragraph uses break-all min-w-0 so it wraps cleanly on any width",
|
||||
"Apprise channel rejected by backend with HTTP 400 - The notifications-test validator's hard-coded channel whitelist (used by POST /api/notifications/test and the history filter) was missing 'apprise', so every Apprise test or send returned 400 \"Invalid channel\" before the library was even invoked. The whitelist is now derived live from notification_channels.CHANNEL_TYPES, so adding a new channel implementation cannot silently regress this validator again",
|
||||
"Apprise error reporting - When a destination (jsons://, ntfy://, slack://, ...) returns a non-2xx response, the channel now captures Apprise's internal logger during notify() and surfaces the real HTTP status plus the destination's response body (capped at 300 chars) instead of the opaque \"Apprise rejected the notification (transport failure)\" message",
|
||||
"fail2ban-client subprocess storm - The cache wrapper around _f2b_get_banned_ips() only updated its timestamp on success, so on hosts where fail2ban-client returned ENOENT (binary not installed) the function fell through the cache check on every single HTTP request and fired 250+ failed execve calls in a 10-minute window. shutil.which('fail2ban-client') is now resolved once at module load and the cache timestamp is updated unconditionally",
|
||||
"smartctl scheduler collision - Disk SMART temperature polling, CPU temperature read and latency probe used to fire at the same offset within each minute, producing a measurable CPU / IO spike when all subprocesses spawned together. The polls are now staggered (latency, then CPU temperature, then disk SMART) while preserving the per-disk 60 s cadence",
|
||||
"LXC inventory subprocess - The mount monitor used to call `lxc-info -n <vmid> -p` for every running CT just to get its PID. It now reads /proc/<lxc-start-pid>/task/<lxc-start-pid>/children directly and falls back to lxc-info only when /proc reads fail, eliminating one subprocess per CT per scan cycle",
|
||||
"Browser-translated terminal pages - The terminal panel used to lose its WebSocket connection when the user enabled the browser's auto-translate feature, because the translator moved DOM nodes that React still held refs to. Added translate=\"no\" on the terminal container divs so the translator skips the embedded tty entirely",
|
||||
"Active Suppressions Save not activating after Re-enable - The Re-enable button used to fire the API call immediately without touching pendingChanges, so the Health Monitor Save button never registered the action. Re-enables are now queued (visual: green border + strike-through on the row + button label changes to Undo) and applied atomically when the user clicks Save, alongside any per-category dropdown changes",
|
||||
"Active Suppressions stale after dashboard Dismiss - Dismissing an alert from the Health Monitor modal while the Settings page was already open did not refresh the Active Suppressions list. The dashboard now dispatches a `health-suppression-changed` browser event on every dismiss / un-dismiss; the Settings page listens for it (plus window focus and document visibilitychange) and re-fetches the active set",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.1.4-beta": {
|
||||
date: "May 30, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Per-error dismiss duration - The Dismiss button on each Health Monitor alert now opens a small dropdown with three options: 24 hours, 7 days, or Permanently. The 24h / 7d paths behave like the existing time-limited dismiss (the alert reappears after the window expires). Permanent dismisses persist with suppression_hours = -1 in the persistence DB, never re-emit, never re-notify, and are marked with a distinct amber Permanent badge in the Health Monitor so the operator knows the alert is intentionally silenced",
|
||||
"Active Suppressions panel in Settings - New section inside Settings -> Health Monitor (below the per-category suppression durations) that lists every currently-dismissed alert, both time-limited (with countdown) and permanent. Each row carries the error_key, category, severity, when it was dismissed, and a Re-enable button that clears the acknowledgment so the alert can fire again on the next scan. The Re-enable button is gated by the Health Monitor Edit mode (same gating as the rest of the Health settings) — toggle Edit at the top of the page first, then the buttons become active. Permanent dismisses can only be reverted from here, time-limited ones can also be force-revived if you don't want to wait for the countdown",
|
||||
"Apprise channel - per-event toggles, Quiet Hours and Daily Digest - The Apprise tab now exposes the same Notification Categories block, per-event sub-toggles, Quiet Hours and Daily Digest controls as Telegram / Gotify / Discord / Email. The backend already supported per-channel filtering for Apprise via the generic channel_overrides logic; the UI just wasn't surfacing it",
|
||||
],
|
||||
changed: [
|
||||
"POST /api/health/acknowledge accepts an optional suppression_hours body field - positive integer for the dismiss duration in hours, -1 for permanent. Omitting the field preserves the previous behaviour (uses the category's configured default). New endpoint POST /api/health/un-acknowledge {error_key} reverses a dismiss (used by Settings -> Active Suppressions and by future automations)",
|
||||
"Health Monitor dismissed annotation - When an alert is currently acknowledged with suppression_hours = -1, the dashboard payload now tags the check with permanent: true alongside dismissed: true so the UI can render the Permanent badge separately from the standard time-limited Dismissed badge",
|
||||
],
|
||||
fixed: [
|
||||
"Apprise URL section - Mobile overflow - On narrow viewports the Apprise URL row used to break the design: the placeholder packed four full example URLs into one line and the inline <code> examples in the description had no break-all rule, so the section pushed past the right edge of the viewport. The placeholder is now a single concise example (tgram://bottoken/ChatID), the URL input wrapper enforces min-w-0 / flex-1 / shrink-0 on its children, and the examples paragraph uses break-all min-w-0 so it wraps cleanly on any width",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.1.3-beta": {
|
||||
date: "May 22, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"LXC Update Detection - A new dedicated section in Settings (between Health Monitor Thresholds and Notifications) with a single toggle that gates the per-CT apt list --upgradable / apk list -u scan end-to-end. Default ON. When OFF the scan stops entirely (no pct exec calls), every type=lxc entry is purged from the managed-installs registry immediately, and the matching notification toggle in Notifications -> Services disappears from the UI while preserving its stored preference",
|
||||
"LXC update checker auto-refresh - The checker now reads the mtime of the CT's package-manager metadata cache and runs apt-get update / apk update from outside via pct exec if it is older than 24h, with a 60s timeout and silent failure. Long-running appliance CTs whose caches were months stale now surface their real upstream backlog (a Debian 12 CT with a 524-day-old cache went from \"0 updates\" to \"117 (12 security)\" on lab hardware)",
|
||||
],
|
||||
changed: [
|
||||
"AI Enhancement section in Notifications - Rewritten from a muted uppercase row that testers consistently scrolled past, to a normal-case foreground label with a leading Sparkles icon and a persistent badge (green Active when AI is enabled, neutral Optional when it isn't) so the feature is visible regardless of state",
|
||||
],
|
||||
fixed: [
|
||||
"Terminal modals on HTTPS hosts - Every terminal modal (dashboard terminal, LXC terminal, script terminal) used to fail with WebSocket connection error on hosts with HTTPS enabled. Root cause: the gevent+SSL path stacked geventwebsocket's WebSocketHandler on top of flask-sock's protocol implementation, so the server emitted two consecutive HTTP/1.1 101 Switching Protocols headers and the browser closed the connection as a corrupt frame. Dropping handler_class=WebSocketHandler restores a single 101 response and lets the handshake complete normally",
|
||||
"Health Monitor kernel updates on PVE 9.x (#208) - The System Updates -> Kernel/PVE row reported \"Kernel/PVE up to date\" on PVE 9.x hosts even when an update for the running kernel was waiting upstream. Three combined fixes: (a) the kernel-package prefix list now includes proxmox-kernel-* and proxmox-firmware-* (PVE 9.x ships kernels under proxmox-kernel-, not pve-kernel- as in 7.x/8.x), (b) the dry-run switched from apt-get upgrade --dry-run to apt-get dist-upgrade --dry-run so kernel updates packaged as new installs are visible at all, (c) the categoriser now reads uname -r and flags an update as a running-kernel update when the package matches the running release exactly or its branch meta-package (e.g. proxmox-kernel-6.14 for a host on 6.14.11-4-pve). The row text now distinguishes \"Running kernel update available (reboot required)\" from \"N kernel update(s) available (none for running kernel)\"",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.1.2-beta": {
|
||||
date: "May 20, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Coral TPU installer - Uninstall path mirroring the NVIDIA flow, and registry-driven update notifications for both the PCIe gasket-dkms driver (tracked against feranick/gasket-driver) and the USB libedgetpu1 runtime (tracked via apt)",
|
||||
"Disk I/O severity tiers - Sliding 24h window classifies dmesg ATA/SCSI errors into silent (0-10), WARNING (11-100) and CRITICAL (100+ or any hard error like UNC / Buffer I/O / Sense Key Hardware Error), so quiet days stay quiet and a single Buffer I/O event still pages immediately",
|
||||
"Quiet Hours buffering - Events suppressed during a channel's quiet window are now persisted to SQLite and released as a grouped summary when the window closes, instead of being silently dropped",
|
||||
],
|
||||
changed: [
|
||||
"Burst aggregation wording - Burst summaries now report only the additional events that arrived after the initial individual alert, so the operator no longer sees the first event counted twice (\"+N more X in window\" instead of the old \"N X in window\" overlap)",
|
||||
"Known-error classifier - Word-boundary regex on ATA/UNC patterns so kernel messages like nvidia_uvm:FatalError are no longer misclassified as ATA cable issues",
|
||||
"Health journal context - Excludes proxmenux-monitor.service systemd lines so internal watchdog SIGKILLs no longer leak into the body of unrelated kernel events",
|
||||
"Resolved notifications severity - The \"previous severity\" now matches the severity the user actually saw in the notification, not whatever escalated value silently landed in the DB during the 24h same-key cooldown",
|
||||
"log2ram apply path - The auto/update flow now restarts log2ram after writing the new size, so a configured 512M actually takes effect on the running tmpfs (previously left at 128M until a manual restart)",
|
||||
"VM/CT control errors - Failed start/stop/restart now surfaces the real pvesh stderr (e.g. \"no space left on device\") in the UI toast and fires a vm_fail / ct_fail notification, instead of a bare 500 INTERNAL SERVER ERROR",
|
||||
"Mobile design of Quiet Hours / Daily Digest - Time inputs are now full-height with inline labels instead of the cramped grid layout that overflowed on narrow screens",
|
||||
],
|
||||
fixed: [
|
||||
"ATA disk error not recorded - disk_observations is now written before the SMART gate, so transient errors that don't yet trip SMART still build the per-disk history",
|
||||
"Quiet Hours toggle not persisting - get_settings now returns the per-channel quiet_*/digest_* fields so the toggle's state reloads correctly after a refresh",
|
||||
"Frontend 401 cascade - Login screen no longer swallows the 401 forever after a brief stale-token state; the dedup flag is cleared on mount and on successful login",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.1.1-beta": {
|
||||
date: "May 9, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Post-install function update detection - The Monitor now tracks installed ProxMenux optimizations (Log2Ram, Memory Settings, System Limits, Logrotate...) and notifies when a newer version of any of them is available, with one-click apply",
|
||||
"Health Monitor Thresholds - Per-category warning and critical levels for CPU, memory, temperature, storage and more, configurable from Settings",
|
||||
"NVIDIA driver update notifications - Kernel-aware detection of new compatible driver versions, surfaced in the Hardware tab and as notifications when a newer build is published upstream",
|
||||
"Secure Gateway update flow - One-click Tailscale update from Settings with Last-checked / Installed / Latest indicators and notification when a new version is available",
|
||||
"Helper-Scripts menu - Richer context and useful information for each entry, making it easier to know what every script does before running it",
|
||||
],
|
||||
changed: [
|
||||
"Disk temperature monitoring - Improved readings, smarter caching across SMART probes and a redesigned history modal that opens at 24h by default with min/avg/max statistics",
|
||||
"VM and LXC modal - Expanded with additional information so a single panel covers the data you previously had to look up across multiple tabs",
|
||||
"Page load - Faster first paint and lighter network usage on the Overview, Storage and Hardware tabs",
|
||||
"Security improvements - Tighter authentication checks across notification, scripts and terminal endpoints, plus a more conservative default policy for new installs",
|
||||
],
|
||||
fixed: [
|
||||
"NVIDIA installer - The version menu now respects the running kernel compatibility window, only offering driver branches that won't fail to compile",
|
||||
"NVIDIA installer on Alpine LXC - Container-side userspace install reworked so it succeeds on Alpine hosts, and free-space detection works reliably across all storage layouts",
|
||||
"NVIDIA installer with NVENC patch - When the host has the NVENC patch applied, the version menu narrows to drivers supported by the patch so reinstalling never silently loses it",
|
||||
"Webhook URL - PVE notification webhook now follows the active SSL state automatically, switching between http and https when you toggle HTTPS in the panel",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.1.2-beta": {
|
||||
date: "March 18, 2026",
|
||||
changes: {
|
||||
@@ -80,47 +298,83 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
},
|
||||
}
|
||||
|
||||
// Each feature carries an i18n key so translations live in the
|
||||
// common.json catalogs and the modal renders in the user's chosen
|
||||
// locale. `text` is the English source of truth — it's what the
|
||||
// build-i18n-messages workflow feeds to Google Translate for locales
|
||||
// that haven't been curated by hand.
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Thermometer className="h-5 w-5" />,
|
||||
text: "Temperature & Latency Charts - Real-time visual monitoring with interactive historical graphs",
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.aiCustomEndpoint",
|
||||
text: "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
},
|
||||
{
|
||||
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: <Wrench className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.secureGatewayArch",
|
||||
text: "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
},
|
||||
{
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
text: "AI-Enhanced Notifications - Intelligent message formatting with support for OpenAI, Groq, Anthropic and Ollama",
|
||||
key: "releaseNotes.currentFeatures.atomicNotifications",
|
||||
text: "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
},
|
||||
{
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.borgSshPort",
|
||||
text: "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
text: "Security Section - Comprehensive security configuration for both ProxMenux and Proxmox systems",
|
||||
key: "releaseNotes.currentFeatures.githubToken",
|
||||
text: "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
},
|
||||
{
|
||||
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",
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.replicationContext",
|
||||
text: "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.).",
|
||||
},
|
||||
]
|
||||
|
||||
// Turn any "@handle" mention inside a release-notes string into a
|
||||
// link to that GitHub profile. Applied to every feature bullet so a
|
||||
// contributor shout-out reads as a real link without needing rich
|
||||
// i18n formatting. Only matches `@` followed by a valid GitHub
|
||||
// username (letters/digits/hyphen, no consecutive hyphens, 1-39
|
||||
// chars) so unrelated punctuation stays untouched.
|
||||
function linkifyGithubMentions(text: string): (string | JSX.Element)[] {
|
||||
const parts: (string | JSX.Element)[] = []
|
||||
const re = /@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)/g
|
||||
let cursor = 0
|
||||
let m: RegExpExecArray | null
|
||||
let idx = 0
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > cursor) parts.push(text.slice(cursor, m.index))
|
||||
const handle = m[1]
|
||||
parts.push(
|
||||
<a
|
||||
key={`gh-${idx++}`}
|
||||
href={`https://github.com/${handle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-500 hover:text-orange-400 underline underline-offset-2"
|
||||
>
|
||||
@{handle}
|
||||
</a>,
|
||||
)
|
||||
cursor = m.index + m[0].length
|
||||
}
|
||||
if (cursor < text.length) parts.push(text.slice(cursor))
|
||||
return parts
|
||||
}
|
||||
|
||||
interface ReleaseNotesModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
const t = useT()
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -133,7 +387,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
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>
|
||||
<DialogTitle className="sr-only">{t("releaseNotes.dialogTitle", { version: APP_VERSION })}</DialogTitle>
|
||||
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -159,10 +413,10 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
<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}
|
||||
{t("releaseNotes.title", { 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!
|
||||
{t("releaseNotes.intro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -173,7 +427,9 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
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>
|
||||
<p className="text-xs md:text-sm text-foreground leading-relaxed">
|
||||
{linkifyGithubMentions(t(feature.key))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -186,7 +442,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
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!
|
||||
{t("releaseNotes.gotIt")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -199,7 +455,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
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
|
||||
{t("releaseNotes.dontShowAgain")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
"use client"
|
||||
|
||||
// Live inline card + detail modal for the post-boot restore.
|
||||
//
|
||||
// apply_cluster_postboot.sh writes /var/lib/proxmenux/restore-state.json
|
||||
// as it works through the milestones (apply cluster config, initramfs,
|
||||
// grub, per-component reinstalls, sanity check, finalize). The Flask
|
||||
// endpoints /api/host-backups/restore/{status,dismiss,history,log}
|
||||
// expose that state to this component. While the restore is running we
|
||||
// poll every 2s; once it's terminal (complete|failed) we back off to
|
||||
// 30s so the card can still be re-opened as a summary. Once the
|
||||
// operator hits Dismiss the card collapses and the History button
|
||||
// keeps the run browsable.
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Button } from "./ui/button"
|
||||
import { Badge } from "./ui/badge"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "./ui/dialog"
|
||||
import { ScrollArea } from "./ui/scroll-area"
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
History,
|
||||
RotateCcw,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
FileText,
|
||||
ArrowDownAZ,
|
||||
Filter,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// ── Shape contracts with the backend ──────────────────────────
|
||||
|
||||
interface RestoreComponent {
|
||||
name: string
|
||||
status: "installing" | "ok" | "failed"
|
||||
log: string
|
||||
exit_code?: string
|
||||
}
|
||||
|
||||
interface RestoreSummary {
|
||||
hostname: string
|
||||
guests: string
|
||||
stubs: string
|
||||
stale_nodes: string
|
||||
components: string
|
||||
duration: string
|
||||
}
|
||||
|
||||
interface RestoreRollback {
|
||||
vms_to_remove?: string[]
|
||||
lxcs_to_remove?: string[]
|
||||
components_to_uninstall?: string[]
|
||||
}
|
||||
|
||||
interface DataPoolsImport {
|
||||
ok: string[]
|
||||
forced: string[]
|
||||
partial: string[]
|
||||
missing: string[]
|
||||
failed: string[]
|
||||
finished_at?: string
|
||||
log_path?: string
|
||||
}
|
||||
|
||||
interface RestoreState {
|
||||
status: "running" | "complete" | "failed"
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
current_step: string
|
||||
steps_done: number
|
||||
steps_total: number
|
||||
log_path: string
|
||||
components: RestoreComponent[]
|
||||
rollback_delta: RestoreRollback
|
||||
sanity_warnings: string[]
|
||||
summary: RestoreSummary | null
|
||||
acknowledged: boolean
|
||||
duration?: string
|
||||
data_pools_import?: DataPoolsImport
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
file: string
|
||||
mtime: number
|
||||
status: string
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
duration: string | null
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetchApi(url)
|
||||
|
||||
const COMPONENT_LABEL: Record<string, string> = {
|
||||
nvidia_driver: "NVIDIA driver",
|
||||
amdgpu_top: "amdgpu_top",
|
||||
intel_gpu_tools: "Intel GPU tools",
|
||||
coral_driver: "Google Coral TPU driver",
|
||||
}
|
||||
|
||||
const formatComponent = (name: string) => COMPONENT_LABEL[name] ?? name
|
||||
|
||||
const formatIso = (iso: string | null | undefined) => {
|
||||
if (!iso) return "—"
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
type Translator = ReturnType<typeof useT>
|
||||
|
||||
const formatRelative = (iso: string, t: Translator) => {
|
||||
try {
|
||||
const then = new Date(iso).getTime()
|
||||
const now = Date.now()
|
||||
const diff = Math.max(0, Math.round((now - then) / 1000))
|
||||
if (diff < 60) return t("restoreProgress.time.secondsAgo", { count: diff })
|
||||
if (diff < 3600) return t("restoreProgress.time.minutesAgo", { count: Math.round(diff / 60) })
|
||||
if (diff < 86400) return t("restoreProgress.time.hoursAgo", { count: Math.round(diff / 3600) })
|
||||
return t("restoreProgress.time.daysAgo", { count: Math.round(diff / 86400) })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
// Rough time-remaining estimate derived from steps_done + elapsed.
|
||||
// Best-effort: at step 0 there's no data yet, so it returns
|
||||
// "estimating time…". After the run is terminal, "—". The output is
|
||||
// a full phrase so the caller doesn't have to add suffix words that
|
||||
// only make sense on some branches.
|
||||
const computeEta = (state: RestoreState, t: Translator): string => {
|
||||
if (state.status !== "running") return "—"
|
||||
if (!state.steps_done || state.steps_done <= 0) return t("restoreProgress.time.estimating")
|
||||
const elapsedSec = Math.max(1, Math.round((Date.now() - new Date(state.started_at).getTime()) / 1000))
|
||||
const perStep = elapsedSec / state.steps_done
|
||||
const remaining = Math.max(0, state.steps_total - state.steps_done)
|
||||
const eta = Math.round(perStep * remaining)
|
||||
if (eta < 60) return t("restoreProgress.time.secondsLeft", { count: eta })
|
||||
if (eta < 3600) return t("restoreProgress.time.minutesLeft", { count: Math.round(eta / 60) })
|
||||
return t("restoreProgress.time.hoursLeft", { count: Math.round(eta / 3600) })
|
||||
}
|
||||
|
||||
// ── Small building blocks ─────────────────────────────────────
|
||||
|
||||
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||
const t = useT()
|
||||
if (status === "running")
|
||||
return (
|
||||
<Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t("restoreProgress.status.running")}
|
||||
</Badge>
|
||||
)
|
||||
if (status === "complete")
|
||||
return (
|
||||
<Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t("restoreProgress.status.complete")}
|
||||
</Badge>
|
||||
)
|
||||
if (status === "failed")
|
||||
return (
|
||||
<Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
{t("restoreProgress.status.failed")}
|
||||
</Badge>
|
||||
)
|
||||
return <Badge variant="outline">{status}</Badge>
|
||||
}
|
||||
|
||||
const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => {
|
||||
if (status === "installing")
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-400" />
|
||||
if (status === "ok")
|
||||
return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
|
||||
return <XCircle className="h-3.5 w-3.5 text-red-400" />
|
||||
}
|
||||
|
||||
// ── Log viewer ────────────────────────────────────────────────
|
||||
|
||||
const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => {
|
||||
const t = useT()
|
||||
const [filter, setFilter] = useState<"all" | "issues">("all")
|
||||
const swrKey = path
|
||||
? `/api/host-backups/restore/log?filter=${filter}&tail=600${historyOnly ? `&path=${encodeURIComponent(path)}` : ""}`
|
||||
: null
|
||||
const { data, isLoading } = useSWR<{ lines: string[]; total_lines: number; path: string | null }>(
|
||||
swrKey,
|
||||
fetcher,
|
||||
{ refreshInterval: historyOnly ? 0 : 4000 },
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{path ?? t("restoreProgress.log.noLog")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filter === "all" ? "default" : "outline"}
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
<ArrowDownAZ className="h-3 w-3 mr-1" />
|
||||
{t("restoreProgress.log.full")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filter === "issues" ? "default" : "outline"}
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setFilter("issues")}
|
||||
>
|
||||
<Filter className="h-3 w-3 mr-1" />
|
||||
{t("restoreProgress.log.issuesOnly")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-72 rounded-md border border-border bg-black/40">
|
||||
<pre className="p-3 text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{isLoading ? t("app.loading") : (data?.lines?.join("\n") || t("restoreProgress.log.noOutput"))}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Rollback delta widget ─────────────────────────────────────
|
||||
|
||||
const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => {
|
||||
const t = useT()
|
||||
const vms = delta?.vms_to_remove ?? []
|
||||
const lxcs = delta?.lxcs_to_remove ?? []
|
||||
const comps = delta?.components_to_uninstall ?? []
|
||||
if (!vms.length && !lxcs.length && !comps.length) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("restoreProgress.rollback.empty")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const Row: React.FC<{ label: string; items: string[]; cmd: (id: string) => string }> = ({ label, items, cmd }) =>
|
||||
items.length === 0 ? null : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((id) => (
|
||||
<Badge key={id} variant="outline" className="font-mono text-xs">
|
||||
{id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{t("restoreProgress.rollback.showCleanup")}
|
||||
</summary>
|
||||
<pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono">
|
||||
{items.map(cmd).join("\n")}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("restoreProgress.rollback.description")}
|
||||
</div>
|
||||
<Row
|
||||
label={t("restoreProgress.rollback.vms")}
|
||||
items={vms}
|
||||
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
|
||||
/>
|
||||
<Row
|
||||
label={t("restoreProgress.rollback.lxcs")}
|
||||
items={lxcs}
|
||||
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
|
||||
/>
|
||||
<Row
|
||||
label={t("restoreProgress.rollback.components")}
|
||||
items={comps}
|
||||
cmd={(name) => t("restoreProgress.rollback.uninstallComponentCommand", { name })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Detail modal ──────────────────────────────────────────────
|
||||
|
||||
const RestoreDetailModal: React.FC<{
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
state: RestoreState
|
||||
historyMode?: boolean
|
||||
}> = ({ open, onClose, state, historyMode }) => {
|
||||
const t = useT()
|
||||
const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RotateCcw className="h-5 w-5 text-blue-500" />
|
||||
{t("restoreProgress.title")}
|
||||
<StatusBadge status={state.status} />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("restoreProgress.startedAt", { time: formatIso(state.started_at) })}
|
||||
{state.finished_at ? ` · ${t("restoreProgress.finishedAt", { time: formatIso(state.finished_at) })}` : ""}
|
||||
{state.summary?.duration ? ` · ${state.summary.duration}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{state.current_step || "—"}</span>
|
||||
<span>
|
||||
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
|
||||
{state.status === "running" && ` · ${computeEta(state, t)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
state.status === "failed" ? "bg-red-500" : state.status === "complete" ? "bg-emerald-500" : "bg-blue-500"
|
||||
}`}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.components.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t("restoreProgress.sections.components")}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{state.components.map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ComponentStatusIcon status={c.status} />
|
||||
<span className="font-medium">{formatComponent(c.name)}</span>
|
||||
<span className="text-muted-foreground">{t(`restoreProgress.componentStatus.${c.status}`)}</span>
|
||||
{c.exit_code && <span className="text-red-400">{t("restoreProgress.exitCode", { code: c.exit_code })}</span>}
|
||||
</div>
|
||||
{c.log && <span className="text-muted-foreground font-mono">{c.log}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.sanity_warnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium flex items-center gap-2 text-amber-400">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t("restoreProgress.sections.bootWarnings")}
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
|
||||
{state.sanity_warnings.map((w) => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t("restoreProgress.sections.rollbackDelta")}</div>
|
||||
<RollbackDelta delta={state.rollback_delta} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t("restoreProgress.sections.log")}</div>
|
||||
<LogViewer path={state.log_path} historyOnly={historyMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Rendered inside RestoreDetailModal — one row per outcome category
|
||||
// (imported / forced / partial skip / missing skip / failed).
|
||||
const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => {
|
||||
const t = useT()
|
||||
const total =
|
||||
section.ok.length +
|
||||
section.forced.length +
|
||||
section.partial.length +
|
||||
section.missing.length +
|
||||
section.failed.length
|
||||
if (total === 0) return null
|
||||
|
||||
const Row: React.FC<{
|
||||
label: string
|
||||
tone: "ok" | "warn" | "info" | "error"
|
||||
items: string[]
|
||||
help?: string
|
||||
}> = ({ label, tone, items, help }) => {
|
||||
if (items.length === 0) return null
|
||||
const toneClass =
|
||||
tone === "ok"
|
||||
? "text-emerald-400"
|
||||
: tone === "warn"
|
||||
? "text-amber-400"
|
||||
: tone === "error"
|
||||
? "text-red-400"
|
||||
: "text-blue-400"
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
|
||||
<div className={`font-medium ${toneClass} flex items-center gap-2`}>
|
||||
{tone === "ok" && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
{tone === "warn" && <AlertTriangle className="h-3.5 w-3.5" />}
|
||||
{tone === "error" && <XCircle className="h-3.5 w-3.5" />}
|
||||
{tone === "info" && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
<span>{label}</span>
|
||||
<span className="text-muted-foreground">({items.length})</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-muted-foreground break-all">{items.join(", ")}</div>
|
||||
{help && <div className="mt-1 text-muted-foreground">{help}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t("restoreProgress.dataPools.title")}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Row label={t("restoreProgress.dataPools.imported")} tone="ok" items={section.ok} />
|
||||
<Row
|
||||
label={t("restoreProgress.dataPools.importedForced")}
|
||||
tone="info"
|
||||
items={section.forced}
|
||||
help={t("restoreProgress.dataPools.importedForcedHelp")}
|
||||
/>
|
||||
<Row
|
||||
label={t("restoreProgress.dataPools.skippedPartial")}
|
||||
tone="warn"
|
||||
items={section.partial}
|
||||
help={t("restoreProgress.dataPools.skippedPartialHelp")}
|
||||
/>
|
||||
<Row
|
||||
label={t("restoreProgress.dataPools.skippedMissing")}
|
||||
tone="warn"
|
||||
items={section.missing}
|
||||
help={t("restoreProgress.dataPools.skippedMissingHelp")}
|
||||
/>
|
||||
<Row
|
||||
label={t("restoreProgress.dataPools.importFailed")}
|
||||
tone="error"
|
||||
items={section.failed}
|
||||
help={t("restoreProgress.dataPools.importFailedHelp")}
|
||||
/>
|
||||
</div>
|
||||
{section.log_path && (
|
||||
<div className="text-xs text-muted-foreground font-mono">{t("restoreProgress.dataPools.logPath", { path: section.log_path })}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── History browser modal ─────────────────────────────────────
|
||||
|
||||
const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const t = useT()
|
||||
const { data } = useSWR<{ entries: HistoryEntry[] }>(open ? "/api/host-backups/restore/history" : null, fetcher)
|
||||
const [detailFile, setDetailFile] = useState<string | null>(null)
|
||||
const { data: detailResp } = useSWR<{ state: RestoreState }>(
|
||||
detailFile ? `/api/host-backups/restore/history?file=${encodeURIComponent(detailFile)}` : null,
|
||||
fetcher,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
{t("restoreProgress.history.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("restoreProgress.history.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-96">
|
||||
<div className="space-y-1.5">
|
||||
{(data?.entries ?? []).length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-6 text-center">{t("restoreProgress.history.empty")}</div>
|
||||
) : (
|
||||
(data?.entries ?? []).map((e) => (
|
||||
<button
|
||||
key={e.file}
|
||||
onClick={() => setDetailFile(e.file)}
|
||||
className="w-full flex items-center justify-between rounded-md border border-border bg-muted/30 hover:bg-muted px-3 py-2 text-xs text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={e.status} />
|
||||
<span className="text-muted-foreground">
|
||||
{e.started_at ? formatIso(e.started_at) : formatIso(new Date(e.mtime * 1000).toISOString())}
|
||||
</span>
|
||||
{e.duration && <span className="text-muted-foreground">· {e.duration}</span>}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{detailFile && detailResp?.state && (
|
||||
<RestoreDetailModal
|
||||
open={!!detailFile}
|
||||
onClose={() => setDetailFile(null)}
|
||||
state={detailResp.state}
|
||||
historyMode
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main inline card ──────────────────────────────────────────
|
||||
|
||||
export const RestoreProgressCard: React.FC = () => {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<{ state: RestoreState | null }>(
|
||||
"/api/host-backups/restore/status",
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: (latest) => (latest?.state?.status === "running" ? 2000 : 30000),
|
||||
revalidateOnFocus: true,
|
||||
},
|
||||
)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [dismissing, setDismissing] = useState(false)
|
||||
|
||||
const state = data?.state ?? null
|
||||
const progressPct = useMemo(() => {
|
||||
if (!state || state.steps_total <= 0) return 0
|
||||
return Math.round((state.steps_done / state.steps_total) * 100)
|
||||
}, [state])
|
||||
|
||||
const dismiss = async () => {
|
||||
if (!state) return
|
||||
setDismissing(true)
|
||||
try {
|
||||
await fetchApi("/api/host-backups/restore/dismiss", { method: "POST" })
|
||||
await mutate()
|
||||
} finally {
|
||||
setDismissing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden entirely when: no restore run has ever happened, OR the
|
||||
// last run is terminal AND acknowledged. History button is still
|
||||
// reachable from the main card header (rendered elsewhere).
|
||||
if (!state) return null
|
||||
if (state.status !== "running" && state.acknowledged) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
|
||||
<History className="h-3.5 w-3.5 mr-1" />
|
||||
{t("restoreProgress.history.title")}
|
||||
</Button>
|
||||
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasWarnings = state.sanity_warnings.length > 0
|
||||
const pools = state.data_pools_import
|
||||
const poolCount =
|
||||
(pools?.ok.length ?? 0) +
|
||||
(pools?.forced.length ?? 0) +
|
||||
(pools?.partial.length ?? 0) +
|
||||
(pools?.missing.length ?? 0) +
|
||||
(pools?.failed.length ?? 0)
|
||||
const poolWarnings = (pools?.partial.length ?? 0) + (pools?.missing.length ?? 0) + (pools?.failed.length ?? 0)
|
||||
const barColor =
|
||||
state.status === "failed" ? "bg-red-500" : state.status === "complete" ? "bg-emerald-500" : "bg-blue-500"
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<RotateCcw
|
||||
className={`h-5 w-5 ${state.status === "running" ? "text-blue-500 animate-spin" : "text-blue-500"}`}
|
||||
/>
|
||||
{t("restoreProgress.title")}
|
||||
<StatusBadge status={state.status} />
|
||||
{hasWarnings && (
|
||||
<Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t("restoreProgress.badges.bootWarnings", { count: state.sanity_warnings.length })}
|
||||
</Badge>
|
||||
)}
|
||||
{poolCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
poolWarnings > 0
|
||||
? "text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1"
|
||||
: "text-emerald-400 border-emerald-500/40 bg-emerald-500/10 gap-1"
|
||||
}
|
||||
>
|
||||
<Cpu className="h-3 w-3" />
|
||||
{t("restoreProgress.badges.zfsPools", { count: poolCount })}
|
||||
{poolWarnings > 0 && ` · ${t("restoreProgress.badges.needAttention", { count: poolWarnings })}`}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
|
||||
{t("restoreProgress.actions.details")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
|
||||
<History className="h-3.5 w-3.5 mr-1" />
|
||||
{t("restoreProgress.actions.history")}
|
||||
</Button>
|
||||
{state.status !== "running" && (
|
||||
<Button size="sm" onClick={dismiss} disabled={dismissing}>
|
||||
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t("restoreProgress.actions.dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{state.current_step || "—"} · {t("restoreProgress.startedRelative", { time: formatRelative(state.started_at, t) })}
|
||||
</span>
|
||||
<span>
|
||||
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
|
||||
{state.status === "running" && ` · ${computeEta(state, t)}`}
|
||||
{state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full transition-all duration-500 ${barColor}`} style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
||||
<div className="text-muted-foreground">{t("restoreProgress.summary.guests")}</div>
|
||||
<div className="font-medium">{state.summary.guests}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
||||
<div className="text-muted-foreground">{t("restoreProgress.summary.bindMountStubs")}</div>
|
||||
<div className="font-medium">{state.summary.stubs}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
||||
<div className="text-muted-foreground">{t("restoreProgress.summary.staleNodesCleaned")}</div>
|
||||
<div className="font-medium">{state.summary.stale_nodes}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
||||
<div className="text-muted-foreground">{t("restoreProgress.summary.components")}</div>
|
||||
<div className="font-medium">{state.summary.components}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RestoreDetailModal open={detailOpen} onClose={() => setDetailOpen(false)} state={state} />
|
||||
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoreProgressCard
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
CornerDownLeft,
|
||||
GripHorizontal,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Clipboard,
|
||||
} from "lucide-react"
|
||||
import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -27,6 +30,8 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface WebInteraction {
|
||||
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
||||
@@ -45,6 +50,14 @@ interface ScriptTerminalModalProps {
|
||||
description: string
|
||||
scriptName?: string
|
||||
params?: Record<string, string>
|
||||
completedSuccessfullyMessage?: string
|
||||
completedWithErrorMessage?: (exitCode: number) => string
|
||||
// Optional callback fired when the script's WebSocket closes
|
||||
// (script_runner sends an exit code and then closes). Lets the
|
||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||
// flow so "Press Enter to close" in the bash script actually
|
||||
// closes the modal without an extra click. Other callers ignore.
|
||||
onComplete?: (exitCode?: number) => void
|
||||
}
|
||||
|
||||
export function ScriptTerminalModal({
|
||||
@@ -54,9 +67,17 @@ export function ScriptTerminalModal({
|
||||
title,
|
||||
description,
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
completedSuccessfullyMessage,
|
||||
completedWithErrorMessage,
|
||||
onComplete,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const t = useT()
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
|
||||
// after dynamic imports resolve — captures the latest value without
|
||||
// re-binding the closure.
|
||||
const isOpenRef = useRef<boolean>(false)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
@@ -68,6 +89,7 @@ export function ScriptTerminalModal({
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const completionReceivedRef = useRef(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
|
||||
@@ -79,6 +101,14 @@ export function ScriptTerminalModal({
|
||||
const resizeBarRef = useRef<HTMLDivElement>(null)
|
||||
const modalHeightRef = useRef(600)
|
||||
|
||||
const getCompletionMessage = useCallback(
|
||||
(exitCode: number) =>
|
||||
exitCode === 0
|
||||
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
|
||||
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, t],
|
||||
)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
const paramsRef = useRef(params)
|
||||
|
||||
@@ -87,6 +117,13 @@ export function ScriptTerminalModal({
|
||||
paramsRef.current = params
|
||||
}, [params])
|
||||
|
||||
// Same trick for onComplete — we want the latest callback inside
|
||||
// the ws.onclose handler without re-running the connection effect.
|
||||
const onCompleteRef = useRef<((exitCode?: number) => void) | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete
|
||||
}, [onComplete])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
@@ -99,14 +136,15 @@ export function ScriptTerminalModal({
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = setTimeout(async () => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -142,6 +180,23 @@ const initMessage = {
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
// The PTY worker always emits this final line. Treat it as a
|
||||
// completion fallback because some WebSocket servers tear down the
|
||||
// connection before the following structured message is flushed.
|
||||
const exitMatch = typeof event.data === "string"
|
||||
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
termRef.current?.write(event.data)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
@@ -164,6 +219,15 @@ const initMessage = {
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
termRef.current?.write(event.data)
|
||||
setIsWaitingNextInteraction(false)
|
||||
@@ -174,6 +238,9 @@ const initMessage = {
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
if (!completionReceivedRef.current) {
|
||||
termRef.current?.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
@@ -182,15 +249,19 @@ const initMessage = {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
if (completionReceivedRef.current) {
|
||||
return
|
||||
}
|
||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath])
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
@@ -213,17 +284,24 @@ const initMessage = {
|
||||
}, [])
|
||||
|
||||
const initializeTerminal = async () => {
|
||||
// Snapshot the open-state at call time. After the dynamic xterm
|
||||
// imports resolve, bail out if the modal has since been closed —
|
||||
// otherwise we attach a Terminal to a stale ref and open a WS that
|
||||
// nobody reads. Audit Tier 6 — useEffect con `import("xterm")` sin
|
||||
// cancelación.
|
||||
const wasOpenAtCall = isOpenRef.current
|
||||
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"),
|
||||
])
|
||||
if (!wasOpenAtCall || !isOpenRef.current) return
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
const term = new TerminalClass({
|
||||
rendererType: "dom",
|
||||
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontFamily: '"MesloLGS NF", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "Symbols Nerd Font", "Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: true,
|
||||
@@ -268,11 +346,17 @@ const initMessage = {
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current) {
|
||||
fitAddonRef.current.fit()
|
||||
// Send the keyboard to the xterm instance so any --yesno /
|
||||
// --menu the script opens receives Enter / arrow keys
|
||||
// directly. Without this the modal's Close button (or any
|
||||
// other focusable descendant) intercepts the keystrokes.
|
||||
termRef.current.focus()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -313,6 +397,23 @@ const initMessage = {
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
// See the reconnect handler above. The exit line is guaranteed to be
|
||||
// sent with the PTY output and is therefore a robust fallback when a
|
||||
// final JSON frame is lost during server-side socket teardown.
|
||||
const exitMatch = typeof event.data === "string"
|
||||
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
term.write(event.data)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
@@ -337,6 +438,15 @@ const initMessage = {
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, es output normal de terminal
|
||||
}
|
||||
@@ -351,26 +461,36 @@ const initMessage = {
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
|
||||
if (!completionReceivedRef.current) {
|
||||
term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[33mConnection closed\x1b[0m")
|
||||
if (!completionReceivedRef.current) {
|
||||
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
|
||||
}
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
if (!completionReceivedRef.current && !isComplete) {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
|
||||
// Read `wsRef.current` inside the handler so reconnect (which swaps
|
||||
// `wsRef.current` to a fresh WebSocket) doesn't leave us writing to the
|
||||
// dead closure-captured `ws`. Without this fix, after reconnect the
|
||||
// user's stdin disappears into the void. Audit residual #8.
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
const live = wsRef.current
|
||||
if (live && live.readyState === WebSocket.OPEN) {
|
||||
live.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -410,6 +530,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
isOpenRef.current = isOpen
|
||||
const savedHeight = localStorage.getItem("scriptModalHeight")
|
||||
if (savedHeight) {
|
||||
const height = Number.parseInt(savedHeight, 10)
|
||||
@@ -445,6 +566,7 @@ const initMessage = {
|
||||
|
||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||
reconnectAttemptsRef.current = 0
|
||||
completionReceivedRef.current = false
|
||||
setIsComplete(false)
|
||||
setInteractionInput("")
|
||||
setCurrentInteraction(null)
|
||||
@@ -624,6 +746,14 @@ const initMessage = {
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile clipboard helpers — see lib/terminal-clipboard.ts.
|
||||
const handleCopy = async () => {
|
||||
await copyTerminalSelection(termRef.current)
|
||||
}
|
||||
const handlePaste = async () => {
|
||||
await pasteFromClipboard(sendCommand)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -635,6 +765,13 @@ const initMessage = {
|
||||
}}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
// Radix defaults to focusing the first focusable descendant
|
||||
// when a Dialog opens — that used to land on the Close button
|
||||
// and every Enter/Space keystroke intended for the shell
|
||||
// dialogs INSIDE the terminal would close the modal instead.
|
||||
// Preempting the auto-focus lets the xterm focus() call in
|
||||
// initializeTerminal below own the keyboard from the start.
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
hideClose
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
@@ -653,7 +790,7 @@ const initMessage = {
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">{t("scriptTerminal.processing")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -775,20 +912,30 @@ const initMessage = {
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</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>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</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>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</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>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -808,18 +955,18 @@ const initMessage = {
|
||||
}`}
|
||||
title={
|
||||
connectionStatus === "online"
|
||||
? "Connected"
|
||||
? t("scriptTerminal.connected")
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting"
|
||||
: "Disconnected"
|
||||
? t("scriptTerminal.connecting")
|
||||
: t("scriptTerminal.disconnected")
|
||||
}
|
||||
></div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connectionStatus === "online"
|
||||
? "Online"
|
||||
? t("scriptTerminal.online")
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting..."
|
||||
: "Offline"}
|
||||
? t("scriptTerminal.connectingStatus")
|
||||
: t("scriptTerminal.offline")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -828,7 +975,7 @@ const initMessage = {
|
||||
variant="outline"
|
||||
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
Close
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -844,12 +991,19 @@ const initMessage = {
|
||||
>
|
||||
<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/>"),
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
Render the interaction message as plain text. The message
|
||||
comes through the WebSocket from a script running as root —
|
||||
a script bug or compromised author could embed `<script>` or
|
||||
`<img onerror=...>` and run JS in the admin's browser, leaking
|
||||
the JWT and any keys held in React state. `whitespace-pre-wrap`
|
||||
already preserves the `\n` formatting we previously emulated
|
||||
via `<br/>`, so we don't need any HTML conversion. See audit
|
||||
Tier 2 #17b.
|
||||
*/}
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{currentInteraction.message.replace(/\\n/g, "\n")}
|
||||
</p>
|
||||
|
||||
{currentInteraction.type === "yesno" && (
|
||||
<div className="flex gap-2">
|
||||
@@ -857,14 +1011,14 @@ const initMessage = {
|
||||
onClick={() => handleInteractionResponse("yes")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
||||
>
|
||||
Yes
|
||||
{t("scriptTerminal.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
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -887,14 +1041,14 @@ const initMessage = {
|
||||
variant="outline"
|
||||
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
||||
<div className="space-y-2">
|
||||
<Label>Your input:</Label>
|
||||
<Label>{t("scriptTerminal.yourInput")}</Label>
|
||||
<Input
|
||||
value={interactionInput}
|
||||
onChange={(e) => setInteractionInput(e.target.value)}
|
||||
@@ -911,14 +1065,14 @@ const initMessage = {
|
||||
onClick={() => handleInteractionResponse(interactionInput)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
Submit
|
||||
{t("scriptTerminal.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
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -930,14 +1084,14 @@ const initMessage = {
|
||||
onClick={() => handleInteractionResponse("ok")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
OK
|
||||
{t("scriptTerminal.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
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1127
-625
File diff suppressed because it is too large
Load Diff
+1381
-183
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
@@ -14,6 +15,8 @@ const menuItems = [
|
||||
]
|
||||
|
||||
const Sidebar = ({ currentPath, setOpen }) => {
|
||||
const t = useT()
|
||||
|
||||
const handleNavigation = (tabName: string) => {
|
||||
// Dispatch custom event to change tab in dashboard
|
||||
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
|
||||
@@ -32,7 +35,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
<span>{t("navigation.overview")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -44,7 +47,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
<span>{t("navigation.storage")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -56,7 +59,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<Network className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
<span>{t("navigation.network")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -68,7 +71,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<Server className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
<span>{t("navigation.virtualMachines")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -80,7 +83,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
<span>{t("navigation.hardware")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -92,7 +95,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
<span>{t("navigation.systemLogs")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -104,7 +107,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
<span>{t("navigation.terminal")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -116,7 +119,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
<span>{t("navigation.settings")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ interface DiskInfo {
|
||||
|
||||
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: {
|
||||
@@ -42,10 +41,9 @@ const fetchStorageData = async (): Promise<StorageData | null> => {
|
||||
}
|
||||
|
||||
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)
|
||||
console.error("Failed to fetch storage data from Flask server:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -110,7 +108,7 @@ export function StorageMetrics() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Overview Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 xl: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>
|
||||
|
||||
+2146
-1336
File diff suppressed because it is too large
Load Diff
+168
-136
@@ -28,7 +28,8 @@ import {
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface Backup {
|
||||
volid: string
|
||||
@@ -88,6 +89,7 @@ interface CombinedLogEntry {
|
||||
}
|
||||
|
||||
export function SystemLogs() {
|
||||
const t = useT()
|
||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||
const [backups, setBackups] = useState<Backup[]>([])
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
@@ -117,6 +119,14 @@ export function SystemLogs() {
|
||||
const [customDays, setCustomDays] = useState("1")
|
||||
const [refreshCounter, setRefreshCounter] = useState(0)
|
||||
|
||||
// Real on-host counts for the selected date range. /api/logs caps
|
||||
// the entries it returns at 10 000 for performance, but the Total
|
||||
// / Errors / Warnings cards must show the actual counts in the
|
||||
// selected window — otherwise on a busy host the user sees "10 000"
|
||||
// when the host really has 438 000 entries. Fetched separately from
|
||||
// /api/logs/counts which runs three lightweight `wc -l` queries.
|
||||
const [logsCounts, setLogsCounts] = useState<{ total: number; errors: number; warnings: number; info: number } | null>(null)
|
||||
|
||||
// Single unified useEffect for all data loading
|
||||
// Fires on mount, when filters change, or when refresh is triggered
|
||||
useEffect(() => {
|
||||
@@ -125,20 +135,24 @@ export function SystemLogs() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [logsRes, backupsRes, eventsRes, notificationsRes] = await Promise.all([
|
||||
const daysAgo = dateFilter === "custom" ? Number.parseInt(customDays) : Number.parseInt(dateFilter)
|
||||
const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90))
|
||||
const [logsRes, backupsRes, eventsRes, notificationsRes, countsRes] = await Promise.all([
|
||||
fetchSystemLogs(dateFilter, customDays),
|
||||
fetchApi("/api/backups"),
|
||||
fetchApi("/api/events?limit=50"),
|
||||
fetchApi("/api/notifications"),
|
||||
fetchApi<{ backups?: Backup[] }>("/api/backups"),
|
||||
fetchApi<{ events?: Event[] }>("/api/events?limit=50"),
|
||||
fetchApi<{ notifications?: Notification[] }>("/api/notifications"),
|
||||
fetchApi<{ total: number; errors: number; warnings: number; info: number }>(`/api/logs/counts?since_days=${clampedDays}`),
|
||||
])
|
||||
if (cancelled) return
|
||||
setLogs(logsRes)
|
||||
setBackups(backupsRes.backups || [])
|
||||
setEvents(eventsRes.events || [])
|
||||
setNotifications(notificationsRes.notifications || [])
|
||||
setLogsCounts(countsRes)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError("Failed to connect to server")
|
||||
setError(t("systemLogs.errors.connectFailed"))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
@@ -162,11 +176,10 @@ export function SystemLogs() {
|
||||
const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90))
|
||||
const apiUrl = `/api/logs?since_days=${clampedDays}`
|
||||
|
||||
const data = await fetchApi(apiUrl)
|
||||
const logsArray = Array.isArray(data) ? data : data.logs || []
|
||||
return logsArray
|
||||
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
|
||||
return Array.isArray(data) ? data : data.logs || []
|
||||
} catch {
|
||||
setError("Failed to load logs. Please try again.")
|
||||
setError(t("systemLogs.errors.loadFailed"))
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -192,26 +205,26 @@ export function SystemLogs() {
|
||||
|
||||
// Generate log content
|
||||
const logContent = [
|
||||
`Proxmox System Logs & Events Export`,
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`,
|
||||
t("systemLogs.export.title"),
|
||||
`${t("systemLogs.fields.generated")}: ${new Date().toISOString()}`,
|
||||
`${t("systemLogs.cards.totalEntries")}: ${filteredCombinedLogs.length.toLocaleString()}`,
|
||||
``,
|
||||
`Filters Applied:`,
|
||||
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`,
|
||||
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`,
|
||||
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`,
|
||||
`- Search: ${searchTerm || "None"}`,
|
||||
`${t("systemLogs.export.filtersApplied")}:`,
|
||||
`- ${t("systemLogs.filters.dateRange")}: ${t("systemLogs.filters.daysAgo", { count: dateFilter === "custom" ? customDays : dateFilter })}`,
|
||||
`- ${t("systemLogs.fields.level")}: ${levelFilter === "all" ? t("systemLogs.filters.allLevels") : levelLabel(levelFilter)}`,
|
||||
`- ${t("systemLogs.fields.service")}: ${serviceFilter === "all" ? t("systemLogs.filters.allServices") : serviceFilter}`,
|
||||
`- ${t("systemLogs.fields.search")}: ${searchTerm || t("systemLogs.fields.none")}`,
|
||||
``,
|
||||
`${"=".repeat(80)}`,
|
||||
``,
|
||||
...filteredCombinedLogs.map((log) => {
|
||||
const lines = [
|
||||
`[${log.timestamp}] ${log.level.toUpperCase()} - ${log.service}${log.isEvent ? " [EVENT]" : ""}`,
|
||||
`Message: ${log.message}`,
|
||||
`Source: ${log.source}`,
|
||||
`[${log.timestamp}] ${levelLabel(log.level)} - ${log.service}${log.isEvent ? ` [${t("systemLogs.badges.event")}]` : ""}`,
|
||||
`${t("systemLogs.fields.message")}: ${log.message}`,
|
||||
`${t("systemLogs.fields.source")}: ${log.source}`,
|
||||
]
|
||||
if (log.pid) lines.push(`PID: ${log.pid}`)
|
||||
if (log.hostname) lines.push(`Hostname: ${log.hostname}`)
|
||||
if (log.hostname) lines.push(`${t("systemLogs.fields.hostname")}: ${log.hostname}`)
|
||||
lines.push(`${"-".repeat(80)}`)
|
||||
return lines.join("\n")
|
||||
}),
|
||||
@@ -242,20 +255,33 @@ export function SystemLogs() {
|
||||
const upid = extractUPID(notification.message)
|
||||
|
||||
if (upid) {
|
||||
// Try to fetch the complete task log from Proxmox
|
||||
// Try to fetch the complete task log from Proxmox.
|
||||
// We use a direct fetch (not fetchApi) because the response is
|
||||
// text/plain — fetchApi assumes JSON and would throw on parse,
|
||||
// landing in the silent catch below. Audit residual #fetchApi-text-arg.
|
||||
try {
|
||||
const taskLog = await fetchApi(`/api/task-log/${encodeURIComponent(upid)}`, {}, "text")
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`
|
||||
const resp = await fetch(getApiUrl(`/api/task-log/${encodeURIComponent(upid)}`), {
|
||||
headers,
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`task-log fetch failed: ${resp.status}`)
|
||||
}
|
||||
const taskLog = await resp.text()
|
||||
|
||||
// Download the complete task log
|
||||
const blob = new Blob(
|
||||
[
|
||||
`Proxmox Task Log\n`,
|
||||
`${t("systemLogs.download.taskLog")}\n`,
|
||||
`================\n\n`,
|
||||
`UPID: ${upid}\n`,
|
||||
`Timestamp: ${notification.timestamp}\n`,
|
||||
`Service: ${notification.service}\n`,
|
||||
`Source: ${notification.source}\n\n`,
|
||||
`Complete Task Log:\n`,
|
||||
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
|
||||
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
|
||||
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
|
||||
`${t("systemLogs.download.completeTaskLog")}:\n`,
|
||||
`${"-".repeat(80)}\n`,
|
||||
`${taskLog}\n`,
|
||||
],
|
||||
@@ -279,13 +305,13 @@ export function SystemLogs() {
|
||||
// If no UPID or failed to fetch task log, download the notification message
|
||||
const blob = new Blob(
|
||||
[
|
||||
`Notification Details\n`,
|
||||
`${t("systemLogs.modals.notificationTitle")}\n`,
|
||||
`==================\n\n`,
|
||||
`Timestamp: ${notification.timestamp}\n`,
|
||||
`Type: ${notification.type}\n`,
|
||||
`Service: ${notification.service}\n`,
|
||||
`Source: ${notification.source}\n\n`,
|
||||
`Complete Message:\n`,
|
||||
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
|
||||
`${t("systemLogs.fields.type")}: ${notification.type}\n`,
|
||||
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
|
||||
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
|
||||
`${t("systemLogs.download.completeMessage")}:\n`,
|
||||
`${notification.message}\n`,
|
||||
],
|
||||
{ type: "text/plain" },
|
||||
@@ -318,7 +344,7 @@ export function SystemLogs() {
|
||||
level: event.level,
|
||||
service: event.type,
|
||||
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
|
||||
source: `Node: ${event.node} • User: ${event.user}`,
|
||||
source: `${t("systemLogs.fields.node")}: ${event.node} • ${t("systemLogs.fields.user")}: ${event.user}`,
|
||||
isEvent: true,
|
||||
eventData: event,
|
||||
sortTimestamp: new Date(event.starttime).getTime(),
|
||||
@@ -368,6 +394,12 @@ export function SystemLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
const levelLabel = (level: string) => {
|
||||
const key = `systemLogs.levels.${safeToLowerCase(level)}`
|
||||
const translated = t(key)
|
||||
return translated === key ? String(level).toUpperCase() : translated
|
||||
}
|
||||
|
||||
const getLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
@@ -527,15 +559,15 @@ export function SystemLogs() {
|
||||
const getSectionLabel = (section: string) => {
|
||||
switch (section) {
|
||||
case "logs":
|
||||
return "Logs"
|
||||
return t("systemLogs.tabs.logs")
|
||||
case "events":
|
||||
return "Events"
|
||||
return t("systemLogs.tabs.events")
|
||||
case "backups":
|
||||
return "Backups"
|
||||
return t("systemLogs.tabs.backups")
|
||||
case "notifications":
|
||||
return "Notifications"
|
||||
return t("systemLogs.tabs.notifications")
|
||||
default:
|
||||
return "Logs"
|
||||
return t("systemLogs.tabs.logs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,8 +578,8 @@ export function SystemLogs() {
|
||||
<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 logs...</div>
|
||||
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
|
||||
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("systemLogs.loading.description")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -561,51 +593,51 @@ export function SystemLogs() {
|
||||
<div className="h-10 w-10 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading logs...</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl: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 Entries</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.totalEntries")}</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{filteredCombinedLogs.length.toLocaleString("fr-FR")}
|
||||
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Filtered</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.selectedRange")}</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">Errors</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.errors")}</CardTitle>
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-500">{logCounts.error.toLocaleString("fr-FR")}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Requires attention</p>
|
||||
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.requiresAttention")}</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">Warnings</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.warnings")}</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-yellow-500">{logCounts.warning.toLocaleString("fr-FR")}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Monitor closely</p>
|
||||
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.monitorClosely")}</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">Backups</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.backups")}</CardTitle>
|
||||
<Database className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -621,11 +653,11 @@ export function SystemLogs() {
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
System Logs & Events
|
||||
{t("systemLogs.title")}
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t("actions.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -634,18 +666,18 @@ export function SystemLogs() {
|
||||
<TabsList className="hidden md:grid w-full grid-cols-3">
|
||||
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Logs
|
||||
{t("systemLogs.tabs.logs")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Backups
|
||||
{t("systemLogs.tabs.backups")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="notifications"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
|
||||
>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Notifications
|
||||
{t("systemLogs.tabs.notifications")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -667,7 +699,7 @@ export function SystemLogs() {
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[280px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sections</SheetTitle>
|
||||
<SheetTitle>{t("systemLogs.sections")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Button
|
||||
@@ -683,7 +715,7 @@ export function SystemLogs() {
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
Logs
|
||||
{t("systemLogs.tabs.logs")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -698,7 +730,7 @@ export function SystemLogs() {
|
||||
}}
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
Backups
|
||||
{t("systemLogs.tabs.backups")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -713,7 +745,7 @@ export function SystemLogs() {
|
||||
}}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
{t("systemLogs.tabs.notifications")}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
@@ -727,7 +759,7 @@ export function SystemLogs() {
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search logs & events..."
|
||||
placeholder={t("systemLogs.filters.searchPlaceholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 bg-background border-border"
|
||||
@@ -737,22 +769,22 @@ export function SystemLogs() {
|
||||
|
||||
<Select value={dateFilter} onValueChange={setDateFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||
<SelectValue placeholder="Time range" />
|
||||
<SelectValue placeholder={t("systemLogs.filters.timeRange")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 day ago</SelectItem>
|
||||
<SelectItem value="3">3 days ago</SelectItem>
|
||||
<SelectItem value="7">1 week ago</SelectItem>
|
||||
<SelectItem value="14">2 weeks ago</SelectItem>
|
||||
<SelectItem value="30">1 month ago</SelectItem>
|
||||
<SelectItem value="custom">Custom days</SelectItem>
|
||||
<SelectItem value="1">{t("systemLogs.filters.oneDay")}</SelectItem>
|
||||
<SelectItem value="3">{t("systemLogs.filters.threeDays")}</SelectItem>
|
||||
<SelectItem value="7">{t("systemLogs.filters.oneWeek")}</SelectItem>
|
||||
<SelectItem value="14">{t("systemLogs.filters.twoWeeks")}</SelectItem>
|
||||
<SelectItem value="30">{t("systemLogs.filters.oneMonth")}</SelectItem>
|
||||
<SelectItem value="custom">{t("systemLogs.filters.customDays")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{dateFilter === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Days ago"
|
||||
placeholder={t("systemLogs.filters.daysAgoPlaceholder")}
|
||||
value={customDays}
|
||||
onChange={(e) => setCustomDays(e.target.value)}
|
||||
className="w-full sm:w-[120px] bg-background border-border"
|
||||
@@ -762,23 +794,23 @@ export function SystemLogs() {
|
||||
|
||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||
<SelectValue placeholder="Filter by level" />
|
||||
<SelectValue placeholder={t("systemLogs.filters.byLevel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="all">{t("systemLogs.filters.allLevels")}</SelectItem>
|
||||
<SelectItem value="error">{t("systemLogs.levels.error")}</SelectItem>
|
||||
<SelectItem value="warning">{t("systemLogs.levels.warning")}</SelectItem>
|
||||
<SelectItem value="info">{t("systemLogs.levels.info")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||
<SelectValue placeholder="Filter by service" />
|
||||
<SelectValue placeholder={t("systemLogs.filters.byService")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="service-all" value="all">
|
||||
All Services
|
||||
{t("systemLogs.filters.allServices")}
|
||||
</SelectItem>
|
||||
{uniqueServices.map((service) => (
|
||||
<SelectItem key={`service-${service}`} value={service}>
|
||||
@@ -790,7 +822,7 @@ export function SystemLogs() {
|
||||
|
||||
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export Logs
|
||||
{t("systemLogs.export.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -820,12 +852,12 @@ export function SystemLogs() {
|
||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||
{getLevelIcon(log.level)}
|
||||
{log.level.toUpperCase()}
|
||||
{levelLabel(log.level)}
|
||||
</Badge>
|
||||
{log.eventData && (
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
EVENT
|
||||
{t("systemLogs.badges.event")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -842,9 +874,9 @@ export function SystemLogs() {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate overflow-hidden">
|
||||
{log.source}
|
||||
{log.unit && log.unit !== log.service && ` • Unit: ${log.unit}`}
|
||||
{log.unit && log.unit !== log.service && ` • ${t("systemLogs.fields.unit")}: ${log.unit}`}
|
||||
{log.pid && ` • PID: ${log.pid}`}
|
||||
{log.hostname && ` • Host: ${log.hostname}`}
|
||||
{log.hostname && ` • ${t("systemLogs.fields.host")}: ${log.hostname}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -854,7 +886,7 @@ export function SystemLogs() {
|
||||
{displayedLogs.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No logs found matching your criteria</p>
|
||||
<p>{t("systemLogs.empty.logs")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -866,7 +898,7 @@ export function SystemLogs() {
|
||||
className="border-border"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Load More ({filteredCombinedLogs.length - displayedLogsCount} remaining)
|
||||
{t("systemLogs.loadMore", { count: filteredCombinedLogs.length - displayedLogsCount })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -882,19 +914,19 @@ export function SystemLogs() {
|
||||
<Card className="bg-card/50 border-border">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-cyan-500">{backupStats.qemu}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">VM Backups</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.vm")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/50 border-border">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-orange-500">{backupStats.lxc}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">LXC Backups</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.lxc")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/50 border-border hidden md:block">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Total Size</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -903,7 +935,7 @@ export function SystemLogs() {
|
||||
<Card className="bg-card/50 border-border md:hidden">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Total Size</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -943,7 +975,7 @@ export function SystemLogs() {
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1 truncate">{t("systemLogs.fields.storage")}: {backup.storage}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center">
|
||||
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{backup.created}</span>
|
||||
@@ -956,7 +988,7 @@ export function SystemLogs() {
|
||||
{backups.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Database className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No backups found</p>
|
||||
<p>{t("systemLogs.empty.backups")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -982,12 +1014,12 @@ export function SystemLogs() {
|
||||
>
|
||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
|
||||
{notification.type.toUpperCase()}
|
||||
{notification.type ? levelLabel(notification.type) : t("app.unknown")}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
|
||||
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
|
||||
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
|
||||
{notification.source.toUpperCase()}
|
||||
{notification.source ? notification.source.toUpperCase() : t("app.unknown")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -1002,7 +1034,7 @@ export function SystemLogs() {
|
||||
{notification.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground break-words overflow-hidden">
|
||||
Service: {notification.service} • Source: {notification.source}
|
||||
{t("systemLogs.fields.service")}: {notification.service} • {t("systemLogs.fields.source")}: {notification.source}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1012,7 +1044,7 @@ export function SystemLogs() {
|
||||
{notifications.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No notifications found</p>
|
||||
<p>{t("systemLogs.empty.notifications")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1027,55 +1059,55 @@ export function SystemLogs() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Log Details
|
||||
{t("systemLogs.modals.logTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete information about this log entry</DialogDescription>
|
||||
<DialogDescription>{t("systemLogs.modals.logDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedLog && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Level</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.level")}</div>
|
||||
<Badge variant="outline" className={getLevelColor(selectedLog.level)}>
|
||||
{getLevelIcon(selectedLog.level)}
|
||||
{selectedLog.level.toUpperCase()}
|
||||
{levelLabel(selectedLog.level)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Service</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.service")}</div>
|
||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.service}</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Timestamp</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.timestamp")}</div>
|
||||
<div className="text-sm text-foreground font-mono break-all overflow-hidden">
|
||||
{selectedLog.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Source</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.source")}</div>
|
||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
|
||||
</div>
|
||||
{selectedLog.unit && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Systemd Unit</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.systemdUnit")}</div>
|
||||
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.pid && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Process ID</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.processId")}</div>
|
||||
<div className="text-sm text-foreground font-mono">{selectedLog.pid}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.hostname && (
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Hostname</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.hostname")}</div>
|
||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.hostname}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">Message</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border overflow-hidden">
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
|
||||
{selectedLog.message}
|
||||
@@ -1092,37 +1124,37 @@ export function SystemLogs() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Event Details
|
||||
{t("systemLogs.modals.eventTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete information about this event</DialogDescription>
|
||||
<DialogDescription>{t("systemLogs.modals.eventDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedEvent && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
|
||||
{getLevelIcon(selectedEvent.level)}
|
||||
{selectedEvent.level.toUpperCase()}
|
||||
{levelLabel(selectedEvent.level)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
EVENT
|
||||
{t("systemLogs.badges.event")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Message</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.message")}</div>
|
||||
<div className="text-sm text-foreground break-words">{selectedEvent.status}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
|
||||
<div className="text-sm text-foreground break-words">{selectedEvent.type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Node</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.node")}</div>
|
||||
<div className="text-sm text-foreground">{selectedEvent.node}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">User</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.user")}</div>
|
||||
<div className="text-sm text-foreground break-words">{selectedEvent.user}</div>
|
||||
</div>
|
||||
{selectedEvent.vmid && (
|
||||
@@ -1132,15 +1164,15 @@ export function SystemLogs() {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Duration</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.duration")}</div>
|
||||
<div className="text-sm text-foreground">{selectedEvent.duration}</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Start Time</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.startTime")}</div>
|
||||
<div className="text-sm text-foreground break-words">{selectedEvent.starttime}</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">End Time</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.endTime")}</div>
|
||||
<div className="text-sm text-foreground break-words">{selectedEvent.endtime}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1162,31 +1194,31 @@ export function SystemLogs() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Backup Details
|
||||
{t("systemLogs.modals.backupTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete information about this backup</DialogDescription>
|
||||
<DialogDescription>{t("systemLogs.modals.backupDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedBackup && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
|
||||
<Badge variant="outline" className={getBackupTypeColor(selectedBackup.volid)}>
|
||||
{getBackupTypeLabel(selectedBackup.volid)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Storage Type</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storageType")}</div>
|
||||
<Badge variant="outline" className={getBackupStorageColor(selectedBackup.volid)}>
|
||||
{getBackupStorageLabel(selectedBackup.volid)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Storage</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storage")}</div>
|
||||
<div className="text-sm text-foreground break-words">{selectedBackup.storage}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Size</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.size")}</div>
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{selectedBackup.size_human}
|
||||
</Badge>
|
||||
@@ -1198,12 +1230,12 @@ export function SystemLogs() {
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Created</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.created")}</div>
|
||||
<div className="text-sm text-foreground break-words">{selectedBackup.created}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">Volume ID</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.volumeId")}</div>
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
|
||||
{selectedBackup.volid}
|
||||
@@ -1220,38 +1252,38 @@ export function SystemLogs() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg pr-8">
|
||||
<Bell className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||
<span className="truncate">Notification Details</span>
|
||||
<span className="truncate">{t("systemLogs.modals.notificationTitle")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
Complete information about this notification
|
||||
{t("systemLogs.modals.notificationDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedNotification && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Type</div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.type")}</div>
|
||||
<Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}>
|
||||
{selectedNotification.type.toUpperCase()}
|
||||
{selectedNotification.type ? levelLabel(selectedNotification.type) : t("app.unknown")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Timestamp</div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.timestamp")}</div>
|
||||
<div className="text-xs sm:text-sm text-foreground font-mono break-all">
|
||||
{selectedNotification.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Service</div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.service")}</div>
|
||||
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.service}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Source</div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.source")}</div>
|
||||
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">Message</div>
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
|
||||
<div className="p-3 sm:p-4 rounded-lg bg-muted/50 border border-border max-h-[180px] sm:max-h-[300px] overflow-y-auto">
|
||||
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
|
||||
{selectedNotification.message}
|
||||
@@ -1265,7 +1297,7 @@ export function SystemLogs() {
|
||||
className="border-border w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
|
||||
<span className="truncate">Download Complete Message</span>
|
||||
<span className="truncate">{t("systemLogs.download.completeMessageButton")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,16 @@ 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 { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
import { ProcessDetailModal } from "./process-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 { useT } from "../lib/i18n/provider"
|
||||
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
||||
|
||||
interface TempDataPoint {
|
||||
@@ -21,9 +23,12 @@ interface TempDataPoint {
|
||||
|
||||
interface SystemData {
|
||||
cpu_usage: number
|
||||
cpu_user?: number // preview restyle
|
||||
cpu_system?: number // preview restyle
|
||||
memory_usage: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_cached?: number // preview restyle
|
||||
temperature: number
|
||||
temperature_sparkline?: TempDataPoint[]
|
||||
uptime: string
|
||||
@@ -167,6 +172,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
}
|
||||
|
||||
export function SystemOverview() {
|
||||
const t = useT()
|
||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||
const [vmData, setVmData] = useState<VMData[]>([])
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
@@ -184,6 +190,8 @@ export function SystemOverview() {
|
||||
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)
|
||||
const [cpuProcModalOpen, setCpuProcModalOpen] = useState(false)
|
||||
const [memProcModalOpen, setMemProcModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = async () => {
|
||||
@@ -199,7 +207,7 @@ export function SystemOverview() {
|
||||
setHasAttemptedLoad(true)
|
||||
|
||||
if (!systemResult) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
setError(t("overview.errors.serverUnavailableDescription"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,7 +263,7 @@ export function SystemOverview() {
|
||||
clearInterval(networkInterval)
|
||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
if (!hasAttemptedLoad || loadingStates.system) {
|
||||
return (
|
||||
@@ -264,8 +272,8 @@ export function SystemOverview() {
|
||||
<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 className="text-sm font-medium text-foreground">{t("overview.loadingTitle")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("overview.loadingDescription")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -278,9 +286,9 @@ export function SystemOverview() {
|
||||
<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="font-semibold text-lg mb-1">{t("overview.errors.serverUnavailableTitle")}</div>
|
||||
<div className="text-sm">
|
||||
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."}
|
||||
{error || t("overview.errors.serverUnavailableDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,14 +307,14 @@ export function SystemOverview() {
|
||||
}
|
||||
|
||||
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" }
|
||||
if (temp === 0) return { status: t("app.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (temp < 60) return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (temp < 75) return { status: t("status.warm"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: t("status.hot"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
if (!seconds || seconds === 0) return "Stopped"
|
||||
if (!seconds || seconds === 0) return t("status.stopped")
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
@@ -316,6 +324,19 @@ export function SystemOverview() {
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
const formatSystemUptime = (uptime: string) => {
|
||||
const trimmed = uptime?.trim()
|
||||
if (!trimmed) return t("app.unknown")
|
||||
|
||||
const dayMatch = trimmed.match(/^(\d+)\s+days?,\s*(.+)$/)
|
||||
if (!dayMatch) return trimmed
|
||||
|
||||
const days = Number(dayMatch[1])
|
||||
const dayKey = days === 1 ? "dayOne" : days >= 2 && days <= 4 ? "dayFew" : "dayMany"
|
||||
|
||||
return `${t(`overview.uptimeDuration.${dayKey}`, { count: days })}, ${dayMatch[2]}`
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
return (bytes / 1024 ** 3).toFixed(2)
|
||||
}
|
||||
@@ -340,40 +361,14 @@ export function SystemOverview() {
|
||||
|
||||
const getLoadStatus = (load: number, cores: number) => {
|
||||
if (load < cores) {
|
||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
return { status: t("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" }
|
||||
return { status: t("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" }
|
||||
return { status: t("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 => {
|
||||
@@ -395,64 +390,141 @@ export function SystemOverview() {
|
||||
|
||||
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">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{/* ── CPU Usage (preview restyle v2: tamaño igual a System Info, bars más anchas) ── */}
|
||||
<Card
|
||||
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
||||
onClick={() => setCpuProcModalOpen(true)}
|
||||
title={t("overview.topProcessesCpu")}
|
||||
>
|
||||
<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" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.cpuUsage")}</CardTitle>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||
</div>
|
||||
</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>
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="#3b82f6" strokeWidth="3"
|
||||
strokeDasharray={`${systemData.cpu_usage} 100`} strokeLinecap="round"
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(systemData.cpu_usage)}%</text>
|
||||
</svg>
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("overview.user")}</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_user !== undefined ? `${Math.round(systemData.cpu_user)}%` : '—'}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${systemData.cpu_user ?? 0}%` }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("overview.system")}</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_system !== undefined ? `${Math.round(systemData.cpu_system)}%` : '—'}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${systemData.cpu_system ?? 0}%`, background: 'rgba(99,102,241,0.55)' }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("overview.cores")}</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_cores ?? '—'}{systemData.cpu_threads ? `/${systemData.cpu_threads}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
{/* ── Memory (preview restyle v2: tamaño igual a System Info, bars más anchas) ── */}
|
||||
<Card
|
||||
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
||||
onClick={() => setMemProcModalOpen(true)}
|
||||
title={t("overview.topProcessesMemory")}
|
||||
>
|
||||
<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" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.memory")}</CardTitle>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<MemoryStick className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||
</div>
|
||||
</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>
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="#3b82f6" strokeWidth="3"
|
||||
strokeDasharray={`${systemData.memory_usage} 100`} strokeLinecap="round"
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(systemData.memory_usage)}%</text>
|
||||
</svg>
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("overview.used")}</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_used.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${systemData.memory_usage}%` }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("overview.cached")}</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_cached !== undefined ? systemData.memory_cached.toFixed(1) : '—'}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${systemData.memory_cached !== undefined && systemData.memory_total > 0 ? (systemData.memory_cached / systemData.memory_total) * 100 : 0}%`, background: 'rgba(99,102,241,0.55)' }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("overview.total")}</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_total.toFixed(0)} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||
<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 className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</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 className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 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">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<span className="text-4xl font-bold leading-none text-foreground">{vmStats.running}</span>
|
||||
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{vmStats.running} Running
|
||||
{t("overview.runningCount", { count: vmStats.running })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-1 flex-wrap">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{t("overview.vmsCount", { count: vmStats.vms })}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{vmStats.lxc} LXC</Badge>
|
||||
{vmStats.stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">
|
||||
{vmStats.stopped} Stopped
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
||||
{t("overview.stoppedCount", { count: vmStats.stopped })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -463,15 +535,20 @@ export function SystemOverview() {
|
||||
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" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.temperature")}</CardTitle>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Thermometer className="h-4 w-4" />
|
||||
{systemData.temperature > 0 && (
|
||||
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||
)}
|
||||
</div>
|
||||
</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`}
|
||||
{systemData.temperature === 0 ? t("app.notAvailable") : `${Math.round(systemData.temperature * 10) / 10}°C`}
|
||||
</span>
|
||||
<Badge variant="outline" className={tempStatus.color}>
|
||||
<Badge variant="outline" className={`${tempStatus.color}`}>
|
||||
{tempStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -499,19 +576,31 @@ export function SystemOverview() {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
|
||||
{systemData.temperature === 0 ? t("overview.noSensorAvailable") : t("overview.collectingData")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<TemperatureDetailModal
|
||||
open={tempModalOpen}
|
||||
<TemperatureDetailModal
|
||||
open={tempModalOpen}
|
||||
onOpenChange={setTempModalOpen}
|
||||
liveTemperature={systemData.temperature}
|
||||
/>
|
||||
|
||||
<ProcessDetailModal
|
||||
open={cpuProcModalOpen}
|
||||
onOpenChange={setCpuProcModalOpen}
|
||||
sort="cpu"
|
||||
/>
|
||||
|
||||
<ProcessDetailModal
|
||||
open={memProcModalOpen}
|
||||
onOpenChange={setMemProcModalOpen}
|
||||
sort="mem"
|
||||
/>
|
||||
|
||||
<NodeMetricsCharts />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -519,7 +608,7 @@ export function SystemOverview() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2" />
|
||||
Storage Overview
|
||||
{t("overview.storageOverview")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -540,7 +629,7 @@ export function SystemOverview() {
|
||||
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-sm font-medium text-foreground">{t("overview.totalNodeCapacity")}</span>
|
||||
<span className="text-lg font-bold text-foreground">
|
||||
{formatStorage(totalCapacity)}
|
||||
</span>
|
||||
@@ -552,13 +641,13 @@ export function SystemOverview() {
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Used:{" "}
|
||||
{t("overview.used")}:{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatStorage(totalUsed)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Free:{" "}
|
||||
{t("overview.free")}:{" "}
|
||||
<span className="font-semibold text-green-500">
|
||||
{formatStorage(totalAvailable)}
|
||||
</span>
|
||||
@@ -572,28 +661,28 @@ export function SystemOverview() {
|
||||
|
||||
<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-sm text-muted-foreground">{t("overview.totalCapacity")}</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 text-muted-foreground">{t("overview.physicalDisks")}</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""}
|
||||
{storageData.disk_count} {storageData.disk_count === 1 ? t("overview.diskSingular") : t("overview.diskPlural")}
|
||||
</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="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Used:</span>
|
||||
<span className="text-xs text-muted-foreground">{t("overview.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-xs text-muted-foreground">{t("overview.available")}:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatStorage(vmLxcStorageAvailable)}
|
||||
</span>
|
||||
@@ -608,28 +697,28 @@ export function SystemOverview() {
|
||||
</div>
|
||||
{vmLxcStorages.length > 1 && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{vmLxcStorages.length} storage volume{vmLxcStorages.length > 1 ? "s" : ""}
|
||||
{vmLxcStorages.length} {vmLxcStorages.length === 1 ? t("overview.storageVolumeSingular") : t("overview.storageVolumePlural")}
|
||||
</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 className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("overview.noVmLxcStorage")}</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="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Used:</span>
|
||||
<span className="text-xs text-muted-foreground">{t("overview.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-xs text-muted-foreground">{t("overview.available")}:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatStorage(localStorage.available)}
|
||||
</span>
|
||||
@@ -646,7 +735,7 @@ export function SystemOverview() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">Storage data not available</div>
|
||||
<div className="text-center py-8 text-muted-foreground">{t("overview.storageDataUnavailable")}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -656,18 +745,18 @@ export function SystemOverview() {
|
||||
<CardTitle className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Network className="h-5 w-5 mr-2" />
|
||||
Network Overview
|
||||
{t("overview.networkOverview")}
|
||||
</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>
|
||||
<SelectItem value="hour">{t("overview.timeframes.hour")}</SelectItem>
|
||||
<SelectItem value="day">{t("overview.timeframes.day")}</SelectItem>
|
||||
<SelectItem value="week">{t("overview.timeframes.week")}</SelectItem>
|
||||
<SelectItem value="month">{t("overview.timeframes.month")}</SelectItem>
|
||||
<SelectItem value="year">{t("overview.timeframes.year")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardTitle>
|
||||
@@ -682,7 +771,7 @@ export function SystemOverview() {
|
||||
) : 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-sm text-muted-foreground">{t("overview.activeInterfaces")}</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
|
||||
</span>
|
||||
@@ -724,7 +813,7 @@ export function SystemOverview() {
|
||||
|
||||
<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-sm text-muted-foreground">{t("overview.received")}</span>
|
||||
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
|
||||
↓{" "}
|
||||
{networkUnit === "Bytes"
|
||||
@@ -734,7 +823,7 @@ export function SystemOverview() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Sent:</span>
|
||||
<span className="text-sm text-muted-foreground">{t("overview.sent")}</span>
|
||||
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
|
||||
↑{" "}
|
||||
{networkUnit === "Bytes"
|
||||
@@ -754,7 +843,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">Network data not available</div>
|
||||
<div className="text-center py-8 text-muted-foreground">{t("overview.networkDataUnavailable")}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -765,27 +854,27 @@ export function SystemOverview() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
System Information
|
||||
{t("overview.systemInformation")}
|
||||
</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>
|
||||
<span className="text-muted-foreground">{t("overview.uptime")}</span>
|
||||
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Proxmox Version:</span>
|
||||
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</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-muted-foreground">{t("overview.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>
|
||||
<span className="text-muted-foreground">{t("overview.availableUpdates")}</span>
|
||||
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
||||
{systemData.available_updates} packages
|
||||
{systemData.available_updates} {t("overview.packages")}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -796,13 +885,13 @@ export function SystemOverview() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Zap className="h-5 w-5 mr-2" />
|
||||
System Overview
|
||||
{t("overview.systemOverview")}
|
||||
</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>
|
||||
<span className="text-sm text-muted-foreground">{t("overview.loadAverage1m")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-foreground font-mono">
|
||||
@@ -815,17 +904,17 @@ export function SystemOverview() {
|
||||
</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-sm text-muted-foreground">{t("overview.cpuThreads")}</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-sm text-muted-foreground">{t("overview.physicalDisks")}</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-sm text-muted-foreground">{t("overview.networkInterfaces")}</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
||||
</span>
|
||||
|
||||
@@ -8,12 +8,13 @@ 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"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||
{ value: "day", labelKey: "overview.timeframes.day" },
|
||||
{ value: "week", labelKey: "overview.timeframes.week" },
|
||||
{ value: "month", labelKey: "overview.timeframes.month" },
|
||||
]
|
||||
|
||||
interface TempHistoryPoint {
|
||||
@@ -70,7 +71,11 @@ const getStatusInfo = (temp: number) => {
|
||||
}
|
||||
|
||||
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
|
||||
const [timeframe, setTimeframe] = useState("hour")
|
||||
const t = useT()
|
||||
// Default to 24 h — matches the disk temperature modal and is the
|
||||
// useful timeframe for spotting trends; the 1-h view rarely tells
|
||||
// you anything that the live reading doesn't already show.
|
||||
const [timeframe, setTimeframe] = useState("day")
|
||||
const [data, setData] = useState<TempHistoryPoint[]>([])
|
||||
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -93,7 +98,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
setStats(result.stats)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[v0] Failed to fetch temperature history:", err)
|
||||
console.error("Failed to fetch temperature history:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -135,7 +140,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
<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
|
||||
{t("details.temperature.title")}
|
||||
</DialogTitle>
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-[130px] bg-card border-border">
|
||||
@@ -144,7 +149,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -155,24 +160,24 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
{/* 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-xs opacity-80 mb-1">{t("details.temperature.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
|
||||
<TrendingDown className="h-3 w-3" /> {t("details.temperature.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
|
||||
<Minus className="h-3 w-3" /> {t("details.temperature.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
|
||||
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||
</div>
|
||||
@@ -191,8 +196,8 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
<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>
|
||||
<p>{t("details.temperature.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("details.temperature.collectionHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -225,7 +230,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name="Temperature"
|
||||
name={t("details.temperature.seriesName")}
|
||||
stroke={chartColor}
|
||||
strokeWidth={2}
|
||||
fill="url(#tempGradient)"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config" // Unificando importaciones de api-config en una sola línea con alias @/
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import {
|
||||
Activity,
|
||||
Trash2,
|
||||
@@ -16,7 +17,10 @@ import {
|
||||
Grid2X2,
|
||||
GripHorizontal,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Clipboard,
|
||||
} from "lucide-react"
|
||||
import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -30,6 +34,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
type TerminalPanelProps = {
|
||||
websocketUrl?: string
|
||||
@@ -74,74 +79,30 @@ function getApiUrl(endpoint?: string): string {
|
||||
}
|
||||
|
||||
const proxmoxCommands = [
|
||||
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" },
|
||||
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" },
|
||||
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" },
|
||||
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" },
|
||||
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" },
|
||||
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" },
|
||||
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" },
|
||||
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" },
|
||||
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" },
|
||||
{ cmd: "qm status <vmid>", desc: "Show VM status" },
|
||||
{ cmd: "qm config <vmid>", desc: "Show VM configuration" },
|
||||
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" },
|
||||
{ cmd: "pct list", desc: "List all LXC containers" },
|
||||
{ cmd: "pct start <vmid>", desc: "Start LXC container" },
|
||||
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" },
|
||||
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" },
|
||||
{ cmd: "pct config <vmid>", desc: "Show container configuration" },
|
||||
{ cmd: "pvesm status", desc: "Show storage status" },
|
||||
{ cmd: "pvesm list <storage>", desc: "List storage content" },
|
||||
{ cmd: "pveperf", desc: "Test Proxmox system performance" },
|
||||
{ cmd: "pveversion", desc: "Show Proxmox VE version" },
|
||||
{ cmd: "systemctl status pve-cluster", desc: "Check cluster status" },
|
||||
{ cmd: "pvecm status", desc: "Show cluster status" },
|
||||
{ cmd: "pvecm nodes", desc: "List cluster nodes" },
|
||||
{ cmd: "zpool status", desc: "Show ZFS pool status" },
|
||||
{ cmd: "zpool list", desc: "List all ZFS pools" },
|
||||
{ cmd: "zfs list", desc: "List all ZFS datasets" },
|
||||
{ cmd: "ls -la", desc: "List all files with details" },
|
||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
||||
{ cmd: "mkdir dirname", desc: "Create new directory" },
|
||||
{ cmd: "rm -rf dirname", desc: "Remove directory recursively" },
|
||||
{ cmd: "cp source dest", desc: "Copy files or directories" },
|
||||
{ cmd: "mv source dest", desc: "Move or rename files" },
|
||||
{ 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: "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: "tar -czf archive.tar.gz dir/", desc: "Create tar.gz archive" },
|
||||
{ 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: "kill -9 PID", desc: "Force kill process" },
|
||||
{ cmd: "systemctl status service", desc: "Check service status" },
|
||||
{ cmd: "systemctl start service", desc: "Start a service" },
|
||||
{ cmd: "systemctl stop service", desc: "Stop a service" },
|
||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
||||
{ cmd: "apt update && apt upgrade", desc: "Update Debian/Ubuntu packages" },
|
||||
{ cmd: "apt install package", desc: "Install package on Debian/Ubuntu" },
|
||||
{ cmd: "apt remove package", desc: "Remove package" },
|
||||
{ cmd: "docker ps", desc: "List running containers" },
|
||||
{ cmd: "docker images", desc: "List Docker images" },
|
||||
{ cmd: "docker exec -it container bash", desc: "Enter container shell" },
|
||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
||||
{ cmd: "wget url", desc: "Download file from URL" },
|
||||
{ cmd: "ssh user@host", desc: "Connect via SSH" },
|
||||
{ cmd: "scp file user@host:/path", desc: "Copy file via SSH" },
|
||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file in real-time" },
|
||||
{ cmd: "history", desc: "Show command history" },
|
||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
||||
"pvesh get /nodes", "pvesh get /nodes/{node}/qemu", "pvesh get /nodes/{node}/lxc",
|
||||
"pvesh get /nodes/{node}/storage", "pvesh get /nodes/{node}/network", "qm list",
|
||||
"qm start <vmid>", "qm stop <vmid>", "qm shutdown <vmid>", "qm status <vmid>",
|
||||
"qm config <vmid>", "qm snapshot <vmid> <snapname>", "pct list", "pct start <vmid>",
|
||||
"pct stop <vmid>", "pct enter <vmid>", "pct config <vmid>", "pvesm status",
|
||||
"pvesm list <storage>", "pveperf", "pveversion", "systemctl status pve-cluster",
|
||||
"pvecm status", "pvecm nodes", "zpool status", "zpool list", "zfs list", "ls -la",
|
||||
"cd /path/to/dir", "mkdir dirname", "rm -rf dirname", "cp source dest", "mv source dest",
|
||||
"cat filename", "grep 'pattern' file", "find . -name 'file'", "chmod 755 file",
|
||||
"chown user:group file", "tar -xzf file.tar.gz", "tar -czf archive.tar.gz dir/", "df -h",
|
||||
"du -sh *", "free -h", "top", "ps aux | grep process", "kill -9 PID",
|
||||
"systemctl status service", "systemctl start service", "systemctl stop service",
|
||||
"systemctl restart service", "apt update && apt upgrade", "apt install package",
|
||||
"apt remove package", "docker ps", "docker images", "docker exec -it container bash",
|
||||
"ip addr show", "ping host", "curl -I url", "wget url", "ssh user@host",
|
||||
"scp file user@host:/path", "tail -f /var/log/syslog", "history", "clear",
|
||||
]
|
||||
|
||||
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
|
||||
const t = useT()
|
||||
const localizedCommands = proxmoxCommands.map((cmd, index) => ({
|
||||
cmd,
|
||||
desc: t(`terminal.commandDescriptions.${index}`),
|
||||
}))
|
||||
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
|
||||
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
||||
const [layout, setLayout] = useState<"single" | "grid">("grid")
|
||||
@@ -150,12 +111,15 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
|
||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(localizedCommands)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||
const [useOnline, setUseOnline] = useState(true)
|
||||
|
||||
const containerRefs = useRef<{ [key: string]: HTMLDivElement | null }>({})
|
||||
// Per-terminal reconnect attempt count + last-fired timestamp for the
|
||||
// exponential backoff in the visibilitychange handler.
|
||||
const reconnectAttemptsRef = useRef<{ [key: string]: { attempts: number; lastAt: number } }>({})
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
@@ -184,21 +148,35 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
// Handle page visibility change for automatic reconnection when user returns
|
||||
// This is especially important for mobile/tablet devices (iPad) where switching apps
|
||||
// puts the browser tab in background and may close WebSocket connections
|
||||
//
|
||||
// Per-terminal exponential backoff (2s, 4s, 8s, ..., capped at 60s) so a
|
||||
// server-side outage doesn't get hammered every time the user switches
|
||||
// tabs. `reconnectAttemptsRef` survives re-renders and tracks attempts +
|
||||
// last-fired timestamps. The success path in `reconnectTerminal.onopen`
|
||||
// resets the counter back to 0.
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// When page becomes visible again, check all terminal connections
|
||||
terminals.forEach((terminal) => {
|
||||
if (terminal.ws && terminal.ws.readyState !== WebSocket.OPEN && terminal.term) {
|
||||
// Terminal is disconnected, attempt to reconnect
|
||||
reconnectTerminal(terminal.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (document.visibilityState !== 'visible') return
|
||||
const now = Date.now()
|
||||
terminals.forEach((terminal) => {
|
||||
if (!(terminal.ws && terminal.ws.readyState !== WebSocket.OPEN && terminal.term)) {
|
||||
return
|
||||
}
|
||||
const state = reconnectAttemptsRef.current[terminal.id] || { attempts: 0, lastAt: 0 }
|
||||
const backoffMs = Math.min(60000, 2000 * Math.pow(2, state.attempts))
|
||||
if (now - state.lastAt < backoffMs) {
|
||||
return
|
||||
}
|
||||
reconnectAttemptsRef.current[terminal.id] = {
|
||||
attempts: state.attempts + 1,
|
||||
lastAt: now,
|
||||
}
|
||||
reconnectTerminal(terminal.id)
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
@@ -251,7 +229,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
const searchCheatSh = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localizedCommands)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -266,10 +244,9 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
})
|
||||
|
||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||
throw new Error("No examples found")
|
||||
throw new Error(t("terminal.noExamplesFound"))
|
||||
}
|
||||
|
||||
console.log("[v0] Received parsed examples from server:", data.examples.length)
|
||||
|
||||
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
||||
command: example.command,
|
||||
@@ -280,8 +257,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
setUseOnline(true)
|
||||
setSearchResults(formattedResults)
|
||||
} catch (error) {
|
||||
console.log("[v0] Error fetching from cheat.sh proxy, using offline commands:", error)
|
||||
const filtered = proxmoxCommands.filter(
|
||||
const filtered = localizedCommands.filter(
|
||||
(item) =>
|
||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||
@@ -299,12 +275,12 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
searchCheatSh(searchQuery)
|
||||
} else {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localizedCommands)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchQuery])
|
||||
}, [searchQuery, t])
|
||||
|
||||
// Function to reconnect a terminal when connection is lost
|
||||
// This is called when page visibility changes (user returns from another app)
|
||||
@@ -313,12 +289,15 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
if (!terminal || !terminal.term) return
|
||||
|
||||
// Show reconnecting message
|
||||
terminal.term.writeln('\r\n\x1b[33m[INFO] Reconnecting...\x1b[0m')
|
||||
|
||||
terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.reconnecting")}\x1b[0m`)
|
||||
|
||||
const wsUrl = websocketUrl || getWebSocketUrl()
|
||||
const ws = new WebSocket(wsUrl)
|
||||
// Append the single-use auth ticket so the backend handshake can validate.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
|
||||
ws.onopen = () => {
|
||||
// Successful connect — reset backoff state for this terminal.
|
||||
reconnectAttemptsRef.current[terminalId] = { attempts: 0, lastAt: 0 }
|
||||
// Clear any existing ping interval
|
||||
if (terminal.pingInterval) {
|
||||
clearInterval(terminal.pingInterval)
|
||||
@@ -336,7 +315,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
setTerminals((prev) =>
|
||||
prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t))
|
||||
)
|
||||
terminal.term.writeln('\r\n\x1b[32m[INFO] Reconnected successfully\x1b[0m')
|
||||
terminal.term.writeln(`\r\n\x1b[32m[INFO] ${t("terminal.reconnected")}\x1b[0m`)
|
||||
|
||||
// Sync terminal size
|
||||
if (terminal.fitAddon) {
|
||||
@@ -362,7 +341,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
terminal.term.writeln('\r\n\x1b[31m[ERROR] Reconnection failed\x1b[0m')
|
||||
terminal.term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.reconnectionFailed")}\x1b[0m`)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
@@ -375,7 +354,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}
|
||||
return t
|
||||
}))
|
||||
terminal.term.writeln('\r\n\x1b[33m[INFO] Connection closed\x1b[0m')
|
||||
terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||
}
|
||||
|
||||
terminal.term.onData((data: string) => {
|
||||
@@ -393,7 +372,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
...prev,
|
||||
{
|
||||
id: newId,
|
||||
title: `Terminal ${prev.length + 1}`,
|
||||
title: t("terminal.terminalTitle", { number: prev.length + 1 }),
|
||||
term: null,
|
||||
ws: null,
|
||||
isConnected: false,
|
||||
@@ -479,11 +458,22 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
import("xterm/css/xterm.css"),
|
||||
]).then(([Terminal, FitAddon]) => [Terminal, FitAddon])
|
||||
|
||||
// After the (potentially slow) dynamic import, verify the container
|
||||
// is still the one we were given. If the user removed the terminal
|
||||
// tab while xterm was loading, the original `container` element is
|
||||
// detached and `containerRefs.current[terminal.id]` is gone — bail
|
||||
// out to avoid attaching to a stale DOM node + opening an orphan
|
||||
// WebSocket. Audit Tier 6 — `import("xterm")` sin cancelación.
|
||||
if (containerRefs.current[terminal.id] !== container) return
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
const term = new TerminalClass({
|
||||
rendererType: "dom",
|
||||
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
// Issue #182: prepend common Nerd Font families so users who already
|
||||
// have one installed see Starship/atuin/ble.sh icons render. Falls
|
||||
// back to Courier if no NF is present.
|
||||
fontFamily: '"MesloLGS NF", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "Symbols Nerd Font", "Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: true,
|
||||
@@ -524,20 +514,21 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
fitAddon.fit()
|
||||
|
||||
const wsUrl = websocketUrl || getWebSocketUrl()
|
||||
|
||||
|
||||
// Connection with timeout for VPN/mobile (15 seconds)
|
||||
const connectionTimeout = 15000
|
||||
let connectionTimedOut = false
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
|
||||
// Set connection timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
connectionTimedOut = true
|
||||
ws.close()
|
||||
term.writeln('\x1b[31m[ERROR] Connection timeout. Please check your network and try again.\x1b[0m')
|
||||
term.writeln('\x1b[33m[TIP] If using VPN, ensure the connection is stable.\x1b[0m')
|
||||
term.writeln(`\x1b[31m[ERROR] ${t("terminal.connectionTimeout")}\x1b[0m`)
|
||||
term.writeln(`\x1b[33m[TIP] ${t("terminal.vpnTip")}\x1b[0m`)
|
||||
}
|
||||
}, connectionTimeout)
|
||||
|
||||
@@ -590,7 +581,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
|
||||
ws.onerror = (error) => {
|
||||
clearTimeout(timeoutId)
|
||||
console.error("[v0] TerminalPanel: WebSocket error:", error)
|
||||
console.error("TerminalPanel: WebSocket error:", error)
|
||||
setTerminals((prev) => prev.map((t) => {
|
||||
if (t.id === terminal.id) {
|
||||
if (t.pingInterval) {
|
||||
@@ -602,7 +593,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}))
|
||||
// Only show error if not already shown by timeout
|
||||
if (!connectionTimedOut) {
|
||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||||
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,7 +610,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}))
|
||||
// Only show close message if not already shown by timeout
|
||||
if (!connectionTimedOut) {
|
||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||||
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,12 +715,35 @@ const handleClose = () => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
|
||||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||||
if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) {
|
||||
activeTerminal.ws.send(seq)
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile clipboard helpers — desktop users have ctrl/cmd shortcuts via xterm,
|
||||
// but on touch devices xterm's selection / clipboard isn't reachable from the
|
||||
// OS clipboard manager so we expose explicit Copy / Paste buttons.
|
||||
const handleCopy = async (e?: React.MouseEvent | React.TouchEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||||
await copyTerminalSelection(activeTerminal?.term)
|
||||
}
|
||||
|
||||
const handlePaste = async (e?: React.MouseEvent | React.TouchEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||||
if (!activeTerminal?.ws || activeTerminal.ws.readyState !== WebSocket.OPEN) return
|
||||
const ws = activeTerminal.ws
|
||||
await pasteFromClipboard((text) => ws.send(text))
|
||||
}
|
||||
|
||||
const getLayoutClass = () => {
|
||||
const count = terminals.length
|
||||
@@ -759,9 +773,9 @@ const handleClose = () => {
|
||||
<Activity className="h-5 w-5 text-blue-500" />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={activeTerminal?.isConnected ? "Connected" : "Disconnected"}
|
||||
title={activeTerminal?.isConnected ? t("terminal.connected") : t("terminal.disconnected")}
|
||||
></div>
|
||||
<span className="text-xs text-zinc-500">{terminals.length} / 4 terminals</span>
|
||||
<span className="text-xs text-zinc-500">{t("terminal.terminalCount", { count: terminals.length })}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -772,7 +786,7 @@ const handleClose = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-8 px-2 ${layout === "single" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
||||
title="Vista apilada (filas)"
|
||||
title={t("terminal.stackedLayout")}
|
||||
>
|
||||
<AlignJustify className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -781,7 +795,7 @@ const handleClose = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
||||
title="Vista cuadrícula 2x2"
|
||||
title={t("terminal.gridLayout")}
|
||||
>
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -795,7 +809,7 @@ const handleClose = () => {
|
||||
className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">New</span>
|
||||
<span className="hidden sm:inline">{t("terminal.new")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setSearchModalOpen(true)}
|
||||
@@ -805,7 +819,7 @@ const handleClose = () => {
|
||||
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>
|
||||
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClear}
|
||||
@@ -815,7 +829,7 @@ const handleClose = () => {
|
||||
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>
|
||||
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
@@ -824,7 +838,7 @@ const handleClose = () => {
|
||||
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>
|
||||
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -867,6 +881,7 @@ const handleClose = () => {
|
||||
<div
|
||||
ref={(el) => (containerRefs.current[terminal.id] = el)}
|
||||
className="w-full h-full flex-1 bg-black overflow-hidden"
|
||||
translate="no"
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
@@ -899,6 +914,7 @@ const handleClose = () => {
|
||||
ref={(el) => (containerRefs.current[terminal.id] = el)}
|
||||
onClick={() => setActiveTerminalId(terminal.id)}
|
||||
className="flex-1 w-full max-w-full bg-black overflow-hidden cursor-pointer"
|
||||
translate="no"
|
||||
data-terminal-container
|
||||
/>
|
||||
</div>
|
||||
@@ -1015,20 +1031,30 @@ const handleClose = () => {
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x12")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||
<span className="text-muted-foreground text-xs">Search history</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -1038,22 +1064,22 @@ const handleClose = () => {
|
||||
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||
<DialogContent 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">
|
||||
<DialogTitle className="text-xl font-semibold">Search Commands</DialogTitle>
|
||||
<DialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</DialogTitle>
|
||||
<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"}
|
||||
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogDescription className="sr-only">Search for Linux and Proxmox commands</DialogDescription>
|
||||
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</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, qm, systemctl)"
|
||||
placeholder={t("terminal.searchPlaceholder")}
|
||||
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"
|
||||
@@ -1067,7 +1093,7 @@ const handleClose = () => {
|
||||
{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>
|
||||
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1095,7 +1121,7 @@ const handleClose = () => {
|
||||
<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
|
||||
{t("terminal.poweredByCheatSh")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -1121,13 +1147,13 @@ const handleClose = () => {
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching && !searchQuery && !useOnline ? (
|
||||
proxmoxCommands.map((item, index) => (
|
||||
localizedCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToActiveTerminal(item.cmd)}
|
||||
@@ -1148,7 +1174,7 @@ const handleClose = () => {
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1159,17 +1185,17 @@ const handleClose = () => {
|
||||
<>
|
||||
<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>
|
||||
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</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>
|
||||
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
|
||||
<div className="text-sm text-zinc-500 space-y-1">
|
||||
<p>Try searching for:</p>
|
||||
<p>{t("terminal.trySearchingFor")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
|
||||
<code
|
||||
@@ -1186,7 +1212,7 @@ const handleClose = () => {
|
||||
{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>
|
||||
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -1195,13 +1221,11 @@ const handleClose = () => {
|
||||
) : 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 or Proxmox commands (qm, pct, zpool)</span>
|
||||
{useOnline && searchResults.length > 0 && (
|
||||
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
|
||||
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
|
||||
</div>
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useTheme } from "next-themes"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Button } from "./ui/button"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const t = useT()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
@@ -14,9 +16,7 @@ export function ThemeToggle() {
|
||||
}, [])
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
console.log("[v0] Current theme:", theme)
|
||||
const newTheme = theme === "light" ? "dark" : "light"
|
||||
console.log("[v0] Switching to theme:", newTheme)
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ThemeToggle() {
|
||||
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>
|
||||
<span className="sr-only">{t("actions.toggleTheme")}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function ThemeToggle() {
|
||||
<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>
|
||||
<span className="sr-only">{t("actions.toggleTheme")}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface TwoFactorSetupProps {
|
||||
open: boolean
|
||||
@@ -14,6 +15,8 @@ interface TwoFactorSetupProps {
|
||||
}
|
||||
|
||||
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
|
||||
const t = useT()
|
||||
const tf = (key: string) => t(`securityPage.twoFactorSetup.${key}`)
|
||||
const [step, setStep] = useState(1)
|
||||
const [qrCode, setQrCode] = useState("")
|
||||
const [secret, setSecret] = useState("")
|
||||
@@ -41,7 +44,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Failed to setup 2FA")
|
||||
throw new Error(data.message || tf("setupFailed"))
|
||||
}
|
||||
|
||||
setQrCode(data.qr_code)
|
||||
@@ -49,7 +52,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
setBackupCodes(data.backup_codes)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
|
||||
setError(err instanceof Error ? err.message : tf("setupFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -57,7 +60,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode || verificationCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code")
|
||||
setError(tf("enterSixDigitCode"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,12 +81,12 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Invalid verification code")
|
||||
throw new Error(data.message || tf("invalidCode"))
|
||||
}
|
||||
|
||||
setStep(3)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verification failed")
|
||||
setError(err instanceof Error ? err.message : tf("verificationFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -92,33 +95,59 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
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.
|
||||
// Path 1: modern Clipboard API. Only works on HTTPS / localhost.
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ok = true
|
||||
}
|
||||
} catch {
|
||||
// fall through to execCommand fallback
|
||||
// fall through
|
||||
}
|
||||
|
||||
// Path 2: legacy execCommand. Picky — some browsers (iOS Safari
|
||||
// especially) refuse to copy from an element placed off-screen
|
||||
// (`left: -9999px`), which is the previous version's mistake.
|
||||
// Keep the textarea inside the viewport but visually invisible.
|
||||
if (!ok) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = text
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.top = "0"
|
||||
textarea.style.left = "0"
|
||||
textarea.style.width = "2em"
|
||||
textarea.style.height = "2em"
|
||||
textarea.style.padding = "0"
|
||||
textarea.style.border = "none"
|
||||
textarea.style.outline = "none"
|
||||
textarea.style.boxShadow = "none"
|
||||
textarea.style.background = "transparent"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.setAttribute("aria-hidden", "true")
|
||||
document.body.appendChild(textarea)
|
||||
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()
|
||||
textarea.setSelectionRange(0, text.length)
|
||||
ok = document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
} catch {
|
||||
ok = false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
// Path 3: last-resort window.prompt — ugly but unblockable. The
|
||||
// user can select+copy from the prompt manually. This guarantees
|
||||
// they can finish the 2FA setup even on plain-HTTP Monitor where
|
||||
// both the Clipboard API and execCommand may be locked down.
|
||||
if (!ok) {
|
||||
try {
|
||||
window.prompt(tf("copyPrompt"), text)
|
||||
ok = true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +186,9 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-blue-500" />
|
||||
Setup Two-Factor Authentication
|
||||
{tf("title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
|
||||
<DialogDescription>{tf("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
@@ -173,22 +202,21 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
<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.
|
||||
{tf("intro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">You will need:</h4>
|
||||
<h4 className="font-medium">{tf("youWillNeed")}</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>
|
||||
<li>{tf("needApp")}</li>
|
||||
<li>{tf("needQrOrKey")}</li>
|
||||
<li>{tf("needBackupCodes")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Starting..." : "Start Setup"}
|
||||
{loading ? tf("starting") : tf("startSetup")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -196,24 +224,24 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
{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>
|
||||
<h4 className="font-medium">{tf("scanTitle")}</h4>
|
||||
<p className="text-sm text-muted-foreground">{tf("scanDescription")}</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" />
|
||||
<img src={qrCode || "/placeholder.svg"} alt={tf("qrCodeAlt")} width={200} height={200} className="rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Or enter the key manually:</h4>
|
||||
<h4 className="font-medium">{tf("manualKey")}</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"
|
||||
title={tf("copyKey")}
|
||||
>
|
||||
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -221,8 +249,8 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
</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>
|
||||
<h4 className="font-medium">{tf("verifyTitle")}</h4>
|
||||
<p className="text-sm text-muted-foreground">{tf("verifyDescription")}</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
@@ -236,10 +264,10 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
|
||||
<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"}
|
||||
{loading ? tf("verifying") : tf("verifyAndEnable")}
|
||||
</Button>
|
||||
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,30 +278,29 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
<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="font-medium text-green-500">{tf("enabledTitle")}</p>
|
||||
<p className="text-sm text-green-500 mt-1">
|
||||
Your account is now protected with two-factor authentication
|
||||
{tf("enabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-orange-500">Important: Save your backup codes</h4>
|
||||
<h4 className="font-medium text-orange-500">{tf("saveCodesTitle")}</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.
|
||||
{tf("saveCodesDescription")}
|
||||
</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>
|
||||
<span className="text-sm font-medium">{tf("backupCodes")}</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
|
||||
{tf("copyAll")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -287,7 +314,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
</div>
|
||||
|
||||
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
||||
Finish
|
||||
{tf("finish")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
@@ -34,28 +35,32 @@ const DialogContent = React.forwardRef<
|
||||
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>
|
||||
))
|
||||
>(({ className, children, hideClose, ...props }, ref) => {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<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">{t("actions.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
})
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -9,7 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
||||
<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",
|
||||
// The previous focus style was `ring-2 ring-ring ring-offset-2`, which
|
||||
// painted a 2px white ring with a 2px gap outside the border. Inside a
|
||||
// ScrollArea or any container with `overflow-hidden` the ring's left
|
||||
// edge got clipped and the result looked broken. We replace it with a
|
||||
// 1px blue ring + matching border so a focused input now sits at the
|
||||
// same visual weight as the colored card selectors used elsewhere
|
||||
// (Backend picker, etc.).
|
||||
"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 placeholder:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = DialogPrimitive.Root
|
||||
@@ -54,18 +55,22 @@ interface SheetContentProps
|
||||
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>
|
||||
),
|
||||
({ side = "right", className, children, ...props }, ref) => {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<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">{t("actions.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
},
|
||||
)
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ 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",
|
||||
"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-slate-300 dark:data-[state=unchecked]:bg-slate-600",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
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",
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground placeholder:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,74 +1,126 @@
|
||||
{
|
||||
"_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",
|
||||
"_updated": "2026-09-02",
|
||||
"_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"
|
||||
"allam-2-7b",
|
||||
"qwen/qwen3.8-27b",
|
||||
"openai/gpt-oss-20b",
|
||||
"groq/compound-mini"
|
||||
],
|
||||
"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."
|
||||
"recommended": "openai/gpt-oss-20b",
|
||||
"_note": "Verified 2026-09-02 with the Groq API. 4 of 12 tested pass. `llama-3.3-70b-versatile` no longer served by Groq. Recommended kept as `openai/gpt-oss-20b` (english-capable) — `allam-2-7b` is fastest but Arabic-focused; add manually if serving Arabic notifications."
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"models": [
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-3.5-flash-lite",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
"gemini-flash-lite-latest",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3-flash-preview"
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash"
|
||||
],
|
||||
"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"]
|
||||
"_deprecated": [
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.0-pro",
|
||||
"gemini-pro"
|
||||
],
|
||||
"_note": "Verified 2026-09-02. gemini-flash-lite-latest passes consistently but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift. gemini-3.1-flash-lite is the stable successor to 3-flash-preview. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation."
|
||||
},
|
||||
|
||||
"openai": {
|
||||
"models": [
|
||||
"gpt-4.1-nano-2025-04-14",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1-2025-04-14",
|
||||
"gpt-4.1",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-mini",
|
||||
"gpt-3.5-turbo-16k",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-5-nano",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo-0125",
|
||||
"gpt-5-nano-2025-08-07",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-mini-2025-04-14",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o",
|
||||
"gpt-5-chat-latest",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5.4-mini"
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-0613"
|
||||
],
|
||||
"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."
|
||||
"_note": "Verified 2026-09-02 with the OpenAI API — 22 of 74 tested pass. Includes stable aliases (gpt-4.1-nano, gpt-4o-mini, ...), dated snapshots for pinning (gpt-4o-2024-11-20, gpt-4.1-nano-2025-04-14) and legacy families (gpt-3.5-turbo, gpt-4) for cost-optimised use cases. Recommended kept as `gpt-4.1-nano` (stable alias, 1.39s)."
|
||||
},
|
||||
|
||||
"anthropic": {
|
||||
"models": [
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-opus-latest"
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-opus-4-6"
|
||||
],
|
||||
"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."
|
||||
"recommended": "claude-haiku-4-5-20251001",
|
||||
"_note": "Verified 2026-09-02 — 9 of 11 tested pass. The `claude-haiku-4-5` alias no longer resolves upstream; the working snapshot is `claude-haiku-4-5-20251001` — used as recommended (2.89s). Full family surfaced: haiku/sonnet/opus/fable across generations 4.5/4.6/4.7/4.8/5."
|
||||
},
|
||||
|
||||
"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",
|
||||
"google/gemini-2.5-flash-lite",
|
||||
"google/gemini-3.5-flash-lite",
|
||||
"amazon/nova-pro-v1",
|
||||
"mistralai/mistral-medium-3",
|
||||
"google/gemini-2.5-flash",
|
||||
"amazon/nova-lite-v1",
|
||||
"openai/gpt-5.4-mini",
|
||||
"openai/gpt-4o-mini",
|
||||
"mistralai/mistral-7b-instruct",
|
||||
"mistralai/mixtral-8x7b-instruct"
|
||||
"openai/gpt-4.1",
|
||||
"perplexity/sonar",
|
||||
"meta-llama/llama-4-scout",
|
||||
"x-ai/grok-4.20",
|
||||
"google/gemini-3-flash-preview",
|
||||
"meta-llama/llama-4-maverick",
|
||||
"perplexity/sonar-pro",
|
||||
"openai/gpt-4.1-nano",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"mistralai/mistral-large-2512",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"qwen/qwen3-235b-a22b-2507",
|
||||
"moonshotai/kimi-k2",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"openai/gpt-5.1",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"mistralai/mistral-small-3.2-24b-instruct",
|
||||
"cohere/command-a",
|
||||
"x-ai/grok-4.3",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"deepseek/deepseek-v3.2",
|
||||
"openai/gpt-oss-120b",
|
||||
"meta-llama/llama-3.1-8b-instruct",
|
||||
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
||||
"nvidia/nemotron-3-super-120b-a12b:free",
|
||||
"google/gemma-4-26b-a4b-it:free",
|
||||
"nvidia/nemotron-nano-12b-v2-vl:free",
|
||||
"nvidia/nemotron-3-nano-30b-a3b:free",
|
||||
"poolside/laguna-s-2.1:free",
|
||||
"openai/gpt-oss-20b:free"
|
||||
],
|
||||
"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."
|
||||
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test."
|
||||
},
|
||||
|
||||
"ollama": {
|
||||
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
||||
"models": [],
|
||||
|
||||
@@ -91,9 +91,83 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
console.error("[v0] fetchApi: 401 UNAUTHORIZED -", endpoint, "- Token present:", !!token)
|
||||
// Token is missing, expired, or signed under a previous JWT_SECRET
|
||||
// (rotated per-install). Drop the stale token and force a single
|
||||
// reload so the page-level auth gate (`app/page.tsx`) can render
|
||||
// <Login> instead of cascading 401s from every authenticated
|
||||
// component on mount.
|
||||
//
|
||||
// Only react when we actually had a token to invalidate. A 401
|
||||
// without any token in localStorage means the caller is the
|
||||
// Login screen itself, or a leftover fetch from a recently
|
||||
// unmounted Dashboard — reloading there does nothing but waste
|
||||
// the user's keystrokes and can leave the cascade flag set
|
||||
// forever, swallowing the very 401 that we'd want to recover
|
||||
// from after a successful re-login. The fix: bail out early
|
||||
// if we have no token to invalidate.
|
||||
if (typeof window !== "undefined") {
|
||||
let hadToken = false
|
||||
try {
|
||||
hadToken = !!localStorage.getItem("proxmenux-auth-token")
|
||||
} catch {
|
||||
// private browsing — assume yes so we attempt recovery.
|
||||
hadToken = true
|
||||
}
|
||||
if (!hadToken) {
|
||||
throw new Error(`Unauthorized: ${endpoint}`)
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
} catch {
|
||||
// localStorage might be unavailable in private browsing — ignore.
|
||||
}
|
||||
try {
|
||||
if (!sessionStorage.getItem("proxmenux-auth-401-handled")) {
|
||||
sessionStorage.setItem("proxmenux-auth-401-handled", "1")
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable — fall back to a plain reload.
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
throw new Error(`Unauthorized: ${endpoint}`)
|
||||
}
|
||||
// Try to surface the backend's JSON error payload instead of a
|
||||
// bare `500 INTERNAL SERVER ERROR`. The Flask routes consistently
|
||||
// return `{error: "..."}` on failure (e.g. /api/vms/<id>/control
|
||||
// includes the pvesh stderr — telling the user "no space left on
|
||||
// device" is infinitely more useful than the raw status text).
|
||||
//
|
||||
// We also attach the FULL parsed JSON body to the thrown Error
|
||||
// as `.body` so callers that want the optional `details` /
|
||||
// `suggestion` fields (e.g. /api/node/metrics) can render them
|
||||
// without re-fetching. Callers that just read `err.message`
|
||||
// keep working exactly as before.
|
||||
try {
|
||||
const ct = response.headers.get("content-type") || ""
|
||||
if (ct.includes("application/json")) {
|
||||
const body = await response.json()
|
||||
const detail =
|
||||
(body && (body.error || body.message)) || ""
|
||||
if (detail) {
|
||||
const e: Error & { body?: unknown; status?: number } = new Error(detail)
|
||||
e.body = body
|
||||
e.status = response.status
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// Backend-supplied detail (the explicit `throw new Error(detail)`
|
||||
// above) MUST propagate so the UI shows "path does not exist…"
|
||||
// instead of the generic "API request failed: 400 BAD REQUEST".
|
||||
// Only swallow when the JSON itself failed to parse — that's a
|
||||
// real SyntaxError and falling through to the generic message
|
||||
// is the right behaviour there.
|
||||
if (!(parseErr instanceof SyntaxError)) {
|
||||
throw parseErr
|
||||
}
|
||||
}
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
@@ -101,14 +175,14 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
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))
|
||||
console.error("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)
|
||||
console.error("fetchApi: JSON parse error for", endpoint, "-", jsonError)
|
||||
throw new Error(`Invalid JSON response from ${endpoint}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Deterministic OKLCH colouring for category badges. Shared between
|
||||
// the Apps dashboard and the LXC App tab so both surfaces show the
|
||||
// exact same colour for a given category name.
|
||||
//
|
||||
// Hue exclusions
|
||||
// --------------
|
||||
// Two bands are skipped because their meaning is already reserved by
|
||||
// the rest of the Monitor and a chip in those hues on the same view
|
||||
// would be visually confusing:
|
||||
// * 260–319° purple/violet — "update available" (ArrowUpCircle)
|
||||
// * 340–19° red — error / danger signal
|
||||
// Green and yellow ARE used elsewhere for health status, but only as
|
||||
// tiny dots in other views — a chip in those hues on an app card
|
||||
// carries no false meaning, so they stay in the allowed range.
|
||||
//
|
||||
// Allowed ranges after the exclusions:
|
||||
// [20, 260) ∪ [320, 340) = 240° + 20° = 260° of usable hues.
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function hueForCategory(text: string): number {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
|
||||
}
|
||||
const raw = Math.abs(hash) % 260
|
||||
if (raw < 240) return 20 + raw // 0-239 → 20-259 (orange..blue)
|
||||
return 320 + (raw - 240) // 240-259 → 320-339 (pink/magenta)
|
||||
}
|
||||
|
||||
// OKLCH is perceptually uniform — L=0.80 looks equally bright for a
|
||||
// blue and a yellow. HSL fails this because eyes weight green/yellow
|
||||
// more, so the same L% renders visually darker for blues.
|
||||
export function categoryChipStyle(text: string, isLight: boolean): {
|
||||
backgroundColor: string
|
||||
color: string
|
||||
borderColor: string
|
||||
} {
|
||||
const h = hueForCategory(text)
|
||||
if (isLight) {
|
||||
return {
|
||||
backgroundColor: `oklch(0.55 0.20 ${h} / 0.14)`,
|
||||
color: `oklch(0.42 0.19 ${h})`,
|
||||
borderColor: `oklch(0.55 0.20 ${h} / 0.5)`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
backgroundColor: `oklch(0.60 0.16 ${h} / 0.18)`,
|
||||
color: `oklch(0.80 0.16 ${h})`,
|
||||
borderColor: `oklch(0.60 0.16 ${h} / 0.55)`,
|
||||
}
|
||||
}
|
||||
|
||||
// Read the effective theme from next-themes' hooks on <html>:
|
||||
// `class="dark|light"` (Tailwind class strategy) or `data-theme`.
|
||||
// Falls back to the OS setting when the user hasn't chosen one.
|
||||
export function readIsLightTheme(): boolean {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return false
|
||||
const el = document.documentElement
|
||||
if (el.classList.contains("dark")) return false
|
||||
if (el.classList.contains("light")) return true
|
||||
const attr = el.getAttribute("data-theme")
|
||||
if (attr === "light") return true
|
||||
if (attr === "dark") return false
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
}
|
||||
|
||||
// React hook — recomputes when the user toggles theme or the OS pref
|
||||
// flips. Watches <html>'s attributes (data-theme + class) and the
|
||||
// system media query. Used by any component that renders category
|
||||
// chips so they stay legible after a theme change.
|
||||
export function useIsLightTheme(): boolean {
|
||||
const [isLight, setIsLight] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
const update = () => setIsLight(readIsLightTheme())
|
||||
update()
|
||||
const mq = window.matchMedia("(prefers-color-scheme: light)")
|
||||
const observer = new MutationObserver(update)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "class"],
|
||||
})
|
||||
mq.addEventListener("change", update)
|
||||
return () => { observer.disconnect(); mq.removeEventListener("change", update) }
|
||||
}, [])
|
||||
return isLight
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shared classifier for physical-disk type. Lives here because the
|
||||
// Storage page and the Hardware page used to ship their own copies
|
||||
// and silently drifted — old SSDs (e.g. OCZ-SOLID2) that don't expose
|
||||
// a SMART rotation rate fell through Hardware's HDD-as-default branch
|
||||
// and got mislabelled, while the Storage page got it right.
|
||||
//
|
||||
// Backend convention for `rotation_rate`:
|
||||
// undefined / null / 0 → SSD (no platters reported)
|
||||
// -1 → HDD detected via /sys rotational flag,
|
||||
// but the drive doesn't expose RPM
|
||||
// > 0 → HDD with known RPM
|
||||
// string "Solid State" → SSD (smartctl wording on a few vendors)
|
||||
|
||||
export type DiskType = "NVMe" | "SSD" | "HDD"
|
||||
|
||||
export function getDiskType(
|
||||
diskName: string,
|
||||
rotationRate: number | string | null | undefined,
|
||||
): DiskType {
|
||||
if (diskName.startsWith("nvme")) return "NVMe"
|
||||
if (rotationRate === -1) return "HDD"
|
||||
if (typeof rotationRate === "string") {
|
||||
if (rotationRate.includes("Solid State")) return "SSD"
|
||||
const parsed = Number.parseInt(rotationRate, 10)
|
||||
if (Number.isNaN(parsed) || parsed === 0) return "SSD"
|
||||
return "HDD"
|
||||
}
|
||||
if (rotationRate == null || rotationRate === 0) return "SSD"
|
||||
return "HDD"
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Shared accessor for the user-configurable health thresholds.
|
||||
//
|
||||
// The backend exposes the full tree at `GET /api/health/thresholds`.
|
||||
// Several frontend components need *just* the disk-temperature pair
|
||||
// per drive class to color badges, chart bands, and SVG bands in the
|
||||
// SMART report — copy-pasting the numbers around led to two
|
||||
// inconsistent versions diverging from the backend (see Sprint 14.5).
|
||||
//
|
||||
// This module memoises the last fetched payload (TTL 30s) and exposes:
|
||||
//
|
||||
// * `getDiskTempThresholdsSync(diskType)` — synchronous read with a
|
||||
// conservative fallback to the backend defaults. Safe to call from
|
||||
// anywhere, including a render path that can't await.
|
||||
// * `loadDiskTempThresholds()` — async fetch + cache update. Returns
|
||||
// the cached map; call once on mount of any component that uses
|
||||
// the sync getter to ensure the cache is warm.
|
||||
// * `useDiskTempThresholds()` — React hook that fires the fetch on
|
||||
// mount, re-renders when fresh data arrives, and returns the
|
||||
// current map (defaults until the first fetch lands).
|
||||
//
|
||||
// The cache is shared across components so opening multiple disk
|
||||
// modals in quick succession doesn't re-hit the API for each.
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { fetchApi } from "./api-config"
|
||||
|
||||
export type DiskClass = "HDD" | "SSD" | "NVMe" | "SAS"
|
||||
|
||||
export interface DiskTempThreshold {
|
||||
warn: number
|
||||
hot: number
|
||||
}
|
||||
|
||||
export type DiskTempMap = Record<DiskClass, DiskTempThreshold>
|
||||
|
||||
// Fallback values when the API hasn't responded yet (or fails). These
|
||||
// match the recommended defaults baked into `health_thresholds.py`.
|
||||
// Keeping them duplicated here is intentional: the alternative is
|
||||
// blocking every render until the API comes back, which is worse UX.
|
||||
export const DEFAULT_DISK_TEMP: DiskTempMap = {
|
||||
HDD: { warn: 60, hot: 65 },
|
||||
SSD: { warn: 70, hot: 75 },
|
||||
NVMe: { warn: 80, hot: 85 },
|
||||
SAS: { warn: 55, hot: 65 },
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 30_000
|
||||
|
||||
// Module-level cache — shared by every component that imports this.
|
||||
let cached: DiskTempMap = DEFAULT_DISK_TEMP
|
||||
let cachedAt = 0
|
||||
let inflight: Promise<DiskTempMap> | null = null
|
||||
|
||||
// Subscribers are notified when a fresh fetch lands, so the
|
||||
// `useDiskTempThresholds` hook can re-render. Plain JS pub/sub —
|
||||
// nothing fancier needed here.
|
||||
const subscribers = new Set<(map: DiskTempMap) => void>()
|
||||
|
||||
interface ApiThresholdsResponse {
|
||||
success: boolean
|
||||
thresholds?: {
|
||||
disk_temperature?: {
|
||||
hdd?: { warning?: { value: number }; critical?: { value: number } }
|
||||
ssd?: { warning?: { value: number }; critical?: { value: number } }
|
||||
nvme?: { warning?: { value: number }; critical?: { value: number } }
|
||||
sas?: { warning?: { value: number }; critical?: { value: number } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pick(node: any, key: string, fallback: number): number {
|
||||
const v = node?.[key]?.value
|
||||
return typeof v === "number" && isFinite(v) ? v : fallback
|
||||
}
|
||||
|
||||
function parse(payload: ApiThresholdsResponse): DiskTempMap {
|
||||
const dt = payload?.thresholds?.disk_temperature
|
||||
if (!dt) return { ...DEFAULT_DISK_TEMP }
|
||||
return {
|
||||
HDD: {
|
||||
warn: pick(dt.hdd, "warning", DEFAULT_DISK_TEMP.HDD.warn),
|
||||
hot: pick(dt.hdd, "critical", DEFAULT_DISK_TEMP.HDD.hot),
|
||||
},
|
||||
SSD: {
|
||||
warn: pick(dt.ssd, "warning", DEFAULT_DISK_TEMP.SSD.warn),
|
||||
hot: pick(dt.ssd, "critical", DEFAULT_DISK_TEMP.SSD.hot),
|
||||
},
|
||||
NVMe: {
|
||||
warn: pick(dt.nvme, "warning", DEFAULT_DISK_TEMP.NVMe.warn),
|
||||
hot: pick(dt.nvme, "critical", DEFAULT_DISK_TEMP.NVMe.hot),
|
||||
},
|
||||
SAS: {
|
||||
warn: pick(dt.sas, "warning", DEFAULT_DISK_TEMP.SAS.warn),
|
||||
hot: pick(dt.sas, "critical", DEFAULT_DISK_TEMP.SAS.hot),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDiskTempThresholds(force = false): Promise<DiskTempMap> {
|
||||
const now = Date.now()
|
||||
if (!force && cachedAt && now - cachedAt < CACHE_TTL_MS) return cached
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const res = await fetchApi<ApiThresholdsResponse>("/api/health/thresholds")
|
||||
if (res?.success) {
|
||||
cached = parse(res)
|
||||
cachedAt = Date.now()
|
||||
subscribers.forEach((cb) => cb(cached))
|
||||
}
|
||||
} catch {
|
||||
// Leave previous cache in place; defaults are good enough.
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
return cached
|
||||
})()
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function getDiskTempThresholdsSync(diskType: string | undefined): DiskTempThreshold {
|
||||
const t = (diskType || "").toUpperCase()
|
||||
if (t === "HDD") return cached.HDD
|
||||
if (t === "SSD") return cached.SSD
|
||||
if (t === "NVME") return cached.NVMe
|
||||
if (t === "SAS") return cached.SAS
|
||||
// Unknown class — assume SSD-ish numbers (mid-range).
|
||||
return cached.SSD
|
||||
}
|
||||
|
||||
/** React hook: triggers a load on mount, re-renders on cache update. */
|
||||
export function useDiskTempThresholds(): DiskTempMap {
|
||||
const [map, setMap] = useState<DiskTempMap>(cached)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const sub = (m: DiskTempMap) => { if (alive) setMap(m) }
|
||||
subscribers.add(sub)
|
||||
loadDiskTempThresholds().then((m) => { if (alive) setMap(m) })
|
||||
return () => { alive = false; subscribers.delete(sub) }
|
||||
}, [])
|
||||
return map
|
||||
}
|
||||
|
||||
/** Imperative invalidate — call after the user saves new thresholds. */
|
||||
export function invalidateDiskTempThresholdsCache() {
|
||||
cachedAt = 0
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const LANGUAGE_STORAGE_KEY = "proxmenux-ui-language"
|
||||
export const DEFAULT_LANGUAGE = "en"
|
||||
|
||||
export type LanguageCode = "en" | "es" | "fr" | "de" | "it" | "pt" | "sk" | "sv"
|
||||
|
||||
export type LanguageStatus = "complete" | "partial" | "needs-translation"
|
||||
|
||||
export interface SupportedLanguage {
|
||||
code: LanguageCode
|
||||
englishName: string
|
||||
nativeName: string
|
||||
status: LanguageStatus
|
||||
}
|
||||
|
||||
export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [
|
||||
{ code: "en", englishName: "English", nativeName: "English", status: "complete" },
|
||||
{ code: "de", englishName: "German", nativeName: "Deutsch", status: "complete" },
|
||||
{ code: "es", englishName: "Spanish", nativeName: "Español", status: "complete" },
|
||||
{ code: "fr", englishName: "French", nativeName: "Français", status: "complete" },
|
||||
{ code: "it", englishName: "Italian", nativeName: "Italiano", status: "complete" },
|
||||
{ code: "pt", englishName: "Portuguese", nativeName: "Português", status: "complete" },
|
||||
{ code: "sk", englishName: "Slovak", nativeName: "Slovenčina", status: "complete" },
|
||||
{ code: "sv", englishName: "Swedish", nativeName: "Svenska", status: "complete" },
|
||||
]
|
||||
|
||||
export function isSupportedLanguage(value: string | null | undefined): value is LanguageCode {
|
||||
return SUPPORTED_LANGUAGES.some((language) => language.code === value)
|
||||
}
|
||||
|
||||
export function detectBrowserLanguage(): LanguageCode {
|
||||
if (typeof navigator === "undefined") return DEFAULT_LANGUAGE
|
||||
|
||||
const candidates = [navigator.language, ...(navigator.languages || [])]
|
||||
for (const candidate of candidates) {
|
||||
const code = candidate?.split("-")[0]?.toLowerCase()
|
||||
if (isSupportedLanguage(code)) return code
|
||||
}
|
||||
|
||||
return DEFAULT_LANGUAGE
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
||||
import enMessages from "../../messages/en/common.json"
|
||||
import deMessages from "../../messages/de/common.json"
|
||||
import esMessages from "../../messages/es/common.json"
|
||||
import frMessages from "../../messages/fr/common.json"
|
||||
import itMessages from "../../messages/it/common.json"
|
||||
import ptMessages from "../../messages/pt/common.json"
|
||||
import skMessages from "../../messages/sk/common.json"
|
||||
import svMessages from "../../messages/sv/common.json"
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_STORAGE_KEY,
|
||||
type LanguageCode,
|
||||
SUPPORTED_LANGUAGES,
|
||||
detectBrowserLanguage,
|
||||
isSupportedLanguage,
|
||||
} from "./languages"
|
||||
|
||||
type MessageTree = Record<string, unknown>
|
||||
type TranslationParams = Record<string, string | number>
|
||||
|
||||
const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
|
||||
en: enMessages as MessageTree,
|
||||
de: deMessages as MessageTree,
|
||||
es: esMessages as MessageTree,
|
||||
fr: frMessages as MessageTree,
|
||||
it: itMessages as MessageTree,
|
||||
pt: ptMessages as MessageTree,
|
||||
sk: skMessages as MessageTree,
|
||||
sv: svMessages as MessageTree,
|
||||
}
|
||||
|
||||
interface I18nContextValue {
|
||||
language: LanguageCode
|
||||
setLanguage: (language: LanguageCode) => void
|
||||
t: (key: string, params?: TranslationParams) => string
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
|
||||
function getInitialLanguage(): LanguageCode {
|
||||
if (typeof window === "undefined") return DEFAULT_LANGUAGE
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY)
|
||||
if (isSupportedLanguage(stored)) return stored
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing.
|
||||
}
|
||||
|
||||
return detectBrowserLanguage()
|
||||
}
|
||||
|
||||
function getMessage(messages: MessageTree, key: string): string | undefined {
|
||||
const value = key.split(".").reduce<unknown>((cursor, segment) => {
|
||||
if (!cursor || typeof cursor !== "object") return undefined
|
||||
return (cursor as Record<string, unknown>)[segment]
|
||||
}, messages)
|
||||
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: TranslationParams): string {
|
||||
if (!params) return template
|
||||
|
||||
return template.replace(/\{(\w+)\}/g, (match, name) => {
|
||||
const value = params[name]
|
||||
return value === undefined ? match : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguageState] = useState<LanguageCode>(DEFAULT_LANGUAGE)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLanguageState(getInitialLanguage())
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHydrated) return
|
||||
|
||||
document.documentElement.lang = language
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language)
|
||||
} catch {
|
||||
// Best-effort; the in-memory language still works for this session.
|
||||
}
|
||||
}, [isHydrated, language])
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === LANGUAGE_STORAGE_KEY && isSupportedLanguage(event.newValue)) {
|
||||
setLanguageState(event.newValue)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("storage", onStorage)
|
||||
return () => window.removeEventListener("storage", onStorage)
|
||||
}, [])
|
||||
|
||||
const setLanguage = useCallback((nextLanguage: LanguageCode) => {
|
||||
setLanguageState(nextLanguage)
|
||||
}, [])
|
||||
|
||||
const t = useCallback(
|
||||
(key: string, params?: TranslationParams) => {
|
||||
const localized = getMessage(MESSAGE_CATALOG[language], key)
|
||||
const fallback = getMessage(MESSAGE_CATALOG.en, key)
|
||||
return interpolate(localized ?? fallback ?? key, params)
|
||||
},
|
||||
[language],
|
||||
)
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ language, setLanguage, t }), [language, setLanguage, t])
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) {
|
||||
throw new Error("useI18n must be used within I18nProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function useT() {
|
||||
return useI18n().t
|
||||
}
|
||||
|
||||
export { SUPPORTED_LANGUAGES }
|
||||
@@ -0,0 +1,89 @@
|
||||
// Shared cross-component cache for the LXC App-tab payload
|
||||
// (registered sidecar + cached detection suggestions). Used by both
|
||||
// virtual-machines.tsx (which prefetches on modal open and on hover)
|
||||
// and lxc-app-panel.tsx (which reads the cache first and only fetches
|
||||
// if empty). The in-flight promise map dedups concurrent requests: if
|
||||
// the parent already fired a prefetch, the panel awaits the SAME
|
||||
// promise instead of duplicating the request against the backend —
|
||||
// no more racing fetches on tab switch during a slow first visit.
|
||||
|
||||
import { fetchApi } from "./api-config"
|
||||
|
||||
export type LxcAppsBundle = {
|
||||
sidecar: any
|
||||
suggestions: any | null
|
||||
}
|
||||
|
||||
const dataCache = new Map<number, LxcAppsBundle>()
|
||||
const inFlight = new Map<number, Promise<LxcAppsBundle | null>>()
|
||||
const cacheRevision = new Map<number, number>()
|
||||
|
||||
export function getLxcAppsCached(vmid: number): LxcAppsBundle | undefined {
|
||||
return dataCache.get(vmid)
|
||||
}
|
||||
|
||||
// Write-through after a successful App/Updates mutation. The API returns the
|
||||
// complete sidecar, so evicting this entry would throw away newer data and
|
||||
// make the App tab flash "Loading applications..." on its next mount. Keep
|
||||
// the already-fetched suggestions unless the caller explicitly replaces them.
|
||||
export function setLxcAppsCached(
|
||||
vmid: number,
|
||||
sidecar: any,
|
||||
suggestions?: any | null,
|
||||
): LxcAppsBundle {
|
||||
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
|
||||
const current = dataCache.get(vmid)
|
||||
const bundle: LxcAppsBundle = {
|
||||
sidecar,
|
||||
suggestions: suggestions === undefined
|
||||
? (current?.suggestions ?? null)
|
||||
: suggestions,
|
||||
}
|
||||
dataCache.set(vmid, bundle)
|
||||
return bundle
|
||||
}
|
||||
|
||||
export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
|
||||
const existing = inFlight.get(vmid)
|
||||
if (existing) return existing
|
||||
const startedRevision = cacheRevision.get(vmid) || 0
|
||||
const p = Promise.all([
|
||||
fetchApi(`/api/vms/${vmid}/apps`).catch(() => null) as Promise<any>,
|
||||
fetchApi(`/api/vms/${vmid}/apps/suggestions`).catch(() => null) as Promise<any>,
|
||||
])
|
||||
.then(([sc, sug]) => {
|
||||
if (!sc) return null
|
||||
const bundle: LxcAppsBundle = { sidecar: sc, suggestions: sug }
|
||||
// A successful write may have completed while these GETs were in
|
||||
// flight. Never let that older response overwrite the mutation result.
|
||||
if ((cacheRevision.get(vmid) || 0) !== startedRevision) {
|
||||
return dataCache.get(vmid) ?? null
|
||||
}
|
||||
dataCache.set(vmid, bundle)
|
||||
return bundle
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(vmid)
|
||||
})
|
||||
inFlight.set(vmid, p)
|
||||
return p
|
||||
}
|
||||
|
||||
export function invalidateLxcApps(vmid: number): void {
|
||||
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
|
||||
dataCache.delete(vmid)
|
||||
}
|
||||
|
||||
// Seed the cache from the bulk modal-cache endpoint. Both registered
|
||||
// apps and startup detection suggestions are already in memory, so
|
||||
// opening the App tab never starts a discovery scan.
|
||||
export function seedLxcAppsCache(
|
||||
vmid: number,
|
||||
sidecar: any,
|
||||
suggestions?: any | null,
|
||||
): void {
|
||||
if (!sidecar) return
|
||||
const existing = dataCache.get(vmid)
|
||||
if (existing) return // per-panel fetch already ran, don't overwrite
|
||||
dataCache.set(vmid, { sidecar, suggestions: suggestions ?? null })
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Proxmox VE tag color scheme — 1:1 port of the algorithm in
|
||||
// proxmoxlib.js (`Proxmox.Utils.stringToRGB` +
|
||||
// `Proxmox.Utils.getTextContrastClass`). Same input → same color
|
||||
// as the PVE web UI, so tags render identically in both places.
|
||||
|
||||
export type TagColor = {
|
||||
bg: string // css `background-color`
|
||||
fg: string // css `color` — auto-picked for contrast (SAPC)
|
||||
border: string // css `border-color`
|
||||
}
|
||||
|
||||
// Verbatim port of stringToRGB from proxmoxlib.js. The `+ 'prox'`
|
||||
// suffix, the `<< 5` hash, and the `alpha=0.7 / bg=255` blend
|
||||
// keep the output in the [76.5, 255] range per channel — that's
|
||||
// why every PVE tag is a "washed" bright color instead of a raw
|
||||
// hash-hue.
|
||||
function stringToRGB(input: string): [number, number, number] {
|
||||
let hash = 0
|
||||
if (!input) return [255, 255, 255]
|
||||
const source = input + "prox"
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
hash = source.charCodeAt(i) + ((hash << 5) - hash)
|
||||
// eslint-disable-next-line no-bitwise
|
||||
hash = hash & hash
|
||||
}
|
||||
const alpha = 0.7
|
||||
const bg = 255
|
||||
return [
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(hash & 255) * alpha + bg * (1 - alpha),
|
||||
// eslint-disable-next-line no-bitwise
|
||||
((hash >> 8) & 255) * alpha + bg * (1 - alpha),
|
||||
// eslint-disable-next-line no-bitwise
|
||||
((hash >> 16) & 255) * alpha + bg * (1 - alpha),
|
||||
]
|
||||
}
|
||||
|
||||
// SAPC-based light/dark text picker — verbatim port of
|
||||
// getTextContrastClass. Same tag → same text color as PVE.
|
||||
function getTextContrastClass(rgb: [number, number, number]): "light" | "dark" {
|
||||
const blkThrs = 0.022
|
||||
const blkClmp = 1.414
|
||||
const r = (rgb[0] / 255) ** 2.4
|
||||
const g = (rgb[1] / 255) ** 2.4
|
||||
const b = (rgb[2] / 255) ** 2.4
|
||||
let bg = r * 0.2126729 + g * 0.7151522 + b * 0.072175
|
||||
bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp
|
||||
const contrastLight = bg ** 0.65 - 1
|
||||
const contrastDark = bg ** 0.56 - 0.046134502
|
||||
return Math.abs(contrastLight) >= Math.abs(contrastDark) ? "light" : "dark"
|
||||
}
|
||||
|
||||
function rgbToCss(rgb: [number, number, number]): string {
|
||||
return `rgb(${Math.round(rgb[0])}, ${Math.round(rgb[1])}, ${Math.round(rgb[2])})`
|
||||
}
|
||||
|
||||
export function tagToColor(tag: string): TagColor {
|
||||
const rgb = stringToRGB(tag)
|
||||
const bg = rgbToCss(rgb)
|
||||
const fg = getTextContrastClass(rgb) === "light" ? "#ffffff" : "#000000"
|
||||
return { bg, fg, border: bg }
|
||||
}
|
||||
|
||||
// Split a PVE tags string into an array. PVE separators are ';' and
|
||||
// ',' (both accepted); whitespace around tokens is stripped and
|
||||
// empty tokens dropped.
|
||||
export function parseTags(raw: string | null | undefined): string[] {
|
||||
if (!raw) return []
|
||||
return raw
|
||||
.split(/[;,]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Join back into the canonical PVE format (';' separator).
|
||||
export function stringifyTags(tags: string[]): string {
|
||||
return tags.map((t) => t.trim()).filter(Boolean).join(";")
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Shared usage-bar palette for storage capacity widgets. Extracted
|
||||
// so the Storage page (overview cards + per-storage rows) and the
|
||||
// Backups page (Available Archives → per-archive capacity bar) flag
|
||||
// a full datastore with the same colour. Previously the Backups bar
|
||||
// was hard-coded to blue, so a 100%-full PBS-Cloud appeared in red
|
||||
// on Storage and in blue on Backups — same datastore, two different
|
||||
// signals.
|
||||
//
|
||||
// Thresholds: < 75 % blue (normal — no alert), 75–89 % amber,
|
||||
// ≥ 90 % red. Matches the Storage page palette: the normal state
|
||||
// stays on the project's brand blue and only switches to amber/red
|
||||
// when the operator should look at it. Green is reserved for OK
|
||||
// signals where green has meaning (SMART status, wear level), not
|
||||
// for ambient bars.
|
||||
|
||||
export type UsageBarColor = {
|
||||
/** Inline `background` value for SVG / style={} consumers. */
|
||||
hex: string
|
||||
/** Tailwind `bg-*` class for div consumers. */
|
||||
bgClass: string
|
||||
/** Tailwind `text-*` class for "Free" / counters that should
|
||||
* share the urgency signal. */
|
||||
textClass: string
|
||||
}
|
||||
|
||||
const BLUE: UsageBarColor = {
|
||||
hex: "#3b82f6",
|
||||
bgClass: "bg-blue-500",
|
||||
// No text-blue override for the "Free" counter — at normal usage
|
||||
// the foreground colour reads better than tinted text.
|
||||
textClass: "",
|
||||
}
|
||||
const AMBER: UsageBarColor = {
|
||||
hex: "#f59e0b",
|
||||
bgClass: "bg-amber-500",
|
||||
textClass: "text-amber-400",
|
||||
}
|
||||
const RED: UsageBarColor = {
|
||||
hex: "#ef4444",
|
||||
bgClass: "bg-red-500",
|
||||
textClass: "text-red-400",
|
||||
}
|
||||
|
||||
export function getStorageUsageColor(percent: number): UsageBarColor {
|
||||
if (percent >= 90) return RED
|
||||
if (percent >= 75) return AMBER
|
||||
return BLUE
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
|
||||
// Persistent top-level tab order for the Monitor dashboard.
|
||||
//
|
||||
// Only the seven top-level slots are user-orderable; the internal
|
||||
// items of the Node and Admin dropdowns keep their canonical order —
|
||||
// grouped items move as a single unit.
|
||||
|
||||
export type TabId = "overview" | "apps" | "vms" | "node" | "backup" | "terminal" | "admin"
|
||||
|
||||
export const DEFAULT_TAB_ORDER: TabId[] = [
|
||||
"overview",
|
||||
"apps",
|
||||
"vms",
|
||||
"node",
|
||||
"backup",
|
||||
"terminal",
|
||||
"admin",
|
||||
]
|
||||
|
||||
const STORAGE_KEY = "proxmenux-nav-order"
|
||||
const CHANGE_EVENT = "proxmenux-nav-order-changed"
|
||||
|
||||
function isTabId(v: unknown): v is TabId {
|
||||
return typeof v === "string" && (DEFAULT_TAB_ORDER as string[]).includes(v)
|
||||
}
|
||||
|
||||
// Read + normalise: unknown ids are dropped, missing ones are
|
||||
// appended in their default position so a future release adding a
|
||||
// new tab still surfaces it for users with a stored order.
|
||||
export function readTabOrder(): TabId[] {
|
||||
if (typeof window === "undefined") return DEFAULT_TAB_ORDER
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_TAB_ORDER
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return DEFAULT_TAB_ORDER
|
||||
const seen = new Set<TabId>()
|
||||
const clean: TabId[] = []
|
||||
for (const item of parsed) {
|
||||
if (isTabId(item) && !seen.has(item)) {
|
||||
clean.push(item)
|
||||
seen.add(item)
|
||||
}
|
||||
}
|
||||
for (const id of DEFAULT_TAB_ORDER) {
|
||||
if (!seen.has(id)) clean.push(id)
|
||||
}
|
||||
return clean
|
||||
} catch {
|
||||
return DEFAULT_TAB_ORDER
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTabOrder(order: TabId[]): void {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(order))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
// Storage full / disabled — the in-memory state still updates.
|
||||
}
|
||||
}
|
||||
|
||||
// Map a top-level slot id to the concrete `activeTab` value the
|
||||
// Tabs component uses. Direct tabs pass through; grouped slots
|
||||
// (Node/Admin) resolve to the first child in the dropdown so the
|
||||
// dashboard lands on a real tab, not a group header.
|
||||
const GROUP_FIRST_CHILD: Record<TabId, string> = {
|
||||
overview: "overview",
|
||||
apps: "apps",
|
||||
vms: "vms",
|
||||
node: "storage",
|
||||
backup: "backup",
|
||||
terminal: "terminal",
|
||||
admin: "logs",
|
||||
}
|
||||
|
||||
export function firstActualTab(order: TabId[] = readTabOrder()): string {
|
||||
const head = order[0]
|
||||
return (head && GROUP_FIRST_CHILD[head]) || "overview"
|
||||
}
|
||||
|
||||
export function resetTabOrder(): void {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that keeps every consumer in sync. Firing a custom event on
|
||||
// write means the Settings card and the top navigation update in the
|
||||
// same tick without prop-drilling.
|
||||
export function useTabOrder(): {
|
||||
order: TabId[]
|
||||
setOrder: (next: TabId[]) => void
|
||||
reset: () => void
|
||||
isCustom: boolean
|
||||
} {
|
||||
const [order, setOrderState] = useState<TabId[]>(DEFAULT_TAB_ORDER)
|
||||
|
||||
useEffect(() => {
|
||||
setOrderState(readTabOrder())
|
||||
const onChange = () => setOrderState(readTabOrder())
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEY) onChange()
|
||||
})
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setOrder = useCallback((next: TabId[]) => {
|
||||
writeTabOrder(next)
|
||||
setOrderState(next)
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
resetTabOrder()
|
||||
setOrderState(DEFAULT_TAB_ORDER)
|
||||
}, [])
|
||||
|
||||
const isCustom =
|
||||
order.length !== DEFAULT_TAB_ORDER.length ||
|
||||
order.some((id, idx) => id !== DEFAULT_TAB_ORDER[idx])
|
||||
|
||||
return { order, setOrder, reset, isCustom }
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Clipboard helpers for the web terminals.
|
||||
*
|
||||
* Mobile browsers (iOS Safari, Android Chrome) don't expose xterm.js's text
|
||||
* selection / clipboard the same way desktop does, and the mobile toolbar
|
||||
* around our terminals doesn't include explicit copy/paste keys. The helpers
|
||||
* below give the toolbar a robust path that:
|
||||
* - Uses the modern async Clipboard API on HTTPS / localhost.
|
||||
* - Falls back to a hidden <textarea> + document.execCommand on plain HTTP
|
||||
* (where the async API is gated by the secure-context requirement).
|
||||
* - Surfaces a user-visible cue (no toast manager in this stack yet) by
|
||||
* returning a result the caller can react to.
|
||||
*/
|
||||
|
||||
// xterm.js is imported dynamically by the terminal components and the
|
||||
// `term` field is typed `any` there. We mirror that here with a minimal
|
||||
// structural type so this helper has no hard dependency on @xterm/xterm.
|
||||
type XtermLike = { getSelection?: () => string }
|
||||
|
||||
export type ClipboardResult = {
|
||||
ok: boolean
|
||||
/** Bytes / chars copied (only meaningful on copy). */
|
||||
length?: number
|
||||
/** Best-effort error string for logging — never surfaced verbatim to the user. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the current xterm selection to the clipboard. If there is no active
|
||||
* selection, returns ok=false with length=0 so the caller can decide whether to
|
||||
* show a "select text first" hint.
|
||||
*/
|
||||
export async function copyTerminalSelection(term: XtermLike | null | undefined): Promise<ClipboardResult> {
|
||||
const text = term?.getSelection?.() ?? ""
|
||||
if (!text) {
|
||||
return { ok: false, length: 0, error: "no-selection" }
|
||||
}
|
||||
return copyText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads text from the clipboard and feeds it to the terminal via `sendFn`.
|
||||
* The `sendFn` is the WebSocket sender (or any fn that takes a string and
|
||||
* pushes it to the remote PTY). Any newlines remain intact so that pasting
|
||||
* a multi-line block triggers as Enter on each line — same as desktop xterm.
|
||||
*
|
||||
* Mobile users on plain HTTP (the common case for this dashboard — accessed
|
||||
* via `http://<host>:8008` from an iPad/phone on the LAN) hit two layers of
|
||||
* blocking:
|
||||
* 1. `window.isSecureContext` is false on plain HTTP, so the legacy code
|
||||
* skipped the async API and surfaced a silent error.
|
||||
* 2. There is no `execCommand('paste')` equivalent that works portably.
|
||||
*
|
||||
* The fix here:
|
||||
* - Attempt `navigator.clipboard.readText()` even when not secure-context;
|
||||
* many modern browsers permit it on localhost/LAN with user gesture, and
|
||||
* when they don't they throw, which falls through cleanly.
|
||||
* - If that fails / returns empty, fall back to `window.prompt()`. The
|
||||
* native prompt accepts a long-press paste from the OS clipboard on
|
||||
* every mobile platform, so the user can finish the paste manually
|
||||
* with one extra tap. Empty / cancelled prompt returns ok=false.
|
||||
*/
|
||||
export async function pasteFromClipboard(
|
||||
sendFn: (text: string) => void,
|
||||
): Promise<ClipboardResult> {
|
||||
// Path 1 — async Clipboard API. Try regardless of `isSecureContext` so
|
||||
// browsers that allow it on LAN-HTTP (Chrome on Android, Firefox) can
|
||||
// succeed. Throws on iOS Safari / strict configurations — we fall through.
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.readText) {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) {
|
||||
sendFn(text)
|
||||
return { ok: true, length: text.length }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Permission denied / not focused / insecure context — fall through to prompt().
|
||||
}
|
||||
|
||||
// Path 2 — `window.prompt()` fallback. Universally supported, accepts a
|
||||
// long-press paste from the system clipboard, and works over plain HTTP.
|
||||
// This is the path mobile users without HTTPS rely on.
|
||||
try {
|
||||
const text = typeof window !== "undefined"
|
||||
? window.prompt("Paste content for the terminal:", "")
|
||||
: null
|
||||
if (text) {
|
||||
sendFn(text)
|
||||
return { ok: true, length: text.length }
|
||||
}
|
||||
return { ok: false, error: "user-cancelled" }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "prompt-failed" }
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<ClipboardResult> {
|
||||
// Preferred path: async Clipboard API on HTTPS / localhost.
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return { ok: true, length: text.length }
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
// Legacy fallback: hidden textarea + execCommand("copy"). Works on plain HTTP
|
||||
// where the async API is blocked by the secure-context gate.
|
||||
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()
|
||||
const ok = document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
return ok ? { ok: true, length: text.length } : { ok: false, error: "execCommand-failed" }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "fallback-failed" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Helpers for opening WebSocket connections that require a single-use ticket.
|
||||
*
|
||||
* The browser WebSocket API does not allow custom request headers, so the JWT
|
||||
* Bearer token used for REST calls cannot be sent on the handshake. Instead we
|
||||
* POST to /api/terminal/ticket (which does require the Bearer token), receive
|
||||
* a one-shot ticket with TTL ~5s, and append it to the WebSocket URL as a
|
||||
* query parameter. The backend consumes the ticket atomically on handshake.
|
||||
*
|
||||
* See AppImage/scripts/flask_terminal_routes.py — `_issue_terminal_ticket`,
|
||||
* `_consume_terminal_ticket`, `_ws_auth_check`.
|
||||
*/
|
||||
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
type TicketResponse = {
|
||||
success?: boolean
|
||||
ticket?: string
|
||||
ttl_seconds?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a one-shot terminal ticket from the backend. Returns the ticket string
|
||||
* or null if the call fails. Callers should treat null as "open without ticket"
|
||||
* — the backend's _ws_auth_check still accepts unticketed handshakes when auth
|
||||
* is disabled or declined, so a fresh-install / no-auth setup keeps working.
|
||||
*/
|
||||
export async function fetchTerminalTicket(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchApi<TicketResponse>("/api/terminal/ticket", { method: "POST" })
|
||||
return typeof res?.ticket === "string" && res.ticket.length > 0 ? res.ticket : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a base WebSocket URL (e.g. "ws://host:8008/ws/terminal") and return a
|
||||
* URL with `?ticket=<value>` appended. If the ticket fetch fails the original
|
||||
* URL is returned unchanged so the handshake can still succeed in unauth mode.
|
||||
*/
|
||||
export async function getTicketedWsUrl(baseUrl: string): Promise<string> {
|
||||
const ticket = await fetchTerminalTicket()
|
||||
if (!ticket) return baseUrl
|
||||
const sep = baseUrl.includes("?") ? "&" : "?"
|
||||
return `${baseUrl}${sep}ticket=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
@@ -19,3 +19,20 @@ export function formatStorage(sizeInGB: number): string {
|
||||
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
|
||||
}
|
||||
}
|
||||
|
||||
// Byte-aware formatter. Scales B → KB → MB → GB → TB. Use when the
|
||||
// raw value comes in bytes (log file sizes from os.path.getsize(),
|
||||
// PBS / Borg datastore capacity reported by the backend in bytes).
|
||||
// The Backups page used to ship its own copy that capped at GB, so a
|
||||
// 7 TB datastore showed up as "7311.55 GB" — extracted here so it
|
||||
// can't drift again.
|
||||
export function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return "—"
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
if (n < 1024 * 1024 * 1024 * 1024) {
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||||
}
|
||||
return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Single source of truth for the app version displayed inside the
|
||||
// Monitor. Every component that renders the version (dashboard footer,
|
||||
// SMART report footer, release-notes modal…) imports from here so a
|
||||
// version bump only has to be applied once per file lane:
|
||||
//
|
||||
// 1. AppImage/lib/version.ts ← this file
|
||||
// 2. AppImage/package.json ← npm/Next.js metadata
|
||||
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
|
||||
//
|
||||
// Keep the three in sync on every bump.
|
||||
export const APP_VERSION = "1.2.6"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Monitor dashboard translations
|
||||
|
||||
The ProxMenux Monitor dashboard uses a small client-side i18n layer.
|
||||
|
||||
- English (`en`) is the source language and the runtime fallback.
|
||||
- Eight locales are shipped and fully populated end-to-end: `en`, `de`, `es`, `fr`, `it`, `pt`, `sk`, `sv`. Non-English catalogs started from an automated bootstrap and are being polished as native speakers pass through them.
|
||||
|
||||
To improve a translation, edit the values in your locale's `common.json` — keep placeholders such as `{uptime}`, `{vmid}` or `{count}` unchanged, and don't translate brand or product names (`ProxMenux Monitor`, `Proxmox Backup Server`, `Secure Gateway`, `Tailscale`, etc.). Missing keys fall back to English at runtime, so a partial refresh is always safe to merge.
|
||||
|
||||
To add a new locale, see [§11 → Adding a new locale](../../CONTRIBUTING.md#adding-a-new-locale) in the Contributing Guide.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user