mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-04 20:02:07 +00:00
Compare commits
1143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b384e64ee | ||
|
|
5155c0c0c8 | ||
|
|
e633ecadcb | ||
|
|
85794a8989 | ||
|
|
11449239cb | ||
|
|
69b76116fd | ||
|
|
873a3cd8e2 | ||
|
|
dd16682603 | ||
|
|
cf830ef143 | ||
|
|
bbec8d34d1 | ||
|
|
d33b131c4d | ||
|
|
0fd3be15c1 | ||
|
|
8b8fed2564 | ||
|
|
b581d4c498 | ||
|
|
5be6c14221 | ||
|
|
04cfa2c587 | ||
|
|
a552da6f91 | ||
|
|
56d7a1839b | ||
|
|
155cafcb73 | ||
|
|
a1bac7aeec | ||
|
|
716b9ae871 | ||
|
|
b46cc4bd6d | ||
|
|
0460db741e | ||
|
|
539f91189e | ||
|
|
522860e352 | ||
|
|
216298377a | ||
|
|
07321333ca | ||
|
|
f319ba4b2b | ||
|
|
47ef6a96c5 | ||
|
|
10e42c3c9a | ||
|
|
c2936d4587 | ||
|
|
9f09272889 | ||
|
|
51851136ab | ||
|
|
9ef621e049 | ||
|
|
9a3f254b10 | ||
|
|
815d98ab9f | ||
|
|
5a892db7c9 | ||
|
|
82f69be859 | ||
|
|
16ab9808ac | ||
|
|
316eb2f104 | ||
|
|
11479bcfe1 | ||
|
|
3bc45535e4 | ||
|
|
66c621fe7a | ||
|
|
5604094907 | ||
|
|
abf436c352 | ||
|
|
1a5fec13ec | ||
|
|
8879abf7bb | ||
|
|
73bca5f130 | ||
|
|
eb5105f582 | ||
|
|
c7efdd2e59 | ||
|
|
693e6ae481 | ||
|
|
07713d5365 | ||
|
|
053b6c81f8 | ||
|
|
f5ad4072b5 | ||
|
|
b629938cf1 | ||
|
|
957fef8c62 | ||
|
|
b927db8708 | ||
|
|
8ff0d68c89 | ||
|
|
f0d7d4f8f7 | ||
|
|
fc14ff5c33 | ||
|
|
feb13949e1 | ||
|
|
41ae752da1 | ||
|
|
6b70c3934e | ||
|
|
1e00eeac4f | ||
|
|
19a7068fee | ||
|
|
f6274e360b | ||
|
|
42c927ecfd | ||
|
|
a60e065d33 | ||
|
|
74df2969dc | ||
|
|
3f75b94ea1 | ||
|
|
747b32489f | ||
|
|
4a3dd8cc43 | ||
|
|
c343b5ade1 | ||
|
|
69c9984ff7 | ||
|
|
be396e0054 | ||
|
|
f788f205b8 | ||
|
|
f464b62190 | ||
|
|
ebb25e5a70 | ||
|
|
cff386053b | ||
|
|
0cf62149fd | ||
|
|
3b6615194f | ||
|
|
b4f4d98e85 | ||
|
|
c6b2b74bf0 | ||
|
|
f04b84cb89 | ||
|
|
85b88f709a | ||
|
|
12632c027e | ||
|
|
8639ed1525 | ||
|
|
374d95921c | ||
|
|
dd28d2d341 | ||
|
|
b2398a1cae | ||
|
|
b36e5292ff | ||
|
|
c7256042e4 | ||
|
|
65f406f13e | ||
|
|
8d456f7ca1 | ||
|
|
37acfbc369 | ||
|
|
32e67ba54b | ||
|
|
75c528e5c2 | ||
|
|
6b50763e54 | ||
|
|
7a4a6e114d | ||
|
|
55de7d1cbb | ||
|
|
73f484834a | ||
|
|
f82d5d1fb0 | ||
|
|
c7dba81f8f | ||
|
|
ee6d53acc5 | ||
|
|
951acfb14e | ||
|
|
f6eccfb25c | ||
|
|
c739699ec7 | ||
|
|
1f9d2acef5 | ||
|
|
7e9f16fdb3 | ||
|
|
cb07f34626 | ||
|
|
a40ad157dd | ||
|
|
badd0f8771 | ||
|
|
9b5cefb81a | ||
|
|
20ee21c08f | ||
|
|
284851627e | ||
|
|
96f3d82e15 | ||
|
|
b7e053b88a | ||
|
|
f80e6c3e39 | ||
|
|
e5fc9c7188 | ||
|
|
37a461c5e5 | ||
|
|
40788855db | ||
|
|
c91e7ecbac | ||
|
|
6d40cfc8f9 | ||
|
|
8e5920faf0 | ||
|
|
872d90e75a | ||
|
|
9cbcd0d764 | ||
|
|
064b169ae9 | ||
|
|
c609fa0e12 | ||
|
|
a4cb9936a5 | ||
|
|
07f88659ea | ||
|
|
ea5fc041b8 | ||
|
|
21babcc14d | ||
|
|
aac75a0753 | ||
|
|
54eef11334 | ||
|
|
3be15211b1 | ||
|
|
8e396f957f | ||
|
|
08598d2863 | ||
|
|
147c401864 | ||
|
|
e24308c017 | ||
|
|
e52f52388a | ||
|
|
2ea261dca3 | ||
|
|
f80e0b6784 | ||
|
|
5faf55a746 | ||
|
|
dcf00cc6b9 | ||
|
|
4f651ffed5 | ||
|
|
d137bb3d31 | ||
|
|
4e6d43f6a5 | ||
|
|
c83f84398b | ||
|
|
d2ac3b7638 | ||
|
|
028026a9a0 | ||
|
|
e9957415ed | ||
|
|
22e8bf2532 | ||
|
|
c1012356f2 | ||
|
|
fda584f65b | ||
|
|
4fd987fdd5 | ||
|
|
0fa112de56 | ||
|
|
2398b605b2 | ||
|
|
f421ac1cb1 | ||
|
|
889fc4daf4 | ||
|
|
438cb935d0 | ||
|
|
c40a335301 | ||
|
|
72c4dcd0be | ||
|
|
135df4b2c5 | ||
|
|
db4ef3f29b | ||
|
|
41099960d6 | ||
|
|
ad24cd9c15 | ||
|
|
b7154ffee9 | ||
|
|
e18ff82eea | ||
|
|
18bf0c3e7e | ||
|
|
c93c0dbcef | ||
|
|
e57a2f3aae | ||
|
|
eb7cc548fa | ||
|
|
a17f4120e3 | ||
|
|
57a69e1f92 | ||
|
|
1f1a2176fd | ||
|
|
25cb81d740 | ||
|
|
4c08ef6ea0 | ||
|
|
d14b968930 | ||
|
|
d27012806b | ||
|
|
72f77069ca | ||
|
|
eb869d1250 | ||
|
|
0b82e91dd7 | ||
|
|
6e303e35ce | ||
|
|
0ae601f5e7 | ||
|
|
4a5dd8a94e | ||
|
|
55999da58a | ||
|
|
328a690712 | ||
|
|
46af79eb51 | ||
|
|
118a757ac7 | ||
|
|
2922fc235d | ||
|
|
58fb192b29 | ||
|
|
7ec6d06dc3 | ||
|
|
488b9b6521 | ||
|
|
79547dec35 | ||
|
|
297c026c95 | ||
|
|
0c754bda60 | ||
|
|
33245a423a | ||
|
|
bf07f0a8a9 | ||
|
|
adc42caae3 | ||
|
|
3d2aff6c66 | ||
|
|
a3f07e643e | ||
|
|
642f1207c7 | ||
|
|
b181fbd1e8 | ||
|
|
82e3791b03 | ||
|
|
2756af0b33 | ||
|
|
4a77d11bc4 | ||
|
|
8e3ef83c19 | ||
|
|
e071fdf4a1 | ||
|
|
3e7ebc4be0 | ||
|
|
fa48be17d1 | ||
|
|
8aade0e55c | ||
|
|
115295acb6 | ||
|
|
2a17ea84ac | ||
|
|
8e321e6d72 | ||
|
|
c026ae59fe | ||
|
|
a0ff593241 | ||
|
|
dcf0052039 | ||
|
|
291b657218 | ||
|
|
cdedf3ed24 | ||
|
|
cb7609bb05 | ||
|
|
5a4fab8abd | ||
|
|
f5627afe27 | ||
|
|
ec391246a1 | ||
|
|
d45da0742d | ||
|
|
b1e11e7666 | ||
|
|
52bb982721 | ||
|
|
1a9136514d | ||
|
|
687f446f00 | ||
|
|
0948d7b078 | ||
|
|
72d3e6aefe | ||
|
|
f3c4959fa4 | ||
|
|
24617f9924 | ||
|
|
11b97d1aea | ||
|
|
c4c654bfc6 | ||
|
|
73edc51f1a | ||
|
|
4d370d3edc | ||
|
|
884817f05c | ||
|
|
ca9dbba65b | ||
|
|
1367bcb7d4 | ||
|
|
6e7be4c029 | ||
|
|
15692fe031 | ||
|
|
1d445f7064 | ||
|
|
8ab72b8bcc | ||
|
|
e72bfcf8be | ||
|
|
8610f66fe1 | ||
|
|
8d9f38e7c2 | ||
|
|
3fa5a379f3 | ||
|
|
c2298a09be | ||
|
|
6d53035936 | ||
|
|
9998a937d5 | ||
|
|
b69aeea360 | ||
|
|
02c4206d49 | ||
|
|
c7b052c424 | ||
|
|
dd4bcfa867 | ||
|
|
93862abdb0 | ||
|
|
e14a9911fd | ||
|
|
1e6b8b1c20 | ||
|
|
00bc122611 | ||
|
|
756851350a | ||
|
|
cb5da6ce76 | ||
|
|
1bf792adce | ||
|
|
f7266e7b44 | ||
|
|
88acceb8ef | ||
|
|
14583ad9f6 | ||
|
|
557c34e1f6 | ||
|
|
d1bb843f67 | ||
|
|
174f178f5b | ||
|
|
ace445b2dd | ||
|
|
c0fa75f404 | ||
|
|
53b415b127 | ||
|
|
91e62b16e0 | ||
|
|
e8a7ba6f47 | ||
|
|
4437a671d2 | ||
|
|
77080367bb | ||
|
|
d468b54226 | ||
|
|
29fe30e2f4 | ||
|
|
42bb91868f | ||
|
|
583e45f568 | ||
|
|
386d33df6e | ||
|
|
2124b97347 | ||
|
|
8333a7ba1f | ||
|
|
32ffa63879 | ||
|
|
4b24ab9681 | ||
|
|
72a38dbf38 | ||
|
|
fe46d3d4bc | ||
|
|
4f79144c5a | ||
|
|
11d1d125dc | ||
|
|
6cf3f4a520 | ||
|
|
8297a5c935 | ||
|
|
dee18df711 | ||
|
|
d415c55187 | ||
|
|
f3bda28338 | ||
|
|
3bda7851a1 | ||
|
|
41b999ffeb | ||
|
|
13ad61e8b0 | ||
|
|
7e39aa550c | ||
|
|
852f14e5b4 | ||
|
|
75f571ecdb | ||
|
|
eafc472279 | ||
|
|
f8fade3c0a | ||
|
|
2a8f220462 | ||
|
|
44db612d01 | ||
|
|
9c9bc7d15b | ||
|
|
5f09af0162 | ||
|
|
4322ae517b | ||
|
|
f3a2b70219 | ||
|
|
1858fd5c60 | ||
|
|
3f3e3f05d9 | ||
|
|
f6bc0ea969 | ||
|
|
6fc893d3f4 | ||
|
|
ce6006094e | ||
|
|
ee12d421f0 | ||
|
|
4594177035 | ||
|
|
b06e0e8d13 | ||
|
|
7ad3e629c8 | ||
|
|
60505a74a9 | ||
|
|
246393d3d6 | ||
|
|
e6d3f2e3cb | ||
|
|
24a2557d8e | ||
|
|
90a925f88c | ||
|
|
56d51dd461 | ||
|
|
65cb75a6b7 | ||
|
|
c2f7bb4efc | ||
|
|
eb13ca620e | ||
|
|
14f0831b05 | ||
|
|
976998806c | ||
|
|
dda8deb6e4 | ||
|
|
bcabcb618c | ||
|
|
b36498f215 | ||
|
|
4f1d70775f | ||
|
|
94c3da003d | ||
|
|
2d25585faf | ||
|
|
f63362b460 | ||
|
|
4d4828e7c8 | ||
|
|
a6ca3d4222 | ||
|
|
3b18e3e664 | ||
|
|
402278ecfd | ||
|
|
db8cc2267a | ||
|
|
6a7ffad9b8 | ||
|
|
5dffb5cb88 | ||
|
|
22c569dc41 | ||
|
|
704eb3f9fb | ||
|
|
bc736926c5 | ||
|
|
e318914d01 | ||
|
|
32355ded88 | ||
|
|
6cd2b516b1 | ||
|
|
f5e4427903 | ||
|
|
e19927af48 | ||
|
|
44e91c4710 | ||
|
|
f6e3738c1e | ||
|
|
d5bce37ed2 | ||
|
|
d8b1716c0a | ||
|
|
daf711328b | ||
|
|
a727cf0118 | ||
|
|
86d6ae4dec | ||
|
|
702c774d31 | ||
|
|
60f71c0c8a | ||
|
|
12684f483d | ||
|
|
c7f58079b9 | ||
|
|
603fb0e03a | ||
|
|
63e72abb27 | ||
|
|
40cfe78aa7 | ||
|
|
1151928b3e | ||
|
|
10a69947dc | ||
|
|
2f8197eb97 | ||
|
|
82c3a49f4c | ||
|
|
661b944e0c | ||
|
|
d61719dcaf | ||
|
|
25b71efab0 | ||
|
|
ad23a1167f | ||
|
|
dc283bed9a | ||
|
|
da7bf37c07 | ||
|
|
38ac46460e | ||
|
|
f11aba3492 | ||
|
|
376b9504f8 | ||
|
|
584c4c31a3 | ||
|
|
ca163cd2bb | ||
|
|
7587b2ad6a | ||
|
|
c83c68b4ee | ||
|
|
49cb8133ed | ||
|
|
dede876f0b | ||
|
|
256f3232e4 | ||
|
|
78971f4ed7 | ||
|
|
c89c19deb2 | ||
|
|
2308b4ddc1 | ||
|
|
cc7b38a141 | ||
|
|
dc05b1125b | ||
|
|
36bca5908b | ||
|
|
cbc5b9d953 | ||
|
|
e61fdbf84f | ||
|
|
e67d5f1617 | ||
|
|
991cf732e5 | ||
|
|
8918675393 | ||
|
|
e0c5740a47 | ||
|
|
9ff1fa1bda | ||
|
|
7b06c90040 | ||
|
|
f6a3dc2f24 | ||
|
|
9bdd0cb9fc | ||
|
|
6f7e94d095 | ||
|
|
8c67f883c9 | ||
|
|
9d3285d0df | ||
|
|
b5fb747271 | ||
|
|
1e6efe37a0 | ||
|
|
abef0c0303 | ||
|
|
bd6d1e323e | ||
|
|
9db1696d5a | ||
|
|
c6cb14c651 | ||
|
|
57b77eb212 | ||
|
|
8d843c90e9 | ||
|
|
9c3f5feda1 | ||
|
|
e4f78a1227 | ||
|
|
983bf5c610 | ||
|
|
78d2d84f20 | ||
|
|
0504fcd41d | ||
|
|
0b64549230 | ||
|
|
f12ca3ed27 | ||
|
|
2a672889bf | ||
|
|
9c930de186 | ||
|
|
bee242afa9 | ||
|
|
e2dadebda1 | ||
|
|
1830852901 | ||
|
|
5d73cd4aec | ||
|
|
b5b128264d | ||
|
|
08810d4b16 | ||
|
|
6b8bb97922 | ||
|
|
a61ad04ebe | ||
|
|
91c453d33c | ||
|
|
22defbfc7d | ||
|
|
0284a7e7da | ||
|
|
8e0d4ff337 | ||
|
|
ca7c9ec58e | ||
|
|
b68105e9f0 | ||
|
|
7a45ac2de3 | ||
|
|
085cbf7e68 | ||
|
|
d99006bb3c | ||
|
|
4915d4044c | ||
|
|
90c4a720e3 | ||
|
|
36071e6bd3 | ||
|
|
715f4195b2 | ||
|
|
023350d7e1 | ||
|
|
c6fde98a80 | ||
|
|
53c27f89dc | ||
|
|
5ec5e11dae | ||
|
|
fb6c9bf22e | ||
|
|
68348e0558 | ||
|
|
da8a480eff | ||
|
|
ae75508eff | ||
|
|
fe9afd90c4 | ||
|
|
1c4484266d | ||
|
|
34041b87c6 | ||
|
|
cc2983a398 | ||
|
|
fa4e8fd087 | ||
|
|
1b592317a1 | ||
|
|
b4e34d0902 | ||
|
|
337cb188c3 | ||
|
|
f1d8b299db | ||
|
|
3a49648f0d | ||
|
|
19c46a86d7 | ||
|
|
6644b62f63 | ||
|
|
de72e18a77 | ||
|
|
4f38d0e2e7 | ||
|
|
ab5767fe94 | ||
|
|
ce6af76cce | ||
|
|
7ae43310f4 | ||
|
|
5e60feab84 | ||
|
|
9934bd2628 | ||
|
|
9cc49da019 | ||
|
|
25b5eaddc5 | ||
|
|
6c44beb46d | ||
|
|
a3d26a2278 | ||
|
|
bfbeb97203 | ||
|
|
a2b03419d6 | ||
|
|
cecffbc89f | ||
|
|
f0f34c3416 | ||
|
|
64d1a86fe5 | ||
|
|
86305cf261 | ||
|
|
d17c31398c | ||
|
|
cbb61d65e4 | ||
|
|
6dc123615b | ||
|
|
56d841aa64 | ||
|
|
8899d8bfe1 | ||
|
|
ac09cc24a8 | ||
|
|
cd40abc359 | ||
|
|
ea8c29ecbd | ||
|
|
eecd93bf1a | ||
|
|
d1fed2fb45 | ||
|
|
e7b69dae91 | ||
|
|
d10acbc895 | ||
|
|
91b4200179 | ||
|
|
45cc2ba36e | ||
|
|
588d8f629d | ||
|
|
686c9456f4 | ||
|
|
14823809d3 | ||
|
|
1bef36fb20 | ||
|
|
c820685241 | ||
|
|
20cf0a4a7f | ||
|
|
287ad44ffd | ||
|
|
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 |
@@ -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']
|
||||
@@ -0,0 +1,105 @@
|
||||
name: OCI validation report
|
||||
description: Report a real test of an application from the OCI manager catalog.
|
||||
title: "OCI validation: "
|
||||
labels: ["oci-validation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
One report describes one application and the scenario you tested. Once it is reviewed, the application is recorded as verified: it shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md](https://github.com/MacRimi/ProxMenux/blob/develop/oci/VALIDATION.md) with your GitHub user, the date and a link to this report.
|
||||
|
||||
Reports that an application does not work are just as useful: they tell others what to expect and what needs fixing.
|
||||
- type: input
|
||||
id: application
|
||||
attributes:
|
||||
label: Application
|
||||
description: Name or ID as it appears in the OCI catalog, for example "Glances" or "glances".
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Image version
|
||||
description: Version or tag shown by the installer.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: digest
|
||||
attributes:
|
||||
label: Image digest
|
||||
description: Shown in the App tab of the container in ProxMenux Monitor, after the build date — for example "2026-09-06 · b1f339cf". The full sha256 digest is also accepted.
|
||||
placeholder: b1f339cf
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: proxmox
|
||||
attributes:
|
||||
label: Proxmox VE version
|
||||
placeholder: "9.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install
|
||||
attributes:
|
||||
label: Install mode
|
||||
options:
|
||||
- Default configuration
|
||||
- Advanced configuration
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: storage
|
||||
attributes:
|
||||
label: Data storage
|
||||
options:
|
||||
- Volume on Proxmox storage
|
||||
- Host directory
|
||||
- Both
|
||||
- No persistent data
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: network
|
||||
attributes:
|
||||
label: Network
|
||||
options:
|
||||
- DHCP
|
||||
- Static address
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: gpu
|
||||
attributes:
|
||||
label: GPU
|
||||
options:
|
||||
- No GPU
|
||||
- Intel
|
||||
- AMD
|
||||
- NVIDIA
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: What was checked
|
||||
options:
|
||||
- label: Installs cleanly
|
||||
- label: The application responds (web interface or service)
|
||||
- label: Keeps working after a container restart
|
||||
- label: Survives a Proxmox backup and restore
|
||||
- label: An image update keeps the data
|
||||
- type: dropdown
|
||||
id: result
|
||||
attributes:
|
||||
label: Result
|
||||
options:
|
||||
- Works
|
||||
- Works with problems
|
||||
- Does not work
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
description: Anything worth knowing. If something failed, what you saw and how to reproduce it.
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "proxmenux-offline-node-tests",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxmenux-offline-node-tests",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "0.454.0",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.454.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
|
||||
"integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "proxmenux-offline-node-tests",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Only the dependencies of the qualified tests/*.cjs CI lane",
|
||||
"dependencies": {
|
||||
"typescript": "5.9.3",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"lucide-react": "0.454.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# GitHub users who can validate an OCI validation report by commenting
|
||||
# /validated on it. A reviewer does not validate their own report.
|
||||
MacRimi
|
||||
Vaso73
|
||||
f3rs3n
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||
against the English source (AppImage/messages/en/common.json).
|
||||
|
||||
Guardrails:
|
||||
- Non-empty string translations are PRESERVED, including values equal
|
||||
to English. --refresh explicitly opts into replacing valid strings.
|
||||
- Arrays, objects and non-string leaves retain their JSON types. Array
|
||||
entries are translated individually, never as serialized Python text.
|
||||
- Preflight all locales; skip locales with existing type mismatches without
|
||||
provider calls or writes, including with --refresh. Valid locales proceed,
|
||||
but any skipped locale makes the run exit 1 (partial failure).
|
||||
The diagnostic identifies the locale
|
||||
and JSON Pointer requiring manual reconciliation. No automatic migration
|
||||
of serialized arrays is attempted: parseability or equal length cannot
|
||||
prove correspondence to current English indices. Recover human text only
|
||||
after checking its original source/order; do not delete it to force refill.
|
||||
Null string leaves remain eligible for filling, as in earlier versions.
|
||||
- `{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.
|
||||
|
||||
Workflow limitation: build-i18n-messages.yml runs Commit + push only after
|
||||
successful generation. Exit 1 for skipped locales prevents that step, even
|
||||
when valid locales made local progress. This does not restore automatic
|
||||
publication for the default locale set until mismatches are reconciled or
|
||||
workflow policy is separately changed. Invalid JSON syntax still aborts
|
||||
preflight globally; per-locale skipping covers parsed catalog type mismatches.
|
||||
|
||||
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_]*\}")
|
||||
|
||||
|
||||
CatalogPath = tuple[str | int, ...]
|
||||
|
||||
|
||||
def flatten(node: object, prefix: CatalogPath = ()) -> dict[CatalogPath, object]:
|
||||
"""Keep typed path segments and empty container markers, without coercion."""
|
||||
if isinstance(node, dict):
|
||||
out = {prefix: {}}
|
||||
children = node.items()
|
||||
elif isinstance(node, list):
|
||||
out = {prefix: []}
|
||||
children = enumerate(node)
|
||||
else:
|
||||
return {prefix: node}
|
||||
for key, value in children:
|
||||
out.update(flatten(value, prefix + (key,)))
|
||||
return out
|
||||
|
||||
|
||||
def unflatten(flat: dict[CatalogPath, object]) -> object:
|
||||
"""Rebuild a tree from typed paths; parents precede children by depth."""
|
||||
nodes = {}
|
||||
for path in sorted(flat, key=len):
|
||||
value = flat[path]
|
||||
nodes[path] = {} if isinstance(value, dict) else [] if isinstance(value, list) else value
|
||||
if path:
|
||||
parent = nodes[path[:-1]]
|
||||
key = path[-1]
|
||||
if isinstance(parent, list):
|
||||
while len(parent) <= key:
|
||||
parent.append(None)
|
||||
parent[key] = nodes[path]
|
||||
return nodes[()]
|
||||
|
||||
|
||||
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))
|
||||
# No underscores: a local model splits on them and returns
|
||||
# "PMX PH 0", so the placeholder never comes back.
|
||||
return f"PMXPH{len(placeholders) - 1:03d}"
|
||||
|
||||
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"PMXPH{i:03d}", original)
|
||||
return text
|
||||
|
||||
|
||||
def translate_argos(text: str, lang: str) -> str:
|
||||
"""LibreTranslate's engine, running locally.
|
||||
|
||||
The remote providers answer a few thousand strings and then start
|
||||
refusing, and a refusal here hands back the English and stores it as
|
||||
a translation. Local models have no quota and no silent failure mode,
|
||||
which is what makes them the right choice for filling a catalogue by
|
||||
hand before it is committed.
|
||||
"""
|
||||
try:
|
||||
import argostranslate.translate as argos # type: ignore
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
"argostranslate is not installed. Install argostranslate and the "
|
||||
"en->target packages, or run with another provider."
|
||||
) from exc
|
||||
return argos.translate(text, "en", lang)
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
elif provider == "argos":
|
||||
raw = translate_argos(text, lang)
|
||||
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=("argos", "appimage", "googletrans", "google-web"),
|
||||
default="googletrans",
|
||||
help=(
|
||||
"Translation provider. The default matches build_translation_cache "
|
||||
"and is what CI uses; `argos` runs locally with no quota and is the "
|
||||
"one to reach for when filling a catalogue before committing it."
|
||||
),
|
||||
)
|
||||
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=(
|
||||
"Retranslate the keys named by --refresh-keys. On its own it has "
|
||||
"no effect: an existing translation is never replaced."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refresh-keys",
|
||||
type=Path,
|
||||
help=(
|
||||
"File with one dotted message key per line. Only these may be "
|
||||
"retranslated over an existing value, and only with --refresh."
|
||||
),
|
||||
)
|
||||
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)
|
||||
|
||||
refresh_keys: set[str] = set()
|
||||
if args.refresh_keys is not None:
|
||||
if not args.refresh:
|
||||
print("--refresh-keys needs --refresh.", file=sys.stderr)
|
||||
return 1
|
||||
refresh_keys = {
|
||||
line.strip()
|
||||
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
print(f"Refreshing {len(refresh_keys)} named keys.", flush=True)
|
||||
elif args.refresh:
|
||||
print("--refresh without --refresh-keys: existing translations are kept.",
|
||||
flush=True)
|
||||
|
||||
total_failures: list[tuple[str, CatalogPath, str]] = []
|
||||
|
||||
# Preflight every locale before any provider call or write. Serialized
|
||||
# arrays have no trustworthy index correspondence, even if JSON parses.
|
||||
# Never guess, evaluate Python repr, or discard human text on --refresh.
|
||||
targets = {}
|
||||
skipped = set()
|
||||
for lang in languages:
|
||||
locale_path = messages_dir / lang / "common.json"
|
||||
target_flat = flatten(read_json(locale_path))
|
||||
targets[lang] = target_flat
|
||||
for key, en_value in en_flat.items():
|
||||
if key not in target_flat:
|
||||
continue
|
||||
existing = target_flat[key]
|
||||
if isinstance(en_value, str) and existing is None:
|
||||
continue # Historical null string leaves are treated as empty.
|
||||
if type(existing) is not type(en_value):
|
||||
pointer = "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in key)
|
||||
location = f"JSON Pointer {json.dumps(pointer)}" + (" (root)" if not key else "")
|
||||
print(
|
||||
f"{locale_path}: {location}: expected {type(en_value).__name__}, "
|
||||
f"found {type(existing).__name__}; manual reconciliation required. "
|
||||
"Locale skipped; its catalog will not be written.", file=sys.stderr,
|
||||
)
|
||||
skipped.add(lang)
|
||||
|
||||
for lang in languages:
|
||||
if lang in skipped:
|
||||
continue
|
||||
locale_path = messages_dir / lang / "common.json"
|
||||
target_flat = targets[lang]
|
||||
|
||||
# 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.
|
||||
# An existing translation is never replaced. --refresh only reaches
|
||||
# the keys named in --refresh-keys, so an overwrite is always one
|
||||
# somebody asked for by name rather than a side effect of a run.
|
||||
# Seed structural nodes and non-text leaves before any checkpoint.
|
||||
# Only strings are sent to a translation provider.
|
||||
original_flat = target_flat.copy()
|
||||
for key, en_value in en_flat.items():
|
||||
if not isinstance(en_value, str) or not en_value:
|
||||
target_flat.setdefault(key, en_value)
|
||||
elif key and isinstance(key[-1], int):
|
||||
# Null keeps an untranslated array position addressable while
|
||||
# allowing the runtime English fallback and a later retry.
|
||||
target_flat.setdefault(key, None)
|
||||
missing: list[CatalogPath] = []
|
||||
for key, en_value in en_flat.items():
|
||||
if not isinstance(en_value, str) or not en_value:
|
||||
continue
|
||||
existing = target_flat.get(key, "")
|
||||
if not existing or ".".join(str(part) for part in key) in refresh_keys:
|
||||
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:
|
||||
if target_flat != original_flat:
|
||||
write_json(locale_path, unflatten(target_flat))
|
||||
print(f" {lang}: nothing to translate", flush=True)
|
||||
continue
|
||||
|
||||
failures_for_lang: list[tuple[str, CatalogPath, 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.
|
||||
if not skipped:
|
||||
return 0
|
||||
|
||||
if skipped:
|
||||
names = ', '.join(dict.fromkeys(lang for lang in languages if lang in skipped))
|
||||
print(
|
||||
f"\nPartial failure: skipped {len(skipped)} locale(s): {names}. "
|
||||
"Valid locales processed; skipped catalogs unchanged. "
|
||||
"Manual reconciliation required; exiting 1.",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
return 1
|
||||
|
||||
print("\ni18n messages generated successfully.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,895 @@
|
||||
#!/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",
|
||||
# Kernel driver and subsystem names. Left to a translator, `nouveau`
|
||||
# becomes the adjective it is borrowed from — "el nuevo controlador"
|
||||
# in Spanish, "Jugendstil" in German — and the reader is told about a
|
||||
# driver that does not exist instead of the one being blacklisted.
|
||||
"nvidia-container-toolkit",
|
||||
"nvidia-smi",
|
||||
"initramfs",
|
||||
"nouveau",
|
||||
"vfio-pci",
|
||||
"modprobe",
|
||||
"Docker",
|
||||
"Coral",
|
||||
"gasket",
|
||||
"apex",
|
||||
"lspci",
|
||||
"dpkg",
|
||||
"DKMS",
|
||||
"QEMU",
|
||||
"LXC",
|
||||
"ZFS",
|
||||
"SSH",
|
||||
"fork",
|
||||
# Vendor, API and acceleration names. A label like "NVIDIA (NVDEC/CUDA)"
|
||||
# is a product name end to end: every provider hands it back as it came,
|
||||
# and without these entries that correct answer is read as a failure and
|
||||
# the string is dropped from the catalogue.
|
||||
"VA-API",
|
||||
"NVIDIA",
|
||||
"NVDEC",
|
||||
"NVENC",
|
||||
"WebUI",
|
||||
"Intel",
|
||||
"CUDA",
|
||||
"KFD",
|
||||
"GPU",
|
||||
"CPU",
|
||||
"AMD",
|
||||
# Proxmox's own words for a container and a virtual machine. Read as
|
||||
# initials they get reordered — "CT" comes back as "TC" in Spanish —
|
||||
# and the reader is told about something Proxmox does not call that.
|
||||
"CT",
|
||||
"VM",
|
||||
# Open Container Initiative. Read as a word it becomes "BEC" in French,
|
||||
# which named nothing, across thirty-one strings of the OCI section.
|
||||
"OCI manager Apps",
|
||||
"OCI",
|
||||
"beta",
|
||||
# Debian release names. "Bookworm" is a bird and a reader of books, and
|
||||
# came back as "el libro de Debian"; the others are equally ordinary
|
||||
# words in the languages that borrowed them.
|
||||
"Bookworm",
|
||||
"Trixie",
|
||||
"Bullseye",
|
||||
"Buster",
|
||||
"Noble",
|
||||
"Jammy",
|
||||
)
|
||||
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,
|
||||
)
|
||||
# Same shape the Monitor's generator uses, so a message reads identically
|
||||
# whichever of the two catalogues it comes from.
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
||||
# Literals a reader copies and runs. Translated as words they stop working:
|
||||
# /dev/apex_0 came back as "/dev/apex 0", --auto-uninstall as "desinstalación
|
||||
# automática", and "pct config 110" as "configuration PCT 110". A glossary can
|
||||
# only hold what someone listed; these are recognised by shape.
|
||||
STRUCTURAL_LITERAL_RE = re.compile(
|
||||
r"""
|
||||
/(?:dev|etc|usr|var|opt|srv|proc|sys|run|boot|tmp)/[\w./+-]*[\w/+-]
|
||||
| (?<![\w-])--[A-Za-z][\w-]*
|
||||
# Angle-bracket placeholders a reader substitutes: <CTID> came back as
|
||||
# "■CTID confiar", with the closing bracket translated as a verb.
|
||||
| <[A-Za-z][\w-]*>
|
||||
# Shell operators. "&&" became the HTML entity "'" in Spanish, and
|
||||
# the command it joined stopped being one command; a lone pipe came back
|
||||
# as the word "Silencio".
|
||||
| &&|\|\||(?<!\|)\|(?!\|)
|
||||
# Environment variable names. THELOUNGE_HOME lost its underscore and
|
||||
# became two words, so the command no longer set anything.
|
||||
| \b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b
|
||||
| \b[\w.-]+\.(?:sh|py|json|yml|yaml|conf|service|timer|func|cfg|list|log)\b
|
||||
# Reverse-DNS identifiers: org.opencontainers.image.version came back as
|
||||
# "org.open containers.image.version" — a label name with a space in it.
|
||||
| \b[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,}\b
|
||||
# A command plus a real subcommand. An allowlist, not "any word": with
|
||||
# a wildcard, "pvesm command not found" and "apt-get exited" were read as
|
||||
# invocations, and the strings a contributor had already translated by
|
||||
# hand were discarded as damaged.
|
||||
| \b(?:pct|qm)\s+(?:config|start|stop|shutdown|reboot|destroy|create|clone
|
||||
|enter|exec|push|pull|set|list|status|snapshot|rollback|resize|migrate
|
||||
|template|unlock|mount|unmount|df|fsck|restore|suspend|resume|monitor)\b
|
||||
| \bpvesm\s+(?:path|status|list|alloc|free|add|remove|set|scan|export|import)\b
|
||||
| \b(?:pveam|pveum|pvecm)\s+(?:available|download|list|update|remove|add|create
|
||||
|delete|status|nodes|keygen|expected|modify|set|useradd|userdel|passwd)\b
|
||||
| \bapt-get\s+(?:install|update|upgrade|remove|purge|autoremove|dist-upgrade|clean)\b
|
||||
| \bdpkg\s+(?:-l|-i|-r|-P|--configure|--get-selections)\b
|
||||
| \b(?:systemctl|journalctl)\s+(?:start|stop|restart|reload|enable|disable|status
|
||||
|daemon-reload|mask|unmask|is-active|is-enabled|list-units|-u|-f|-b)\b
|
||||
| \bzpool\s+(?:status|list|import|export|scrub|trim|get|set|history)\b
|
||||
| \bsmartctl\s+(?:-a|-A|-H|-i|-t|-l|-x|--all|--health|--info)\b
|
||||
| \b(?:modprobe|blkid|lsblk|mkfs)\s+(?:-a|-r|-n|-o|-f|-t|\.[a-z0-9]+)\b
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
# Providers that answer the same thing every time for the same input, so a
|
||||
# second attempt cannot produce a different result. `appimage` shells out to a
|
||||
# binary that may reach a network service, so it is not on the list.
|
||||
DETERMINISTIC_PROVIDERS = frozenset({"argos"})
|
||||
|
||||
|
||||
def protect_catalog_titles(directories) -> None:
|
||||
"""Add every application name in the catalog to the protected glossary.
|
||||
|
||||
They are product names, and a translator treats them as words: "HAOS One"
|
||||
comes back as "HAOS Man". Protecting them costs nothing and the failure it
|
||||
prevents reaches the reader as an application that does not exist.
|
||||
"""
|
||||
global TECHNICAL_TERM_RE
|
||||
titles: set[str] = set()
|
||||
for directory in directories:
|
||||
for path in sorted(Path(directory).rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
title = ((data.get("catalog_ui") or {}).get("title") or {}).get("en_US")
|
||||
if isinstance(title, str) and title.strip():
|
||||
titles.add(title.strip())
|
||||
if not titles:
|
||||
return
|
||||
terms = tuple(sorted(set(PROTECTED_TECHNICAL_TERMS) | titles, key=len, reverse=True))
|
||||
# Same word boundaries as the module-level pattern. Without them a short
|
||||
# term matches inside longer words, and every application title in the
|
||||
# catalogue joins the glossary here.
|
||||
TECHNICAL_TERM_RE = re.compile(
|
||||
"|".join(
|
||||
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
|
||||
for term in terms
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
print(f"Protected application names: {len(titles)}", flush=True)
|
||||
|
||||
|
||||
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
||||
"""Replace each ``{name}`` with a token before translation.
|
||||
|
||||
A provider reads the name inside the braces as a word and translates
|
||||
it: ``{count}`` comes back as ``{conta}`` in Portuguese and ``{počet}``
|
||||
in Slovak. The consumer then finds no placeholder to substitute and
|
||||
falls back to English, so the translation was paid for and never used.
|
||||
The token carries no underscores because argos splits on them.
|
||||
"""
|
||||
found: list[str] = []
|
||||
|
||||
def _swap(match: re.Match[str]) -> str:
|
||||
found.append(match.group(0))
|
||||
return f"PMXPH{len(found) - 1:03d}"
|
||||
|
||||
return PLACEHOLDER_RE.sub(_swap, text), found
|
||||
|
||||
|
||||
def restore_placeholders(text: str, found: list[str]) -> str:
|
||||
"""Put the original ``{name}`` back where each token landed."""
|
||||
for index, original in enumerate(found):
|
||||
text = text.replace(f"PMXPH{index:03d}", original)
|
||||
return text
|
||||
|
||||
|
||||
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
|
||||
"""Replace glossary terms with stable tokens before translation.
|
||||
|
||||
Structural literals go first. A glossary can only list what someone
|
||||
thought of, and what reaches the reader as a broken command is never on
|
||||
that list: an absolute path, a long option, a filename. They are
|
||||
recognised by shape instead, which also covers the next one added.
|
||||
"""
|
||||
protected: list[str] = []
|
||||
|
||||
def _swap(match: re.Match[str]) -> str:
|
||||
protected.append(match.group(0))
|
||||
return f"PMXTERM{len(protected) - 1:03d}"
|
||||
|
||||
text = STRUCTURAL_LITERAL_RE.sub(_swap, text)
|
||||
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"PMXTERM{index:03d}", original)
|
||||
return text
|
||||
|
||||
|
||||
# A provider can alter a token instead of carrying it through: argos returned
|
||||
# MPXTERM000 for PMXTERM000, the replace found nothing, and the token shipped
|
||||
# to the reader — "MPXTERM000 configuré" is in the French catalogue now.
|
||||
SENTINEL_RESIDUE_RE = re.compile(r"[MP][MPX]X?\s?(?:TERM|PH)\s?\d{2,4}", re.IGNORECASE)
|
||||
|
||||
|
||||
def assert_no_sentinel_residue(text: str) -> None:
|
||||
"""Fail the translation when a protection token did not survive intact.
|
||||
|
||||
Leaving the key out is recoverable: the next run tries again. Storing a
|
||||
string with a token in it is not, because nothing looks at a value that
|
||||
already exists.
|
||||
"""
|
||||
found = SENTINEL_RESIDUE_RE.search(text)
|
||||
if found:
|
||||
raise RuntimeError(f"the provider altered a protection token: {found.group(0)!r}")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
PYTHON_TRANSLATE_CALLS = {"translate", "N_"}
|
||||
CATALOG_TEXT_KEYS = {"prompt", "enable_prompt", "path_prompt", "size_prompt", "label", "warning"}
|
||||
CATALOG_TEXT_LISTS = {"stack_completion_notes", "completion_notes"}
|
||||
|
||||
|
||||
def extract_python_texts(directories: Iterable[Path]) -> list[str]:
|
||||
"""translate("...") and N_("...") calls with a literal argument. The parser
|
||||
joins implicitly concatenated literals, so wrapped strings are found whole."""
|
||||
found: dict[str, None] = {}
|
||||
for directory in directories:
|
||||
for path in sorted(directory.rglob("*.py")):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and getattr(node.func, "id", None) in PYTHON_TRANSLATE_CALLS
|
||||
and node.args and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)):
|
||||
text = node.args[0].value.strip()
|
||||
if text:
|
||||
found.setdefault(text, None)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def extract_catalog_texts(directories: Iterable[Path]) -> list[str]:
|
||||
"""User-visible text stored in the OCI catalog JSON: prompts, labels,
|
||||
warnings, completion notes, category labels and descriptive usernames."""
|
||||
found: dict[str, None] = {}
|
||||
|
||||
def add(value: object) -> None:
|
||||
if isinstance(value, str) and value.strip():
|
||||
found.setdefault(value.strip(), None)
|
||||
|
||||
def walk(value: object, key: str = "") -> None:
|
||||
if isinstance(value, dict):
|
||||
for child_key, child in value.items():
|
||||
if child_key in CATALOG_TEXT_KEYS:
|
||||
add(child)
|
||||
elif child_key in CATALOG_TEXT_LISTS and isinstance(child, list):
|
||||
for item in child:
|
||||
add(item)
|
||||
elif child_key == "username" and isinstance(child, str) and " " in child:
|
||||
add(child)
|
||||
elif child_key == "catalog_ui" and isinstance(child, dict):
|
||||
# What the application detail screen shows: the tagline,
|
||||
# and the description only where there is no tagline. The
|
||||
# catalog stores the source English; the translation lives
|
||||
# in the language cache with every other string.
|
||||
tagline = (child.get("tagline") or {}).get("en_US")
|
||||
add(tagline or (child.get("description") or {}).get("en_US"))
|
||||
elif child_key == "labels" and key == "" and isinstance(child, dict):
|
||||
for item in child.values():
|
||||
add(item)
|
||||
walk(child, child_key)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
walk(item, key)
|
||||
|
||||
for directory in directories:
|
||||
for path in sorted(directory.rglob("*.json")):
|
||||
try:
|
||||
walk(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def translate_argos(text: str, dest_lang: str) -> str:
|
||||
"""LibreTranslate's engine, running locally.
|
||||
|
||||
A public endpoint answers a few thousand strings and then starts
|
||||
refusing — and the library wrapper around it returns the English
|
||||
unchanged rather than raising, which writes the source text into the
|
||||
catalogue as if it were a translation. Local models have no quota and
|
||||
no silent failure mode.
|
||||
"""
|
||||
try:
|
||||
import argostranslate.translate as argos # type: ignore
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"argostranslate is not installed. Install argostranslate and the "
|
||||
"en->target packages, or run with another provider."
|
||||
) from exc
|
||||
return argos.translate(text, "en", dest_lang)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# Lowercase-or-digit, then the mark, then an uppercase letter: that is a
|
||||
# sentence boundary and not `8.0`, `storage.cfg` or `proxmox.com/docs`.
|
||||
_SENTENCE_RUN = re.compile(
|
||||
"(?<=[a-z0-9\u00e0-\u00ff\u0107\u010d\u011b\u013e\u0148\u0159\u0161\u0165\u016f\u017a\u017e])"
|
||||
"([.?!])"
|
||||
"(?=[A-Z\u00c0-\u00de\u0106\u010c\u011a\u013d\u0147\u0158\u0160\u0164\u016e\u0179\u017d])"
|
||||
)
|
||||
|
||||
|
||||
def restore_sentence_spacing(text: str) -> str:
|
||||
"""Every provider drops the blank between sentences it translated apart."""
|
||||
return _SENTENCE_RUN.sub(r"\1 ", text)
|
||||
|
||||
|
||||
def translate_text(
|
||||
text: str,
|
||||
dest_lang: str,
|
||||
provider: str,
|
||||
context: str,
|
||||
timeout: int,
|
||||
appimage_path: Path,
|
||||
) -> str:
|
||||
protected_text, placeholders = protect_placeholders(text)
|
||||
protected_text, protected_terms = protect_technical_terms(protected_text)
|
||||
if provider == "argos":
|
||||
translated = translate_argos(protected_text, dest_lang)
|
||||
elif 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)
|
||||
translated = restore_placeholders(translated, placeholders)
|
||||
assert_no_sentinel_residue(translated)
|
||||
return restore_sentence_spacing(translated) if translated else 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 is_fully_protected(source: str) -> bool:
|
||||
"""Whether the string is protected material and punctuation, nothing else.
|
||||
|
||||
"Docker Volume Backup" and "NVIDIA (NVDEC/CUDA)" are product and API
|
||||
names from end to end, and "apt-get exited" is a command with a word
|
||||
the command itself supplies. Coming back unchanged is the right answer
|
||||
for all of them, so the guard below must not read it as a silent
|
||||
failure and throw the result away. Both kinds of protection count:
|
||||
the glossary catches the names, the structural pattern the rest.
|
||||
"""
|
||||
remainder = STRUCTURAL_LITERAL_RE.sub(" ", source)
|
||||
return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", remainder))
|
||||
|
||||
|
||||
def looks_untranslated(source: str, result: str) -> bool:
|
||||
"""Whether a provider handed back the text it was given.
|
||||
|
||||
A single technical word legitimately survives translation — Docker, GPU,
|
||||
LXC — but a sentence coming back byte-identical means the provider failed
|
||||
without saying so. Accepting it writes English into the catalogue, where
|
||||
it counts as translated and is never looked at again.
|
||||
"""
|
||||
if source.strip() != result.strip():
|
||||
return False
|
||||
if is_fully_protected(source):
|
||||
return False
|
||||
return len([word for word in re.findall(r"[A-Za-z]{2,}", source)]) >= 3
|
||||
|
||||
|
||||
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-dir",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help="Extra directory scanned for translate calls in .sh files. Repeatable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--python-dir",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help="Directory scanned for translate()/N_() calls in .py files. Repeatable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--catalog-dir",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help="Directory of OCI catalog JSON files with user-visible text. Repeatable.",
|
||||
)
|
||||
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=("argos", "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("--retries", default=4, type=int,
|
||||
help="Attempts per string before giving up on it.")
|
||||
parser.add_argument("--retry-wait", default=15, type=float,
|
||||
help="Seconds before the first retry; it doubles each time.")
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="Retranslate the strings named by --refresh-keys. On its own it has "
|
||||
"no effect: an existing translation is never replaced.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refresh-keys",
|
||||
type=Path,
|
||||
help="File with one source string per line. Only these may be "
|
||||
"retranslated over an existing value, and only with --refresh.",
|
||||
)
|
||||
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)
|
||||
for directory in args.extra_dir:
|
||||
if directory.is_dir():
|
||||
texts += extract_translate_texts(directory.resolve())
|
||||
texts += extract_python_texts(d.resolve() for d in args.python_dir if d.is_dir())
|
||||
catalog_dirs = [d.resolve() for d in args.catalog_dir if d.is_dir()]
|
||||
protect_catalog_titles(catalog_dirs)
|
||||
texts += extract_catalog_texts(catalog_dirs)
|
||||
texts = sorted(dict.fromkeys(text for text in texts if "$" not in text and "`" not in text))
|
||||
if args.limit > 0:
|
||||
texts = texts[: args.limit]
|
||||
existing_by_lang = {
|
||||
lang: load_language_cache(output_dir / f"{lang}.json")
|
||||
for lang in languages
|
||||
}
|
||||
# Seeded with what is already translated so a periodic save — or an
|
||||
# interrupted run — writes a superset of the file it replaces, never a
|
||||
# truncated one.
|
||||
next_by_lang: dict[str, dict[str, str]] = {
|
||||
lang: dict(existing_by_lang.get(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)
|
||||
|
||||
refresh_keys: set[str] = set()
|
||||
if args.refresh_keys is not None:
|
||||
if not args.refresh:
|
||||
print("--refresh-keys needs --refresh.", file=sys.stderr)
|
||||
return 1
|
||||
refresh_keys = {
|
||||
line.rstrip("\n")
|
||||
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
print(f"Refreshing {len(refresh_keys)} named strings.", flush=True)
|
||||
elif args.refresh:
|
||||
print("--refresh without --refresh-keys: existing translations are kept.",
|
||||
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
|
||||
# A translation that is already there is never replaced. Most of
|
||||
# this catalogue is hand-written by the people who speak the
|
||||
# language, and a machine pass over it trades their wording for
|
||||
# something worse without anyone seeing the swap. Only a string
|
||||
# named in --refresh-keys may be overwritten, so every overwrite
|
||||
# is one somebody asked for by name.
|
||||
if existing.get(text) and text not in refresh_keys:
|
||||
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)
|
||||
# A rate limit is a "come back later", not an answer. Retrying with
|
||||
# a growing wait recovers it; giving up on the first one is what
|
||||
# left thousands of strings untranslated.
|
||||
value, last_error = None, None
|
||||
for attempt in range(1, args.retries + 1):
|
||||
unchanged = False
|
||||
try:
|
||||
value = translate_text(
|
||||
text,
|
||||
lang,
|
||||
args.provider,
|
||||
args.context,
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
if looks_untranslated(text, value):
|
||||
value = None
|
||||
unchanged = True
|
||||
raise RuntimeError("the provider returned the source text unchanged")
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
# Unchanged text from a remote provider is how a rate limit
|
||||
# shows up, so it is worth waiting for. A local engine is
|
||||
# deterministic: asking again returns the same string, and
|
||||
# the backoff only buys minutes of sleeping per phrase.
|
||||
if unchanged and args.provider in DETERMINISTIC_PROVIDERS:
|
||||
break
|
||||
if attempt < args.retries:
|
||||
wait = args.retry_wait * (2 ** (attempt - 1))
|
||||
print(f" retry {attempt}/{args.retries - 1} in {wait}s: {exc}",
|
||||
file=sys.stderr, flush=True)
|
||||
time.sleep(wait)
|
||||
if value is not None:
|
||||
next_by_lang[lang][text] = value
|
||||
print(f" => {value[:100]}", flush=True)
|
||||
else:
|
||||
# The key is left out on purpose. Writing the English here
|
||||
# would count as a translation on the next run and the string
|
||||
# would never be translated again.
|
||||
previous = existing.get(text)
|
||||
if previous:
|
||||
next_by_lang[lang][text] = previous
|
||||
failures.append((text, lang, str(last_error)))
|
||||
print(f" failed: {last_error}", 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,899 @@
|
||||
#!/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_argos,
|
||||
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"),
|
||||
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
|
||||
)
|
||||
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]*)>")
|
||||
# Every tag as written, attributes and self-closing slash included.
|
||||
ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
||||
# Set from --provider: a local model rewrites bracketed sentinels, a remote
|
||||
# one drops bare ones beside inline markup. See protect_text.
|
||||
BARE_SENTINELS = False
|
||||
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, ...],
|
||||
forced_tokens: set[str] | None = None,
|
||||
) -> bool:
|
||||
if should_copy(source, path):
|
||||
return False
|
||||
# An existing translation is never replaced. Only a leaf whose token was
|
||||
# named on the command line may be retranslated over a real value, so an
|
||||
# overwrite is always one somebody asked for rather than a side effect.
|
||||
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())
|
||||
)
|
||||
if BARE_SENTINELS:
|
||||
# A remote provider carries inline tags through untouched, so they are
|
||||
# only validated afterwards. A local model rewrites them — dropping a
|
||||
# </code>, merging two <em> — and the whole file is then discarded for
|
||||
# a broken contract. Protecting them costs the translator the tag as
|
||||
# context and saves the paragraph.
|
||||
candidates.extend((match.start(), match.end()) for match in ANY_TAG_RE.finditer(text))
|
||||
# A local model also rewrites what a literal block holds: it drops the
|
||||
# underscores of an identifier, translates a subcommand and turns a
|
||||
# pipe into punctuation. The whole block is one opaque token instead.
|
||||
candidates.extend(literal_ranges)
|
||||
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.
|
||||
#
|
||||
# A local model treats the brackets as punctuation instead, and
|
||||
# rewrites them: argos returns [PMXDOC0000] in German, [[PMXDOC0000]]
|
||||
# in Slovak and [[[[PMXDOC0000]]]]] in Italian. The bare identifier
|
||||
# survives every provider tried, so it is what a local run uses.
|
||||
token = f"PMXDOC{index:04d}" if BARE_SENTINELS else 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(ANY_TAG_RE.findall(source)) != sorted(ANY_TAG_RE.findall(target)):
|
||||
raise ValueError("markup changed")
|
||||
literal = lambda text: sorted(match.group(0) for match in LITERAL_TAG_RE.finditer(text))
|
||||
if literal(source) != literal(target):
|
||||
raise ValueError("literal code 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 == "argos":
|
||||
raw = translate_argos(text, language)
|
||||
elif 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,
|
||||
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,
|
||||
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, set[str]]:
|
||||
resolved: dict[tuple[str | int, ...], str] = {}
|
||||
failures: list[str] = []
|
||||
failed_tokens: set[str] = set()
|
||||
jobs: dict[str, list[tuple[str | int, ...]]] = {}
|
||||
|
||||
for leaf in leaves:
|
||||
if 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}")
|
||||
failed_tokens.update(leaf_token(path) for path in jobs[text])
|
||||
if args.sleep:
|
||||
time.sleep(args.sleep)
|
||||
|
||||
# What did translate is kept. merge_tree leaves a string it has no
|
||||
# translation for as whatever the locale already held, or as the English
|
||||
# source, so a file is written with the strings that worked rather than
|
||||
# discarded whole: a dense paragraph that a provider cannot carry its
|
||||
# protected tokens through used to take the other hundred and seventy
|
||||
# strings of its file with it.
|
||||
return merge_tree(source, target, resolved), failures, len(jobs), failed_tokens
|
||||
|
||||
|
||||
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=("argos", "google-web", "googletrans", "appimage"),
|
||||
default="googletrans",
|
||||
help=(
|
||||
"Translation provider. The default is what CI uses; `argos` runs "
|
||||
"locally with no quota, which is what a corpus this size needs."
|
||||
),
|
||||
)
|
||||
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:
|
||||
global BARE_SENTINELS
|
||||
args = build_parser().parse_args()
|
||||
BARE_SENTINELS = args.provider == "argos"
|
||||
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("--refresh no longer overwrites existing translations; "
|
||||
"name the leaves with --tokens instead.")
|
||||
|
||||
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)
|
||||
)
|
||||
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
|
||||
# The provider decides the sentinel format, so a run that cannot be
|
||||
# attributed to one cannot be diagnosed afterwards: a night of failures
|
||||
# reading "translation provider changed protected token" left no way to
|
||||
# tell whether the provider was the one the tokens were shaped for.
|
||||
print(f"Provider: {args.provider} | bare sentinels: {BARE_SENTINELS}")
|
||||
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, 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, failed_tokens = translate_file(
|
||||
source,
|
||||
target,
|
||||
leaves,
|
||||
language,
|
||||
memory,
|
||||
translate,
|
||||
args,
|
||||
)
|
||||
if failures:
|
||||
total_failures += len(failures)
|
||||
print(
|
||||
f" {len(failures)} strings left untranslated ({calls} calls)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for failure in failures[:5]:
|
||||
print(f" - {failure}", file=sys.stderr)
|
||||
write_json(target_path, built)
|
||||
# The strings that failed stay pending, so the next run comes back
|
||||
# for them and leaves the ones that translated alone.
|
||||
set_state_pending_tokens(state, language, relative_key, failed_tokens)
|
||||
if failures:
|
||||
write_json(source_state_path, state)
|
||||
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
@@ -59,7 +59,7 @@ def fetch_directory_json(api_url: str) -> list[dict]:
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError("GitHub API no devolvió una lista.")
|
||||
raise RuntimeError("The GitHub API did not return a list.")
|
||||
return data
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ def main() -> int:
|
||||
try:
|
||||
directory = fetch_directory_json(API_URL)
|
||||
except Exception as e:
|
||||
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
|
||||
print(f"ERROR: Could not read the JSON index: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cache: list[dict] = []
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OCI validation record: renders oci/VALIDATION.md from verification.json,
|
||||
checks the record, checks who a pull request credits, and records a reviewed
|
||||
validation report."""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VERIFICATION = ROOT / "oci/catalog/verification.json"
|
||||
INDEX = ROOT / "oci/catalog/index.json"
|
||||
OUTPUT = ROOT / "oci/VALIDATION.md"
|
||||
REVIEWERS = ROOT / ".github/oci-validation-reviewers"
|
||||
|
||||
REPO = "https://github.com/MacRimi/ProxMenux"
|
||||
BOARD = "https://github.com/users/MacRimi/projects/1"
|
||||
FORM = f"{REPO}/issues/new?template=oci-validation.yml"
|
||||
DISCUSSION = f"{REPO}/discussions/360"
|
||||
MAINTAINERS = {"macrimi"}
|
||||
LAB = "laboratory-validated"
|
||||
|
||||
USER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")
|
||||
REPORT_RE = re.compile(r"^https://github\.com/MacRimi/ProxMenux/"
|
||||
r"(issues/[0-9]+|discussions/[0-9]+(#discussioncomment-[0-9]+)?)$")
|
||||
ENTRY_KEYS = {"status", "community_tested"}
|
||||
COMMUNITY_KEYS = {"by", "date", "report", "digest", "scenario"}
|
||||
DIGEST_RE = re.compile(r"^[a-f0-9]{8,64}$")
|
||||
# Form fields that describe the tested scenario, in the order they are read.
|
||||
SCENARIO_FIELDS = ("Install mode", "Data storage", "Network", "GPU")
|
||||
NO_RESPONSE = "_No response_"
|
||||
|
||||
|
||||
def load(path):
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def dump(data):
|
||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def applications(index):
|
||||
return {item["id"]: item for item in index.get("applications", [])}
|
||||
|
||||
|
||||
def lab_validated(entry, item):
|
||||
# Same rule as the catalog: an application that cannot be installed is not verified.
|
||||
return entry.get("status") == LAB and bool(item.get("automatic_install_candidate"))
|
||||
|
||||
|
||||
def community(entry):
|
||||
tested = entry.get("community_tested")
|
||||
return tested if isinstance(tested, dict) and tested.get("by") else None
|
||||
|
||||
|
||||
def cell(text):
|
||||
return str(text).replace("|", "\\|").strip()
|
||||
|
||||
|
||||
def render(verification, index):
|
||||
apps = applications(index)
|
||||
entries = {app: entry for app, entry in verification.get("applications", {}).items()
|
||||
if app in apps and isinstance(entry, dict)}
|
||||
lab = sorted((app for app, entry in entries.items() if lab_validated(entry, apps[app])),
|
||||
key=lambda app: str(apps[app].get("title") or app).casefold())
|
||||
tested = sorted((app for app, entry in entries.items() if community(entry)),
|
||||
key=lambda app: str(apps[app].get("title") or app).casefold())
|
||||
visible = sum(1 for item in apps.values() if not item.get("hidden"))
|
||||
verified = len(set(lab) | set(tested))
|
||||
lines = [
|
||||
"# OCI application validation",
|
||||
"",
|
||||
"<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->",
|
||||
"",
|
||||
f"{verified} of {visible} applications in the OCI catalog have been tested for real: "
|
||||
f"{len(lab)} in the ProxMenux lab and {len(tested)} by the community. "
|
||||
"The OCI installer shows them as verified, with a ✓ in the lists.",
|
||||
"",
|
||||
"Each test describes the scenario that was run and names the image it ran on. It does not cover every possible "
|
||||
"configuration of the application, and a newer image has not been tested until someone reports it.",
|
||||
"",
|
||||
"## Taking part",
|
||||
"",
|
||||
"Any application that is not listed here is open for testing.",
|
||||
"",
|
||||
f"1. Check the [ProxMenux Roadmap]({BOARD}) to see which applications someone is already testing. "
|
||||
"With access to the board, create a card for the application and move it to In progress while you test it.",
|
||||
f"2. Test the application with the OCI manager and fill in the [OCI validation report]({FORM}). "
|
||||
"The report records the image and the exact scenario: install mode, storage, network and GPU.",
|
||||
"3. A reviewer reads the report and comments `/validated` on it. The application is then added to this list "
|
||||
"with your GitHub user and shown as verified in the OCI installer, and the report is closed.",
|
||||
"",
|
||||
"Reviewers are listed in [.github/oci-validation-reviewers](../.github/oci-validation-reviewers), and a "
|
||||
"reviewer does not validate their own report. A validation can also be added with a pull request that adds "
|
||||
"the entry to `oci/catalog/verification.json` and regenerates this file with "
|
||||
"`python3 .github/scripts/oci_validation.py render`; CI checks that the entry credits the author of the pull request.",
|
||||
"",
|
||||
"An application that cannot be validated for a reason outside the tester's hands — hardware nobody has, something "
|
||||
"only Docker provides, a fix still pending in ProxMenux — moves to Blocked on the board, with a line saying why "
|
||||
"and what would make it worth trying again.",
|
||||
"",
|
||||
f"Questions and ideas about OCI testing go in [discussion #360]({DISCUSSION}).",
|
||||
"",
|
||||
"## Tested by the community",
|
||||
"",
|
||||
]
|
||||
if tested:
|
||||
lines += ["| Application | Tested by | Date | Image | Scenario | Report |", "|---|---|---|---|---|---|"]
|
||||
for app in tested:
|
||||
entry = community(entries[app])
|
||||
report = f"[Report]({entry['report']})" if entry.get("report") else "—"
|
||||
image = f"`{entry['digest'][:12]}`" if entry.get("digest") else "—"
|
||||
lines.append(f"| {cell(apps[app].get('title') or app)} | [@{entry['by']}](https://github.com/{entry['by']}) "
|
||||
f"| {entry.get('date') or '—'} | {image} | {cell(entry.get('scenario') or '—')} | {report} |")
|
||||
else:
|
||||
lines.append("No application has been tested by the community yet.")
|
||||
lines += ["", "## Tested in the ProxMenux lab", ""]
|
||||
if lab:
|
||||
lines += ["| Application | Category |", "|---|---|"]
|
||||
for app in lab:
|
||||
lines.append(f"| {cell(apps[app].get('title') or app)} | {cell(apps[app].get('category_label') or '—')} |")
|
||||
else:
|
||||
lines.append("No application has been tested in the ProxMenux lab yet.")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def problems(verification, index, rendered=None, today=None):
|
||||
today = today or datetime.date.today()
|
||||
apps = applications(index)
|
||||
found = []
|
||||
entries = verification.get("applications") if isinstance(verification, dict) else None
|
||||
if not isinstance(entries, dict):
|
||||
return ["verification.json must hold an \"applications\" object"]
|
||||
for app, entry in entries.items():
|
||||
where = f"verification.json: {app}"
|
||||
if app not in apps:
|
||||
found.append(f"{where}: not an application of the OCI catalog")
|
||||
if not isinstance(entry, dict) or not entry:
|
||||
found.append(f"{where}: the entry must be a non-empty object")
|
||||
continue
|
||||
if set(entry) - ENTRY_KEYS:
|
||||
found.append(f"{where}: unknown keys {sorted(set(entry) - ENTRY_KEYS)}")
|
||||
if "status" in entry and entry["status"] != LAB:
|
||||
found.append(f"{where}: status can only be \"{LAB}\"")
|
||||
if "community_tested" not in entry:
|
||||
continue
|
||||
tested = entry["community_tested"]
|
||||
if not isinstance(tested, dict):
|
||||
found.append(f"{where}: community_tested must be an object")
|
||||
continue
|
||||
if set(tested) - COMMUNITY_KEYS:
|
||||
found.append(f"{where}: unknown community_tested keys {sorted(set(tested) - COMMUNITY_KEYS)}")
|
||||
if not USER_RE.fullmatch(str(tested.get("by", ""))):
|
||||
found.append(f"{where}: \"by\" must be a GitHub user name")
|
||||
try:
|
||||
date = datetime.date.fromisoformat(str(tested.get("date", "")))
|
||||
if date > today + datetime.timedelta(days=1):
|
||||
found.append(f"{where}: the date is in the future")
|
||||
except ValueError:
|
||||
found.append(f"{where}: \"date\" must be YYYY-MM-DD")
|
||||
if "report" in tested and not REPORT_RE.fullmatch(str(tested["report"])):
|
||||
found.append(f"{where}: \"report\" must link to an issue or discussion of MacRimi/ProxMenux")
|
||||
if "digest" in tested and not DIGEST_RE.fullmatch(str(tested["digest"])):
|
||||
found.append(f"{where}: \"digest\" must be 8 to 64 lowercase hex characters of the image digest")
|
||||
if "scenario" in tested and (not isinstance(tested["scenario"], str) or len(tested["scenario"]) > 300):
|
||||
found.append(f"{where}: \"scenario\" must be text of at most 300 characters")
|
||||
if rendered is not None and rendered != render(verification, index):
|
||||
found.append("oci/VALIDATION.md is out of date: run python3 .github/scripts/oci_validation.py render")
|
||||
return found
|
||||
|
||||
|
||||
def author_problems(base, head, author):
|
||||
"""A pull request only adds or changes community tests credited to its author."""
|
||||
if author.casefold() in MAINTAINERS:
|
||||
return []
|
||||
found = []
|
||||
before = base.get("applications", {}) if isinstance(base, dict) else {}
|
||||
after = head.get("applications", {})
|
||||
for app in sorted(set(before) | set(after)):
|
||||
old = before.get(app) if isinstance(before.get(app), dict) else {}
|
||||
new = after.get(app) if isinstance(after.get(app), dict) else {}
|
||||
if old.get("status") != new.get("status"):
|
||||
found.append(f"{app}: the ProxMenux lab status is changed by the maintainer only")
|
||||
if old.get("community_tested") == new.get("community_tested"):
|
||||
continue
|
||||
credited = community(new)
|
||||
if not credited:
|
||||
found.append(f"{app}: a community test is removed by the maintainer only")
|
||||
elif str(credited["by"]).casefold() != author.casefold():
|
||||
found.append(f"{app}: credits @{credited['by']}, but the pull request is from @{author}")
|
||||
return found
|
||||
|
||||
|
||||
def parse_form(body):
|
||||
"""Issue form answers, keyed by the field label."""
|
||||
fields, label, lines = {}, None, []
|
||||
for line in (body or "").replace("\r\n", "\n").split("\n"):
|
||||
if line.startswith("### "):
|
||||
if label is not None:
|
||||
fields[label] = "\n".join(lines).strip()
|
||||
label, lines = line[4:].strip(), []
|
||||
elif label is not None:
|
||||
lines.append(line)
|
||||
if label is not None:
|
||||
fields[label] = "\n".join(lines).strip()
|
||||
return {key: ("" if value == NO_RESPONSE else value) for key, value in fields.items()}
|
||||
|
||||
|
||||
def resolve(name, index):
|
||||
wanted = " ".join(name.split()).casefold()
|
||||
matches = [item["id"] for item in index.get("applications", [])
|
||||
if wanted in (str(item["id"]).casefold(), " ".join(str(item.get("title") or "").split()).casefold())]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def image_digest(text):
|
||||
"""The digest as the Monitor shows it (b1f339cf) or in full (sha256:b1f3...)."""
|
||||
value = str(text or "").strip().lower()
|
||||
value = value.split("·")[-1].strip()
|
||||
value = value[7:] if value.startswith("sha256:") else value
|
||||
return value if DIGEST_RE.fullmatch(value) else None
|
||||
|
||||
|
||||
def scenario(fields):
|
||||
parts = [fields.get(name, "") for name in SCENARIO_FIELDS]
|
||||
if fields.get("Proxmox VE version"):
|
||||
parts.append(f"Proxmox VE {fields['Proxmox VE version']}")
|
||||
if fields.get("Image version"):
|
||||
parts.append(f"image {fields['Image version']}")
|
||||
text = " · ".join(part.strip() for part in parts if part and part.strip())
|
||||
if fields.get("Result") == "Works with problems":
|
||||
text = f"Works with problems: {text}"
|
||||
return text[:300]
|
||||
|
||||
|
||||
def reviewers(text):
|
||||
"""User names of the reviewer list, one per line; # starts a comment."""
|
||||
names = {line.split("#", 1)[0].strip() for line in text.splitlines()}
|
||||
return {name.casefold() for name in names if USER_RE.fullmatch(name)}
|
||||
|
||||
|
||||
def record(event, verification, index, today, allowed=frozenset()):
|
||||
"""Returns (verification, app id, message); raises ValueError with the reason to refuse.
|
||||
|
||||
A label can only be added by someone with write access to the repository.
|
||||
A /validated comment is accepted from the reviewer list, never from the
|
||||
report's own author unless it is the maintainer."""
|
||||
issue = event.get("issue") or {}
|
||||
labels = {label.get("name") for label in issue.get("labels", [])}
|
||||
if "oci-validation" not in labels:
|
||||
raise ValueError("This issue is not an OCI validation report.")
|
||||
author = str((issue.get("user") or {}).get("login", ""))
|
||||
report = str(issue.get("html_url", ""))
|
||||
if not USER_RE.fullmatch(author) or not REPORT_RE.fullmatch(report):
|
||||
raise ValueError("The report author or link could not be read.")
|
||||
reviewer = str(((event.get("comment") or {}).get("user") or {}).get("login", ""))
|
||||
if event.get("comment") is not None:
|
||||
if reviewer.casefold() not in allowed:
|
||||
raise ValueError(f"@{reviewer} is not in the list of OCI validation reviewers "
|
||||
"(.github/oci-validation-reviewers), so the report is not recorded.")
|
||||
if reviewer.casefold() == author.casefold() and reviewer.casefold() not in MAINTAINERS:
|
||||
raise ValueError("A report is validated by a reviewer other than its author.")
|
||||
fields = parse_form(issue.get("body"))
|
||||
name = fields.get("Application", "")
|
||||
app = resolve(name, index)
|
||||
if app is None:
|
||||
raise ValueError(f"\"{name}\" does not match one application of the OCI catalog. "
|
||||
"Edit the Application field with its name or ID as shown in the catalog, then add the label again.")
|
||||
if fields.get("Result") == "Does not work":
|
||||
raise ValueError("The report says the application does not work, so it is not recorded as verified.")
|
||||
digest = image_digest(fields.get("Image digest"))
|
||||
if digest is None:
|
||||
raise ValueError("The Image digest field does not hold an image digest. Edit it with the digest shown in the "
|
||||
"App tab of the Monitor, for example b1f339cf, then add the label again.")
|
||||
data = json.loads(json.dumps(verification))
|
||||
entries = data.setdefault("applications", {})
|
||||
entry = entries.setdefault(app, {})
|
||||
previous = community(entry)
|
||||
entry["community_tested"] = {"by": author, "date": today.isoformat(), "report": report,
|
||||
"digest": digest, "scenario": scenario(fields)}
|
||||
data["applications"] = dict(sorted(entries.items()))
|
||||
title = applications(index)[app].get("title") or app
|
||||
validated = f" Validated by @{reviewer}." if reviewer else ""
|
||||
message = (f"Thank you, @{author}! {title} is now recorded as verified.{validated} "
|
||||
f"It shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md]({REPO}/blob/develop/oci/VALIDATION.md).")
|
||||
if previous and previous.get("by") != author:
|
||||
message += f" This report replaces the previous one from @{previous['by']}."
|
||||
return data, app, message
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
commands.add_parser("render", help="Write oci/VALIDATION.md")
|
||||
commands.add_parser("check", help="Check verification.json and that oci/VALIDATION.md is current")
|
||||
authors = commands.add_parser("authors", help="Check who a pull request credits")
|
||||
authors.add_argument("--base", required=True, help="verification.json of the base branch; may be missing")
|
||||
authors.add_argument("--author", required=True)
|
||||
rec = commands.add_parser("record", help="Record a reviewed validation report")
|
||||
rec.add_argument("--event", required=True)
|
||||
rec.add_argument("--message", required=True, help="File that receives the reply for the issue")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
verification, index = load(VERIFICATION), load(INDEX)
|
||||
if args.command == "render":
|
||||
OUTPUT.write_text(render(verification, index), encoding="utf-8")
|
||||
return 0
|
||||
if args.command == "check":
|
||||
rendered = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
|
||||
found = problems(verification, index, rendered)
|
||||
elif args.command == "authors":
|
||||
base = load(args.base) if Path(args.base).exists() and Path(args.base).stat().st_size else {}
|
||||
found = author_problems(base, verification, args.author)
|
||||
else:
|
||||
try:
|
||||
allowed = reviewers(REVIEWERS.read_text(encoding="utf-8")) if REVIEWERS.exists() else frozenset()
|
||||
data, app, message = record(load(args.event), verification, index, datetime.date.today(), allowed)
|
||||
except ValueError as error:
|
||||
Path(args.message).write_text(str(error) + "\n", encoding="utf-8")
|
||||
return 2
|
||||
VERIFICATION.write_text(dump(data), encoding="utf-8")
|
||||
OUTPUT.write_text(render(data, index), encoding="utf-8")
|
||||
Path(args.message).write_text(message + "\n", encoding="utf-8")
|
||||
if os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
||||
output.write(f"app={app}\n")
|
||||
return 0
|
||||
for problem in found:
|
||||
print(f"::error::{problem}")
|
||||
return 1 if found else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only the independently qualified, host-independent tests/ fixtures.
|
||||
|
||||
No filesystem discovery widens this manifest. Run from the repository root;
|
||||
Python and Node have separate CI jobs so either failure blocks the check.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PYTHON_FILES = (
|
||||
"tests/test_audit_presentation.py",
|
||||
"tests/test_audit_safety_wording.py",
|
||||
"tests/test_audit_policy.py",
|
||||
"tests/test_audit_report.py",
|
||||
"tests/test_audit_catalog.py",
|
||||
"tests/storage/test_nvme_status_message.py",
|
||||
"tests/test_fastfetch_config_generation.py",
|
||||
"tests/test_notification_active_payloads.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/lxc_updates/test_docker_delegated_ui.cjs",
|
||||
"tests/test_audit_diagnostic_document.cjs",
|
||||
"tests/test_audit_policy.cjs",
|
||||
"tests/test_audit_presentation.cjs",
|
||||
"tests/test_audit_safety_wording.cjs",
|
||||
"tests/test_audit_summary.cjs",
|
||||
"tests/test_backup_archives_empty.cjs",
|
||||
"tests/test_backup_destination_messages.cjs",
|
||||
"tests/test_borg_ssh_guidance.cjs",
|
||||
"tests/test_storage_messages.cjs",
|
||||
"tests/test_health_text.cjs",
|
||||
)
|
||||
|
||||
# unittest's CLI accepts a missing pattern as a successful zero-test run.
|
||||
# Load the exact file in a fresh interpreter and reject zero tests and skips.
|
||||
PYTHON_RUN = """import sys, unittest
|
||||
folder, filename = sys.argv[1:]
|
||||
suite = unittest.TestLoader().discover(start_dir=folder, pattern=filename)
|
||||
count = suite.countTestCases()
|
||||
if count == 0:
|
||||
raise SystemExit('No tests discovered: ' + folder + '/' + filename)
|
||||
print('DISCOVERED=' + str(count), flush=True)
|
||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
sys.exit(0 if result.wasSuccessful() and result.testsRun == count and not result.skipped else 1)
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--lane", required=True, choices=("python", "node"))
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
files = PYTHON_FILES if args.lane == "python" else NODE_FILES
|
||||
if not files or Path.cwd().resolve() != root:
|
||||
parser.error("nonempty manifest and repository-root working directory required")
|
||||
for path in files:
|
||||
if not (root / path).is_file():
|
||||
print(f"Missing qualified test: {path}", file=sys.stderr)
|
||||
return 1
|
||||
if args.lane == "node" and not (root / "AppImage/node_modules/typescript").is_dir():
|
||||
print("Install the locked offline-node dependencies first", file=sys.stderr)
|
||||
return 1
|
||||
for path in files:
|
||||
print(f"RUN {path}", flush=True)
|
||||
if args.lane == "python":
|
||||
folder, filename = str(Path(path).parent), Path(path).name
|
||||
command = [sys.executable, "-I", "-B", "-c", PYTHON_RUN, folder, filename]
|
||||
else:
|
||||
command = ["node", path]
|
||||
result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False)
|
||||
if result.returncode != 0:
|
||||
print(f"FAIL {path}: exit {result.returncode}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"PASS {path}", flush=True)
|
||||
print(f"PASS {args.lane}: {len(files)} qualified files", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,558 @@
|
||||
{
|
||||
"pin": "dede876f0b92f8a571325e96ed7591b30a85368e",
|
||||
"fallback_indices": [
|
||||
9,
|
||||
10,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
34,
|
||||
43,
|
||||
44,
|
||||
45,
|
||||
58,
|
||||
59
|
||||
],
|
||||
"locales": {
|
||||
"es": [
|
||||
"Listar todos los nodos Proxmox",
|
||||
"Listar VM en un nodo",
|
||||
"Listar contenedores LXC en un nodo",
|
||||
"Listar almacenamiento en un nodo",
|
||||
"Listar interfaces de red",
|
||||
"Listar todas las máquinas virtuales QEMU/KVM",
|
||||
"Iniciar una máquina virtual",
|
||||
"Detener una máquina virtual",
|
||||
"Apagar una máquina virtual de forma segura",
|
||||
"Mostrar estado de VM",
|
||||
"Mostrar configuración de VM",
|
||||
"Crear una instantánea de VM",
|
||||
"Listar todos los contenedores LXC",
|
||||
"Iniciar un contenedor LXC",
|
||||
"Detener un contenedor LXC",
|
||||
"Abrir una consola de contenedor LXC",
|
||||
"Mostrar configuración del contenedor",
|
||||
"Mostrar estado de almacenamiento",
|
||||
"Enumerar el contenido del almacenamiento",
|
||||
"Probar el rendimiento del sistema Proxmox",
|
||||
"Mostrar la versión de Proxmox VE",
|
||||
"Verificar el estado del clúster",
|
||||
"Mostrar el estado del clúster",
|
||||
"Enumerar los nodos del clúster",
|
||||
"Mostrar el estado del grupo ZFS",
|
||||
"Enumerar todos los grupos ZFS",
|
||||
"Enumerar todo Conjuntos de datos ZFS",
|
||||
"Enumerar todos los archivos con detalles",
|
||||
"Cambiar directorio",
|
||||
"Crear un directorio",
|
||||
"Eliminar un directorio de forma recursiva",
|
||||
"Copiar archivos o directorios",
|
||||
"Mover o cambiar el nombre de archivos",
|
||||
"Mostrar el contenido del archivo",
|
||||
"Buscar un patrón en un archivo",
|
||||
"Buscar archivos por nombre",
|
||||
"Cambiar permisos de archivos",
|
||||
"Cambiar el propietario del archivo",
|
||||
"Extraer un archivo tar.gz",
|
||||
"Crear un tar.gz archivo",
|
||||
"Mostrar uso del disco",
|
||||
"Mostrar tamaños de directorio",
|
||||
"Mostrar uso de memoria",
|
||||
"Mostrar procesos en ejecución",
|
||||
"Buscar un proceso en ejecución",
|
||||
"Forzar la eliminación de un proceso",
|
||||
"Verificar el estado del servicio",
|
||||
"Iniciar un servicio",
|
||||
"Detener un servicio",
|
||||
"Reiniciar un servicio",
|
||||
"Actualizar paquetes de Debian/Ubuntu",
|
||||
"Instalar un paquete de Debian/Ubuntu",
|
||||
"Eliminar un paquete",
|
||||
"Listar contenedores en ejecución",
|
||||
"Listar Docker imágenes",
|
||||
"Abrir un shell en un contenedor",
|
||||
"Mostrar direcciones IP",
|
||||
"Probar conectividad de red",
|
||||
"Obtener encabezados HTTP",
|
||||
"Descargar un archivo",
|
||||
"Conectarse a través de SSH",
|
||||
"Copiar un archivo a través de SSH",
|
||||
"Seguir un archivo de registro en tiempo real",
|
||||
"Mostrar historial de comandos",
|
||||
"Borrar la pantalla del terminal"
|
||||
],
|
||||
"de": [
|
||||
"Alle Proxmox-Knoten auflisten",
|
||||
"VMs auf einem Knoten auflisten",
|
||||
"LXC-Container auf einem Knoten auflisten",
|
||||
"Speicher auf einem Knoten auflisten",
|
||||
"Netzwerkschnittstellen auflisten",
|
||||
"Alle virtuellen QEMU/KVM-Maschinen auflisten",
|
||||
"Eine virtuelle Maschine starten",
|
||||
"Eine virtuelle Maschine stoppen",
|
||||
"Eine virtuelle Maschine sicher herunterfahren",
|
||||
"VM-Status anzeigen",
|
||||
"VM-Konfiguration anzeigen",
|
||||
"Einen VM-Snapshot erstellen",
|
||||
"Alle LXC-Container auflisten",
|
||||
"Einen LXC-Container starten",
|
||||
"Einen LXC-Container stoppen",
|
||||
"Eine LXC-Containerkonsole öffnen",
|
||||
"Containerkonfiguration anzeigen",
|
||||
"Speicherstatus anzeigen",
|
||||
"Speicherinhalte auflisten",
|
||||
"Proxmox-Systemleistung testen",
|
||||
"Proxmox VE-Version anzeigen",
|
||||
"Clusterstatus prüfen",
|
||||
"Clusterstatus anzeigen",
|
||||
"Clusterknoten auflisten",
|
||||
"ZFS-Poolstatus anzeigen",
|
||||
"Alle ZFS-Pools auflisten",
|
||||
"Alle ZFS-Datensätze auflisten",
|
||||
"Alle Dateien mit Details auflisten",
|
||||
"Verzeichnis ändern",
|
||||
"Verzeichnis erstellen",
|
||||
"Verzeichnis rekursiv entfernen",
|
||||
"Dateien oder Verzeichnisse kopieren",
|
||||
"Dateien verschieben oder umbenennen",
|
||||
"Dateiinhalte anzeigen",
|
||||
"Nach einem Muster in einer Datei suchen",
|
||||
"Dateien nach Namen suchen",
|
||||
"Dateiberechtigungen ändern",
|
||||
"Dateieigentümer ändern",
|
||||
"Tar.gz-Archiv extrahieren",
|
||||
"Tar.gz-Archiv erstellen",
|
||||
"Festplattennutzung anzeigen",
|
||||
"Verzeichnisgrößen anzeigen",
|
||||
"Speichernutzung anzeigen",
|
||||
"Laufende Prozesse anzeigen",
|
||||
"Laufenden Prozess suchen",
|
||||
"Prozess beenden",
|
||||
"Dienststatus prüfen",
|
||||
"Dienst starten",
|
||||
"Dienst stoppen",
|
||||
"Dienst neu starten",
|
||||
"Debian/Ubuntu-Pakete aktualisieren",
|
||||
"Debian/Ubuntu-Paket installieren",
|
||||
"Paket entfernen",
|
||||
"Laufende Container auflisten",
|
||||
"Docker-Images auflisten",
|
||||
"Eine Shell in einem Container öffnen",
|
||||
"IP-Adressen anzeigen",
|
||||
"Netzwerkkonnektivität testen",
|
||||
"HTTP-Header abrufen",
|
||||
"Eine Datei herunterladen",
|
||||
"Über SSH verbinden",
|
||||
"Eine Datei über SSH kopieren",
|
||||
"Eine Protokolldatei in Echtzeit verfolgen",
|
||||
"Befehlsverlauf anzeigen",
|
||||
"Terminalbildschirm löschen"
|
||||
],
|
||||
"fr": [
|
||||
"Liste de tous les nœuds Proxmox",
|
||||
"Liste des machines virtuelles sur un nœud",
|
||||
"Liste des conteneurs LXC sur un nœud",
|
||||
"Liste du stockage sur un nœud",
|
||||
"Liste des interfaces réseau",
|
||||
"Liste de toutes les machines virtuelles QEMU/KVM",
|
||||
"Démarrer une machine virtuelle",
|
||||
"Arrêter une machine virtuelle",
|
||||
"Arrêter une machine virtuelle en toute sécurité",
|
||||
"Afficher l'état de la VM",
|
||||
"Afficher la VM configuration",
|
||||
"Créer un instantané de VM",
|
||||
"Liste de tous les conteneurs LXC",
|
||||
"Démarrer un conteneur LXC",
|
||||
"Arrêter un conteneur LXC",
|
||||
"Ouvrir une console de conteneur LXC",
|
||||
"Afficher la configuration du conteneur",
|
||||
"Afficher l'état du stockage",
|
||||
"Liste du contenu du stockage",
|
||||
"Tester les performances du système Proxmox",
|
||||
"Afficher la version Proxmox VE",
|
||||
"Vérifier l'état du cluster",
|
||||
"Afficher l'état du cluster",
|
||||
"Liste des nœuds du cluster",
|
||||
"Afficher l'état du pool ZFS",
|
||||
"Liste de tous les pools ZFS",
|
||||
"Liste de tous les ensembles de données ZFS",
|
||||
"Liste de tous les fichiers avec détails",
|
||||
"Changer de répertoire",
|
||||
"Créer un répertoire",
|
||||
"Supprimer un répertoire de manière récursive",
|
||||
"Copier des fichiers ou des répertoires",
|
||||
"Déplacer ou renommer des fichiers",
|
||||
"Afficher le contenu du fichier",
|
||||
"Rechercher un modèle dans un fichier",
|
||||
"Rechercher des fichiers par nom",
|
||||
"Modifier les autorisations du fichier",
|
||||
"Modifier le propriétaire du fichier",
|
||||
"Extraire une archive tar.gz",
|
||||
"Créer une archive tar.gz",
|
||||
"Afficher l'utilisation du disque",
|
||||
"Afficher la taille des répertoires",
|
||||
"Afficher l'utilisation de la mémoire",
|
||||
"Afficher les processus en cours d'exécution",
|
||||
"Trouver un processus en cours d'exécution",
|
||||
"Forcer l'arrêt d'un processus",
|
||||
"Vérifier le service\". statut",
|
||||
"Démarrer un service",
|
||||
"Arrêter un service",
|
||||
"Redémarrer un service",
|
||||
"Mettre à jour les packages Debian/Ubuntu",
|
||||
"Installer un package Debian/Ubuntu",
|
||||
"Supprimer un package",
|
||||
"Liste des conteneurs en cours d'exécution",
|
||||
"Liste des images Docker",
|
||||
"Ouvrir un shell dans un conteneur",
|
||||
"Afficher les adresses IP",
|
||||
"Tester la connectivité réseau",
|
||||
"Obtenir les en-têtes HTTP",
|
||||
"Télécharger un fichier",
|
||||
"Connecter via SSH",
|
||||
"Copier un fichier via SSH",
|
||||
"Suivre un fichier journal en temps réel",
|
||||
"Afficher l'historique des commandes",
|
||||
"Effacer l'écran du terminal"
|
||||
],
|
||||
"it": [
|
||||
"Elenca tutti i nodi Proxmox",
|
||||
"Elenca VM su un nodo",
|
||||
"Elenca container LXC su un nodo",
|
||||
"Elenca storage su un nodo",
|
||||
"Elenca interfacce di rete",
|
||||
"Elenca tutte le VM QEMU/KVM",
|
||||
"Avvia una VM",
|
||||
"Arresta una VM",
|
||||
"Spegni una VM in modo ordinato",
|
||||
"Mostra stato VM",
|
||||
"Mostra configurazione VM",
|
||||
"Crea snapshot VM",
|
||||
"Elenca tutti i container LXC",
|
||||
"Avvia un container LXC",
|
||||
"Arresta un container LXC",
|
||||
"Apri una shell in un container LXC",
|
||||
"Mostra configurazione container",
|
||||
"Mostra stato storage",
|
||||
"Elenca contenuto storage",
|
||||
"Verifica prestazioni del sistema Proxmox",
|
||||
"Mostra versione Proxmox VE",
|
||||
"Mostra stato servizio pve-cluster",
|
||||
"Mostra stato cluster",
|
||||
"Elenca nodi cluster",
|
||||
"Mostra stato pool ZFS",
|
||||
"Elenca tutti i pool ZFS",
|
||||
"Elenca filesystem e volumi ZFS",
|
||||
"Elenca tutti i file con dettagli",
|
||||
"Cambia directory",
|
||||
"Crea una directory",
|
||||
"Rimuovi una directory ricorsivamente",
|
||||
"Copia file",
|
||||
"Sposta o rinomina file",
|
||||
"Visualizza contenuto file",
|
||||
"Cerca un pattern in un file",
|
||||
"Trova file per nome",
|
||||
"Modifica permessi file",
|
||||
"Cambia proprietario file",
|
||||
"Estrai un archivio tar.gz",
|
||||
"Crea un archivio tar.gz",
|
||||
"Mostra utilizzo disco",
|
||||
"Mostra dimensioni directory",
|
||||
"Mostra utilizzo memoria",
|
||||
"Mostra processi in esecuzione",
|
||||
"Trova un processo in esecuzione",
|
||||
"Termina forzatamente un processo",
|
||||
"Controlla stato servizio",
|
||||
"Avvia un servizio",
|
||||
"Arresta un servizio",
|
||||
"Riavvia un servizio",
|
||||
"Aggiorna pacchetti Debian/Ubuntu",
|
||||
"Installa un pacchetto Debian/Ubuntu",
|
||||
"Rimuovi un pacchetto",
|
||||
"Elenca i container in esecuzione",
|
||||
"Elenca immagini Docker",
|
||||
"Apri una shell in un container",
|
||||
"Mostra indirizzi IP",
|
||||
"Verifica connettività di rete",
|
||||
"Ottieni intestazioni HTTP",
|
||||
"Scarica un file",
|
||||
"Connettiti tramite SSH",
|
||||
"Copia un file tramite SSH",
|
||||
"Segui un file di log in tempo reale",
|
||||
"Mostra cronologia comandi",
|
||||
"Pulisci lo schermo del terminale"
|
||||
],
|
||||
"pt": [
|
||||
"Listar todos os nós Proxmox",
|
||||
"Listar VMs em um nó",
|
||||
"Listar contêineres LXC em um nó",
|
||||
"Listar armazenamento em um nó",
|
||||
"Listar interfaces de rede",
|
||||
"Listar todas as máquinas virtuais QEMU/KVM",
|
||||
"Iniciar uma máquina virtual",
|
||||
"Parar uma máquina virtual",
|
||||
"Desligar uma máquina virtual com segurança",
|
||||
"Mostrar status da VM",
|
||||
"Mostrar configuração da VM",
|
||||
"Criar um instantâneo de VM",
|
||||
"Listar todos os LXC containers",
|
||||
"Iniciar um contêiner LXC",
|
||||
"Parar um contêiner LXC",
|
||||
"Abrir um console de contêiner LXC",
|
||||
"Mostrar configuração do contêiner",
|
||||
"Mostrar status de armazenamento",
|
||||
"Listar conteúdo de armazenamento",
|
||||
"Testar desempenho do sistema Proxmox",
|
||||
"Mostrar a versão Proxmox VE",
|
||||
"Verificar status do cluster",
|
||||
"Mostrar status do cluster",
|
||||
"Listar nós do cluster",
|
||||
"Mostrar status do pool ZFS",
|
||||
"Listar todos os pools ZFS",
|
||||
"Listar todos os conjuntos de dados ZFS",
|
||||
"Listar todos os arquivos com detalhes",
|
||||
"Alterar diretório",
|
||||
"Criar um diretório",
|
||||
"Remover um diretório recursivamente",
|
||||
"Copiar arquivos ou diretórios",
|
||||
"Mover ou renomear arquivos",
|
||||
"Exibir conteúdo do arquivo",
|
||||
"Pesquisar um padrão em um arquivo",
|
||||
"Encontrar arquivos por nome",
|
||||
"Alterar permissões de arquivo",
|
||||
"Alterar o proprietário do arquivo",
|
||||
"Extrair um arquivo tar.gz",
|
||||
"Criar um arquivo tar.gz",
|
||||
"Mostrar uso de disco",
|
||||
"Mostrar tamanhos de diretório",
|
||||
"Mostrar uso de memória",
|
||||
"Mostrar processos em execução",
|
||||
"Encontrar um processo em execução",
|
||||
"Forçar a eliminação de um processo",
|
||||
"Verificar status do serviço",
|
||||
"Iniciar um serviço",
|
||||
"Parar um serviço",
|
||||
"Reiniciar um serviço",
|
||||
"Atualizar pacotes Debian/Ubuntu",
|
||||
"Instalar um pacote Debian/Ubuntu",
|
||||
"Remover um pacote",
|
||||
"Listar contêineres em execução",
|
||||
"Listar imagens do Docker",
|
||||
"Abrir um shell em um contêiner",
|
||||
"Mostrar endereços IP",
|
||||
"Testar conectividade de rede",
|
||||
"Obter cabeçalhos HTTP",
|
||||
"Baixar um arquivo",
|
||||
"Conectar através de SSH",
|
||||
"Copiar um arquivo através de SSH",
|
||||
"Seguir um arquivo de log em tempo real",
|
||||
"Mostrar histórico de comandos",
|
||||
"Limpar a tela do terminal"
|
||||
],
|
||||
"sv": [
|
||||
"Lista alla Proxmox-noder",
|
||||
"Lista virtuella datorer på en nod",
|
||||
"Lista LXC-behållare på en nod",
|
||||
"Lista lagringsutrymme på en nod",
|
||||
"Lista nätverksgränssnitt",
|
||||
"Lista alla virtuella QEMU/KVM-maskiner",
|
||||
"Starta en virtuell maskin",
|
||||
"Stoppa en virtuell maskin",
|
||||
"Stäng av en virtuell maskin på ett säkert sätt",
|
||||
"Show VM status",
|
||||
"Show VM configuration",
|
||||
"Skapa en VM Snapshot",
|
||||
"Lista alla LXC-behållare",
|
||||
"Starta en LXC-behållare",
|
||||
"Stoppa en LXC-behållare",
|
||||
"Öppna en LXC-containerkonsol",
|
||||
"Visa behållarkonfiguration",
|
||||
"Visa lagringsstatus",
|
||||
"Lista lagringsinnehåll",
|
||||
"Test Proxmox system performance",
|
||||
"Show the Proxmox VE version",
|
||||
"Check cluster status",
|
||||
"Show cluster status",
|
||||
"Lista klusternoder",
|
||||
"Visa ZFS-poolstatus",
|
||||
"Lista alla ZFS-pooler",
|
||||
"Lista alla ZFS-datauppsättningar",
|
||||
"Lista alla filer med detaljer",
|
||||
"Ändra katalog",
|
||||
"Skapa en katalog",
|
||||
"Ta bort en katalog rekursivt",
|
||||
"Copy files or directories",
|
||||
"Move or rename files",
|
||||
"Display file contents",
|
||||
"Search for a pattern in a file",
|
||||
"Hitta filer efter namn",
|
||||
"Ändra filbehörigheter",
|
||||
"Ändra filägare",
|
||||
"Extrahera ett tar.gz-arkiv",
|
||||
"Skapa ett tar.gz-arkiv",
|
||||
"Visa diskanvändning",
|
||||
"Visa katalogstorlekar",
|
||||
"Visa minnesanvändning",
|
||||
"Show running processes",
|
||||
"Find a running process",
|
||||
"Force-kill a process",
|
||||
"Kontrollera tjänsten status",
|
||||
"Starta en tjänst",
|
||||
"Stoppa en tjänst",
|
||||
"Starta om en tjänst",
|
||||
"Uppdatera Debian/Ubuntu-paket",
|
||||
"Installera ett Debian/Ubuntu-paket",
|
||||
"Ta bort ett paket",
|
||||
"Lista behållare som körs",
|
||||
"Lista Docker-avbildningar",
|
||||
"Öppna ett skal i en behållare",
|
||||
"Visa IP-adresser",
|
||||
"Testa en nätverksanslutning",
|
||||
"Get HTTP headers",
|
||||
"Download a file",
|
||||
"Anslut via SSH",
|
||||
"Kopiera en fil via SSH",
|
||||
"Följ en loggfil i realtid",
|
||||
"Visa kommandohistorik",
|
||||
"Rensa terminalskärmen"
|
||||
]
|
||||
},
|
||||
"english": [
|
||||
"List all Proxmox nodes",
|
||||
"List VMs on a node",
|
||||
"List LXC containers on a node",
|
||||
"List storage on a node",
|
||||
"List network interfaces",
|
||||
"List all QEMU/KVM virtual machines",
|
||||
"Start a virtual machine",
|
||||
"Stop a virtual machine",
|
||||
"Shut down a virtual machine safely",
|
||||
"Show VM status",
|
||||
"Show VM configuration",
|
||||
"Create a VM Snapshot",
|
||||
"List all LXC containers",
|
||||
"Start an LXC container",
|
||||
"Stop an LXC container",
|
||||
"Open a shell in an LXC container",
|
||||
"Show container configuration",
|
||||
"Show storage status",
|
||||
"List storage contents",
|
||||
"Test Proxmox system performance",
|
||||
"Show the Proxmox VE version",
|
||||
"Show pve-cluster service status",
|
||||
"Show cluster status",
|
||||
"List cluster nodes",
|
||||
"Show ZFS pool status",
|
||||
"List all ZFS pools",
|
||||
"List ZFS filesystems and volumes",
|
||||
"List all files with details",
|
||||
"Change directory",
|
||||
"Create a directory",
|
||||
"Remove a directory recursively",
|
||||
"Copy files",
|
||||
"Move or rename files",
|
||||
"Display file contents",
|
||||
"Search for a pattern in a file",
|
||||
"Find files by name",
|
||||
"Change file permissions",
|
||||
"Change the file owner",
|
||||
"Extract a tar.gz archive",
|
||||
"Create a tar.gz archive",
|
||||
"Show disk usage",
|
||||
"Show directory sizes",
|
||||
"Show memory usage",
|
||||
"Show running processes",
|
||||
"Find a running process",
|
||||
"Force-kill a process",
|
||||
"Check service status",
|
||||
"Start a service",
|
||||
"Stop a service",
|
||||
"Restart a service",
|
||||
"Update Debian/Ubuntu packages",
|
||||
"Install a Debian/Ubuntu package",
|
||||
"Remove a package",
|
||||
"List running containers",
|
||||
"List Docker images",
|
||||
"Open a shell in a container",
|
||||
"Show IP addresses",
|
||||
"Test network connectivity",
|
||||
"Get HTTP headers",
|
||||
"Download a file",
|
||||
"Connect through SSH",
|
||||
"Copy a file through SSH",
|
||||
"Follow a log file in real time",
|
||||
"Show command history",
|
||||
"Clear the terminal screen"
|
||||
],
|
||||
"commands": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Execute the actual Security handlers against inert UI state and transport.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
|
||||
const catalogs = Object.fromEntries(['en','de','es','fr','it','pt','sk','sv'].map(lang => [lang, require(path.join(root, `AppImage/messages/${lang}/common.json`)).securityPage]));
|
||||
const begin = source.indexOf(' const handleUninstallLynis = async () => {');
|
||||
const end = source.indexOf('\n const loadFail2banDetails =', begin);
|
||||
assert(begin !== -1 && end > begin);
|
||||
const eraseTypes = text => text.replace(/fetchApi<\{[^}]+\}>/g, 'fetchApi')
|
||||
.replace(/ as \{ partial\?: boolean \} \| undefined/g, '');
|
||||
const uninstall = eraseTypes(source.slice(begin, end));
|
||||
const anchor = source.indexOf('if (confirm(st("confirm.deleteAuditReport")))');
|
||||
assert(anchor !== -1);
|
||||
const clickBegin = source.lastIndexOf('onClick={(e) => {', anchor);
|
||||
const clickEnd = source.indexOf('\n className=', anchor);
|
||||
assert(clickBegin !== -1 && clickEnd > anchor);
|
||||
const click = eraseTypes(source.slice(clickBegin + 'onClick={'.length, clickEnd).trim().replace(/}\s*$/, ''));
|
||||
async function exercise(lang, action, reply, priorSuccess = '') {
|
||||
const state = {report: {id: 'inert'}, expanded: true, success: priorSuccess, error: '', loads: 0, calls: []};
|
||||
const st = (key) => key.split('.').reduce((v, k) => v?.[k], catalogs[lang]);
|
||||
const ctx = {
|
||||
st, Error, confirm: () => true,
|
||||
setLynisReport: v => {state.report = v}, setLynisShowReport: v => {state.expanded = v},
|
||||
setSuccess: v => {state.success = v}, setError: v => {state.error = v},
|
||||
setUninstallingLynis: () => {}, setShowLynisUninstallConfirm: () => {},
|
||||
loadSecurityTools: () => {state.loads++},
|
||||
fetchApi: (endpoint, options) => {
|
||||
assert.equal(endpoint, action === 'delete' ? '/api/security/lynis/report' : '/api/security/lynis/uninstall');
|
||||
assert.equal(options.method, action === 'delete' ? 'DELETE' : 'POST');
|
||||
state.calls.push(options.method);
|
||||
return reply instanceof Error ? Promise.reject(reply) : Promise.resolve(reply);
|
||||
},
|
||||
};
|
||||
const fn = vm.runInNewContext(action === 'delete' ? `(${click})` : `${uninstall}; handleUninstallLynis`, ctx);
|
||||
await fn({stopPropagation() {}});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return state;
|
||||
}
|
||||
(async () => {
|
||||
for (const lang of Object.keys(catalogs)) {
|
||||
for (const action of ['delete', 'uninstall']) {
|
||||
const success = await exercise(lang, action, {success:true, message:'inert'});
|
||||
assert.equal(success.report, null, `${lang} ${action} clean report`);
|
||||
assert(success.success, `${lang} ${action} clean success`);
|
||||
assert.equal(success.error, '', `${lang} ${action} clean no error`);
|
||||
assert.equal(success.loads, 1);
|
||||
for (const [kind, reply] of [
|
||||
['no_files', {success:false,outcome:'no_files',message:'No report files found to delete'}],
|
||||
['malformed_success', {success:'false',message:'inert malformed response'}],
|
||||
['first_failure', {success:false,partial:false,message:'inert denied'}],
|
||||
['partial_failure', {success:false,partial:true,message:'inert denied'}],
|
||||
['http_failure', Object.assign(new Error('inert denied'), {body:{success:false,partial:true,message:'inert denied'}})],
|
||||
['nonjson_failure', new Error('Invalid JSON response')],
|
||||
['network_failure', new Error('network failure')],
|
||||
]) {
|
||||
const state = await exercise(lang, action, reply);
|
||||
assert.notEqual(state.report, null, `${lang} ${action} ${kind}: report retained`);
|
||||
assert.equal(state.success, '', `${lang} ${action} ${kind}: no success`);
|
||||
assert.equal(state.loads, 0, `${lang} ${action} ${kind}: no success refresh`);
|
||||
assert(state.error, `${lang} ${action} ${kind}: error visible`);
|
||||
const expectedKey = kind === 'no_files' ? (action === 'delete' ? 'deleteReportNoFiles' : 'lynisUninstallNoFiles')
|
||||
: kind === 'partial_failure' || kind === 'http_failure' ? 'lynisRemovalPartial'
|
||||
: 'lynisRemovalUnconfirmed';
|
||||
assert.equal(state.error, catalogs[lang].errors[expectedKey], `${lang} ${action} ${kind}: native whole-message feedback`);
|
||||
if (action === 'delete' && kind === 'no_files') {
|
||||
const stale = await exercise(lang, action, reply, 'Earlier unrelated success');
|
||||
assert.equal(stale.success, '', `${lang} delete clears stale success before failure`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('actual Lynis handlers: eight locales, clean/no-files/pre-first/partial/HTTP/non-JSON/network state');
|
||||
})().catch(err => { console.error(err); process.exitCode = 1 });
|
||||
@@ -0,0 +1,57 @@
|
||||
// Execute the actual printable-report generator, without mounting the app.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
|
||||
const begin = source.indexOf(' const generatePrintableReport = (report: LynisReport) => {');
|
||||
const end = source.indexOf('\n const loadSslStatus =', begin);
|
||||
assert(begin > 0 && end > begin, 'print consumer not found');
|
||||
const body = source.slice(begin, end)
|
||||
.replace('(report: LynisReport)', '(report)')
|
||||
.replace('(raw: unknown): string', '(raw)');
|
||||
const lang = process.env.LYNIS_FIXTURE_LANG || 'en';
|
||||
assert(['en','de','es','fr','it','pt','sk','sv'].includes(lang));
|
||||
const catalog = require(path.join(root, `AppImage/messages/${lang}/common.json`));
|
||||
const translate = (key, params = {}) => {
|
||||
const value = key.split('.').reduce((v, k) => v?.[k], catalog.securityPage);
|
||||
assert.equal(typeof value, 'string', `missing ${lang} key ${key}`);
|
||||
return value.replace(/\{(\w+)\}/g, (_, k) => String(params[k]));
|
||||
};
|
||||
const context = {
|
||||
st: translate,
|
||||
window: { location: { origin: 'https://offline.invalid' } },
|
||||
document: { documentElement: { lang } },
|
||||
getLynisScoreState: () => ({rawScore: 80, displayScore: 80, reportComplete: true, hasAdjustment: false}),
|
||||
getActionableCount: (total, expected) => Math.max(0, total - expected),
|
||||
lynisCountText: (key, count) => translate(`lynis.counts.${key}.${count === 1 ? 'one' : 'many'}`, { count }),
|
||||
};
|
||||
const render = vm.runInNewContext(`${body}\ngeneratePrintableReport`, context);
|
||||
const report = {
|
||||
hostname: 'inert', os_name: 'Linux', os_version: '', os_fullname: 'Linux',
|
||||
kernel_version: 'test', lynis_version: 'test', datetime_start: '2026-01-01',
|
||||
hardening_index: 80, tests_performed: 1,
|
||||
warnings: [
|
||||
{test_id: 'EXPECTED-W', description: 'expected warning', solution: '', proxmox_expected: true},
|
||||
{test_id: 'ACTION-W', description: 'actionable warning', solution: '', proxmox_expected: false},
|
||||
],
|
||||
suggestions: [
|
||||
{test_id: 'EXPECTED-S', description: 'expected suggestion', solution: '', proxmox_expected: true},
|
||||
{test_id: 'ACTION-S', description: 'actionable suggestion', solution: '', proxmox_expected: false},
|
||||
],
|
||||
proxmox_expected_warnings: 1, proxmox_expected_suggestions: 1, sections: [],
|
||||
};
|
||||
const html = render(report);
|
||||
if (process.env.LYNIS_FIXTURE_HTML) fs.writeFileSync(process.env.LYNIS_FIXTURE_HTML, html);
|
||||
for (const id of ['EXPECTED-W', 'ACTION-W', 'EXPECTED-S', 'ACTION-S']) {
|
||||
assert(html.includes(id), `missing actual finding ${id}`);
|
||||
}
|
||||
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-W[\s\S]*?f-tag-pve/);
|
||||
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-S[\s\S]*?f-tag-pve/);
|
||||
for (const key of ['lynis.report.warningsDescription', 'lynis.report.suggestionsDescription']) {
|
||||
const description = translate(key);
|
||||
assert(!/hidden|nascosti|ocult|caché|skryt|dold|versteckt/i.test(description));
|
||||
assert(html.includes(description), `missing rendered description ${key}`);
|
||||
}
|
||||
console.log(`print HTML (${lang}): expected/actionable findings shown and badged`);
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
|
||||
import datetime
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import typing
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from notification_fixture import templates, SCRIPTS, EmailChannel, extract
|
||||
|
||||
|
||||
def visible(markup):
|
||||
class Text(HTMLParser):
|
||||
def __init__(self): super().__init__(); self.parts = []; self.tags = []
|
||||
def handle_data(self, data): self.parts.append(data)
|
||||
def handle_starttag(self, tag, attrs): self.tags.append(tag)
|
||||
parser = Text(); parser.feed(markup)
|
||||
return '\n'.join(p.strip() for p in parser.parts if p.strip()), parser.tags
|
||||
|
||||
|
||||
def restore_event(warnings):
|
||||
events = []
|
||||
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
|
||||
'hostname':'node-a', 'guests':'3', 'stubs':'1', 'stale_nodes':'0',
|
||||
'components':'2', 'duration':'2m', 'warnings':warnings}),
|
||||
'notification_manager':types.SimpleNamespace(emit_event=lambda **kw:events.append(kw)),
|
||||
'jsonify':lambda value:value}
|
||||
handler = extract(SCRIPTS/'flask_notification_routes.py', 'internal_restore_event', None, ns)
|
||||
response, status = handler()
|
||||
assert status == 200 and len(events) == 1
|
||||
return events[0]
|
||||
|
||||
|
||||
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
|
||||
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
|
||||
captured = []
|
||||
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
|
||||
def sink(title, body, severity, data):
|
||||
markup = channel._format_html(title, body, severity, data)
|
||||
text, tags = visible(markup)
|
||||
captured.append(dict(title=title, body=body, severity=severity, data=dict(data), html=markup, text=text, tags=tags))
|
||||
return {'success':True}
|
||||
manager = types.SimpleNamespace(_config={'email.rich_format':'true'}, _lock=threading.RLock(),
|
||||
_channels={'email':types.SimpleNamespace(send=sink)},
|
||||
_group_limiter=types.SimpleNamespace(allow=lambda group:True),
|
||||
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
|
||||
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
|
||||
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
|
||||
_record_history=lambda *a:None, _stats={'total_sent':0,'total_errors':0},
|
||||
is_event_enabled=lambda event:True)
|
||||
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
|
||||
resolve_notification_hostname=lambda host, config:host or 'node-a',
|
||||
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
|
||||
_should_bypass_ai=lambda event:True, _AI_BYPASS_EVENTS=frozenset({'backup_complete','backup_fail'}))
|
||||
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
|
||||
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
|
||||
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
|
||||
db = Path(scratch)/'pending.sqlite'
|
||||
rows = []
|
||||
if quiet:
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute('CREATE TABLE quiet_pending (id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, event_group TEXT, severity TEXT, ts INTEGER, title TEXT, body TEXT)')
|
||||
conn.commit(); conn.close()
|
||||
qns = dict(vars(typing), sqlite3=sqlite3, DB_PATH=db, time=time, datetime=datetime.datetime,
|
||||
_resolve_display_hostname=lambda config:'node-a', runtime_message=templates.runtime_message,
|
||||
EVENT_EMOJI=templates.EVENT_EMOJI, CATEGORY_EMOJI=templates.CATEGORY_EMOJI)
|
||||
for name in ('_buffer_quiet_event', '_flush_quiet_for_channel', '_compose_digest_body'):
|
||||
setattr(manager,name,types.MethodType(extract(SCRIPTS/'notification_manager.py',name,'NotificationManager',qns),manager))
|
||||
for earlier in quiet_before:
|
||||
manager._dispatch_event(types.SimpleNamespace(**earlier,source='inert',entity_type='node',entity_id='',event_id='inert',fingerprint='inert'))
|
||||
if manual:
|
||||
assert not quiet
|
||||
result = manager.send_notification(event_type, severity, '', '', dict(data))
|
||||
assert result['success']
|
||||
else:
|
||||
event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert')
|
||||
manager._dispatch_event(event)
|
||||
if quiet:
|
||||
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
|
||||
assert len(rows)==1+len(quiet_before) and not captured
|
||||
manager._flush_quiet_for_channel('email', manager._channels['email'])
|
||||
conn=sqlite3.connect(db); remaining=conn.execute('SELECT count(*) FROM quiet_pending').fetchone()[0]; conn.close()
|
||||
assert remaining==0
|
||||
assert len(captured)==1
|
||||
if quiet: captured[0]['buffered']=rows
|
||||
return captured[0]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Assertion-free inert actual consumers; no operational host imports."""
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / 'AppImage/scripts'
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import notification_templates as templates
|
||||
# Display-name resolution is an infrastructure boundary, never load manager.
|
||||
templates._get_hostname = lambda: 'node-a'
|
||||
from notification_channels import EmailChannel
|
||||
LANGUAGES = ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
def extract(path, name, owner, ns):
|
||||
tree = ast.parse(path.read_text())
|
||||
nodes = tree.body if owner is None else next(n.body for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
|
||||
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
|
||||
return ns[name]
|
||||
|
||||
def receive(message, severity='info', title='Backup', kind='vzdump'):
|
||||
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
|
||||
class Event:
|
||||
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
|
||||
ns['NotificationEvent'] = Event
|
||||
methods = {name: extract(SCRIPTS / 'notification_events.py', name, 'ProxmoxHookWatcher', ns)
|
||||
for name in ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')}
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, event): self.items.append(event)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = methods['_classify_pve']
|
||||
_map_severity = staticmethod(methods['_map_severity'])
|
||||
_backup_outcome = staticmethod(methods['_backup_outcome'])
|
||||
process_webhook = methods['process_webhook']
|
||||
def __init__(self): self._queue = Queue()
|
||||
target = Receiver()
|
||||
result = target.process_webhook({'fields': {'type': kind}, 'severity': severity, 'title': title, 'message': message})
|
||||
assert result['accepted'] and len(target._queue.items) == 1
|
||||
return target._queue.items[0]
|
||||
|
||||
def email(event_type, data, severity='INFO', language='en'):
|
||||
result = templates.render_template(event_type, data, language)
|
||||
channel = object.__new__(EmailChannel)
|
||||
channel.subject_prefix = '[ProxMenux]'
|
||||
context = {**data, 'severity': severity, '_event_type': event_type, '_group': result['group'], '_notification_language': language}
|
||||
return result, channel._format_html(result['title'], result['body'], severity, context)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""A host backup and a finished restore tell the Monitor on its own port,
|
||||
over HTTPS once the Monitor has a certificate."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LIBRARY = ROOT / 'scripts/backup_restore/lib_host_backup_common.sh'
|
||||
POSTBOOT = ROOT / 'scripts/backup_restore/apply_cluster_postboot.sh'
|
||||
SETTING = '/etc/proxmenux/ssl_config.json'
|
||||
|
||||
|
||||
def function(text, name):
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}\n', start) + 3]
|
||||
|
||||
|
||||
class BackupNotifyHttps(TestCase):
|
||||
def address(self, setting):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'ssl_config.json'
|
||||
if setting is not None:
|
||||
path.write_text(setting)
|
||||
body = function(LIBRARY.read_text(), 'hb_notify_lifecycle').replace(SETTING, str(path))
|
||||
# The function silences curl, so the stub writes the address it was given.
|
||||
called = Path(folder) / 'called'
|
||||
script = (f'curl() {{ printf "%s" "${{@: -1}}" > {called}; }}\n' + body
|
||||
+ 'HB_NOTIFY_JOB_ID=job1 hb_notify_lifecycle complete\n')
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return called.read_text()
|
||||
|
||||
def test_plain_http_while_the_monitor_has_no_certificate(self):
|
||||
expected = 'http://127.0.0.1:8008/api/notifications/webhook'
|
||||
self.assertEqual(self.address(None), expected)
|
||||
self.assertEqual(self.address('{"enabled": false}'), expected)
|
||||
|
||||
def test_https_once_the_monitor_has_a_certificate(self):
|
||||
self.assertEqual(self.address('{"enabled": true, "source": "proxmox"}'),
|
||||
'https://127.0.0.1:8008/api/notifications/webhook')
|
||||
|
||||
def test_the_finished_restore_follows_the_same_setting(self):
|
||||
source = POSTBOOT.read_text()
|
||||
self.assertIn('"${NOTIFY_SCHEME}://127.0.0.1:8008/api/internal/restore-event"', source)
|
||||
self.assertNotIn('"http://127.0.0.1:8008/api/internal/restore-event"', source)
|
||||
check = source[source.index('NOTIFY_SCHEME="http"'):source.index('NOTIFY_HTTP=$(curl')]
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'ssl_config.json'
|
||||
for setting, expected in (('{"enabled": true}', 'https'), ('{"enabled":false}', 'http'), (None, 'http')):
|
||||
path.unlink(missing_ok=True)
|
||||
if setting is not None:
|
||||
path.write_text(setting)
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c',
|
||||
check.replace(SETTING, str(path)) + '\necho "$NOTIFY_SCHEME"'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.stdout.strip(), expected, setting)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Offline regression tests for the Monitor catalog generator."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "build_i18n_messages.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_i18n_messages", SCRIPT)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class I18nMessagesTests(unittest.TestCase):
|
||||
def run_generator(self, source, target, *options, provider=None):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
en = root / "en" / "common.json"
|
||||
it = root / "it" / "common.json"
|
||||
en.parent.mkdir()
|
||||
it.parent.mkdir()
|
||||
en.write_text(json.dumps(source), encoding="utf-8")
|
||||
it.write_text(json.dumps(target), encoding="utf-8")
|
||||
before = it.read_bytes()
|
||||
argv = [str(SCRIPT), "--source", str(en), "--messages-dir", str(root),
|
||||
"--languages", "it", "--sleep", "0", *options]
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||
MODULE, "translate_one", side_effect=provider or (lambda text, *args: "IT " + text)
|
||||
) as translate, redirect_stdout(output), redirect_stderr(output):
|
||||
result = MODULE.main()
|
||||
return result, json.loads(it.read_text()), translate.call_args_list, output.getvalue(), before == it.read_bytes()
|
||||
|
||||
def test_generator_fills_array_leaves_preserving_existing_values(self):
|
||||
source = {"items": ["One", {"title": "Two", "children": ["Three"]}],
|
||||
"empty": [], "object": {}, "enabled": True, "count": 2, "none": None}
|
||||
target = {"items": ["Uno"], "extra": {"0": "Keep"}}
|
||||
result, actual, calls, _, _ = self.run_generator(source, target, "--save-every", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, {**source, "items": ["Uno", {"title": "IT Two", "children": ["IT Three"]}],
|
||||
"extra": {"0": "Keep"}})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Two", "Three"])
|
||||
|
||||
def test_wrong_types_defer_locale_without_calls_or_writes_even_on_refresh(self):
|
||||
source = {"new": "New", "terminal": {"commandDescriptions": ["One", "Two"]}}
|
||||
for malformed in ["['Uno', 'Due']", '["Uno", "Due"]', "ambiguous human text", {}, None, 7]:
|
||||
for options in [(), ("--refresh",)]:
|
||||
with self.subTest(malformed=malformed, options=options):
|
||||
target = {"terminal": {"commandDescriptions": malformed}, "human": "Keep"}
|
||||
result, actual, calls, output, unchanged = self.run_generator(source, target, *options)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(actual, target)
|
||||
self.assertTrue(unchanged)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertIn("/terminal/commandDescriptions", output)
|
||||
self.assertIn("manual", output)
|
||||
|
||||
def test_structure_only_catalog_is_written_without_translation(self):
|
||||
source = {"items": ["", [], {}, None, False, 0]}
|
||||
result, actual, calls, _, _ = self.run_generator(source, {})
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, source)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_limit_keeps_array_positions_as_null_fallbacks(self):
|
||||
result, actual, calls, _, _ = self.run_generator(
|
||||
{"items": ["One", "", "Three", "Four"]}, {}, "--limit", "1")
|
||||
self.assertEqual(actual, {"items": ["IT One", "", None, None]})
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_existing_nonempty_including_english_and_stale_text_is_unchanged(self):
|
||||
source = {"items": ["Changed English", "CPU", "Three"]}
|
||||
target = {"items": ["Human translation", "CPU", "Tre", "Extra"]}
|
||||
result, actual, calls, _, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(actual, target)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertTrue(unchanged)
|
||||
|
||||
def test_second_run_retries_null_placeholders_preserving_translations(self):
|
||||
source = {"items": ["One", "Two", "", "Three", "Four"]}
|
||||
target = {"items": ["Human"], "extra": "Keep"}
|
||||
result, first, calls, _, _ = self.run_generator(source, target, "--limit", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(first, {"items": ["Human", "IT Two", "", None, None], "extra": "Keep"})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Two"])
|
||||
result, second, calls, _, _ = self.run_generator(source, first, "--save-every", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(second, {"items": ["Human", "IT Two", "", "IT Three", "IT Four"], "extra": "Keep"})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Three", "Four"])
|
||||
|
||||
def test_refresh_and_provider_failure_keep_existing_fallback_semantics(self):
|
||||
def provider(text, *args):
|
||||
if text == "Two":
|
||||
raise RuntimeError("offline fixture failure")
|
||||
return "IT " + text
|
||||
# --refresh alone never replaces an existing translation.
|
||||
result, actual, calls, output, _ = self.run_generator(
|
||||
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
|
||||
"--refresh", "--save-every", "1", provider=provider)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, {"items": ["Uno", "Due", "IT Three"]})
|
||||
self.assertEqual(len(calls), 1)
|
||||
# Named keys are retranslated; a provider failure keeps the value.
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as keys:
|
||||
keys.write("items.0\nitems.1\n")
|
||||
try:
|
||||
result, actual, calls, output, _ = self.run_generator(
|
||||
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
|
||||
"--refresh", "--refresh-keys", keys.name, "--save-every", "1", provider=provider)
|
||||
finally:
|
||||
Path(keys.name).unlink()
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, {"items": ["IT One", "Due", "IT Three"]})
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertIn("1 translation failures", output)
|
||||
|
||||
def test_null_and_empty_string_are_filled_and_tokens_restored(self):
|
||||
source = {"items": ["Hello {vmid}", "Use Proxmox"]}
|
||||
result, actual, calls, _, _ = self.run_generator(source, {"items": [None, ""]})
|
||||
self.assertEqual(actual, {"items": ["IT Hello {vmid}", "IT Use Proxmox"]})
|
||||
self.assertNotIn("{vmid}", calls[0].args[0])
|
||||
self.assertNotIn("Proxmox", calls[1].args[0])
|
||||
|
||||
def test_root_pointer_is_empty_not_empty_key_pointer(self):
|
||||
for source, target, pointer in [({"a": "Text"}, [], 'JSON Pointer "" (root)'),
|
||||
({"": []}, {"": "Text"}, 'JSON Pointer "/"'),
|
||||
({"a~/": []}, {"a~/": "Text"}, 'JSON Pointer "/a~0~1"')]:
|
||||
with self.subTest(pointer=pointer):
|
||||
result, _, calls, output, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertTrue(unchanged)
|
||||
self.assertIn(pointer + ": expected", output)
|
||||
|
||||
def test_wrong_scalar_and_nested_types_are_rejected(self):
|
||||
for source, target in [({"a": "Text"}, {"a": 42}),
|
||||
({"a": [{"b": "Text"}]}, {"a": ["human"]}),
|
||||
({"a": False}, {"a": 0}),
|
||||
({"a": {}}, {"a": []})]:
|
||||
with self.subTest(source=source):
|
||||
result, actual, calls, _, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertTrue(unchanged)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_real_english_array_round_trip_and_generation(self):
|
||||
catalog = json.loads((SCRIPT.parents[2] / "AppImage/messages/en/common.json").read_text())
|
||||
descriptions = catalog["terminal"]["commandDescriptions"]
|
||||
self.assertIsInstance(descriptions, list)
|
||||
self.assertTrue(descriptions)
|
||||
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
|
||||
result, actual, calls, _, _ = self.run_generator({"terminal": {"commandDescriptions": descriptions}}, {})
|
||||
self.assertEqual(len(calls), sum(isinstance(text, str) and bool(text) for text in descriptions))
|
||||
self.assertEqual(actual["terminal"]["commandDescriptions"], ["IT " + text for text in descriptions])
|
||||
|
||||
def test_preflight_skips_invalid_locales_but_processes_valid_locales(self):
|
||||
for options in [(), ("--refresh",)]:
|
||||
with self.subTest(options=options), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
catalogs = {"en": {"items": ["One", "Two"]},
|
||||
"sk": {"items": ["Human"]},
|
||||
"it": {"items": "Uno"}, "de": {"items": {}}}
|
||||
for lang, data in catalogs.items():
|
||||
path = root / lang / "common.json"
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(data))
|
||||
before = {lang: (root / lang / "common.json").read_bytes() for lang in catalogs}
|
||||
reads = []
|
||||
real_read = MODULE.read_json
|
||||
|
||||
def read(path):
|
||||
reads.append(path.parent.name)
|
||||
return real_read(path)
|
||||
|
||||
def translate(text, lang, *args):
|
||||
self.assertEqual(reads, ["en", "sk", "it", "de"])
|
||||
self.assertEqual(lang, "sk")
|
||||
return "SK " + text
|
||||
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", "sk,it,de",
|
||||
"--sleep", "0", "--save-every", "1", *options]
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "read_json", side_effect=read), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=translate) as provider, \
|
||||
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as writer, \
|
||||
redirect_stderr(output), redirect_stdout(output):
|
||||
self.assertEqual(MODULE.main(), 1)
|
||||
# --refresh without --refresh-keys keeps the existing value.
|
||||
self.assertEqual(json.loads((root / "sk/common.json").read_text()),
|
||||
{"items": ["Human", "SK Two"]})
|
||||
self.assertEqual(provider.call_count, 1)
|
||||
self.assertTrue(all(call.args[0].parent.name == "sk" for call in writer.call_args_list))
|
||||
for lang in ["en", "it", "de"]:
|
||||
self.assertEqual((root / lang / "common.json").read_bytes(), before[lang])
|
||||
self.assertIn("Partial failure: skipped 2 locale(s): it, de", output.getvalue())
|
||||
self.assertNotIn("generated successfully", output.getvalue())
|
||||
|
||||
def test_round_trip_preserves_json_types_and_literal_keys(self):
|
||||
catalog = {"terminal": {"commandDescriptions": ["One", {"nested": ["Two", [], {}]}]},
|
||||
"0": {"a.b": "literal", "": None}, "flags": [True, False, 2, 1.5]}
|
||||
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,220 @@
|
||||
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), [])
|
||||
|
||||
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,
|
||||
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()
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Frozen conservative recovery: update the fixture only after explicit review.
|
||||
|
||||
Run offline with Python unittest and Node >=22.14 on PATH (or NODE_BINARY).
|
||||
The consumer test executes only extracted pure lookup functions, not the UI.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from unittest import mock
|
||||
|
||||
from test_build_i18n_messages import MODULE, SCRIPT
|
||||
|
||||
ROOT = SCRIPT.parents[2]
|
||||
FIXTURE = json.loads((Path(__file__).parent / "fixtures/command_descriptions.json").read_text())
|
||||
|
||||
|
||||
def require_node():
|
||||
diagnostic = "Node >=22.14 is required; install Node 22 LTS or set NODE_BINARY to its executable"
|
||||
node = os.environ.get("NODE_BINARY") or shutil.which("node")
|
||||
if not node:
|
||||
raise AssertionError(diagnostic)
|
||||
try:
|
||||
result = subprocess.run([node, "--version"], capture_output=True, text=True)
|
||||
except OSError as exc:
|
||||
raise AssertionError(f"{diagnostic}; cannot execute {node}: {exc}") from exc
|
||||
version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", result.stdout.strip())
|
||||
if result.returncode or not version or tuple(map(int, version.groups())) < (22, 14, 0):
|
||||
raise AssertionError(f"{diagnostic}; found {result.stdout.strip() or result.stderr.strip()!r}")
|
||||
return node
|
||||
|
||||
|
||||
def catalog(lang):
|
||||
return json.loads((ROOT / f"AppImage/messages/{lang}/common.json").read_text())
|
||||
|
||||
|
||||
class NodeRuntimeTests(unittest.TestCase):
|
||||
def test_missing_node_reports_setup_instructions(self):
|
||||
with mock.patch.dict(os.environ, {"PATH": "", "NODE_BINARY": ""}):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
|
||||
require_node()
|
||||
|
||||
def test_invalid_override_reports_setup_instructions(self):
|
||||
with mock.patch.dict(os.environ, {"NODE_BINARY": "/nonexistent/f05-node"}):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
|
||||
require_node()
|
||||
|
||||
def test_older_node_reports_detected_version(self):
|
||||
with mock.patch.dict(os.environ, {"NODE_BINARY": "node"}), \
|
||||
mock.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess(
|
||||
["node", "--version"], 0, "v20.19.0\n", "")):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*v20\.19\.0"):
|
||||
require_node()
|
||||
|
||||
|
||||
class CommandDescriptionsTests(unittest.TestCase):
|
||||
def test_real_catalogs_exact_indexed_recovery_and_source_order(self):
|
||||
self.assertEqual(catalog("en")["terminal"]["commandDescriptions"], FIXTURE["english"])
|
||||
consumer = (ROOT / "AppImage/components/terminal-panel.tsx").read_text()
|
||||
commands = re.search(r"const proxmoxCommands = (\[.*?\])", consumer, re.S).group(1)
|
||||
commands = json.loads(re.sub(r",\s*\]$", "]", commands))
|
||||
self.assertEqual(commands, FIXTURE["commands"])
|
||||
self.assertIn('desc: t(`terminal.commandDescriptions.${index}`)', consumer)
|
||||
for lang, expected in FIXTURE["locales"].items():
|
||||
with self.subTest(locale=lang):
|
||||
actual = catalog(lang)["terminal"]["commandDescriptions"]
|
||||
self.assertIsInstance(actual, list)
|
||||
self.assertEqual(len(actual), len(commands))
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertTrue(all(isinstance(x, str) and x for x in actual))
|
||||
# Explicit English recovery values are frozen locale text, not live fallback.
|
||||
for index in FIXTURE["fallback_indices"]:
|
||||
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
|
||||
FIXTURE["locales"]["sv"][index])
|
||||
|
||||
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
|
||||
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
|
||||
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
|
||||
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for lang in ["en", *languages.split(",")]:
|
||||
path = root / lang / "common.json"
|
||||
path.parent.mkdir()
|
||||
shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
|
||||
# This steady-state fixture assumes generation already finished.
|
||||
# Seed only intentional message additions; keep the
|
||||
# recovered command arrays and the repo catalogs exact. Runtime
|
||||
# missing-key fallback is covered by the JSX seam tests and by
|
||||
# test_storage_messages.cjs and test_health_text.cjs.
|
||||
if lang != "en":
|
||||
temporary = json.loads(path.read_text())
|
||||
for section, key in (
|
||||
("archives", "emptyMessage"),
|
||||
("destinations", "backupsKeptMessage"),
|
||||
("destinations", "localAttachHelpMessage"),
|
||||
):
|
||||
temporary["backup"][section].setdefault(
|
||||
key, catalog("en")["backup"][section][key])
|
||||
temporary["storage"].setdefault(
|
||||
"savedSmartData", catalog("en")["storage"]["savedSmartData"])
|
||||
report = temporary["storage"]["smartReport"]
|
||||
source_report = catalog("en")["storage"]["smartReport"]
|
||||
for key in ("passedAssessment", "noReallocatedSectorsReported",
|
||||
"passedMeaningTitle", "passedMeaning"):
|
||||
report.setdefault(key, source_report[key])
|
||||
for key in ("passedTitle", "passedText"):
|
||||
report["recommendations"].setdefault(
|
||||
key, source_report["recommendations"][key])
|
||||
# New health-popup messages deliberately use runtime English
|
||||
# fallback until each locale is translated. Model generator
|
||||
# steady state only in this disposable copy, as for thermal keys.
|
||||
for key in (
|
||||
'certificateExpired',
|
||||
'certificateExpiresDays',
|
||||
'certificateInconclusive',
|
||||
'cpuCheckFailed',
|
||||
'cpuHighSustained',
|
||||
'cpuSustained',
|
||||
'fail2banCheckUnavailable',
|
||||
'fail2banInactive',
|
||||
'failedLoginsWithinThreshold',
|
||||
'kernelPackagesMore',
|
||||
'kernelPveUpdates',
|
||||
'loginCheckUnavailable',
|
||||
'memoryCheckFailed',
|
||||
'memoryPressure',
|
||||
'noFailedLogins',
|
||||
'noSwapConfigured',
|
||||
'noTemperatureSensor',
|
||||
'nonRunningKernelUpdates',
|
||||
'pendingPackageUpdates',
|
||||
'pendingSecurityUpdates',
|
||||
'pendingSecurityUpdatesDays',
|
||||
'pendingSecurityUpdatesUnpatched',
|
||||
'pveVersionAvailable',
|
||||
'ramHighSustained',
|
||||
'ramSustained',
|
||||
'runningKernelUpdate',
|
||||
'securityCheckUnavailable',
|
||||
'sensorHighSamplesSpan',
|
||||
'swapRamPressure',
|
||||
'systemNotUpdatedMonths',
|
||||
'systemNotUpdatedYear',
|
||||
'temperatureElevated',
|
||||
'unknownDetail',
|
||||
'updatesAptError',
|
||||
'updatesAptTimeout',
|
||||
'updatesCheckUnavailable',
|
||||
'uptimeKernelAdvice',
|
||||
'uptimeUnavailable',
|
||||
):
|
||||
temporary["healthStatus"]["details"].setdefault(
|
||||
key, catalog("en")["healthStatus"]["details"][key])
|
||||
# New thermal messages also await automatic translation.
|
||||
# Seed only these intentional additions in the temporary copy.
|
||||
for key in (
|
||||
"temperatureNoDisks", "temperatureNormal", "temperatureHighCount",
|
||||
"temperatureCriticalCount", "temperatureUnavailable",
|
||||
"temperatureIncomplete", "temperatureUnavailableCount",
|
||||
):
|
||||
temporary["storage"].setdefault(key, catalog("en")["storage"][key])
|
||||
# Every shipped locale carries the temperature alert fallback
|
||||
# keys; the seed only guards a catalog that lacks them. The
|
||||
# runtime fallback is tested separately.
|
||||
source_fallback = catalog("en")["runtime"]["notifications"]["fallback"]
|
||||
fallback = temporary["runtime"]["notifications"]["fallback"]
|
||||
for key in ("temperatureAlertTitle", "temperatureAlertBody",
|
||||
"recordedReason", "recordedDetails"):
|
||||
fallback.setdefault(key, source_fallback[key])
|
||||
# Slovak remains the exact upstream catalog; model the
|
||||
# pending outcome-key generator additions in disposable
|
||||
# copies rather than modifying its curated values.
|
||||
if lang == 'sk':
|
||||
local = temporary['runtime']['notifications']
|
||||
source = catalog('en')['runtime']['notifications']
|
||||
for key, value in source['backup'].items():
|
||||
local.setdefault('backup', {}).setdefault(key, value)
|
||||
local['channels']['email']['status'].setdefault(
|
||||
'unconfirmed', source['channels']['email']['status']['unconfirmed'])
|
||||
local['channels']['email']['status'].setdefault(
|
||||
'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings'])
|
||||
path.write_text(json.dumps(temporary, ensure_ascii=False))
|
||||
# Model steady state after the bot fills these intentional new
|
||||
# messages; keep repository locales and all other leaves intact.
|
||||
if lang != "en":
|
||||
messages = json.loads(path.read_text())
|
||||
english = catalog("en")
|
||||
for section, key in (("encryption", "pbsHelp"),
|
||||
("keyfileActions", "pveKeyDescription")):
|
||||
messages["backup"][section].setdefault(key, english["backup"][section][key])
|
||||
for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle",
|
||||
"sshKeySetupHelp", "sshAuthorizedKeyHelp"):
|
||||
messages["backup"]["destinations"].setdefault(
|
||||
key, english["backup"]["destinations"][key])
|
||||
messages["backup"]["actions"].setdefault(
|
||||
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
|
||||
path.write_text(json.dumps(messages, ensure_ascii=False))
|
||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
|
||||
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
|
||||
redirect_stdout(output), redirect_stderr(output):
|
||||
result = MODULE.main()
|
||||
self.assertEqual(result, 0, output.getvalue())
|
||||
provider.assert_not_called()
|
||||
writer.assert_not_called()
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||
for lang in languages.split(","):
|
||||
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
|
||||
|
||||
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
|
||||
node = require_node()
|
||||
source = (ROOT / "AppImage/lib/i18n/provider.tsx").read_text()
|
||||
functions = source[source.index("function getMessage("):source.index("export function I18nProvider(")]
|
||||
lookup = re.search(r" const localized =.*?return interpolate\(.*?\)\n", source, re.S).group(0)
|
||||
catalogs = {lang: catalog(lang) for lang in ["en", *FIXTURE["locales"]]}
|
||||
script = ('import assert from "node:assert/strict";\n'
|
||||
'type MessageTree = Record<string, unknown>;\n'
|
||||
'type TranslationParams = Record<string, string | number>;\n' + functions +
|
||||
'\nconst MESSAGE_CATALOG = ' + json.dumps(catalogs) + ';\n' +
|
||||
'function t(language: string, key: string, params?: TranslationParams) {\n' + lookup + '}\n' +
|
||||
'const fixture = ' + json.dumps(FIXTURE) + ';\n' + '''
|
||||
fixture.english.forEach((text, i) => {
|
||||
assert.equal(t("en", `terminal.commandDescriptions.${i}`), text);
|
||||
});
|
||||
for (const [lang, texts] of Object.entries(fixture.locales)) {
|
||||
texts.forEach((text, i) => {
|
||||
const key = `terminal.commandDescriptions.${i}`;
|
||||
assert.equal(getMessage(MESSAGE_CATALOG[lang], key), text);
|
||||
assert.equal(t(lang, key), text);
|
||||
});
|
||||
}
|
||||
for (const i of fixture.fallback_indices) {
|
||||
const key = `terminal.commandDescriptions.${i}`;
|
||||
const selected = MESSAGE_CATALOG.sv.terminal.commandDescriptions[i];
|
||||
MESSAGE_CATALOG.en.terminal.commandDescriptions[i] = "CHANGED EN";
|
||||
assert.equal(t("sv", key), selected); // Explicit English is a locale value.
|
||||
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = null;
|
||||
assert.equal(t("sv", key), "CHANGED EN"); // Null would dynamically fall back.
|
||||
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = "";
|
||||
assert.equal(t("sv", key), ""); // Empty strings do NOT fall back.
|
||||
}
|
||||
assert.equal(t("sv", "missing.key"), "missing.key");
|
||||
console.log("390 indexed consumer lookups; 15 explicit-English/null/empty cases passed");
|
||||
''')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "consumer.ts"
|
||||
path.write_text(script)
|
||||
result = subprocess.run([node, "--experimental-strip-types", str(path)],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("390 indexed consumer lookups", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Inert fencing wording contract: execute extracted producers and template renderer."""
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import unittest
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
EVENTS = ROOT / 'AppImage/scripts/notification_events.py'
|
||||
TEMPLATES = ROOT / 'AppImage/scripts/notification_templates.py'
|
||||
MESSAGES = ROOT / 'AppImage/messages'
|
||||
FIELDS = ('title', 'body', 'label')
|
||||
|
||||
|
||||
def extract_method(path, owner, method, namespace):
|
||||
tree = ast.parse(path.read_text(encoding='utf-8'))
|
||||
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
|
||||
node = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == method)
|
||||
node.decorator_list = []
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), namespace)
|
||||
return namespace[method]
|
||||
|
||||
|
||||
def notification_renderer(catalogs):
|
||||
tree = ast.parse(TEMPLATES.read_text(encoding='utf-8'))
|
||||
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
|
||||
node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'render_template')
|
||||
|
||||
def lookup(obj, dotted):
|
||||
for part in dotted.split('.'):
|
||||
obj = obj.get(part) if isinstance(obj, dict) else None
|
||||
return obj
|
||||
|
||||
namespace = {'TEMPLATES': templates, 'Dict': dict, 'Any': object,
|
||||
'_get_hostname': lambda: 'node-a',
|
||||
'_load_runtime_catalog': lambda lang: catalogs[lang]['runtime']['notifications'],
|
||||
'_catalog_value': lookup, 'runtime_message': lambda key, lang, **kw: key,
|
||||
'time': time, 're': re}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(TEMPLATES), 'exec'), namespace)
|
||||
return templates, namespace['render_template']
|
||||
|
||||
|
||||
class FencingWordingTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.catalogs = {p.parent.name: json.loads(p.read_text(encoding='utf-8'))
|
||||
for p in MESSAGES.glob('*/common.json')}
|
||||
cls.templates, render = notification_renderer(cls.catalogs)
|
||||
cls.render = staticmethod(render)
|
||||
|
||||
def test_webhook_fencing_renders_without_split_brain_or_quorum_diagnosis(self):
|
||||
namespace: dict[str, Any] = {'re': re}
|
||||
classify = extract_method(EVENTS, 'ProxmoxHookWatcher', '_classify_pve', namespace)
|
||||
severity = extract_method(EVENTS, 'ProxmoxHookWatcher', '_map_severity', namespace)
|
||||
namespace['capture_journal_context'] = lambda **kw: ''
|
||||
class Event:
|
||||
def __init__(self, **kw):
|
||||
self.__dict__.update(kw)
|
||||
self.event_id = 'inert-id'
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, item): self.items.append(item)
|
||||
namespace['NotificationEvent'] = Event
|
||||
webhook = extract_method(EVENTS, 'ProxmoxHookWatcher', 'process_webhook', namespace)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = classify
|
||||
_map_severity = staticmethod(severity)
|
||||
def __init__(self): self._queue = Queue()
|
||||
receiver = Receiver()
|
||||
for fields in ({'type': 'fencing'}, {'type': 'fencing', 'quorum': 'lost'}):
|
||||
result = webhook(receiver, {'fields': fields, 'severity': 'warning',
|
||||
'title': 'Fence requested', 'message': 'Check node state'})
|
||||
self.assertEqual(result['event_type'], 'split_brain')
|
||||
event = receiver._queue.items.pop()
|
||||
self.assertEqual(event.severity, 'WARNING')
|
||||
self.assertNotIn('quorum', event.data)
|
||||
for lang, catalog in self.catalogs.items():
|
||||
rendered = self.render(event.event_type, event.data, lang)
|
||||
expected = catalog['runtime']['notifications']['templates']['split_brain']
|
||||
self.assertEqual(rendered['title'], expected['title'].format(hostname='node-a'), lang)
|
||||
# The body names the source event, as received, and adds no diagnosis of its own.
|
||||
self.assertEqual(rendered['body'], expected['body'].replace('{reason}', event.data['reason']), lang)
|
||||
self.assertIn('Check node state', rendered['body'], lang)
|
||||
self.assertNotIn('split-brain', '\n'.join((rendered['title'], rendered['body'])).lower(), lang)
|
||||
self.assertNotIn('quorum', rendered['body'].lower(), lang)
|
||||
self.assertEqual(webhook(receiver, {'type': 'fencing', 'severity': 'warning',
|
||||
'title': 'Unclassified', 'message': 'Other'})['event_type'], 'system_problem')
|
||||
self.assertEqual(self.templates['split_brain']['group'], 'cluster')
|
||||
self.assertIs(self.templates['split_brain']['default_enabled'], True)
|
||||
|
||||
def test_journal_family_uses_same_truthful_wording(self):
|
||||
tree = ast.parse(EVENTS.read_text(encoding='utf-8'))
|
||||
owner = next(n.name for n in tree.body if isinstance(n, ast.ClassDef)
|
||||
and any(isinstance(m, ast.FunctionDef) and m.name == '_check_cluster_events' for m in n.body))
|
||||
namespace: dict[str, Any] = {'re': re}
|
||||
method = extract_method(EVENTS, owner, '_check_cluster_events', namespace)
|
||||
class Journal:
|
||||
_hostname = 'node-a'
|
||||
def __init__(self): self.events = []
|
||||
def _emit(self, *args, **kwargs): self.events.append((args, kwargs))
|
||||
for message in ('fencing required for node', 'cluster partition suspected', 'split-brain reported'):
|
||||
journal = Journal()
|
||||
method(journal, message, 'syslog')
|
||||
args, _ = journal.events[0]
|
||||
self.assertEqual(args[0], 'split_brain')
|
||||
for lang, catalog in self.catalogs.items():
|
||||
rendered = self.render(args[0], args[2], lang)
|
||||
body = catalog['runtime']['notifications']['templates']['split_brain']['body']
|
||||
self.assertEqual(rendered['body'], body.replace('{reason}', message), lang)
|
||||
journal = Journal()
|
||||
method(journal, 'quorum lost', 'syslog')
|
||||
self.assertEqual(journal.events[0][0][0], 'node_disconnect')
|
||||
|
||||
def test_english_family_and_settings_label_align_without_changing_key(self):
|
||||
en = self.catalogs['en']
|
||||
self.assertEqual(self.templates['split_brain']['label'], 'Cluster event')
|
||||
self.assertEqual(en['runtime']['notifications']['templates']['split_brain']['label'], 'Cluster event')
|
||||
self.assertEqual(en['settings']['notifications']['eventTypes']['split_brain'], 'Cluster events')
|
||||
settings = (ROOT / 'AppImage/components/notification-settings.tsx').read_text(encoding='utf-8')
|
||||
self.assertIn('settings.notifications.eventTypes.${evt.type}', settings)
|
||||
self.assertIn('evt.title : translated', settings)
|
||||
for field in FIELDS:
|
||||
self.assertEqual(en['runtime']['notifications']['templates']['split_brain'][field],
|
||||
self.templates['split_brain'][field])
|
||||
|
||||
def test_every_shipped_locale_renders_cluster_event_without_diagnosis(self):
|
||||
expected = {
|
||||
'de': ('{hostname}: Clusterereignis gemeldet', 'Ein Clusterereignis wurde gemeldet:\n{reason}', 'Clusterereignis', 'Clusterereignisse'),
|
||||
'es': ('{hostname}: evento del clúster notificado', 'Se ha notificado un evento del clúster:\n{reason}', 'Evento del clúster', 'Eventos del clúster'),
|
||||
'fr': ('{hostname}\u00a0: événement du cluster signalé', 'Un événement du cluster a été signalé\u00a0:\n{reason}', 'Événement du cluster', 'Événements du cluster'),
|
||||
'it': ('{hostname}: evento del cluster segnalato', 'È stato segnalato un evento del cluster:\n{reason}', 'Evento del cluster', 'Eventi del cluster'),
|
||||
'pt': ('{hostname}: evento do cluster comunicado', 'Foi comunicado um evento do cluster:\n{reason}', 'Evento do cluster', 'Eventos do cluster'),
|
||||
'sk': ('{hostname}: hlásená udalosť klastra', 'Bola hlásená udalosť klastra:\n{reason}', 'Udalosť klastra', 'Udalosti klastra'),
|
||||
'sv': ('{hostname}: klusterhändelse rapporterad', 'En klusterhändelse har rapporterats:\n{reason}', 'Klusterhändelse', 'Klusterhändelser'),
|
||||
}
|
||||
self.assertEqual(set(self.catalogs), {'en', *expected})
|
||||
for lang, (title, body, label, settings) in expected.items():
|
||||
with self.subTest(lang=lang):
|
||||
leaves = self.catalogs[lang]['runtime']['notifications']['templates']['split_brain']
|
||||
self.assertEqual((leaves['title'], leaves['body'], leaves['label'],
|
||||
self.catalogs[lang]['settings']['notifications']['eventTypes']['split_brain']),
|
||||
(title, body, label, settings))
|
||||
result = self.render('split_brain', {'reason': 'fencing node node-b'}, lang)
|
||||
self.assertEqual(result['title'], title.format(hostname='node-a'))
|
||||
self.assertEqual(result['body'], body.replace('{reason}', 'fencing node node-b'))
|
||||
self.assertNotIn('quorum', result['body'].lower())
|
||||
|
||||
def test_fallback_and_translated_lookup_are_real_for_missing_and_synthetic_values(self):
|
||||
import copy
|
||||
catalogs = copy.deepcopy(self.catalogs)
|
||||
for field in FIELDS:
|
||||
catalogs['it']['runtime']['notifications']['templates']['split_brain'].pop(field)
|
||||
_, render = notification_renderer(catalogs)
|
||||
self.assertEqual(render('split_brain', {'reason': 'fencing node node-b'}, 'it')['body'],
|
||||
'A cluster event was reported:\nfencing node node-b')
|
||||
catalogs['it']['runtime']['notifications']['templates']['split_brain']['body'] = 'Evento del cluster segnalato.'
|
||||
_, render = notification_renderer(catalogs)
|
||||
self.assertEqual(render('split_brain', {}, 'it')['body'], 'Evento del cluster segnalato.')
|
||||
for lang, catalog in self.catalogs.items():
|
||||
if lang == 'en': continue
|
||||
leaves = catalog['runtime']['notifications']['templates']['split_brain']
|
||||
self.assertTrue(all(leaves[field] for field in FIELDS), lang)
|
||||
self.assertTrue(catalog['settings']['notifications']['eventTypes']['split_brain'], lang)
|
||||
self.assertNotEqual(leaves['body'], self.catalogs['en']['runtime']['notifications']['templates']['split_brain']['body'], lang)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""The menus that install drivers inside a container leave out the ones
|
||||
created from an OCI image: those are changed from OCI manager Apps."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENUS = ('scripts/gpu_tpu/add_gpu_lxc.sh', 'scripts/gpu_tpu/install_coral_lxc.sh')
|
||||
ORDINARY = "arch: amd64\ncores: 2\nhostname: debian\nostype: debian\nrootfs: local-lvm:vm-111-disk-0,size=8G\n"
|
||||
OCI = ORDINARY + "entrypoint: /init\nlxc.signal.halt: SIGTERM\n"
|
||||
|
||||
|
||||
def function(path, name):
|
||||
source = (ROOT / path).read_text()
|
||||
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
|
||||
|
||||
|
||||
class GpuMenusHideOci(TestCase):
|
||||
def is_oci(self, config):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
(Path(folder) / '111.conf').write_text(config)
|
||||
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
|
||||
return subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 111'], capture_output=True).returncode == 0
|
||||
|
||||
def test_a_container_from_an_oci_image_is_told_apart_by_its_configuration(self):
|
||||
self.assertTrue(self.is_oci(OCI))
|
||||
self.assertTrue(self.is_oci(ORDINARY + "cmd: nginx -g 'daemon off;'\n"))
|
||||
self.assertFalse(self.is_oci(ORDINARY))
|
||||
# A setting that only mentions the word is not an entrypoint.
|
||||
self.assertFalse(self.is_oci(ORDINARY + "description: entrypoint: none\n"))
|
||||
|
||||
def test_a_container_without_configuration_is_not_one(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
|
||||
self.assertNotEqual(subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 999'],
|
||||
capture_output=True).returncode, 0)
|
||||
|
||||
def test_both_menus_skip_them_and_say_so(self):
|
||||
for menu in MENUS:
|
||||
body = function(menu, 'select_container')
|
||||
self.assertLess(body.index('pmx_lxc_is_oci "$ctid"'), body.index('menu_items+=('), menu)
|
||||
self.assertIn('Containers created from an OCI image are not listed:', body, menu)
|
||||
self.assertEqual(body.count('${oci_note}'), 2, menu)
|
||||
|
||||
|
||||
SWITCHES = (('scripts/gpu_tpu/switch_gpu_mode.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
|
||||
('scripts/gpu_tpu/switch_gpu_mode_direct.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
|
||||
('scripts/gpu_tpu/add_gpu_vm.sh', 'cleanup_lxc_configs', None))
|
||||
|
||||
|
||||
class GpuToVmKeepsOciConfiguration(TestCase):
|
||||
"""A GPU that moves to a VM stops the containers that use it. One created
|
||||
from an OCI image keeps its configuration, which OCI manager Apps records."""
|
||||
|
||||
def test_an_oci_container_is_never_edited(self):
|
||||
for script, apply, _ in SWITCHES:
|
||||
body = function(script, apply)
|
||||
guard = body.index('pmx_lxc_is_oci "$ctid"')
|
||||
self.assertLess(guard, body.index('action="keep_gpu_disable_onboot"'), script)
|
||||
removal = body.index('"$action" == "remove_gpu_keep_onboot"')
|
||||
self.assertLess(guard, removal, script)
|
||||
# The only edit of a configuration hangs from the per-container action.
|
||||
self.assertEqual(len(re.findall(r'_from_lxc_conf "\$', body)), 1, script)
|
||||
edit = body.index('_from_lxc_conf "$')
|
||||
condition = body.rindex('if [[', 0, edit)
|
||||
self.assertIn('"$action" == "remove_gpu_keep_onboot"', body[condition:edit], script)
|
||||
|
||||
def test_the_list_names_them_and_nothing_is_asked_when_all_are_oci(self):
|
||||
for script, _, prompt in SWITCHES:
|
||||
source = (ROOT / script).read_text() if prompt is None else function(script, prompt)
|
||||
self.assertIn('", OCI"', source, script)
|
||||
self.assertIn('OCI containers keep their configuration', source, script)
|
||||
self.assertIn('"$oci_count" -eq ${#LXC_AFFECTED_CTIDS[@]}', source, script)
|
||||
|
||||
|
||||
class DiskAndSharePickersHideOci(TestCase):
|
||||
"""Adding a disk or a mount, and the NFS and Samba set-ups, change the
|
||||
container they are given. A container from an OCI image is not offered."""
|
||||
|
||||
def test_every_picker_filters_before_it_lists(self):
|
||||
pickers = ((function('scripts/global/share-common.func', 'select_lxc_container'), 'options+=('),
|
||||
(function('scripts/global/share-common.func', 'select_privileged_lxc'), 'ct_list+='),
|
||||
(function('scripts/share/lxc-mount-manager_minimal.sh', 'select_lxc_container'), 'options+=('))
|
||||
for body, listing in pickers:
|
||||
self.assertLess(body.index('pmx_lxc_is_oci "$id"'), body.index(listing))
|
||||
self.assertIn('Containers created from an OCI image are not listed', body)
|
||||
disk = (ROOT / 'scripts/storage/disk-passthrough_ct.sh').read_text()
|
||||
picker = disk[disk.index('CT_LIST=""'):disk.index('if [ -z "$CTID" ]')]
|
||||
self.assertLess(picker.index('pmx_lxc_is_oci "$ct_id"'), picker.index('CT_LIST+='))
|
||||
self.assertIn('${OCI_NOTE}" $UI_MENU_H $UI_MENU_W $CT_LIST_H', picker)
|
||||
|
||||
def test_a_disk_in_use_is_still_looked_for_in_every_container(self):
|
||||
for script in ('scripts/storage/disk-passthrough_ct.sh', 'scripts/storage/disk-passthrough.sh'):
|
||||
source = (ROOT / script).read_text()
|
||||
check = source[source.index('disk_referenced_in_config "$CT_CONFIG_RAW" "$DISK"'):]
|
||||
scan = check[:check.index("done < <(pct list | awk 'NR>1 {print $1, $3}')")]
|
||||
self.assertNotIn('pmx_lxc_is_oci', scan, script)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""An addon of the Helper-Scripts runs where its catalog says it belongs: on
|
||||
the Proxmox host, inside a container, or wherever the user chooses when it
|
||||
can be both. Until this, every addon ran on the host."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / "scripts/menus/menu_Helper_Scripts.sh"
|
||||
PLAIN = ROOT / "scripts/menus/helper_plain_text.py"
|
||||
|
||||
# Only the functions under test are loaded, not the menu or its catalog.
|
||||
PRELUDE = textwrap.dedent(f"""
|
||||
set -uo pipefail
|
||||
eval "$(sed -n '/^download_script() {{/,/^RETURN_TO_MAIN=false/p' '{MENU}' | sed '$d')"
|
||||
translate() {{ printf '%s' "$1"; }}
|
||||
clear() {{ :; }}
|
||||
dialog() {{ :; }}
|
||||
pmx_lxc_is_oci() {{ [[ "$1" == 100 ]]; }}
|
||||
MENU_HEIGHT=22; MENU_ROWS=14
|
||||
""")
|
||||
|
||||
|
||||
def bash(script):
|
||||
return subprocess.run(["bash", "-c", PRELUDE + textwrap.dedent(script)], capture_output=True, text=True)
|
||||
|
||||
|
||||
class TargetChoiceTests(unittest.TestCase):
|
||||
def choice(self, kind, places):
|
||||
return bash(f'helper_target_choice {kind} "{places}"').stdout.strip()
|
||||
|
||||
def test_an_addon_for_the_host_runs_on_the_host(self):
|
||||
self.assertEqual(self.choice("addon", "pve"), "host")
|
||||
|
||||
def test_an_addon_for_containers_runs_in_one(self):
|
||||
self.assertEqual(self.choice("addon", "lxc vm"), "lxc")
|
||||
self.assertEqual(self.choice("addon", "lxc"), "lxc")
|
||||
|
||||
def test_an_addon_for_both_asks_where(self):
|
||||
self.assertEqual(self.choice("addon", "pve vm lxc"), "ask")
|
||||
self.assertEqual(self.choice("addon", ""), "ask")
|
||||
|
||||
def test_what_is_not_an_addon_runs_on_the_host(self):
|
||||
# A script that creates a container or a VM belongs on the host,
|
||||
# whatever the catalog lists for the application it installs.
|
||||
self.assertEqual(self.choice("lxc", "lxc"), "host")
|
||||
self.assertEqual(self.choice("vm", "vm"), "host")
|
||||
self.assertEqual(self.choice("pve", "pve"), "host")
|
||||
|
||||
|
||||
class ContainerListTests(unittest.TestCase):
|
||||
def test_oci_containers_are_left_out_and_a_lock_is_shown(self):
|
||||
result = bash(r"""
|
||||
pct() { printf 'VMID Status Lock Name\n100 running chromium\n109 running ordinary\n110 stopped stopped-one\n112 stopped backup locked-one\n'; }
|
||||
helper_lxc_rows
|
||||
""")
|
||||
self.assertEqual(result.stdout,
|
||||
"109\tordinary · running\n110\tstopped-one · stopped\n112\tlocked-one · stopped · backup\n")
|
||||
|
||||
|
||||
class RunTests(unittest.TestCase):
|
||||
def test_the_script_runs_on_the_host_or_inside_the_container_chosen(self):
|
||||
result = bash(r"""
|
||||
curl() { printf 'printf on-host'; }
|
||||
pct() { [[ "$1" == exec && "$2" == 109 && "$3" == --keep-env && "$4" == 0 && "$5" == -- ]] && printf 'in-lxc:%s' "${*: -1}"; }
|
||||
HELPER_LXC_TARGET=''; download_script https://example.invalid/a; echo
|
||||
HELPER_LXC_TARGET=109; download_script https://example.invalid/a; echo
|
||||
""")
|
||||
self.assertEqual(result.stdout, "on-host\nin-lxc:printf on-host\n")
|
||||
|
||||
def test_a_failed_or_empty_download_runs_nothing(self):
|
||||
result = bash(r"""
|
||||
bash() { echo RAN; }
|
||||
pct() { echo RAN; }
|
||||
HELPER_LXC_TARGET=109
|
||||
curl() { return 22; }; download_script https://example.invalid/a && echo UNEXPECTED
|
||||
curl() { printf ' \n'; }; download_script https://example.invalid/a && echo UNEXPECTED
|
||||
echo done
|
||||
""")
|
||||
self.assertEqual(result.stdout, "done\n")
|
||||
|
||||
def test_cancelling_the_choice_leaves_no_target(self):
|
||||
result = bash(r"""
|
||||
dialog() { return 1; }
|
||||
select_helper_target Test ask && echo UNEXPECTED
|
||||
printf '[%s]\n' "$HELPER_LXC_TARGET"
|
||||
""")
|
||||
self.assertEqual(result.stdout, "[]\n")
|
||||
|
||||
def test_the_host_needs_no_question(self):
|
||||
result = bash(r"""
|
||||
dialog() { echo ASKED; return 1; }
|
||||
select_helper_target Test host && printf 'ok[%s]\n' "$HELPER_LXC_TARGET"
|
||||
""")
|
||||
self.assertEqual(result.stdout, "ok[]\n")
|
||||
|
||||
|
||||
class PlainTextTests(unittest.TestCase):
|
||||
def test_html_becomes_text_and_commands_are_kept(self):
|
||||
result = subprocess.run(["python3", str(PLAIN)], capture_output=True, text=True,
|
||||
input="<p>Run <code>ls -la && pwd</code></p><ul><li>one</li><li>two</li></ul>")
|
||||
self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""A Proxmox host without a subscription is refused by the enterprise
|
||||
repositories on every apt refresh. The installers tell that apart from a
|
||||
refresh that really failed."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
INSTALLERS = ('install_proxmenux.sh', 'install_proxmenux_beta.sh')
|
||||
|
||||
ENTERPRISE = """Hit:1 http://deb.debian.org/debian trixie InRelease
|
||||
Err:2 https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease
|
||||
401 Unauthorized [IP: 51.91.38.34 443]
|
||||
Err:4 https://enterprise.proxmox.com/debian/pve trixie InRelease
|
||||
401 Unauthorized [IP: 51.91.38.34 443]
|
||||
Reading package lists...
|
||||
E: Failed to fetch https://enterprise.proxmox.com/debian/ceph-squid/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
|
||||
E: The repository 'https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease' is not signed.
|
||||
E: Failed to fetch https://enterprise.proxmox.com/debian/pve/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
|
||||
E: The repository 'https://enterprise.proxmox.com/debian/pve trixie InRelease' is not signed.
|
||||
"""
|
||||
NETWORK = """Err:1 http://deb.debian.org/debian trixie InRelease
|
||||
Temporary failure resolving 'deb.debian.org'
|
||||
E: Failed to fetch http://deb.debian.org/debian/dists/trixie/InRelease Temporary failure resolving 'deb.debian.org'
|
||||
"""
|
||||
LOCKED = "E: Could not get lock /var/lib/apt/lists/lock. It is held by process 4321 (apt-get)\n"
|
||||
# apt reports a repository it cannot reach as a warning, not as an error.
|
||||
UNREACHABLE = """Err:6 http://repo.invalid/debian trixie InRelease
|
||||
Could not resolve 'repo.invalid'
|
||||
W: Failed to fetch http://repo.invalid/debian/dists/trixie/InRelease Could not resolve 'repo.invalid'
|
||||
W: Some index files failed to download. They have been ignored, or old ones used instead.
|
||||
"""
|
||||
|
||||
|
||||
def function(installer, name):
|
||||
source = (ROOT / installer).read_text()
|
||||
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
|
||||
|
||||
|
||||
class InstallerAptRefresh(TestCase):
|
||||
def refused(self, installer, log):
|
||||
with tempfile.NamedTemporaryFile('w', suffix='.log') as handle:
|
||||
handle.write(log)
|
||||
handle.flush()
|
||||
script = function(installer, 'apt_only_enterprise_refused') + \
|
||||
f'APT_ERROR_LOG={handle.name if log is not None else ""}\napt_only_enterprise_refused\n'
|
||||
return subprocess.run(['bash', '-c', script], capture_output=True).returncode == 0
|
||||
|
||||
def test_a_refresh_refused_only_by_the_enterprise_repositories_is_good(self):
|
||||
for installer in INSTALLERS:
|
||||
self.assertTrue(self.refused(installer, ENTERPRISE), installer)
|
||||
|
||||
def test_any_other_failure_is_still_a_failure(self):
|
||||
for installer in INSTALLERS:
|
||||
self.assertFalse(self.refused(installer, NETWORK), installer)
|
||||
self.assertFalse(self.refused(installer, LOCKED), installer)
|
||||
self.assertFalse(self.refused(installer, ENTERPRISE + NETWORK), installer)
|
||||
self.assertFalse(self.refused(installer, ENTERPRISE + UNREACHABLE), installer)
|
||||
self.assertFalse(self.refused(installer, 'Reading package lists...\n'), installer)
|
||||
|
||||
def test_the_refresh_step_asks_before_it_warns(self):
|
||||
for installer in INSTALLERS:
|
||||
source = (ROOT / installer).read_text()
|
||||
step = source[source.index('if run_apt update -y; then'):]
|
||||
step = step[:step.index('\n fi\n')]
|
||||
self.assertLess(step.index('elif apt_only_enterprise_refused; then'), step.index('apt cache refresh failed'))
|
||||
self.assertIn('discard_apt_error', step[step.index('elif'):step.index('else\n')])
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Inert branch tests for the real Lynis deletion/uninstall producers."""
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
def extracted(path, name, namespace):
|
||||
tree = ast.parse((ROOT / path).read_text())
|
||||
fn = next(x for x in tree.body if isinstance(x, ast.FunctionDef) and x.name == name)
|
||||
fn.decorator_list = []
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[fn], type_ignores=[])), str(path), 'exec'), namespace)
|
||||
return namespace[name]
|
||||
|
||||
class LynisFeedbackProducer(unittest.TestCase):
|
||||
def test_actual_ui_feedback_handlers_all_shipped_locales(self):
|
||||
result = subprocess.run(['node', str(ROOT / '.github/scripts/tests/fixtures/lynis_feedback_contract.cjs')],
|
||||
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_uninstall_nothing_to_remove_is_not_reported_as_uninstalled(self):
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: False, join=lambda *p: '/'.join(p)), remove=lambda p: self.fail(p))
|
||||
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
|
||||
self.assertFalse(result[0])
|
||||
self.assertEqual(result[3], 'no_files')
|
||||
manager = SimpleNamespace(uninstall_lynis=lambda: result)
|
||||
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
|
||||
self.assertEqual(route()['outcome'], 'no_files')
|
||||
|
||||
def test_report_outcomes(self):
|
||||
paths = ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
|
||||
for fail_at in (None, 0, 1, 'missing'):
|
||||
with self.subTest(fail_at=fail_at):
|
||||
removed = []
|
||||
def remove(p):
|
||||
if fail_at in (0, 1) and p == paths[fail_at]:
|
||||
raise PermissionError('inert denial')
|
||||
removed.append(p)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: fail_at != 'missing'), remove=remove)
|
||||
ns = {'security_manager': object(), 'jsonify': lambda data: data}
|
||||
orig_import = __import__
|
||||
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_os if n == 'os' else orig_import(n, *a, **kw)):
|
||||
result = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_report_delete', ns)()
|
||||
body, status = result if isinstance(result, tuple) else (result, 200)
|
||||
self.assertEqual(removed, paths[:fail_at] if fail_at in (0, 1) else ([] if fail_at == 'missing' else paths))
|
||||
self.assertEqual(body['success'], fail_at is None)
|
||||
if fail_at == 'missing':
|
||||
self.assertEqual(body['outcome'], 'no_files')
|
||||
elif fail_at in (0, 1):
|
||||
self.assertEqual(status, 500)
|
||||
self.assertEqual(body['partial'], fail_at == 1)
|
||||
self.assertIn('inert denial', body['message'])
|
||||
|
||||
def test_uninstall_outcomes_and_route(self):
|
||||
targets = ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
|
||||
for fail_at in (None, 0, 2):
|
||||
with self.subTest(fail_at=fail_at):
|
||||
removed = []
|
||||
def remove(path):
|
||||
if fail_at is not None and path == targets[fail_at]: raise PermissionError('inert denial')
|
||||
removed.append(path)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p in targets, join=lambda *parts: '/'.join(parts)), remove=remove)
|
||||
fake_shutil = SimpleNamespace(rmtree=remove)
|
||||
orig_import = __import__
|
||||
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_shutil if n == 'shutil' else orig_import(n, *a, **kw)):
|
||||
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
|
||||
self.assertEqual(removed, targets[:fail_at] if fail_at is not None else targets)
|
||||
self.assertEqual(result[0], fail_at is None)
|
||||
self.assertEqual(result[2], fail_at == 2)
|
||||
manager = SimpleNamespace(uninstall_lynis=lambda: result)
|
||||
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
|
||||
self.assertEqual(route()['partial'], fail_at == 2)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Inert source-contract checks; never import operational modules or touch host paths."""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
APP = ROOT / 'AppImage'
|
||||
|
||||
|
||||
def extract_function(path, name, *, owner=None):
|
||||
tree = ast.parse(path.read_text())
|
||||
container = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner) if owner else tree
|
||||
node = next(n for n in container.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
namespace = {}
|
||||
return node, ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[]))
|
||||
|
||||
|
||||
def leaf(pointer):
|
||||
data = json.loads((APP / 'messages/en/common.json').read_text())
|
||||
for segment in pointer.split('.'):
|
||||
data = data[segment]
|
||||
return data
|
||||
|
||||
|
||||
class LynisThresholdContracts(unittest.TestCase):
|
||||
def test_delete_confirmation_names_all_three_actual_targets(self):
|
||||
node, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
removed = []
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=removed.append)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == 'os':
|
||||
return fake_os
|
||||
return original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, str(APP / 'scripts/flask_security_routes.py'), 'exec'), namespace)
|
||||
result = namespace['lynis_report_delete']()
|
||||
self.assertEqual(result['success'], True)
|
||||
self.assertEqual(removed, ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
|
||||
text = leaf('securityPage.confirm.deleteAuditReport').lower()
|
||||
self.assertIn('report', text)
|
||||
self.assertIn('logs', text)
|
||||
self.assertNotIn('this audit report?', text)
|
||||
|
||||
def test_delete_route_partial_failure_is_not_reported_as_full_success(self):
|
||||
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
removed = []
|
||||
def remove(path):
|
||||
if path.endswith('lynis.log'):
|
||||
raise PermissionError('inert denial')
|
||||
removed.append(path)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=remove)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
|
||||
result, status = namespace['lynis_report_delete']()
|
||||
self.assertEqual(removed, ['/var/log/lynis-report.dat'])
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('inert denial', result['message'])
|
||||
|
||||
def test_delete_route_with_no_files_returns_success_false_not_a_clean_result(self):
|
||||
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: False), remove=lambda p: self.fail(p))
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
|
||||
result = namespace['lynis_report_delete']()
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('No report files', result['message'])
|
||||
|
||||
def test_uninstall_notice_describes_existing_cleanup_targets(self):
|
||||
_, module = extract_function(APP / 'scripts/security_manager.py', 'uninstall_lynis')
|
||||
removed = []
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p != '/usr/local/share/proxmenux/components_status.json', join=lambda *s: '/'.join(s)), remove=removed.append)
|
||||
class FakeShutil:
|
||||
@staticmethod
|
||||
def rmtree(path):
|
||||
removed.append(path)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return FakeShutil if name == 'shutil' else original_import(name, *args, **kwargs)
|
||||
namespace = {'os': fake_os}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis uninstall>', 'exec'), namespace)
|
||||
result = namespace['uninstall_lynis']()
|
||||
self.assertTrue(result[0])
|
||||
self.assertEqual(removed, ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
|
||||
for key in ('securityPage.lynis.removeReports', 'securityPage.lynis.uninstallConfirmDescription'):
|
||||
text = leaf(key).lower()
|
||||
self.assertIn('report', text)
|
||||
self.assertIn('logs', text)
|
||||
self.assertNotIn('monitor-created', text)
|
||||
|
||||
def test_swap_hint_qualifies_sampling_and_independent_ram_alarm(self):
|
||||
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
|
||||
# The method is bound to an inert class, not the production constructor.
|
||||
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
|
||||
MEMORY_WARNING=80, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
|
||||
memory = SimpleNamespace(percent=50, available=10, total=100)
|
||||
swap = SimpleNamespace(percent=90, used=90, total=100)
|
||||
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
|
||||
'time': SimpleNamespace(time=lambda: 1000)}
|
||||
exec(compile(module, '<inert memory check>', 'exec'), namespace)
|
||||
first = namespace['_check_memory_comprehensive'](self_obj)
|
||||
second = namespace['_check_memory_comprehensive'](self_obj)
|
||||
self.assertEqual(first['status'], 'OK')
|
||||
self.assertEqual(second['checks']['swap_usage']['status'], 'CRITICAL')
|
||||
self_obj.state_history.clear()
|
||||
memory.percent = 91
|
||||
swap.percent = 0
|
||||
for _ in range(8):
|
||||
last = namespace['_check_memory_comprehensive'](self_obj)
|
||||
self.assertEqual(last['status'], 'CRITICAL')
|
||||
self.assertEqual(last['checks']['swap_usage']['status'], 'OK')
|
||||
hint = leaf('settings.healthThresholds.swapPressureHint').lower()
|
||||
self.assertIn('swap usage', hint)
|
||||
self.assertIn('repeated', hint)
|
||||
self.assertIn('ram', hint)
|
||||
self.assertNotIn('swap file', hint)
|
||||
self.assertNotIn('swap file', leaf('settings.healthThresholds.swapHighLabel').lower())
|
||||
self.assertNotIn('only when both', hint)
|
||||
|
||||
def test_print_generator_and_descriptions(self):
|
||||
node = ROOT / '.github/scripts/tests/fixtures/lynis_print_contract.cjs'
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
result = subprocess.run(['node', str(node)], cwd=ROOT, text=True, capture_output=True,
|
||||
env={**os.environ, 'LYNIS_FIXTURE_LANG': lang})
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn(f'print HTML ({lang}):', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,182 @@
|
||||
"""The search of the main menu finds an option of ProxMenux, an OCI application
|
||||
or a Helper-Script by its name or by related words, and opens it where it
|
||||
lives: the scripts it launches are the ones the menus launch."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
|
||||
|
||||
def load():
|
||||
spec = importlib.util.spec_from_file_location("search_menu", SCRIPTS / "menus/search_menu.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
# The repository in the place of the installed copy.
|
||||
module.BASE, module.SCRIPTS = ROOT, SCRIPTS
|
||||
module.OCI_INDEX = ROOT / "oci/catalog/index.json"
|
||||
module.HELPERS_CACHE = ROOT / "json/helpers_cache.json"
|
||||
module._translations = {}
|
||||
return module
|
||||
|
||||
|
||||
class IndexTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.index = json.loads((SCRIPTS / "menus/search_index.json").read_text(encoding="utf-8"))
|
||||
cls.spanish = json.loads((ROOT / "lang/es.json").read_text(encoding="utf-8"))
|
||||
|
||||
def test_every_entry_opens_a_script_that_exists(self):
|
||||
missing = sorted({item["script"] for item in self.index if not (SCRIPTS / item["script"]).is_file()})
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_every_title_is_a_text_the_menus_already_translate(self):
|
||||
names = {"ZimaOS", "Synology DSM"}
|
||||
unknown = [item["title"] for item in self.index
|
||||
if item["title"] not in self.spanish and item["title"] not in names]
|
||||
self.assertEqual(unknown, [])
|
||||
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
|
||||
|
||||
def test_every_script_a_menu_launches_can_be_found(self):
|
||||
indexed = {item["script"] for item in self.index}
|
||||
launched = set()
|
||||
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
|
||||
if menu.name in ("main_menu.sh", "lxc_menu.sh"):
|
||||
continue
|
||||
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
|
||||
launched -= {"menus/main_menu.sh"}
|
||||
# Opened from their own menu, which is what the index points at.
|
||||
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
|
||||
self.assertEqual(sorted(launched - indexed), [])
|
||||
|
||||
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
|
||||
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||
self.assertIn('search "$(translate "Search options and applications")"', menu)
|
||||
self.assertIn('search_menu.py" --handoff "$handoff"', menu)
|
||||
self.assertIn('[[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]', menu)
|
||||
self.assertIn('exec bash "${target[@]}"', menu)
|
||||
|
||||
def test_an_option_inside_a_menu_names_one_that_menu_has(self):
|
||||
for item in self.index:
|
||||
argument = item.get("argument")
|
||||
if not argument:
|
||||
continue
|
||||
script = (SCRIPTS / item["script"]).read_text()
|
||||
self.assertIn("PRESELECTED_OPTION", script, item["script"])
|
||||
if not argument.isdigit():
|
||||
self.assertIn(argument, script, f"{item['script']}: {argument}")
|
||||
|
||||
|
||||
class CatalogTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.search = load()
|
||||
cls.items = cls.search.catalog(refresh=False)
|
||||
|
||||
def found(self, query):
|
||||
return self.search.search(self.items, query)
|
||||
|
||||
def test_the_three_sources_are_in_the_catalog(self):
|
||||
self.assertEqual({item["source"] for item in self.items}, set(self.search.SOURCES))
|
||||
|
||||
def test_proxmenux_comes_first_then_oci_then_helper_scripts(self):
|
||||
sources = [item["source"] for item in self.found("samba")]
|
||||
self.assertEqual(sources, sorted(sources, key=self.search.SOURCES.index))
|
||||
self.assertEqual(sources[0], "ProxMenux")
|
||||
|
||||
def test_a_name_is_found_by_the_start_of_any_of_its_words(self):
|
||||
self.assertIn("Jellyfin", [item["title"] for item in self.found("jelly")])
|
||||
self.assertTrue(any(item["source"] == "OCI Manager" for item in self.found("plex")))
|
||||
|
||||
def test_accents_and_case_do_not_matter(self):
|
||||
self.assertEqual([item["title"] for item in self.found("NVIDIA")],
|
||||
[item["title"] for item in self.found("nvidia")])
|
||||
|
||||
def test_related_words_find_an_option(self):
|
||||
self.assertTrue(any("NFS" in item["title"] for item in self.found("nfs storage")))
|
||||
self.assertTrue(any(item["action"][-1].endswith("fail2ban_installer.sh") for item in self.found("fail2ban")))
|
||||
|
||||
def test_a_match_by_category_comes_after_the_names(self):
|
||||
matches = self.found("vpn")
|
||||
self.assertTrue(any(item["title"] == "Tailscale" for item in matches))
|
||||
by_category = False
|
||||
for item in matches:
|
||||
words = re.findall(r"[a-z0-9]+", self.search.fold(item["terms"]))
|
||||
in_name = any(word.startswith("vpn") for word in words)
|
||||
self.assertFalse(in_name and by_category)
|
||||
by_category = by_category or not in_name
|
||||
|
||||
def test_an_application_is_not_found_by_what_its_description_mentions(self):
|
||||
titles = [item["title"] for item in self.found("plex")]
|
||||
self.assertNotIn("Jellyfin", titles)
|
||||
self.assertTrue(all("plex" in title.casefold() for title in titles))
|
||||
|
||||
def test_nothing_matches_nothing(self):
|
||||
self.assertEqual(self.found("zzqqxx"), [])
|
||||
self.assertEqual(self.found(" "), [])
|
||||
|
||||
def test_a_sub_menu_is_handed_back_to_the_main_menu(self):
|
||||
by_script = {item["action"][1]: item for item in self.items if item["source"] == "ProxMenux"}
|
||||
self.assertTrue(by_script[str(SCRIPTS / "menus/network_menu.sh")]["handoff"])
|
||||
self.assertFalse(by_script[str(SCRIPTS / "storage/format-disk.sh")]["handoff"])
|
||||
|
||||
def test_an_option_inside_a_menu_is_given_to_that_menu(self):
|
||||
zfs = next(item for item in self.items if item["action"][1:] == [str(SCRIPTS / "help_info_menu.sh"), "7"])
|
||||
self.assertFalse(zfs["handoff"])
|
||||
language = next(item for item in self.items if item["action"][-1] == "change_language")
|
||||
self.assertEqual(language["action"][1], str(SCRIPTS / "menus/config_menu.sh"))
|
||||
self.assertTrue(language["handoff"])
|
||||
|
||||
|
||||
class OpenedInPlaceTests(unittest.TestCase):
|
||||
def test_an_option_of_the_post_install_comes_selected(self):
|
||||
script = (SCRIPTS / "post_install/customizable_post_install.sh").read_text()
|
||||
self.assertIn('PRESELECTED_OPTION="${1:-}"', script)
|
||||
self.assertIn('--default-item "${preselected_item:-1}"', script)
|
||||
|
||||
def test_a_utility_comes_selected(self):
|
||||
script = (SCRIPTS / "utilities/system_utils.sh").read_text()
|
||||
self.assertIn('PRESELECTED_UTILITY="${1:-}"', script)
|
||||
|
||||
def test_a_helper_script_is_opened_on_its_own(self):
|
||||
script = (SCRIPTS / "menus/menu_Helper_Scripts.sh").read_text()
|
||||
self.assertIn('run_script_by_slug "$1"', script)
|
||||
|
||||
def test_an_oci_application_opens_its_page(self):
|
||||
cli = (ROOT / "oci/src/proxmenux_oci/cli.py").read_text()
|
||||
self.assertIn('subparsers.add_parser("open"', cli)
|
||||
|
||||
|
||||
class TranslationTests(unittest.TestCase):
|
||||
"""What the search and the main menu say reaches the translation workflow,
|
||||
read with the folders the workflow itself passes."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
workflow = (ROOT / ".github/workflows/build-translation-cache.yml").read_text()
|
||||
extractor = runpy.run_path(str(ROOT / ".github/scripts/build_translation_cache.py"))
|
||||
folders = [ROOT / folder for folder in re.findall(r"--python-dir (\S+)", workflow)]
|
||||
cls.found = set(extractor["extract_python_texts"](folders))
|
||||
cls.found |= set(extractor["extract_translate_texts"](SCRIPTS))
|
||||
cls.triggers = workflow
|
||||
|
||||
def test_every_text_of_the_search_is_found(self):
|
||||
source = (SCRIPTS / "menus/search_menu.py").read_text()
|
||||
said = set(re.findall(r"translate\('([^']+)'\)", source))
|
||||
self.assertIn("No results", said)
|
||||
self.assertEqual(sorted(said - self.found), [])
|
||||
|
||||
def test_the_title_of_the_main_menu_is_found(self):
|
||||
self.assertIn("Main menu", self.found)
|
||||
|
||||
def test_a_change_in_the_search_runs_the_workflow(self):
|
||||
self.assertIn("'scripts/**/*.py'", self.triggers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Backup-only maintainer contract at actual render/dispatch/email seams.
|
||||
|
||||
Scope-approved seams: receiver, template lookup, rich enrichment, inert queued
|
||||
and manual delivery with an email capture sink. No host operations or sends.
|
||||
"""
|
||||
import unittest
|
||||
from notification_fixture import templates, LANGUAGES
|
||||
from notification_final_fixture import deliver
|
||||
|
||||
|
||||
class BackupSplitTests(unittest.TestCase):
|
||||
def test_recovery_default_is_upstream_resolved_without_proof(self):
|
||||
for language in LANGUAGES:
|
||||
data = {'hostname': 'alias', 'category': 'temperature',
|
||||
'reason': 'Temperature high (recovered)', 'duration': '2m',
|
||||
'original_severity': 'WARNING'}
|
||||
rendered = templates.render_template('error_resolved', data, language)
|
||||
expected = templates.runtime_message('templates.error_resolved.title', language,
|
||||
hostname='alias', category='temperature', entity_suffix='')
|
||||
self.assertEqual(rendered['title'], expected)
|
||||
rich, _ = templates.enrich_with_emojis('error_resolved', rendered['title'], rendered['body'], data)
|
||||
self.assertTrue(rich.startswith('✅ '))
|
||||
result = deliver('error_resolved', data, 'OK', language)
|
||||
self.assertIn('background:#f0fdf4;', result['html'])
|
||||
|
||||
def test_pre_guest_native_subject_keeps_only_cause_once_with_display_alias(self):
|
||||
from notification_fixture import receive
|
||||
raw_host = 'pve-production.internal.example'
|
||||
message = ('Details\n=======\nVMID Name Status Time Size Filename\n'
|
||||
'\nTotal running time: 0s\nTotal size: 0 B')
|
||||
for raw in (message, 'ERROR: unable to activate storage PBS\n' + message):
|
||||
event = receive(raw, 'error', 'vzdump backup status (' + raw_host +
|
||||
'): backup failed: unable to activate storage PBS')
|
||||
event.data['hostname'] = 'display-alias {rack.location}'
|
||||
for language in LANGUAGES:
|
||||
for manual in (False, True):
|
||||
result = deliver(event.event_type, event.data, event.severity, language, manual=manual)
|
||||
self.assertNotIn(raw_host, result['text'])
|
||||
self.assertNotIn('vzdump backup status', result['text'])
|
||||
self.assertEqual(result['text'].count('unable to activate storage PBS'), 1)
|
||||
self.assertIn('display-alias {rack.location}', result['title'])
|
||||
self.assertEqual(result['data']['pve_title'], event.data['pve_title'])
|
||||
self.assertEqual(result['data']['pve_message'], raw)
|
||||
# No global hostname or guest-name substitution: actual diagnostics are
|
||||
# authoritative, even when they happen to contain the original hostname.
|
||||
event = receive(message, 'error', 'vzdump backup status (' + raw_host +
|
||||
'): backup failed: cannot connect to ' + raw_host)
|
||||
result = deliver(event.event_type, {**event.data, 'hostname': 'alias'}, event.severity)
|
||||
self.assertEqual(result['body'].count('cannot connect to ' + raw_host), 1)
|
||||
self.assertNotIn('vzdump backup status', result['body'])
|
||||
|
||||
def test_backup_legacy_keys_keep_meaning_and_unknown_uses_new_keys(self):
|
||||
# Frozen upstream contract; no Git history required in shipped tests.
|
||||
legacy = {'en': {'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})', 'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}', 'label': 'Backup complete'}, 'de': {'title': '{hostname} → {storage}: Sicherung abgeschlossen – {vmname} ({vmid})', 'body': 'Die Sicherung von {vmname} (ID: {vmid}) wurde am {storage} erfolgreich abgeschlossen.\nGröße: {size}', 'label': 'Sicherung abgeschlossen'}, 'es': {'title': '{hostname} → {storage}: Backup completado — {vmname} ({vmid})', 'body': 'El backup de {vmname} (ID: {vmid}) se ha completado correctamente en {storage}.\nTamaño: {size}', 'label': 'Backup completado'}, 'fr': {'title': '{hostname} → {storage}\xa0: Sauvegarde terminée — {vmname} ({vmid})', 'body': "La sauvegarde de {vmname} (ID\xa0: {vmid}) s'est terminée avec succès le {storage}.\nTaille\xa0: {size}", 'label': 'Sauvegarde terminée'}, 'it': {'title': '{hostname} → {storage}: Backup completato — {vmname} ({vmid})', 'body': 'Backup di {vmname} (ID: {vmid}) completato con successo su {storage}.\nTaglia: {size}', 'label': 'Backup completato'}, 'pt': {'title': '{hostname} → {storage}: Backup concluído — {vmname} ({vmid})', 'body': 'Backup de {vmname} (ID: {vmid}) concluído com sucesso em {storage}.\nTamanho: {size}', 'label': 'Backup concluído'}, 'sk': {'title': '{hostname} → {storage}: Záloha dokončená — {vmname} ({vmid})', 'body': 'Záloha {vmname} (ID: {vmid}) na úložisku {storage} bola úspešne dokončená.\nVeľkosť: {size}', 'label': 'Záloha bola dokončená'}, 'sv': {'title': '{hostname} → {storage}: Säkerhetskopiering klar — {vmname} ({vmid})', 'body': 'Säkerhetskopiering av {vmname} (ID: {vmid}) slutfördes framgångsrikt på {storage}.\nStorlek: {size}', 'label': 'Säkerhetskopieringen är klar'}}
|
||||
for language, expected in legacy.items():
|
||||
self.assertEqual(templates._load_runtime_catalog(language)['templates']['backup_complete'], expected)
|
||||
result = templates.render_template('backup_complete', {'hostname': 'alias {rack.location}'}, language)
|
||||
self.assertIn('alias {rack.location}', result['title'])
|
||||
self.assertNotIn('()', result['title'])
|
||||
new_title = templates.runtime_message('backup.unconfirmedTitle', language, hostname='alias {rack.location}')
|
||||
self.assertTrue(new_title)
|
||||
self.assertEqual(result['title'], new_title)
|
||||
self.assertIn(templates.runtime_message('backup.unconfirmedBody', language), result['body'])
|
||||
# Absent/blank/non-string translation uses English per-key fallback.
|
||||
from unittest.mock import patch
|
||||
english = templates._load_runtime_catalog('en')
|
||||
for value in (None, '', {}, []):
|
||||
missing = {'backup': {'unconfirmedTitle': value, 'unconfirmedBody': value}}
|
||||
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else missing):
|
||||
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
|
||||
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
|
||||
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
|
||||
# Both catalogs missing: new outcome text still has explicit EN defaults.
|
||||
with patch.object(templates, '_load_runtime_catalog', return_value={}):
|
||||
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
|
||||
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
|
||||
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
|
||||
|
||||
def test_restore_reports_its_tasks_without_calling_the_node_ready_and_keeps_the_success_icon(self):
|
||||
import json
|
||||
from pathlib import Path
|
||||
from notification_final_fixture import restore_event
|
||||
messages = Path(__file__).resolve().parents[3] / 'AppImage/messages'
|
||||
first = {language: json.loads((messages / language / 'common.json').read_text())
|
||||
['runtime']['notifications']['templates']['system_restore_completed']['body'].split('\n')[0]
|
||||
for language in LANGUAGES}
|
||||
for warning in ('', 'missing module zfs'):
|
||||
event = restore_event(warning)
|
||||
for language in LANGUAGES:
|
||||
result = deliver(event['event_type'], event['data'], event['severity'], language)
|
||||
self.assertTrue(result['title'].startswith('✅ '))
|
||||
self.assertIn(first[language], result['body'])
|
||||
self.assertIn(first[language], result['text'])
|
||||
self.assertNotIn('fully ready', result['body'])
|
||||
self.assertIn('2m', result['text'])
|
||||
if warning:
|
||||
self.assertIn(warning, result['text'])
|
||||
quiet = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
|
||||
self.assertIn('✅', quiet['body'])
|
||||
self.assertIn(first[language], quiet['body'])
|
||||
self.assertIn('white-space:pre-wrap;', quiet['html'])
|
||||
|
||||
def test_spanish_outcome_and_restore_titles_are_capitalized_and_failure_is_exact(self):
|
||||
expected = {'confirmed': 'Backup completado', 'completed_with_warnings': 'Backup completado con advertencias',
|
||||
'unconfirmed': 'Resultado del backup sin confirmar', 'failed': 'Backup fallido'}
|
||||
for outcome, title in expected.items():
|
||||
result = templates.render_template('backup_complete', {'hostname': 'alias', 'backup_outcome': outcome}, 'es')
|
||||
self.assertEqual(result['title'], 'alias: ' + title)
|
||||
failure = templates.render_template('backup_fail', {'hostname': 'alias'}, 'es')
|
||||
self.assertEqual(failure['title'], 'alias: Backup fallido')
|
||||
restore = templates.render_template('system_restore_completed', {'hostname': 'alias'}, 'es')
|
||||
self.assertEqual(restore['title'], 'alias: Restauración del host finalizada')
|
||||
|
||||
def test_recovery_only_quiet_release_keeps_upstream_summary_contract(self):
|
||||
result = deliver('error_resolved', {'hostname': 'alias', 'category': 'temperature',
|
||||
'reason': 'old (recovered)', 'duration': '2m'}, 'OK', quiet=True)
|
||||
self.assertIn(templates.runtime_message('digest.lead', 'en', count=1).strip(), result['body'])
|
||||
self.assertIn(templates.runtime_message('digest.footer', 'en'), result['body'])
|
||||
self.assertIn('✅', result['body'])
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Whole-PR outcome corrections, inert producer/actual email consumers."""
|
||||
import html
|
||||
import unittest
|
||||
from notification_fixture import templates, receive, email, LANGUAGES
|
||||
|
||||
REPORT = """Details
|
||||
=======
|
||||
VMID Name Status Time Size Filename
|
||||
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
|
||||
|
||||
Total running time: 1m 1s
|
||||
Total size: 1 GiB
|
||||
"""
|
||||
|
||||
class CorrectionTests(unittest.TestCase):
|
||||
def test_reversed_finish_is_not_completion_evidence(self):
|
||||
event = receive('INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Starting Backup of VM 100 (qemu)')
|
||||
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
|
||||
|
||||
|
||||
def test_interleaved_complete_logs_keep_both_finished_guests(self):
|
||||
message = ('INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: web\n'
|
||||
'INFO: Starting Backup of VM 101 (lxc)\nINFO: CT Name: db\n'
|
||||
'INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Finished Backup of VM 101 (00:01:02)')
|
||||
event = receive(message)
|
||||
self.assertEqual(event.data['backup_outcome'], 'confirmed')
|
||||
result, markup = email(event.event_type, event.data, event.severity)
|
||||
self.assertIn('✅ VM web (100)', result['body'])
|
||||
self.assertIn('✅ CT db (101)', result['body'])
|
||||
self.assertNotIn('❔', result['body'])
|
||||
self.assertIn('00:01:01', result['body'])
|
||||
|
||||
|
||||
def test_backup_identity_has_event_scoped_mail_compatible_wrapping(self):
|
||||
result, markup = email('backup_complete', {'hostname': 'n' * 64,
|
||||
'backup_outcome': 'confirmed', 'pve_message': 'INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: customerproductionpostgresqlreplicaeuropewestdatacenter01\nINFO: Finished Backup of VM 100 (00:01:01)'})
|
||||
self.assertIn('table-layout:fixed;', markup)
|
||||
title_tag = markup.split('<h2 style="', 1)[1].split('"', 1)[0]
|
||||
self.assertIn('overflow-wrap:break-word;', title_tag)
|
||||
self.assertIn('word-wrap:break-word;', title_tag)
|
||||
_, unrelated = email('node_reconnect', {'hostname': 'node-a'}, 'OK')
|
||||
self.assertNotIn('table-layout:fixed;', unrelated)
|
||||
|
||||
|
||||
def test_catalog_parity_accepts_eventual_valid_slovak_keys(self):
|
||||
from notification_fixture import extract, SCRIPTS
|
||||
import copy
|
||||
import string
|
||||
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
|
||||
ns = {'string': string}
|
||||
ns['_placeholders'] = extract(path, '_placeholders', None, ns)
|
||||
parity = extract(path, 'test_runtime_catalog_keys_and_placeholders_match', 'RuntimeCatalogTests', ns)
|
||||
probe = unittest.TestCase()
|
||||
probe.catalogs = {lang: copy.deepcopy(templates._load_runtime_catalog(lang)) for lang in LANGUAGES}
|
||||
parity(probe) # shipped missing keys remain allowed
|
||||
probe.catalogs['sk']['backup'] = copy.deepcopy(probe.catalogs['en']['backup'])
|
||||
probe.catalogs['sk']['channels']['email']['status']['unconfirmed'] = probe.catalogs['en']['channels']['email']['status']['unconfirmed']
|
||||
parity(probe) # generation of exactly the pending keys is legal
|
||||
probe.catalogs['sk']['backup']['confirmedTitle'] = 'Missing hostname token'
|
||||
with self.assertRaises(AssertionError): parity(probe)
|
||||
|
||||
|
||||
def test_actual_neutral_style_is_not_success_green(self):
|
||||
for event, severity, data in (('backup_complete', 'INFO', {'backup_outcome': 'unconfirmed'}),):
|
||||
result, markup = email(event, data, severity)
|
||||
self.assertIn('background:#f9fafb;', markup)
|
||||
self.assertNotIn('background:#f0fdf4;', markup)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_actual_restore_endpoint_warnings_and_counts_reach_email(self):
|
||||
from notification_fixture import extract, SCRIPTS
|
||||
from types import SimpleNamespace
|
||||
for warnings in ('', 'missing module zfs'):
|
||||
events = []
|
||||
ns = {'request': SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
|
||||
'hostname': 'node-a', 'guests': '3', 'stubs': '1', 'stale_nodes': '0',
|
||||
'components': '2', 'duration': '2m', 'warnings': warnings}),
|
||||
'notification_manager': SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
|
||||
'jsonify': lambda value: value}
|
||||
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', None, ns)
|
||||
response, status = handler()
|
||||
self.assertEqual(status, 200)
|
||||
event = events[0]
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event['event_type'], event['data'], event['severity'], language)
|
||||
self.assertIn('2m', markup)
|
||||
for line in result['body'].splitlines():
|
||||
if line.strip(): self.assertIn(line.strip(), html.unescape(markup))
|
||||
if warnings: self.assertIn(warnings, markup)
|
||||
|
||||
|
||||
def test_raw_display_hostname_is_substituted_exactly_once(self):
|
||||
for event_type, outcome in (('backup_complete', 'confirmed'), ('backup_complete', 'failed'), ('backup_fail', 'failed')):
|
||||
for hostname in ('Sala {rack} – Zürich', 'node-{vmid}', 'Sala {rack.location}'):
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event_type, {'hostname': hostname,
|
||||
'backup_outcome': outcome, 'pve_message': REPORT}, language=language)
|
||||
self.assertIn(hostname, result['title'])
|
||||
self.assertIn(hostname, html.unescape(markup))
|
||||
|
||||
|
||||
def test_malformed_numeric_report_is_queued_uncertain_and_renderable(self):
|
||||
for size in ('1..5 GiB', '..5 GiB'):
|
||||
message = REPORT.replace('1 GiB ', size.ljust(9)).split('Total size:')[0]
|
||||
event = receive(message)
|
||||
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
|
||||
result, markup = email(event.event_type, event.data, event.severity)
|
||||
self.assertIn(size, result['body'])
|
||||
|
||||
|
||||
def test_unknown_backup_type_keeps_explicit_err_but_cannot_certify_ok(self):
|
||||
event = receive(REPORT.replace('ok ', 'err '), kind='')
|
||||
self.assertEqual(event.event_type, 'backup_complete')
|
||||
self.assertEqual(event.severity, 'INFO')
|
||||
self.assertEqual(event.data['backup_outcome'], 'failed')
|
||||
result, markup = email(event.event_type, event.data, event.severity)
|
||||
self.assertIn('FAILED', markup)
|
||||
self.assertEqual(receive(REPORT, kind='').data['backup_outcome'], 'unconfirmed')
|
||||
|
||||
|
||||
def test_confirmed_metadata_only_context_is_retained(self):
|
||||
data = {'hostname': 'node-a', 'backup_outcome': 'confirmed',
|
||||
'vmid': '100', 'vmname': 'web {literal}', 'storage': 'PBS', 'size': '1 GiB'}
|
||||
for language in LANGUAGES:
|
||||
result, markup = email('backup_complete', data, language=language)
|
||||
self.assertIn('web {literal} (100)', result['title'])
|
||||
self.assertIn('1 GiB', result['body'])
|
||||
self.assertIn('1 GiB', markup)
|
||||
|
||||
|
||||
def test_explicit_guest_failure_overrides_only_the_linked_ok_row(self):
|
||||
other = '101 db ok 1m 1s 1 GiB ct/101/2026-09-29T17:00:00Z'
|
||||
message = REPORT.replace('\n\nTotal', '\n' + other + '\n\nTotal')
|
||||
diagnostic = '100: 2026-09-29 17:00:00 ERROR: Backup of VM 100 failed - archive write failed'
|
||||
for severity in ('info', 'error'):
|
||||
event = receive(message + '\n' + diagnostic, severity)
|
||||
self.assertEqual(event.data.get('vmid'), '100')
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertIn('❌ VM web (100)', result['body'])
|
||||
self.assertNotIn('✅ VM web (100)', markup)
|
||||
self.assertIn('✅ CT db (101)', result['body'])
|
||||
self.assertIn('web (100)', result['title'])
|
||||
|
||||
|
||||
def test_official_week_month_year_durations_stay_confirmed(self):
|
||||
for duration in ('1w', '1w 1m 1s', '1M', '1y'):
|
||||
# Fixed-column widths are unchanged for these bounded values.
|
||||
message = REPORT.replace('1m 1s ', duration.ljust(9))
|
||||
event = receive(message)
|
||||
self.assertEqual(event.data['backup_outcome'], 'confirmed', duration)
|
||||
result, markup = email(event.event_type, event.data, event.severity)
|
||||
self.assertIn(duration, result['body'])
|
||||
|
||||
|
||||
def test_prefixed_error_diagnostics_survive_both_source_severities(self):
|
||||
diagnostic = '100: 2026-09-29 17:00:00 ERROR: archive write failed: permission denied'
|
||||
for severity in ('info', 'error'):
|
||||
event = receive(REPORT + '\n' + diagnostic, severity)
|
||||
self.assertEqual(event.data['backup_outcome'], 'failed')
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertIn(diagnostic, result['body'])
|
||||
self.assertIn(diagnostic, html.unescape(markup))
|
||||
# A job-level error must not invent a failed guest.
|
||||
self.assertIn('✅ VM web (100)', result['body'])
|
||||
|
||||
|
||||
def test_official_completed_report_warning_is_distinct_and_retained(self):
|
||||
warning = '100: 2026-09-29 17:00:00 WARN: unable to add notes - permission denied'
|
||||
event = receive(REPORT + '\n' + warning)
|
||||
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertIn(warning, result['body'])
|
||||
self.assertIn(warning, html.unescape(markup))
|
||||
|
||||
def test_batch_and_abort_failure_titles_have_no_empty_guest_slot(self):
|
||||
failed = REPORT.replace('ok ', 'err ')
|
||||
failed = failed.replace('\n\nTotal', '\n101 db err 1m 1s 0 B null\n\nTotal')
|
||||
for message in (failed, REPORT.replace('ok ', 'todo '),
|
||||
REPORT.split('100 web')[0] + '\nTotal running time: 0s'):
|
||||
event = receive(message + '\nINFO: vzdump --storage PBS', 'error')
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertNotIn('()', result['title'])
|
||||
self.assertIn('PBS', result['title'])
|
||||
self.assertNotIn('web (100)', result['title'])
|
||||
|
||||
def test_subject_only_setup_failure_survives_actual_email(self):
|
||||
reason = 'unable to activate storage PBS'
|
||||
title = 'vzdump backup status (node-a): backup failed: ' + reason
|
||||
message = REPORT.split('100 web')[0] + '\nTotal running time: 0s\nTotal size: 0 B'
|
||||
event = receive(message, 'error', title)
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertIn(reason, result['body'])
|
||||
self.assertIn(reason, html.unescape(markup))
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Final review contracts at actual locale/manual/queued/quiet email seams.
|
||||
All operational dependencies are inert; no manager or route module import.
|
||||
"""
|
||||
import ast
|
||||
import copy
|
||||
import html
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from notification_fixture import templates, SCRIPTS, LANGUAGES, receive
|
||||
from notification_final_fixture import deliver, restore_event
|
||||
|
||||
# Literal native send_notification body, produced by pinned PVE Perl helpers.
|
||||
NATIVE_REPORT = 'Details\n=======\n' + '''VMID Name Status Time Size Filename
|
||||
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
|
||||
|
||||
Total running time: 1m 1s
|
||||
Total size: 1 GiB
|
||||
|
||||
Logs
|
||||
====
|
||||
vzdump --all 1 --storage PBS --mode snapshot
|
||||
|
||||
100: no log available
|
||||
'''
|
||||
|
||||
|
||||
|
||||
class FinalCorrectionsTests(unittest.TestCase):
|
||||
def test_runtime_backup_assertions_accept_generated_and_missing_slovak_title(self):
|
||||
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
|
||||
tree = ast.parse(path.read_text())
|
||||
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'RuntimeCatalogTests')
|
||||
method = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == 'test_special_formatters_digest_and_test_message_are_slovak')
|
||||
start = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='backup' for t in n.targets))
|
||||
stop = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='manager' for t in n.targets))
|
||||
block = ast.Module(body=method.body[start:stop], type_ignores=[])
|
||||
english = templates._load_runtime_catalog('en')
|
||||
for title in (None, '{hostname}: GENERATED_SK potvrdené'):
|
||||
sk = copy.deepcopy(templates._load_runtime_catalog('sk'))
|
||||
if title is not None: sk.setdefault('backup', {})['confirmedTitle'] = title
|
||||
else: sk.get('backup', {}).pop('confirmedTitle', None)
|
||||
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else sk):
|
||||
exec(compile(block,str(path),'exec'), {'self':self, 'notification_templates':templates})
|
||||
|
||||
|
||||
def test_native_multiline_failure_diagnostics_survive_receiver_dispatch_once(self):
|
||||
subject = 'vzdump backup status (node-a): backup failed: multiple problems'
|
||||
for diagnostic, report in (
|
||||
('external provider job cleanup failed\njob-abort hook permission denied', NATIVE_REPORT),
|
||||
('job interrupted\njob-abort hook permission denied', NATIVE_REPORT.replace('ok ', 'todo ')),
|
||||
('unable to initialize external provider\njob-abort hook permission denied', NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z\n', '')),
|
||||
):
|
||||
event = receive(diagnostic + '\n' + report, 'error', subject)
|
||||
self.assertEqual((event.event_type, event.severity), ('backup_fail', 'CRITICAL'))
|
||||
for language in LANGUAGES:
|
||||
result = deliver(event.event_type, event.data, event.severity, language)
|
||||
for line in diagnostic.splitlines():
|
||||
self.assertEqual(result['body'].count(line), 1)
|
||||
self.assertEqual(result['text'].count(line), 1)
|
||||
if 'ok ' in report:
|
||||
self.assertIn('✅ VM web (100)', result['body'])
|
||||
|
||||
|
||||
def test_restore_endpoint_quiet_release_keeps_warning_counts_and_truthful_footer(self):
|
||||
reason = 'missing module zfs <raw> & {literal}'
|
||||
event = restore_event(reason)
|
||||
self.assertEqual(event['severity'], 'WARNING')
|
||||
for language in LANGUAGES:
|
||||
result = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
|
||||
self.assertEqual(result['severity'], 'INFO')
|
||||
buffered_body = result['buffered'][0][3]
|
||||
self.assertEqual(result['text'].count(reason), 1)
|
||||
for line in buffered_body.splitlines():
|
||||
if line.strip(): self.assertIn(line.strip(), result['text'])
|
||||
self.assertIn('2m', result['text'])
|
||||
self.assertNotIn(templates.runtime_message('digest.footer', language), result['body'])
|
||||
self.assertNotIn('script', result['tags'])
|
||||
|
||||
|
||||
|
||||
|
||||
def test_raw_restore_cells_use_event_scoped_mail_wrapping(self):
|
||||
token = 'b' * 64
|
||||
event = restore_event('Boot check: recorded token ' + token + '; verification pending')
|
||||
for language in LANGUAGES:
|
||||
results = [deliver(event['event_type'],event['data'],event['severity'],language,quiet=quiet) for quiet in (False,True)]
|
||||
for result in results:
|
||||
self.assertIn('table-layout:fixed;', result['html'])
|
||||
self.assertIn('word-wrap:break-word;', result['html'])
|
||||
self.assertIn('overflow-wrap:break-word;', result['html'])
|
||||
self.assertEqual(result['text'].count(token), 1)
|
||||
unrelated = deliver('node_reconnect', {'hostname':'node-a'}, 'OK')
|
||||
self.assertNotIn('table-layout:fixed;', unrelated['html'])
|
||||
|
||||
|
||||
def test_manual_failed_and_unconfirmed_backup_keep_short_actionable_reason(self):
|
||||
for outcome, reason in (('failed','PBS permission denied for datastore remote'),
|
||||
('unconfirmed','Task status unavailable: upstream API timed out')):
|
||||
for language in LANGUAGES:
|
||||
result = deliver('backup_complete',{'hostname':'node-a','vmid':'100','vmname':'web',
|
||||
'storage':'PBS','backup_outcome':outcome,'reason':reason},'INFO',language,manual=True)
|
||||
self.assertEqual(result['text'].count(reason), 1)
|
||||
self.assertNotIn('>COMPLETED</span>', result['html'])
|
||||
|
||||
|
||||
def test_backup_reason_threshold_and_raw_body_deduplication(self):
|
||||
for event in ('backup_complete', 'backup_fail'):
|
||||
for length in (79, 80, 81, 120):
|
||||
prefix = '<raw> & {rack.location} '
|
||||
reason = prefix + 'b' * (length - len(prefix))
|
||||
self.assertEqual(len(reason), length)
|
||||
for raw in ('', reason):
|
||||
data = {'hostname':'node-a','backup_outcome':'failed','reason':reason,'pve_message':raw}
|
||||
for manual in (False, True):
|
||||
result = deliver(event,data,'INFO','en',manual=manual)
|
||||
self.assertEqual(result['text'].count(reason), 1, (event,length,raw,manual))
|
||||
self.assertNotIn('raw', result['tags'])
|
||||
|
||||
|
||||
def test_quiet_restore_after_preview_limit_is_not_omitted_or_called_info(self):
|
||||
event = restore_event('missing module zfs')
|
||||
earlier = [dict(event_type='service_fail',severity='WARNING',data={'hostname':'node-a','service_name':f'unit-{i}','reason':'recorded'}) for i in range(8)]
|
||||
result = deliver(event['event_type'],event['data'],event['severity'],quiet=True,quiet_before=earlier)
|
||||
self.assertEqual(result['text'].count('missing module zfs'), 1)
|
||||
self.assertNotIn(templates.runtime_message('digest.lead','en',count=9), result['body'])
|
||||
self.assertEqual(result['data']['_count'], 9)
|
||||
|
||||
|
||||
def test_native_error_block_does_not_deduplicate_a_substring_of_inventory(self):
|
||||
event = receive('web\nexternal provider job cleanup failed\n' + NATIVE_REPORT,
|
||||
'error','vzdump backup status (node-a): backup failed: multiple problems')
|
||||
result = deliver(event.event_type,event.data,event.severity)
|
||||
self.assertEqual(result['body'].splitlines().count('web'), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Maintainer acceptance at inert actual notification consumers."""
|
||||
import unittest
|
||||
from notification_fixture import templates, receive, LANGUAGES, SCRIPTS, extract
|
||||
from notification_final_fixture import deliver
|
||||
|
||||
NATIVE_REPORT = '''Details
|
||||
=======
|
||||
VMID Name Status Time Size Filename
|
||||
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
|
||||
|
||||
Total running time: 1m 1s
|
||||
Total size: 1 GiB
|
||||
|
||||
Logs
|
||||
====
|
||||
vzdump --all 1 --storage PBS --mode snapshot
|
||||
|
||||
100: 2026-09-29 17:00:00 INFO: Starting Backup of VM 100 (qemu)
|
||||
100: 2026-09-29 17:01:01 INFO: Finished Backup of VM 100 (00:01:01)
|
||||
'''
|
||||
|
||||
class MaintainerFollowupTests(unittest.TestCase):
|
||||
def test_completed_with_warnings_requires_independent_completion(self):
|
||||
warning = '\n100: 2026-09-29 17:00:01 WARN: file changed during backup'
|
||||
event = receive(NATIVE_REPORT + warning)
|
||||
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
|
||||
self.assertEqual((event.event_type,event.severity), ('backup_complete','INFO'))
|
||||
for manual in (False,True):
|
||||
for lang in LANGUAGES:
|
||||
result = deliver(event.event_type,event.data,event.severity,lang,manual=manual)
|
||||
label = templates.runtime_message('backup.warningTitle',lang,hostname='node-a')
|
||||
self.assertIn(label,result['title'])
|
||||
status = templates.runtime_message('channels.email.status.completed_with_warnings',lang)
|
||||
self.assertIn(status,result['text'])
|
||||
self.assertIn('file changed during backup',result['text'])
|
||||
# Manual sends intentionally skip channel emoji enrichment.
|
||||
if not manual: self.assertTrue(result['title'].startswith('💾⚠️'))
|
||||
rich, _ = templates.enrich_with_emojis(event.event_type,result['title'],result['body'],event.data)
|
||||
self.assertTrue(rich.startswith('💾⚠️'))
|
||||
self.assertEqual(receive('WARN: file changed during backup').data['backup_outcome'],'unconfirmed')
|
||||
self.assertEqual(receive(NATIVE_REPORT.split('Total running time:')[0]+warning).data['backup_outcome'],'unconfirmed')
|
||||
self.assertEqual(receive(NATIVE_REPORT+warning+'\nERROR: cleanup failed').data['backup_outcome'],'failed')
|
||||
self.assertEqual(receive(NATIVE_REPORT+warning,'warning').data['backup_outcome'],'completed_with_warnings')
|
||||
self.assertEqual(receive('INFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'+warning).data['backup_outcome'],'completed_with_warnings')
|
||||
self.assertEqual(receive(NATIVE_REPORT+warning,kind='').data['backup_outcome'],'unconfirmed')
|
||||
|
||||
def test_null_filename_failed_guest_uses_own_start_identity(self):
|
||||
report = NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z',
|
||||
'100 web err 1m 1s 0 B null')
|
||||
for kind, prefix in (('qemu','VM'),('lxc','CT')):
|
||||
own = report.replace('VM 100 (qemu)',f'VM 100 ({kind})')
|
||||
# Unrelated guest appears first and must never supply the failed type.
|
||||
message = f'INFO: Starting Backup of VM 999 ({"lxc" if kind == "qemu" else "qemu"})\n' + own
|
||||
event = receive(message,'error','vzdump backup status (raw-node): backup failed')
|
||||
parsed = templates._parse_vzdump_message(message)
|
||||
self.assertEqual(parsed['vms'][0]['type'],kind)
|
||||
result = deliver(event.event_type,event.data,event.severity)
|
||||
self.assertIn(f'{prefix} web (100)',result['title'])
|
||||
self.assertIn(f'❌ {prefix} web (100)',result['body'])
|
||||
for message in (report.replace('Starting Backup of VM 100','Starting Backup of VM 999'),
|
||||
report+'\nINFO: Starting Backup of VM 100 (lxc)'):
|
||||
self.assertEqual(templates._parse_vzdump_message(message)['vms'][0]['type'],'')
|
||||
|
||||
def test_native_subject_keeps_cause_without_host_envelope(self):
|
||||
subject = 'vzdump backup status (raw-host): backup failed: multiple problems'
|
||||
event = receive('ERROR: archive write failed\n'+NATIVE_REPORT,'error',subject)
|
||||
event.data['hostname']='configured-alias'
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang)
|
||||
self.assertNotIn(subject,result['text'])
|
||||
self.assertEqual(result['text'].count('ERROR: archive write failed'),1)
|
||||
self.assertIn('configured-alias',result['title'])
|
||||
setup=receive('Details\n=======\nVMID Name Status Time Size Filename\n\nTotal running time: 0s\nTotal size: 0 B','error',subject.replace('multiple problems','unable to open storage'))
|
||||
result=deliver(setup.event_type,setup.data,setup.severity)
|
||||
self.assertEqual(result['text'].count('unable to open storage'),1)
|
||||
self.assertNotIn(setup.data['pve_title'],result['text'])
|
||||
unique=receive(NATIVE_REPORT,'error',subject.replace('multiple problems','job-end hook denied'))
|
||||
result=deliver(unique.event_type,unique.data,unique.severity)
|
||||
self.assertIn('job-end hook denied',result['body'])
|
||||
self.assertNotIn('vzdump backup status',result['body'])
|
||||
|
||||
def test_backup_diagnostics_are_bounded_with_principal_cause_and_notice(self):
|
||||
raw = NATIVE_REPORT + '\n' + '\n'.join(f'WARN: repeated warning {i}' for i in range(80)) + '\nERROR: principal archive write failure'
|
||||
event=receive(raw)
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang)
|
||||
self.assertIn('ERROR: principal archive write failure',result['body'])
|
||||
self.assertLessEqual(len('\n'.join(line for line in result['body'].splitlines() if line.startswith(('WARN:','ERROR:')))),1024)
|
||||
self.assertLessEqual(sum(line.startswith(('WARN:','ERROR:')) for line in result['body'].splitlines()),8)
|
||||
notice=templates.runtime_message('backup.diagnosticsOmitted',lang,count=73)
|
||||
self.assertIn(notice,result['body'])
|
||||
self.assertEqual(event.data['pve_message'],raw)
|
||||
long=receive('ERROR: '+ 'b'*5000,'error','vzdump backup status (node): backup failed')
|
||||
result=deliver(long.event_type,long.data,long.severity)
|
||||
self.assertLess(len(result['body']),1400)
|
||||
self.assertIn('ERROR: '+ 'b'*100,result['body'])
|
||||
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted','en',count=1),result['body'])
|
||||
|
||||
def test_real_quiet_digest_retains_each_backup_outcome_icon(self):
|
||||
samples=[(NATIVE_REPORT,'confirmed','💾✅'),(NATIVE_REPORT+'\nWARN: changed file','completed_with_warnings','💾⚠️'),(NATIVE_REPORT+'\nERROR: write failed','failed','💾❌'),('INFO: Starting Backup of VM 100 (qemu)','unconfirmed','💾❔')]
|
||||
for raw,outcome,icon in samples:
|
||||
event=receive(raw)
|
||||
self.assertEqual(event.data['backup_outcome'],outcome)
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang,quiet=True)
|
||||
self.assertIn(icon,result['body'])
|
||||
self.assertNotIn('💾❔',result['body']) if outcome!='unconfirmed' else None
|
||||
import types,datetime
|
||||
ns={'datetime':datetime.datetime,'runtime_message':templates.runtime_message,'EVENT_EMOJI':templates.EVENT_EMOJI,'CATEGORY_EMOJI':templates.CATEGORY_EMOJI}
|
||||
compose=extract(SCRIPTS/'notification_manager.py','_compose_digest_body','NotificationManager',ns)
|
||||
target=types.SimpleNamespace(_notification_language=lambda:'en')
|
||||
rows=[(i,'backup_complete','backup',1,icon+' node: Backup','') for i,(_,_,icon) in enumerate(samples)]
|
||||
body=compose(target,rows,use_icons=True)
|
||||
for _,_,icon in samples:self.assertIn(icon,body)
|
||||
plain=compose(target,rows,use_icons=False)
|
||||
for _,_,icon in samples:self.assertNotIn(icon,plain)
|
||||
should=extract(SCRIPTS/'notification_manager.py','_should_buffer_for_digest','NotificationManager',{})
|
||||
self.assertFalse(should(types.SimpleNamespace(_DIGEST_EXEMPT_EVENTS={'backup_complete'},_config={'email.digest_enabled':'true'}),'email','INFO','backup_complete'))
|
||||
|
||||
def test_quiet_restore_details_are_subordinate_in_text_and_email(self):
|
||||
from notification_final_fixture import restore_event
|
||||
event=restore_event('missing module zfs')
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event['event_type'],event['data'],event['severity'],lang,quiet=True)
|
||||
body_lines=result['buffered'][0][3].splitlines()
|
||||
for line in body_lines:
|
||||
if line.strip():
|
||||
self.assertIn(' '+line.strip(),result['body'])
|
||||
self.assertIn(' '+__import__('html').escape(line.strip()),result['html'])
|
||||
self.assertIn('white-space:pre-wrap;',result['html'])
|
||||
self.assertNotIn(templates.runtime_message('digest.footer',lang),result['body'])
|
||||
|
||||
def test_concrete_subject_cause_already_in_error_log_is_not_repeated(self):
|
||||
event=receive(NATIVE_REPORT+'\n100: ERROR: job-end hook denied','error',
|
||||
'vzdump backup status (raw-host): backup failed: job-end hook denied')
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang)
|
||||
self.assertEqual(result['text'].count('job-end hook denied'),1)
|
||||
self.assertIn('100: ERROR: job-end hook denied',result['body'])
|
||||
|
||||
def test_job_level_subject_cause_survives_warning_cap(self):
|
||||
raw=NATIVE_REPORT+'\n'+'\n'.join('WARN: repeated diagnostic '+str(i) for i in range(80))
|
||||
event=receive(raw,'error','vzdump backup status (raw-host): backup failed: job-end hook denied')
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang)
|
||||
self.assertEqual(result['body'].count('job-end hook denied'),1)
|
||||
self.assertNotIn('vzdump backup status',result['body'])
|
||||
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted',lang,count=73),result['body'])
|
||||
|
||||
def test_backup_quiet_email_uses_event_scoped_wrapping(self):
|
||||
event=receive(NATIVE_REPORT+'\nWARN: changed file')
|
||||
result=deliver(event.event_type,event.data,event.severity,'sv',quiet=True)
|
||||
self.assertTrue(result['data'].get('_backup_summary'))
|
||||
self.assertIn('table-layout:fixed;',result['html'])
|
||||
self.assertIn('overflow-wrap:break-word;',result['html'])
|
||||
unrelated=deliver('node_reconnect',{'hostname':'node-a'},'OK')
|
||||
self.assertNotIn('table-layout:fixed;',unrelated['html'])
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Inert producer-to-renderer checks for notification outcome claims."""
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from notification_fixture import templates as actual_templates
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / 'AppImage/scripts'
|
||||
CATALOG = ROOT / 'AppImage/messages/en/common.json'
|
||||
EXPECTED = {'system_restore_completed': {'body': 'Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}'}}
|
||||
|
||||
|
||||
def extract(path, name, owner=None, namespace=None):
|
||||
tree = ast.parse(path.read_text())
|
||||
nodes = tree.body
|
||||
if owner:
|
||||
nodes = next(n.body for n in nodes if isinstance(n, ast.ClassDef) and n.name == owner)
|
||||
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
ns = namespace if namespace is not None else {}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
|
||||
return ns[name]
|
||||
|
||||
|
||||
def renderer(catalog, translated=None):
|
||||
path = SCRIPTS / 'notification_templates.py'
|
||||
tree = ast.parse(path.read_text())
|
||||
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
|
||||
def lookup(obj, key):
|
||||
for part in key.split('.'):
|
||||
obj = obj.get(part) if isinstance(obj, dict) else None
|
||||
return obj
|
||||
def message(key, language='en', **values):
|
||||
source = translated if language == 'it' and translated is not None else catalog
|
||||
namespace = source['runtime']['notifications']
|
||||
value = lookup(namespace, key) or lookup(catalog['runtime']['notifications'], key) or ''
|
||||
return value.format_map(type('Safe', (dict,), {'__missing__': lambda self, k: ''})(values))
|
||||
ns = {'TEMPLATES': templates, 'Dict': dict, 'Any': Any, 'time': time, 're': re,
|
||||
'_get_hostname': lambda: 'node-a',
|
||||
'_load_runtime_catalog': lambda lang: (translated if lang == 'it' and translated is not None
|
||||
else catalog)['runtime']['notifications'],
|
||||
'_catalog_value': lookup, 'runtime_message': message}
|
||||
from typing import Optional
|
||||
ns['Optional'] = Optional
|
||||
extract(path, '_parse_vzdump_table', namespace=ns)
|
||||
extract(path, '_parse_vzdump_message', namespace=ns)
|
||||
extract(path, '_format_vzdump_body', namespace=ns)
|
||||
return templates, extract(path, 'render_template', namespace=ns)
|
||||
|
||||
|
||||
class OutcomeWording(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.catalog = json.loads(CATALOG.read_text())
|
||||
cls.templates, render = renderer(cls.catalog)
|
||||
cls.render = staticmethod(render)
|
||||
|
||||
def test_spanish_restore_uses_maintainer_guests_terminology(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('isolated_spanish_restore', SCRIPTS / 'notification_templates.py')
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
result = module.render_template('system_restore_completed', {
|
||||
'hostname':'node-a','guests':3,'stubs':0,'stale_nodes':0,
|
||||
'components':1,'duration':'2m','warnings_block':''}, 'es')
|
||||
self.assertIn('Configuración de guests copiada: 3', result['body'])
|
||||
self.assertNotIn('invitados', result['body'].lower())
|
||||
|
||||
def test_settings_labels_stay_at_upstream_values_in_all_locales(self):
|
||||
# Frozen from develop eb7cc548; CI shallow checkouts have no base history.
|
||||
labels = {'en': {'backup_complete': 'Backup complete', 'error_resolved': 'Recovery notification'}, 'de': {'backup_complete': 'Sicherung abgeschlossen', 'error_resolved': 'Wiederherstellungsbenachrichtigung'}, 'es': {'backup_complete': 'Backup completado', 'error_resolved': 'Notificación de recuperación'}, 'fr': {'backup_complete': 'Sauvegarde terminée', 'error_resolved': 'Notification de récupération'}, 'it': {'backup_complete': 'Backup completato', 'error_resolved': 'Notifica di recupero'}, 'pt': {'backup_complete': 'Backup concluído', 'error_resolved': 'Notificação de recuperação'}, 'sk': {'backup_complete': 'Záloha bola dokončená', 'error_resolved': 'Problém bol vyriešený'}, 'sv': {'backup_complete': 'Säkerhetskopieringen är klar', 'error_resolved': 'Återställningsmeddelande'}}
|
||||
for lang in labels:
|
||||
path = f'AppImage/messages/{lang}/common.json'
|
||||
current = json.loads((ROOT / path).read_text())
|
||||
for event in ('backup_complete', 'error_resolved'):
|
||||
with self.subTest(lang=lang, event=event):
|
||||
expected = labels[lang][event]
|
||||
self.assertEqual(current['runtime']['notifications']['templates'][event]['label'], expected)
|
||||
if lang == 'en': self.assertEqual(self.templates[event]['label'], expected)
|
||||
|
||||
def test_exact_four_english_leaves_match_source_and_catalog(self):
|
||||
for event, fields in EXPECTED.items():
|
||||
for field, value in fields.items():
|
||||
with self.subTest(event=event, field=field):
|
||||
self.assertEqual(self.templates[event][field], value)
|
||||
self.assertEqual(self.catalog['runtime']['notifications']['templates'][event][field], value)
|
||||
|
||||
def test_webhook_backup_outcome_is_evidence_based_without_rerouting(self):
|
||||
path = SCRIPTS / 'notification_events.py'
|
||||
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
|
||||
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
|
||||
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
|
||||
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
|
||||
class Event:
|
||||
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, event): self.items.append(event)
|
||||
ns['NotificationEvent'] = Event
|
||||
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = classify
|
||||
_map_severity = staticmethod(severity_map)
|
||||
_backup_outcome = staticmethod(backup_outcome)
|
||||
def __init__(self): self._queue = Queue()
|
||||
header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
|
||||
row_ok = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','OK','00:01:00','1.5 GiB','archive')
|
||||
row_warning = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','WARNINGS','00:01:00','1.5 GiB','archive')
|
||||
row_error = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','ERROR','00:01:00','1.5 GiB','archive')
|
||||
row_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','err','00:01:00','1.5 GiB','archive')
|
||||
truncated = 'INFO: Log output was too long to be displayed. Please see task log for details.'
|
||||
cases = [
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_err+'\nTotal running time: 00:02:00', 'failed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\n'+header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_err+'\nTotal running time: 00:01:00\n'+truncated, 'failed'),
|
||||
('vzdump', 'warning', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'completed_with_warnings'),
|
||||
('vzdump', 'info', header+'\n'+row_ok, 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_ok, 'unconfirmed'),
|
||||
('vzdump', 'warning', header+'\n'+row_err+'\nTotal running time: 00:01:00', 'failed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00\n'+truncated, 'unconfirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\nERROR: archive write failed', 'failed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
|
||||
('vzdump', 'info', header+'\n'+row_error, 'failed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'confirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00', 'unconfirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok[:30], 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'confirmed'),
|
||||
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'completed_with_warnings'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)', 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nWARNING: skipped file', 'completed_with_warnings'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: TASK OK\n104 alpha WARNINGS: 1', 'completed_with_warnings'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Finished Backup of VM 105 (00:01:00)', 'confirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed for VM 104', 'failed'),
|
||||
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'failed'),
|
||||
('', 'warning', 'Backup scheduled', 'unconfirmed'),
|
||||
('', 'info', 'Backup complete', 'unconfirmed'),
|
||||
]
|
||||
for kind, severity, message, expected in cases:
|
||||
with self.subTest(message=message, severity=severity):
|
||||
receiver = Receiver()
|
||||
receive(receiver, {'fields': {'type': kind}, 'severity': severity,
|
||||
'title': 'Backup', 'message': message})
|
||||
event = receiver._queue.items[0]
|
||||
self.assertEqual(event.event_type, 'backup_complete')
|
||||
self.assertEqual(event.data['backup_outcome'], expected)
|
||||
|
||||
def test_backup_classifier_preserves_confirmed_and_unverified_paths(self):
|
||||
path = SCRIPTS / 'notification_events.py'
|
||||
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
|
||||
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
|
||||
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
|
||||
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
|
||||
class Event:
|
||||
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, event): self.items.append(event)
|
||||
ns['NotificationEvent'] = Event
|
||||
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = classify
|
||||
_map_severity = staticmethod(severity_map)
|
||||
_backup_outcome = staticmethod(backup_outcome)
|
||||
def __init__(self): self._queue = Queue()
|
||||
samples = [('vzdump', 'info', 'Backup', 'job finished'),
|
||||
('vzdump', 'warning', 'Backup', 'job incomplete'),
|
||||
('', 'warning', 'backup job', 'Backup scheduled')]
|
||||
for kind, severity, title, message in samples:
|
||||
with self.subTest(kind=kind, message=message):
|
||||
event, entity, _ = classify(None, kind, severity, title, message)
|
||||
self.assertEqual((event, entity), ('backup_complete', 'vm'))
|
||||
receiver = Receiver()
|
||||
reply = receive(receiver, {'fields': {'type': kind}, 'severity': severity,
|
||||
'title': title, 'message': message})
|
||||
self.assertEqual(reply['event_type'], event)
|
||||
self.assertEqual(len(receiver._queue.items), 1)
|
||||
emitted = receiver._queue.items[0]
|
||||
self.assertEqual(emitted.severity, 'WARNING' if severity == 'warning' else 'INFO')
|
||||
output = self.render(emitted.event_type, emitted.data, 'en')
|
||||
self.assertIn('Backup outcome unconfirmed', output['title'])
|
||||
self.assertEqual(output['body'].splitlines()[-1], message) # raw PVE body retained
|
||||
self.assertEqual(classify(None, 'vzdump', 'error', 'Backup', 'failed')[0], 'backup_fail')
|
||||
|
||||
def test_incomplete_vzdump_log_does_not_certify_a_guest(self):
|
||||
from typing import Dict, Optional
|
||||
ns = {'re': re, 'Dict': Dict, 'Optional': Optional, 'Any': Any,
|
||||
'runtime_message': lambda key, lang, **kw: key}
|
||||
extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_table', namespace=ns)
|
||||
parser = extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_message', namespace=ns)
|
||||
formatter = extract(SCRIPTS / 'notification_templates.py', '_format_vzdump_body', namespace=ns)
|
||||
incomplete = parser('INFO: Starting Backup of VM 104 (qemu)')
|
||||
table_header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
|
||||
table_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','err','00:01:00','1.5 GiB','archive')
|
||||
failed_table = parser(table_header+'\n'+table_err+'\nTotal running time: 00:01:00')
|
||||
self.assertEqual(failed_table['vms'][0]['status'].lower(), 'error')
|
||||
self.assertIn('❌', formatter(failed_table, False, 'en'))
|
||||
self.assertEqual(incomplete['vms'][0]['status'], 'unknown')
|
||||
self.assertNotIn('✅', formatter(incomplete, False, 'en'))
|
||||
mixed = parser('INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Starting Backup of VM 105 (lxc)')
|
||||
self.assertEqual([vm['status'] for vm in mixed['vms']], ['ok', 'unknown'])
|
||||
formatted = formatter(mixed, False, 'en')
|
||||
self.assertEqual(formatted.count('✅'), 1)
|
||||
self.assertNotIn('❌', formatted)
|
||||
conflicting = parser('INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed\nINFO: Finished Backup of VM 104 (00:01:00)')
|
||||
self.assertEqual(conflicting['vms'][0]['status'], 'error')
|
||||
|
||||
def test_backup_render_preserves_success_and_marks_unconfirmed_and_failure(self):
|
||||
for outcome, message, title_part, body_part in (
|
||||
('confirmed', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'Backup complete', '✅'),
|
||||
('unconfirmed', 'INFO: Starting Backup of VM 104 (qemu)', 'Backup outcome unconfirmed', '❔'),
|
||||
('failed', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'Backup error reported', '❌'),
|
||||
):
|
||||
with self.subTest(outcome=outcome):
|
||||
output = self.render('backup_complete', {'hostname': 'node-a', 'pve_type': 'vzdump',
|
||||
'pve_message': message, 'pve_title': 'Backup complete', 'backup_outcome': outcome}, 'en')
|
||||
self.assertIn(title_part, output['title'])
|
||||
self.assertIn(body_part, output['body'])
|
||||
if outcome != 'confirmed': self.assertNotIn('Backup complete', output['title'])
|
||||
output = self.render('backup_complete', {'hostname': 'node-a', 'vmname': 'vm', 'vmid': '104'}, 'en')
|
||||
self.assertIn('Backup outcome unconfirmed', output['title'])
|
||||
self.assertNotIn('successfully', output['body'])
|
||||
conflict = self.render('backup_complete', {'hostname': 'node-a','backup_outcome':'failed',
|
||||
'pve_message':'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nERROR: archive write failed'}, 'en')
|
||||
self.assertIn('ERROR: archive write failed', conflict['body'])
|
||||
self.assertNotIn('Backup complete', conflict['title'])
|
||||
|
||||
def test_confirmed_title_keeps_single_guest_and_destination_without_misnaming_batches(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('isolated_backup_title', SCRIPTS / 'notification_templates.py')
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
actual_render = module.render_template
|
||||
log = ('INFO: starting new backup job: vzdump 104 --storage PBS-Cloud --mode snapshot\n'
|
||||
'INFO: Starting Backup of VM 104 (qemu)\nINFO: VM Name: Alpha\n'
|
||||
'INFO: Finished Backup of VM 104 (00:01:00)')
|
||||
single = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
|
||||
'pve_message':log}, 'en')
|
||||
self.assertIn('PBS-Cloud',single['title'])
|
||||
self.assertIn('VM Alpha (104)',single['title'])
|
||||
batch = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
|
||||
'pve_message':log+'\nINFO: Starting Backup of VM 105 (lxc)\n'
|
||||
'INFO: Finished Backup of VM 105 (00:01:00)'}, 'en')
|
||||
self.assertIn('PBS-Cloud',batch['title'])
|
||||
self.assertNotIn('Alpha (104)',batch['title'])
|
||||
no_context = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed'}, 'en')
|
||||
self.assertEqual(no_context['title'], 'node-a: Backup complete')
|
||||
named = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
|
||||
'pve_message':log.replace('Alpha','Alpha {literal}')}, 'en')
|
||||
self.assertIn('Alpha {literal} (104)', named['title'])
|
||||
|
||||
def test_html_email_badge_and_backup_status_are_context_specific(self):
|
||||
import html
|
||||
path = SCRIPTS / 'notification_channels.py'
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
with self.subTest(lang=lang):
|
||||
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
|
||||
english = self.catalog['runtime']['notifications']
|
||||
def text(key, data=None, **values):
|
||||
def lookup(source):
|
||||
value = source['channels']
|
||||
for part in key.split('.'):
|
||||
value = value.get(part) if isinstance(value, dict) else None
|
||||
return value
|
||||
return (lookup(catalog) or lookup(english) or '').format(**values)
|
||||
ns = {'Dict': dict, 'Optional': __import__('typing').Optional,
|
||||
'_runtime_text': text, '_runtime_notification_text': lambda key, data=None: ''}
|
||||
build = extract(path, '_build_detail_rows', 'EmailChannel', ns)
|
||||
fmt = extract(path, '_format_html', 'EmailChannel', ns)
|
||||
class Email:
|
||||
from notification_channels import EmailChannel
|
||||
_SEV_STYLE = EmailChannel._SEV_STYLE
|
||||
_SEV_DEFAULT = EmailChannel._SEV_DEFAULT
|
||||
subject_prefix = 'ProxMenux'
|
||||
_build_detail_rows = staticmethod(build)
|
||||
unrelated = fmt(Email(), 'Reconnected', 'Body', 'OK', {'_event_type': 'node_reconnect',
|
||||
'_notification_language': lang, '_group': 'cluster'})
|
||||
self.assertIn('>' + catalog['channels']['email']['severity']['ok'].upper() + '</span>', unrelated)
|
||||
for outcome, status in [('confirmed', 'completed'), ('unconfirmed','unconfirmed'), ('failed','failed')]:
|
||||
email = fmt(Email(), 'Backup', 'Details', 'INFO', {'_event_type': 'backup_complete',
|
||||
'backup_outcome': outcome, '_notification_language': lang, '_group': 'backup'})
|
||||
label = catalog['channels']['email']['status'].get(status) or english['channels']['email']['status'][status]
|
||||
self.assertIn(label, html.unescape(email))
|
||||
badge_label = label.upper()
|
||||
self.assertIn('>' + badge_label + '</span>', html.unescape(email))
|
||||
if outcome == 'failed':
|
||||
self.assertIn('color:#dc2626;font-weight:600;', email)
|
||||
self.assertIn('background:#fef2f2;', email)
|
||||
elif outcome == 'unconfirmed':
|
||||
self.assertIn('background:#f9fafb;', email)
|
||||
else:
|
||||
self.assertIn('background:#f0fdf4;', email)
|
||||
|
||||
def test_actual_all_locale_rendering_and_fallback_for_backup_outcomes(self):
|
||||
import importlib.util
|
||||
module_path = SCRIPTS / 'notification_templates.py'
|
||||
spec = importlib.util.spec_from_file_location('isolated_notification_templates', module_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # templates and catalogs only; no manager or sends
|
||||
samples = {
|
||||
'confirmed': 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)',
|
||||
'unconfirmed': 'INFO: Starting Backup of VM 104 (qemu)',
|
||||
'failed': 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed',
|
||||
}
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
|
||||
for state, message in samples.items():
|
||||
with self.subTest(lang=lang, state=state):
|
||||
data = {'hostname':'node-with-a-long-name','backup_outcome':state,'pve_type':'vzdump',
|
||||
'pve_message':message, 'pve_title':'Backup complete', '_notification_language':lang}
|
||||
result = module.render_template('backup_complete', data, lang)
|
||||
if state != 'unconfirmed':
|
||||
key = 'confirmedTitle' if state == 'confirmed' else 'errorTitle'
|
||||
expected_title = (catalog.get('backup', {}).get(key) or
|
||||
self.catalog['runtime']['notifications']['backup'][key]).format(hostname=data['hostname'])
|
||||
self.assertTrue(result['title'].startswith(expected_title), result['title'])
|
||||
else:
|
||||
self.assertEqual(result['title'], module.runtime_message('backup.unconfirmedTitle', lang, hostname=data['hostname']))
|
||||
self.assertNotIn('{hostname}', result['title'])
|
||||
if state == 'unconfirmed':
|
||||
source = (catalog if catalog.get('backup', {}).get('unconfirmedBody')
|
||||
else self.catalog['runtime']['notifications'])
|
||||
self.assertIn(source['backup']['unconfirmedBody'], result['body'])
|
||||
if state == 'failed':
|
||||
self.assertIn(catalog.get('backup', {}).get('errorBody') or
|
||||
self.catalog['runtime']['notifications']['backup']['errorBody'], result['body'])
|
||||
enriched, _ = module.enrich_with_emojis('backup_complete', result['title'], result['body'], data)
|
||||
self.assertTrue(enriched.startswith({'confirmed':'💾✅','unconfirmed':'💾❔','failed':'💾❌'}[state]))
|
||||
restore = module.render_template('system_restore_completed', {'hostname':'node', 'guests':4,
|
||||
'stubs':1,'stale_nodes':2,'components':1,'duration':'2m','warnings_block':'Missing module'},lang)
|
||||
self.assertIn('Missing module',restore['body'])
|
||||
if lang == 'en': self.assertNotIn('fully ready',restore['body'].lower())
|
||||
|
||||
|
||||
|
||||
def test_warning_and_clean_restore_keep_only_reported_outcome(self):
|
||||
for warnings in ('', '⚠️ Boot sanity: missing modules\n'):
|
||||
with self.subTest(warnings=warnings):
|
||||
events = []
|
||||
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
|
||||
'hostname': 'node-a', 'guests': '2', 'stubs': '0', 'stale_nodes': '0',
|
||||
'components': 'none', 'duration': '2m',
|
||||
'warnings': 'missing modules' if warnings else ''}),
|
||||
'notification_manager': types.SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
|
||||
'jsonify': lambda obj: obj}
|
||||
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', namespace=ns)
|
||||
response, status = handler()
|
||||
self.assertEqual((status, response['event_type']), (200, 'system_restore_completed'))
|
||||
event = events[0]
|
||||
self.assertEqual(event['severity'], 'WARNING' if warnings else 'INFO')
|
||||
result = self.render(event['event_type'], event['data'], 'en')
|
||||
self.assertIn('Post-restore tasks completed', result['body'])
|
||||
self.assertNotIn('fully ready', result['body'])
|
||||
if warnings: self.assertIn('missing modules', result['body'])
|
||||
|
||||
|
||||
def test_rich_backup_icon_tracks_outcome_and_digest_default_is_neutral(self):
|
||||
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
|
||||
def assign(name):
|
||||
return ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == name for t in n.targets)))
|
||||
icons = assign('EVENT_EMOJI')
|
||||
self.assertNotIn('✅', icons['backup_complete'])
|
||||
ns = {'TEMPLATES': assign('TEMPLATES'), 'EVENT_EMOJI': icons,
|
||||
'Dict': dict, 'Any': Any,
|
||||
'CATEGORY_EMOJI': assign('CATEGORY_EMOJI'), 'SEVERITY_ICONS': assign('SEVERITY_ICONS'),
|
||||
'FIELD_EMOJI': assign('FIELD_EMOJI'), '_localized_template_labels': lambda *a: {},
|
||||
'_lxc_update_label_icons': lambda *a: {}}
|
||||
enrich = extract(SCRIPTS / 'notification_templates.py', 'enrich_with_emojis', namespace=ns)
|
||||
for state, icon in [('confirmed', '💾✅'), ('unconfirmed', '💾❔'), ('failed', '💾❌')]:
|
||||
with self.subTest(state=state):
|
||||
title, body = enrich('backup_complete', 'node-a: Backup', 'Backup report',
|
||||
{'backup_outcome': state, '_notification_language': 'en'})
|
||||
self.assertTrue(title.startswith(icon), title)
|
||||
|
||||
def test_uncertain_outcomes_have_no_blanket_success_icon(self):
|
||||
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
|
||||
icon_map = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == 'EVENT_EMOJI' for t in n.targets)))
|
||||
self.assertNotIn('✅', icon_map['backup_complete']) # buffered digest has no outcome metadata
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Principal diagnostics must not be deduplicated against inventory substrings."""
|
||||
import unittest
|
||||
from notification_fixture import receive, LANGUAGES
|
||||
from notification_final_fixture import deliver
|
||||
|
||||
|
||||
def noisy_report(name='ordinary-web', storage='PBS', filename='null', cause='denied'):
|
||||
# Frozen native Perl notifier/log-reader shape; fixed-column table is
|
||||
# source-modeled from the official plaintext renderer, not a live PVE run.
|
||||
return ('\nDetails\n=======\nVMID Name Status Time Size Filename\n'
|
||||
f'100 {name} err 1m 1s 0 B {filename}\n\n'
|
||||
'Total running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\n'
|
||||
f'vzdump --all 1 --storage {storage} --mode snapshot\n\n' +
|
||||
'\n'.join(f'100: 2026-09-29 17:00:00 ERROR: earlier diagnostic {i}' for i in range(12)) +
|
||||
f'\n100: 2026-09-29 17:00:00 ERROR: {cause}\n\n')
|
||||
|
||||
|
||||
class PrincipalInventoryTests(unittest.TestCase):
|
||||
def assert_principal(self, raw, cause):
|
||||
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
|
||||
for language in LANGUAGES:
|
||||
for manual in (False, True):
|
||||
with self.subTest(language=language, manual=manual):
|
||||
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'},
|
||||
event.severity, language, manual=manual)
|
||||
exact = [line for line in result['body'].splitlines()
|
||||
if line.strip() == cause or line.rstrip().endswith('ERROR: ' + cause)]
|
||||
self.assertEqual(len(exact), 1)
|
||||
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
|
||||
self.assertLessEqual(len(diagnostics), 8)
|
||||
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
|
||||
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
|
||||
self.assertEqual(result['data']['pve_message'], raw)
|
||||
self.assertNotIn('raw-host', result['text'])
|
||||
self.assertIn('alias {rack.location}', result['text'])
|
||||
self.assertEqual(result['text'].count('ERROR: ' + cause), 1)
|
||||
|
||||
def test_late_principal_survives_incidental_guest_storage_and_archive(self):
|
||||
for name, storage, filename in (('ordinary-web','PBS','null'), ('denied','PBS','null'),
|
||||
('ordinary-web','denied','null'), ('ordinary-web','PBS','denied.tar')):
|
||||
with self.subTest(name=name, storage=storage, filename=filename):
|
||||
self.assert_principal(noisy_report(name,storage,filename), 'denied')
|
||||
|
||||
def test_raw_principal_braces_and_markup_survive_once(self):
|
||||
cause = 'denied {rack.location} <native>'
|
||||
self.assert_principal(noisy_report('denied', cause=cause), cause)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Frozen PVE 9.2 report through inert actual receiver and renderers.
|
||||
|
||||
No git history, host-management import, notification send or generated fixture.
|
||||
"""
|
||||
import ast
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / 'AppImage/scripts'
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import notification_templates as templates
|
||||
|
||||
PVE92 = '''Details
|
||||
=======
|
||||
VMID Name Status Time Size Filename
|
||||
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
|
||||
|
||||
Total running time: 1m 1s
|
||||
Total size: 1 GiB
|
||||
'''
|
||||
|
||||
|
||||
def receiver():
|
||||
tree = ast.parse((SCRIPTS / 'notification_events.py').read_text())
|
||||
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'ProxmoxHookWatcher')
|
||||
names = ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')
|
||||
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
|
||||
class Event:
|
||||
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
|
||||
ns['NotificationEvent'] = Event
|
||||
for name in names:
|
||||
node = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), '<inert-webhook>', 'exec'), ns)
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, event): self.items.append(event)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = ns['_classify_pve']
|
||||
_map_severity = staticmethod(ns['_map_severity'])
|
||||
_backup_outcome = staticmethod(ns['_backup_outcome'])
|
||||
process_webhook = ns['process_webhook']
|
||||
def __init__(self): self._queue = Queue()
|
||||
return Receiver()
|
||||
|
||||
|
||||
def event_for(message, severity='info'):
|
||||
target = receiver()
|
||||
target.process_webhook({'fields': {'type': 'vzdump'}, 'severity': severity,
|
||||
'title': 'Backup', 'message': message})
|
||||
return target._queue.items[0]
|
||||
|
||||
|
||||
class PVE92Tests(unittest.TestCase):
|
||||
def test_exact_maintainer_report_is_confirmed(self):
|
||||
event = event_for(PVE92)
|
||||
self.assertEqual(event.data['backup_outcome'], 'confirmed')
|
||||
result = templates.render_template(event.event_type, event.data, 'en')
|
||||
self.assertIn('Backup complete', result['title'])
|
||||
self.assertIn('web (100)', result['title'])
|
||||
self.assertIn('✅', result['body'])
|
||||
|
||||
def test_failed_guest_identity_does_not_come_from_timestamp_or_ok_guest(self):
|
||||
failed = '101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z'
|
||||
ok = PVE92.splitlines()[3]
|
||||
for rows in (ok + '\n' + failed, failed + '\n' + ok):
|
||||
for severity in ('info', 'error'):
|
||||
with self.subTest(rows=rows, severity=severity):
|
||||
message = ('INFO: 100 01:01:06 OK\nINFO: Starting Backup of VM 100 (qemu)\n'
|
||||
+ PVE92.replace(ok, rows))
|
||||
event = event_for(message, severity)
|
||||
self.assertEqual(event.data['backup_outcome'], 'failed')
|
||||
self.assertEqual(event.data.get('vmname'), 'db')
|
||||
self.assertEqual(event.data.get('vmid'), '101')
|
||||
result = templates.render_template(event.event_type, event.data, 'en')
|
||||
self.assertIn('db (101)', result['title'])
|
||||
self.assertNotIn('web', result['title'])
|
||||
self.assertNotIn('01:01:06', result['title'])
|
||||
self.assertIn('❌ CT db (101)', result['body'])
|
||||
|
||||
def test_pbs_prefix_is_used_in_parsed_type_and_title(self):
|
||||
for prefix, kind, label in (('vm', 'qemu', 'VM'), ('ct', 'lxc', 'CT'),
|
||||
('other', '', 'VM/CT')):
|
||||
with self.subTest(prefix=prefix):
|
||||
message = PVE92.replace('vm/100/', prefix + '/100/')
|
||||
parsed = templates._parse_vzdump_message(message)
|
||||
self.assertEqual(parsed['vms'][0]['type'], kind)
|
||||
event = event_for(message)
|
||||
result = templates.render_template(event.event_type, event.data, 'en')
|
||||
self.assertIn(label + ' web (100)', result['title'])
|
||||
|
||||
def test_slovak_uses_normal_locale_resolution_without_source_sentence_overrides(self):
|
||||
from unittest.mock import patch
|
||||
data = {'hostname': 'node-a', 'category': 'temperature', 'entity_suffix': '',
|
||||
'reason': 'old', 'duration': '3d', 'original_severity': 'WARNING',
|
||||
'vmname': 'web', 'vmid': '100', 'storage': 'PBS', 'size': '1 GiB',
|
||||
'guests': 3, 'stubs': 0, 'stale_nodes': 0, 'components': 1,
|
||||
'warnings_block': ''}
|
||||
slovak = templates._load_runtime_catalog('sk')
|
||||
english = templates._load_runtime_catalog('en')
|
||||
for event, field in (('system_restore_completed', 'body'),):
|
||||
with self.subTest(event=event, field=field):
|
||||
value = slovak['templates'][event][field]
|
||||
result = templates.render_template(event, data, 'sk')
|
||||
self.assertEqual(result[field].strip(), value.format(**data).strip())
|
||||
self.assertNotIn(value, (SCRIPTS / 'notification_templates.py').read_text())
|
||||
# Independently updated and absent leaves use the usual provider.
|
||||
import copy
|
||||
future = copy.deepcopy(slovak)
|
||||
future['templates'][event][field] = 'REVIEWED {hostname}'
|
||||
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
|
||||
self.assertEqual(templates.render_template(event, data, 'sk')[field], 'REVIEWED node-a')
|
||||
future['templates'][event].pop(field)
|
||||
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
|
||||
self.assertEqual(templates.render_template(event, data, 'sk')[field],
|
||||
templates.render_template(event, data, 'en')[field])
|
||||
|
||||
def test_complete_table_beats_only_truncated_supplemental_log(self):
|
||||
message = ('INFO: Starting Backup of VM 100 (qemu)\n' + PVE92 +
|
||||
'\nLogs\n====\nINFO: Log output was too long to be displayed. Please see task log for details.')
|
||||
self.assertEqual(event_for(message).data['backup_outcome'], 'confirmed')
|
||||
for message, severity, expected in (
|
||||
(PVE92.replace('ok ', 'OK '), 'info', 'confirmed'),
|
||||
(message, 'warning', 'completed_with_warnings'),
|
||||
(message, 'error', 'failed'),
|
||||
(message + '\nERROR: archive write failed', 'info', 'failed'),
|
||||
(message + '\nWARNING: skipped file', 'info', 'completed_with_warnings'),
|
||||
(PVE92.replace('ok ', 'WARNINGS '), 'info', 'unconfirmed'),
|
||||
):
|
||||
with self.subTest(message=message, severity=severity):
|
||||
self.assertEqual(event_for(message, severity).data['backup_outcome'], expected)
|
||||
|
||||
def test_incomplete_and_unrelated_sections_cannot_certify_table(self):
|
||||
for message in (
|
||||
PVE92.split('Total running time:')[0],
|
||||
PVE92.replace('vm/100/2026-09-29T17:00:00Z', ''),
|
||||
PVE92.replace('\n\nTotal', '\n\nLogs\n======\nTotal'),
|
||||
PVE92.replace('\n\nTotal', '\n\nUnrelated section\n100 web ok\nTotal'),
|
||||
PVE92.replace('1m 1s 1 GiB', 'nonsense 1 GiB'),
|
||||
PVE92.replace('1 GiB vm/', 'garbage vm/'),
|
||||
PVE92.replace('1m 1s 1 GiB vm/', '1m 1s'),
|
||||
):
|
||||
with self.subTest(message=message):
|
||||
# Complete guest logs cannot rescue a genuinely incomplete table.
|
||||
message += '\nINFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'
|
||||
self.assertEqual(event_for(message).data['backup_outcome'], 'unconfirmed')
|
||||
|
||||
def test_blank_line_between_rows_does_not_hide_a_failure(self):
|
||||
message = PVE92.replace('\n\nTotal',
|
||||
'\n\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
|
||||
event = event_for(message)
|
||||
self.assertEqual(event.data['backup_outcome'], 'failed')
|
||||
result = templates.render_template(event.event_type, event.data, 'en')
|
||||
self.assertIn('db (101)', result['title'])
|
||||
self.assertIn('❌ CT db (101)', result['body'])
|
||||
|
||||
def test_changed_titles_and_rows_reach_actual_html_email_in_all_locales(self):
|
||||
import html
|
||||
from notification_channels import EmailChannel
|
||||
email = object.__new__(EmailChannel)
|
||||
email.subject_prefix = '[ProxMenux]'
|
||||
failed = PVE92.replace('\n\nTotal',
|
||||
'\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
for message, severity in ((PVE92, 'info'), (failed, 'info'), (failed, 'error')):
|
||||
with self.subTest(lang=lang, severity=severity, message=message):
|
||||
event = event_for(message, severity)
|
||||
result = templates.render_template(event.event_type, event.data, lang)
|
||||
context = {**event.data, '_event_type': event.event_type,
|
||||
'_notification_language': lang, '_group': result['group']}
|
||||
markup = html.unescape(email._format_html(result['title'], result['body'], event.severity, context))
|
||||
self.assertIn(result['title'], markup)
|
||||
if event.data['backup_outcome'] == 'failed':
|
||||
self.assertIn('db (101)', result['title'])
|
||||
self.assertNotIn('web', result['title'])
|
||||
self.assertIn('❌ CT db (101)', markup)
|
||||
status = templates.runtime_message('channels.email.status.failed', lang)
|
||||
else:
|
||||
self.assertIn('VM web (100)', result['title'])
|
||||
self.assertIn('✅ VM web (100)', markup)
|
||||
status = templates.runtime_message('channels.email.status.completed', lang)
|
||||
badge = (templates.runtime_message('channels.email.severity.critical', lang)
|
||||
if event.event_type == 'backup_fail' else status)
|
||||
self.assertIn('>' + badge.upper() + '</span>', markup)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Review regressions at actual consumer seams; no operational imports."""
|
||||
import unittest
|
||||
from notification_fixture import receive, LANGUAGES
|
||||
from notification_final_fixture import deliver
|
||||
|
||||
class ReviewCorrectionTests(unittest.TestCase):
|
||||
def test_subject_equivalent_late_error_is_reserved_before_cap(self):
|
||||
cause = 'job-end hook denied'
|
||||
# Frozen native Perl notifier/log-reader output; Rust table source-modeled.
|
||||
raw = '\nDetails\n=======\nVMID Name Status Time Size Filename \n100 web err 1m 1s 0 B null \n\nTotal running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\nvzdump --all 1 --storage PBS --mode snapshot\n\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 0\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 1\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 2\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 3\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 4\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 5\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 6\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 7\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 8\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 9\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 10\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 11\n100: 2026-09-29 17:00:00 ERROR: job-end hook denied\n\n\n'
|
||||
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
|
||||
for language in LANGUAGES:
|
||||
for manual in (False, True):
|
||||
with self.subTest(language=language, manual=manual):
|
||||
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'}, event.severity, language, manual=manual)
|
||||
self.assertEqual(result['text'].count(cause), 1)
|
||||
self.assertNotIn('raw-host', result['text'])
|
||||
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
|
||||
self.assertLessEqual(len(diagnostics), 8)
|
||||
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
|
||||
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
|
||||
self.assertEqual(result['data']['pve_message'], raw)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Keep the Monitor OCI editor path distinct from coordinated stack recreation."""
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
CLI = ROOT / 'oci/src/proxmenux_oci/cli.py'
|
||||
MONITOR = ROOT / 'AppImage/components/virtual-machines.tsx'
|
||||
WRAPPER = ROOT / 'scripts/oci/manage_instance.sh'
|
||||
EXTRA_DEVICES = ROOT / 'oci/src/proxmenux_oci/extra_devices.py'
|
||||
STACK_RECREATION = ROOT / 'oci/src/proxmenux_oci/stack_recreation.py'
|
||||
|
||||
|
||||
def extracted_stack_manager(scope):
|
||||
source = MENU.read_text(encoding='utf-8').replace(
|
||||
' from .stack_recreation import modify_stack\n', '')
|
||||
node = next(item for item in ast.parse(source).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == '_manage_stack')
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||
str(MENU), 'exec'), scope)
|
||||
return scope['_manage_stack']
|
||||
|
||||
|
||||
class MonitorModifyContractTests(TestCase):
|
||||
def test_monitor_sends_modify_for_every_instance(self):
|
||||
source = MONITOR.read_text(encoding='utf-8')
|
||||
self.assertIn('action: "update" | "modify" | "recover"', source)
|
||||
self.assertIn('action: "modify",', source)
|
||||
self.assertNotIn('"recreate"', source)
|
||||
|
||||
def test_the_editor_has_one_name_for_one_container_and_for_several(self):
|
||||
menu = MENU.read_text(encoding='utf-8')
|
||||
self.assertIn("('modify', translate('Modify: edit resources, network, paths and GPU'))", menu)
|
||||
self.assertIn("('modify', translate('Modify extra paths and devices'))", menu)
|
||||
self.assertEqual(menu.count("('recreate', translate("), 1)
|
||||
self.assertIn("('recreate', translate('Recreate every container with its saved configuration'))", menu)
|
||||
|
||||
def test_recreate_on_a_single_instance_changes_nothing(self):
|
||||
node = next(item for item in ast.parse(MENU.read_text(encoding='utf-8')).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'manage_instance')
|
||||
row = {'vmid': 101, 'reason': 'matched', 'stack': None, 'pending': False, 'status': 'installed'}
|
||||
run_lifecycle = Mock()
|
||||
scope = {'carry_records': lambda *_, **__: {}, 'check_selected': lambda _project, selected: selected,
|
||||
'translate': lambda text: text, '_run_lifecycle': run_lifecycle, '_manage_stack': Mock()}
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), str(MENU), 'exec'), scope)
|
||||
ui = Mock()
|
||||
self.assertFalse(scope['manage_instance'](Path('/inert'), ui, row, action='recreate'))
|
||||
self.assertEqual(ui.method_calls, [])
|
||||
run_lifecycle.assert_not_called()
|
||||
|
||||
def test_cli_and_monitor_wrapper_accept_the_modify_action(self):
|
||||
self.assertIn('choices=("update", "modify", "recreate")', CLI.read_text(encoding='utf-8'))
|
||||
self.assertIn('${ACTION:-} != "modify"', WRAPPER.read_text(encoding='utf-8'))
|
||||
|
||||
def test_modify_device_filter_matches_the_device_prompt_contract(self):
|
||||
caller = STACK_RECREATION.read_text(encoding='utf-8')
|
||||
device_prompt = EXTRA_DEVICES.read_text(encoding='utf-8')
|
||||
prompt = next(node for node in ast.parse(device_prompt).body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == 'ask_extra_devices')
|
||||
self.assertIn('kinds', [argument.arg for argument in prompt.args.args])
|
||||
self.assertIn("ask_extra_devices(ui, attached, True, kinds=", caller)
|
||||
|
||||
def test_modify_action_opens_the_stack_editor_without_running_lifecycle(self):
|
||||
primary = {'stack': {'members': [{'vmid': 101}]}}
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda *_: primary
|
||||
modify_stack = Mock(return_value=True)
|
||||
run_lifecycle = Mock()
|
||||
manager = extracted_stack_manager({
|
||||
'sys': SimpleNamespace(path=[]),
|
||||
'Path': Path,
|
||||
'modify_stack': modify_stack,
|
||||
'_run_lifecycle': run_lifecycle,
|
||||
})
|
||||
ui = SimpleNamespace()
|
||||
with patch.dict(sys.modules, {
|
||||
'oci_instances': instances,
|
||||
'oci_stack_replay': ModuleType('oci_stack_replay'),
|
||||
}):
|
||||
result = manager(Path('/inert'), ui, {'vmid': 101}, action='modify')
|
||||
|
||||
self.assertTrue(result)
|
||||
modify_stack.assert_called_once_with(Path('/inert'), ui, primary, run_lifecycle)
|
||||
run_lifecycle.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import unittest
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Every result the OCI engine reports has a notification the Monitor accepts and words."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
EVENTS = [f'oci_{kind}_{result}' for kind in ('update', 'modify', 'recreate') for result in ('completed', 'failed')]
|
||||
EVENTS += ['oci_watchdog_restarted', 'oci_watchdog_failed']
|
||||
|
||||
|
||||
class OciOperationEvents(TestCase):
|
||||
def test_the_monitor_accepts_and_words_every_event(self):
|
||||
route = (ROOT / 'AppImage/scripts/flask_notification_routes.py').read_text()
|
||||
accepted = route[route.index('_OCI_EVENTS = {'):route.index('@notification_bp.route(\'/api/internal/oci-event\'')]
|
||||
templates = (ROOT / 'AppImage/scripts/notification_templates.py').read_text()
|
||||
for event in EVENTS:
|
||||
self.assertIn(f"'{event}':", accepted, event)
|
||||
self.assertEqual(len(re.findall(rf"^ '{event}': \{{$", templates, re.MULTILINE)), 1, event)
|
||||
self.assertEqual(len(re.findall(rf"^ '{event}': +'\\[uU]", templates, re.MULTILINE)), 1, event)
|
||||
|
||||
def test_every_language_has_the_label_and_the_message_of_every_event(self):
|
||||
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
|
||||
for locale in LOCALES:
|
||||
catalog = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())
|
||||
for event in EVENTS:
|
||||
self.assertTrue(catalog['settings']['notifications']['eventTypes'][event].strip(), f'{locale} {event}')
|
||||
message = catalog['runtime']['notifications']['templates'][event]
|
||||
for part in ('title', 'body', 'label'):
|
||||
self.assertEqual(fields(message[part]), fields(english[event][part]), f'{locale} {event} {part}')
|
||||
self.assertEqual(message[part].count('\n'), english[event][part].count('\n'), f'{locale} {event} {part}')
|
||||
|
||||
def test_changing_an_application_is_reported_as_modified(self):
|
||||
single = (ROOT / 'oci/remote/oci_update_current.py').read_text()
|
||||
self.assertIn("kind = 'update' if operation == 'update' else 'modify'", single)
|
||||
self.assertIn('oci_operation_notice.operation([vmid], kind,', single)
|
||||
stack = (ROOT / 'oci/remote/oci_stack_modify.py').read_text()
|
||||
self.assertIn("oci_operation_notice.operation([vmid], 'modify',", stack)
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
|
||||
self.assertIn('new options', english['oci_modify_completed']['body'])
|
||||
self.assertNotIn('new options', english['oci_recreate_completed']['body'])
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Inert, extracted consumers for Rclone guidance and HedgeDoc metadata."""
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
INSTALLER = ROOT / 'oci/src/proxmenux_oci/installer.py'
|
||||
RCLONE = ('Configure the remote in the Rclone Web UI first. Checking its name may start a '
|
||||
'stopped CT, even in a dry run. Applying the mount restarts the CT and attempts '
|
||||
'to publish two FUSE views on the host; a dry run does not publish them.')
|
||||
|
||||
|
||||
def extract_function(path, name, **dependencies):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
scope = dict(dependencies)
|
||||
future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[future, node], type_ignores=[])),
|
||||
str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class RcloneHedgeDocWording(TestCase):
|
||||
def test_i18n_workflow_runs_for_remote_shell_edits(self):
|
||||
workflow = (ROOT / '.github/workflows/test-i18n.yml').read_text()
|
||||
self.assertEqual(workflow.count(" - 'oci/remote/**/*.sh'"), 2)
|
||||
|
||||
def test_remote_dry_run_messages_are_extracted_and_translate_with_fallback(self):
|
||||
import runpy
|
||||
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
shell = ROOT / 'oci/remote/configure_rclone_mount.sh'
|
||||
keys = [
|
||||
'Dry run completed; mount configuration was not applied. The CT was already running.',
|
||||
'Dry run completed; mount configuration was not applied. The CT was started for the remote check.',
|
||||
]
|
||||
extracted = extractor['extract_translate_texts'](ROOT / 'oci/remote')
|
||||
self.assertTrue(set(keys).issubset(extracted))
|
||||
ui = (ROOT / 'oci/remote/oci_ui.sh').read_text()
|
||||
lookup = 'translate() {' + ui.split('translate() {', 1)[1].split('\n}', 1)[0] + '\n}\n'
|
||||
gate = shell.read_text().split('if [[ $DRY_RUN == 1 ]]; then', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
|
||||
gate = 'if [[ $DRY_RUN == 1 ]]; then' + gate + '\nfi\n'
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cache = Path(directory) / 'locale.json'
|
||||
for status, expected_key in [('running', keys[0]), ('stopped', keys[1])]:
|
||||
for translations, expected in [({}, expected_key),
|
||||
({expected_key: 'MESSAGGIO DI TEST'}, 'MESSAGGIO DI TEST')]:
|
||||
with self.subTest(status=status, translations=translations):
|
||||
cache.write_text(json.dumps(translations))
|
||||
setup = (f'_OCI_LANGUAGE=it\n_OCI_LANG_FILE={cache}\n'
|
||||
f'STATUS={status}\nDRY_RUN=1\n' + lookup +
|
||||
'msg_ok() { printf "%s\\n" "$1"; }\n')
|
||||
result = subprocess.run(['bash', '-c', setup + gate],
|
||||
text=True, capture_output=True, timeout=5)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), expected)
|
||||
|
||||
def test_remote_dry_run_reports_stopped_ct_start_and_no_mount_publication(self):
|
||||
script = (ROOT / 'oci/remote/configure_rclone_mount.sh').read_text()
|
||||
seam = script.split('msg_info "$(translate "Checking the remote...")"', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
|
||||
# This is the real status/listremotes/dry-run gate, not the root-guarded script.
|
||||
seam = 'msg_info "$(translate "Checking the remote...")"' + seam + '\nfi\n'
|
||||
for status, dry_run, starts, post_gate in (
|
||||
('stopped', '1', 1, False), ('running', '1', 0, False),
|
||||
('stopped', '0', 1, True), ('running', '0', 0, True)):
|
||||
with self.subTest(status=status, dry_run=dry_run):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
trace = Path(directory) / 'pct.log'
|
||||
harness = '''
|
||||
set -Eeuo pipefail
|
||||
VMID=101 REMOTE_NAME=notes DRY_RUN=%s
|
||||
TRACE_FILE=%s
|
||||
pct() {
|
||||
printf 'pct:%%s\\n' "$*" >>"$TRACE_FILE"
|
||||
case "$1" in
|
||||
status) printf 'status: %s\\n' ;;
|
||||
exec) if [[ "${5:-}" == version ]]; then return 0; else printf 'notes:\\n'; fi ;;
|
||||
start) return 0 ;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
}
|
||||
oci_quiet() { "$@"; }
|
||||
oci_log() { :; }
|
||||
sleep() { :; }
|
||||
translate() { printf '%%s' "$1"; }
|
||||
msg_info() { printf 'info:%%s\\n' "$1"; }
|
||||
msg_ok() { printf 'ok:%%s\\n' "$1"; }
|
||||
die() { printf 'error:%%s\\n' "$1"; exit 1; }
|
||||
''' % (dry_run, str(trace), status)
|
||||
result = subprocess.run(['bash', '-c', harness + seam + '\nprintf "POST_DRY_GATE\\n"\n'],
|
||||
text=True, capture_output=True, timeout=5)
|
||||
calls = trace.read_text()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(calls.count('pct:start 101'), starts)
|
||||
self.assertIn('pct:exec 101 -- /usr/local/bin/rclone listremotes', calls)
|
||||
self.assertEqual('POST_DRY_GATE' in result.stdout, post_gate)
|
||||
if dry_run == '1':
|
||||
self.assertIn('mount configuration was not applied', result.stdout)
|
||||
if starts:
|
||||
self.assertIn('CT was started for the remote check', result.stdout)
|
||||
else:
|
||||
self.assertIn('CT was already running', result.stdout)
|
||||
self.assertNotIn('pct:stop', calls)
|
||||
|
||||
def test_hedgedoc_overlay_survives_index_enrichment(self):
|
||||
sys.path.insert(0, str(ROOT / 'oci/src'))
|
||||
try:
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
catalog = Catalog(root, source=Mock())
|
||||
catalog.apps_dir.mkdir(parents=True)
|
||||
catalog.overlays_dir.mkdir(parents=True)
|
||||
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
|
||||
upstream = 'HedgeDoc gives you access to all your files wherever you are.'
|
||||
corrected = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
|
||||
template['catalog_ui']['description']['en_US'] = upstream
|
||||
template['catalog_ui']['tagline']['en_US'] = upstream
|
||||
overlay = json.loads((ROOT / 'oci/catalog/overlays/hedgedoc.json').read_text())
|
||||
self.assertTrue(overlay['catalog_ui']['hidden'])
|
||||
self.assertEqual(overlay['catalog_ui']['description']['en_US'], corrected)
|
||||
self.assertEqual(overlay['catalog_ui']['tagline']['en_US'], corrected)
|
||||
(catalog.overlays_dir / 'hedgedoc.json').write_text(json.dumps(overlay))
|
||||
catalog._apply_overlay('hedgedoc', template)
|
||||
self.assertEqual(template['catalog_ui']['description']['en_US'], corrected)
|
||||
self.assertEqual(template['catalog_ui']['tagline']['en_US'], corrected)
|
||||
(catalog.apps_dir / 'hedgedoc.json').write_text(json.dumps(template))
|
||||
payload = {'applications': [{'id': 'hedgedoc', 'description': upstream},
|
||||
{'id': 'unmodified', 'description': 'Original summary'}]}
|
||||
catalog._enrich_index_from_templates(payload)
|
||||
self.assertEqual(payload['applications'][0]['description'], corrected)
|
||||
self.assertEqual(payload['applications'][1]['description'], 'Original summary')
|
||||
|
||||
def test_rclone_guidance_precedes_inputs_and_confirmation(self):
|
||||
events = []
|
||||
class UI:
|
||||
def message(self, text): events.append(('message', text))
|
||||
def ask(self, label, default=None, required=True):
|
||||
events.append(('ask', label))
|
||||
return {'VMID of the Rclone OCI container': '101',
|
||||
'Exact name of the remote': 'notes',
|
||||
'Mount name': 'notes'}.get(label, default or '')
|
||||
def choose(self, label, options, default):
|
||||
events.append(('choose', label))
|
||||
return default
|
||||
def confirm(self, label, default):
|
||||
events.append(('confirm', label))
|
||||
return True
|
||||
schema = {name: {'default': value} for name, value in (
|
||||
('vfs_cache_mode', 'writes'), ('shared_mount_root_parent', '/mnt/rclone'),
|
||||
('shared_mount_root', '/mnt/rclone/rw'),
|
||||
('shared_mount_read_only_root', '/mnt/rclone/ro'))}
|
||||
schema['vfs_cache_mode']['options'] = ['writes']
|
||||
template = {'proxmox': {'laboratory_contract': {'configuration_schema': schema}}}
|
||||
fn = extract_function(INSTALLER, 'build_rclone_mount_deployment',
|
||||
TerminalUI=UI, DialogUI=UI, translate=lambda text: text,
|
||||
re=re, InstallError=ValueError, UserCancelled=ValueError)
|
||||
deployment = fn(template, UI())
|
||||
self.assertEqual(events[0], ('message', RCLONE))
|
||||
self.assertEqual(events[1][0], 'ask')
|
||||
self.assertEqual(events[-1][0], 'confirm')
|
||||
self.assertEqual(deployment['remote_name'], 'notes')
|
||||
|
||||
def test_hedgedoc_detail_uses_corrected_template_and_index(self):
|
||||
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
|
||||
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
|
||||
item = next(item for item in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
|
||||
if item['id'] == 'hedgedoc')
|
||||
self.assertEqual(template['catalog_ui']['tagline']['en_US'], key)
|
||||
self.assertEqual(template['catalog_ui']['description']['en_US'], key)
|
||||
self.assertEqual(item['description'], key)
|
||||
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text',
|
||||
Any=object)
|
||||
fn = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
|
||||
source_text=source, translate=lambda text: text,
|
||||
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
|
||||
publisher=lambda _: 'LinuxServer.io',
|
||||
is_tested=lambda _: False,
|
||||
_display_architectures=lambda _: 'amd64',
|
||||
_image_label=lambda text: text,
|
||||
_recommended_storage=lambda _: 'Container volume')
|
||||
result = fn(SimpleNamespace(), item, template)
|
||||
self.assertIn(key, result)
|
||||
self.assertNotIn('gives you access to all your files', result)
|
||||
|
||||
def test_missing_empty_and_synthetic_translation_on_real_consumers(self):
|
||||
from unittest.mock import Mock
|
||||
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
|
||||
scope = {'language': lambda: 'it', '_cache': {}}
|
||||
lookup = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', **scope)
|
||||
cache = lookup.__globals__
|
||||
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
|
||||
item = next(x for x in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
|
||||
if x['id'] == 'hedgedoc')
|
||||
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text')
|
||||
detail = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
|
||||
source_text=source, translate=lookup,
|
||||
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
|
||||
publisher=lambda _: 'LinuxServer.io', is_tested=lambda _: False,
|
||||
_display_architectures=lambda _: 'amd64',
|
||||
_image_label=lambda text: text,
|
||||
_recommended_storage=lambda _: 'Container volume')
|
||||
for values, expected_rclone, expected_hedgedoc in (
|
||||
({}, RCLONE, key),
|
||||
({RCLONE: '', key: ''}, RCLONE, key),
|
||||
({RCLONE: 'GUIDA RCLONE', key: 'EDITOR COLLABORATIVO'},
|
||||
'GUIDA RCLONE', 'EDITOR COLLABORATIVO')):
|
||||
with self.subTest(values=values):
|
||||
cache['_cache'] = values
|
||||
ui = SimpleNamespace(message=Mock(), ask=Mock(side_effect=StopIteration))
|
||||
rclone = extract_function(INSTALLER, 'build_rclone_mount_deployment',
|
||||
TerminalUI=object, DialogUI=object, translate=lookup,
|
||||
re=re, InstallError=ValueError,
|
||||
UserCancelled=ValueError)
|
||||
# Stop at the first prompt: no operational path is entered.
|
||||
with self.assertRaises(StopIteration):
|
||||
rclone(json.loads((ROOT / 'oci/catalog/apps/rclone.json').read_text()), ui)
|
||||
ui.message.assert_called_once_with(expected_rclone)
|
||||
self.assertIn(expected_hedgedoc, detail(SimpleNamespace(), item, template))
|
||||
|
||||
def test_extractor_sees_both_new_sources(self):
|
||||
import runpy
|
||||
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
keys = extractor['extract_python_texts']([ROOT / 'oci/src'])
|
||||
self.assertIn(RCLONE, keys)
|
||||
keys = extractor['extract_catalog_texts']([ROOT / 'oci/catalog'])
|
||||
self.assertIn('HedgeDoc is a self-hosted collaborative Markdown editor for notes.', keys)
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Offline checks of real OCI messages; never import administrative modules."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
REMOVE = ROOT / 'oci/remote/oci_remove.py'
|
||||
PREVIEW_HOST = 'Host paths found in container configs or saved records (not targeted for removal):'
|
||||
EMPTY_HOST = 'No host directories found in the available container configs or saved records.'
|
||||
POST_HOST = 'Host directory listed in saved records (not targeted for removal):'
|
||||
UPDATE = ('All {count} containers of the application are updated together (main CT: {vmid}). If there are new '
|
||||
'versions, all images are downloaded and verified, the application is stopped, each container is backed '
|
||||
'up and replaced with its new image. If the operation fails after a container was changed, the backups are '
|
||||
'restored. An operation that stops halfway is recovered from the OCI management menu.')
|
||||
PENDING = ('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack '
|
||||
'where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.')
|
||||
KEYS = (PREVIEW_HOST, EMPTY_HOST, POST_HOST, UPDATE, PENDING)
|
||||
TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class InventoryMessages(unittest.TestCase):
|
||||
def preview(self, configs, records, cache=None, translate=lambda text: text):
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
def read(root, vmid):
|
||||
if vmid not in records:
|
||||
raise OSError('unreadable record')
|
||||
return records[vmid]
|
||||
instances.read = read
|
||||
remover = ModuleType('oci_remove')
|
||||
remover.members_of = lambda root, vmid: (101, {}, [102, 101])
|
||||
remover.guest_config = lambda vmid: configs.get(vmid)
|
||||
remover.host_directories = lambda root, members: cache or []
|
||||
remover.granted_directories = lambda root, members: []
|
||||
remover.private_bridge = lambda primary: None
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
|
||||
'translate': translate, 're': re}
|
||||
summary = extracted(MENU, '_removal_summary', scope)
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
return summary(Path('/inert'), 101)
|
||||
|
||||
def test_missing_metadata_is_not_reported_as_no_host_usage(self):
|
||||
text = self.preview({101: None, 102: None}, {})
|
||||
self.assertIn(EMPTY_HOST, text)
|
||||
self.assertNotIn('No host directory is used by this application.', text)
|
||||
|
||||
def test_discovered_paths_do_not_promise_exhaustive_preservation(self):
|
||||
text = self.preview({101: b'mp0: /bind/current,mp=/data\n', 102: None}, {}, ['/bind/saved'])
|
||||
self.assertIn(PREVIEW_HOST + '\n /bind/current\n /bind/saved', text)
|
||||
self.assertNotIn('Host directories that are kept, with their content:', text)
|
||||
|
||||
def test_removal_notice_qualifies_saved_record_source(self):
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
identity=lambda raw: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: b'description: owned',
|
||||
'host_directories': lambda root, members: ['/bind/saved'], 'granted_directories': lambda *args: [],
|
||||
'private_bridge': lambda primary: None,
|
||||
'remove_owned_host_firewall': lambda primary: True,
|
||||
'run': lambda *args: events.append(('run', args)),
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
|
||||
're': re, 'guest_node': lambda vmid: None,
|
||||
'remove_host_state': lambda vmid: events.append(('host', vmid)),
|
||||
'release_shared_host_files': lambda hookscripts: None,
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(('info', text)),
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
remove = extracted(REMOVE, 'remove', scope)
|
||||
remove(Path('/inert'), 101)
|
||||
self.assertIn(('info', POST_HOST + ' /bind/saved'), events)
|
||||
self.assertIn(('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1')), events)
|
||||
self.assertIn(('log', 101), events)
|
||||
self.assertIn(('host', 101), events)
|
||||
scope['translate'] = lambda text: 'Tradotto: ' + text if text == POST_HOST else text
|
||||
events.clear()
|
||||
remove(Path('/inert'), 101)
|
||||
self.assertIn(('info', 'Tradotto: ' + POST_HOST + ' /bind/saved'), events)
|
||||
|
||||
|
||||
class RecoveryMessages(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.journal = Path(self.tmp.name) / 'journal.json'
|
||||
self.members = [{'vmid': 101, 'deployment': {'mounts': []}}]
|
||||
self.primary = {'stack': {'members': self.members}}
|
||||
self.calls = []
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: self.primary
|
||||
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
|
||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
|
||||
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'_remove': lambda *args: self.fail('remove must not run'),
|
||||
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
|
||||
self.ui = SimpleNamespace(
|
||||
choose=lambda *args: 'update',
|
||||
review=lambda message, *args, **kwargs: self.calls.append(('review', message)) or False,
|
||||
confirm=lambda *args: self.fail('host confirmation must not run'),
|
||||
message=lambda *args: self.fail('unexpected error'))
|
||||
|
||||
def manage(self):
|
||||
with patch.dict(sys.modules, self.modules):
|
||||
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
|
||||
return next(message for kind, message in self.calls if kind == 'review')
|
||||
|
||||
def test_update_preview_names_the_stack_and_what_happens(self):
|
||||
message = self.manage()
|
||||
self.assertIn(UPDATE.format(count=len(self.members), vmid=101), message)
|
||||
|
||||
def test_pending_terminal_and_recovery_failed_states_share_bounded_wording(self):
|
||||
for phase in ('committed', 'rolled-back', 'recovery-failed'):
|
||||
with self.subTest(phase=phase):
|
||||
self.calls.clear()
|
||||
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
self.assertEqual(self.manage(), PENDING)
|
||||
self.assertFalse(any(isinstance(item[0], list) for item in self.calls))
|
||||
|
||||
def test_pending_confirmation_preserves_recover_command_in_each_state(self):
|
||||
self.ui.review = lambda message, *args, **kwargs: True
|
||||
for phase in ('committed', 'recovery-failed'):
|
||||
with self.subTest(phase=phase):
|
||||
self.calls.clear()
|
||||
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
with patch.dict(sys.modules, self.modules):
|
||||
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
|
||||
self.assertEqual(self.calls, [
|
||||
(['python3', '/inert/remote/oci_stack_native.py', '101', '--recover',
|
||||
'--acknowledge-external-data'], 'Recover OCI stack')])
|
||||
|
||||
def test_actual_transaction_distinguishes_terminal_cleanup_from_failed_restore(self):
|
||||
events = []
|
||||
scope = {'TERMINAL': {'committed', 'rolled-back'},
|
||||
'translate': lambda message: message,
|
||||
'member_tx': SimpleNamespace(log=lambda message: None),
|
||||
'write': lambda path, state: Path(path).write_text(json.dumps(state)),
|
||||
'msg_info': lambda message: None, 'msg_ok': lambda message: None,
|
||||
'member': lambda adapter, vmid: f'CT {vmid}'}
|
||||
extracted(TRANSACTION, 'save', scope)
|
||||
recover = extracted(TRANSACTION, 'recover_state', scope)
|
||||
class Adapter:
|
||||
def finalize(self, state): events.append('finalize')
|
||||
def validate(self, plan): events.append('validate')
|
||||
def restore(self, vmid, backup, txid):
|
||||
events.append('restore')
|
||||
raise RuntimeError('restore failed')
|
||||
def stop(self, vmid): events.append('stop')
|
||||
plan = {'start_order': [101], 'stop_order': [101]}
|
||||
terminal = {'phase': 'committed', 'plan': plan}
|
||||
self.assertIs(recover(self.journal, terminal, Adapter()), terminal)
|
||||
self.assertEqual(events, ['finalize'])
|
||||
events.clear()
|
||||
failed = dict(terminal, phase='recovery-failed', id='tx', running={'101': True},
|
||||
stop_intent=True, replacement_intent=True, backups={'101': {'archive': 'a'}})
|
||||
with self.assertRaisesRegex(RuntimeError, 'restore failed'):
|
||||
recover(self.journal, failed, Adapter())
|
||||
self.assertEqual(events, ['validate', 'stop', 'restore'])
|
||||
self.assertEqual(json.loads(self.journal.read_text())['phase'], 'recovery-failed')
|
||||
|
||||
def test_new_keys_extract_and_shipped_fallback_and_synthetic_translation(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(KEYS), len(set(KEYS)))
|
||||
self.assertTrue(set(KEYS) <= set(found), set(KEYS) - set(found))
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
|
||||
setattr(module, '_cache', {})
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
|
||||
module._language, module._cache = 'it', {key: 'Tradotto: ' + key for key in KEYS}
|
||||
self.assertEqual(module.translate(EMPTY_HOST), 'Tradotto: ' + EMPTY_HOST)
|
||||
preview = InventoryMessages().preview({101: None, 102: None}, {}, translate=module.translate)
|
||||
self.assertIn('Tradotto: ' + EMPTY_HOST, preview)
|
||||
preview = InventoryMessages().preview({101: b'mp0: /bind,mp=/data'}, {},
|
||||
translate=module.translate)
|
||||
self.assertIn('Tradotto: ' + PREVIEW_HOST + '\n /bind', preview)
|
||||
# Exercise the actual management consumer, not just the provider.
|
||||
self.scope['translate'] = module.translate
|
||||
self.assertIn(('Tradotto: ' + UPDATE).format(count=len(self.members), vmid=101), self.manage())
|
||||
self.journal.write_text(json.dumps({'phase': 'committed', 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
self.calls.clear()
|
||||
self.assertEqual(self.manage(), 'Tradotto: ' + PENDING)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Exercise extracted removal/UI/guard consumers with inert dependencies only."""
|
||||
import ast
|
||||
import argparse
|
||||
from contextlib import nullcontext
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
REMOVE = ROOT / 'oci/remote/oci_remove.py'
|
||||
INSTALL = ROOT / 'oci/remote/install_oci.sh'
|
||||
PREVIEW = 'Private network targeted for release if no other guest uses it:'
|
||||
CONFIRM = 'Remove the application? Its container disks are deleted, and only a backup can bring them back.'
|
||||
RESULT = 'Removal command finished; review any warnings above.'
|
||||
BRIDGE = 'Private network release attempted:'
|
||||
BRIDGE_FAILURE = 'Could not complete private network release:'
|
||||
SUCCESS = 'The application was removed'
|
||||
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
|
||||
PORT = 'The host-monitor firewall port does not match exactly one TCP port in the container contract'
|
||||
OPTIONAL = 'A matching managed host firewall rule may also be removed.'
|
||||
MEMBERS = 'All of them are targeted for removal.'
|
||||
WHOLE = ('It cannot be removed on its own, because the application would stop '
|
||||
'working: continuing targets the whole application for removal.')
|
||||
TARGETS = 'Containers targeted for removal:'
|
||||
DATA = 'Container data targeted for deletion:'
|
||||
KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, PORT, OPTIONAL, MEMBERS, WHOLE, TARGETS, DATA)
|
||||
|
||||
|
||||
def extract(path, name, scope):
|
||||
node = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class RemovalWordings(unittest.TestCase):
|
||||
def preview(self, bridge='vmbr9', translated=None, firewall=True):
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: {'installation_id': 'owned'}
|
||||
remover = ModuleType('oci_remove')
|
||||
remover.members_of = lambda root, vmid: (101, {'stack': {}, 'deployment':
|
||||
{'host_firewall': {'port': 8080}} if firewall else {}}, [102, 101])
|
||||
remover.guest_config = lambda member: None
|
||||
remover.host_directories = lambda root, members: []
|
||||
remover.granted_directories = lambda root, members: []
|
||||
remover.private_bridge = lambda primary: bridge
|
||||
remover.bridge_in_use = lambda bridge, removed: False
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: {}
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
||||
'translate': translated or (lambda value: value), 're': re}
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
|
||||
|
||||
def test_preview_conditions_network_and_discloses_optional_firewall(self):
|
||||
preview = self.preview()
|
||||
self.assertIn(PREVIEW + ' vmbr9', preview)
|
||||
self.assertIn(OPTIONAL, preview)
|
||||
self.assertIn(MEMBERS, preview)
|
||||
self.assertIn(TARGETS, preview)
|
||||
self.assertIn(DATA, preview)
|
||||
self.assertNotIn('Private network of the application that is released:', preview)
|
||||
self.assertNotIn(PREVIEW, self.preview(bridge=None))
|
||||
self.assertNotIn(OPTIONAL, self.preview(firewall=False))
|
||||
|
||||
def test_member_preview_does_not_promise_whole_stack_removed(self):
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: {'installation_id': 'owned'}
|
||||
remover = ModuleType('oci_remove')
|
||||
remover.members_of = lambda root, vmid: (101, {'stack': {}}, [102, 101])
|
||||
remover.guest_config = lambda member: None
|
||||
remover.host_directories = lambda root, members: []
|
||||
remover.granted_directories = lambda root, members: []
|
||||
remover.private_bridge = lambda primary: None
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: {}
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
||||
'translate': lambda value: value, 're': re}
|
||||
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
|
||||
self.assertIn(WHOLE, preview)
|
||||
|
||||
def test_cancellation_preserves_lifecycle_boundary_and_warns_precisely(self):
|
||||
calls = []
|
||||
ui = SimpleNamespace(review=lambda message, title, **kw: calls.append((message, kw)) or False,
|
||||
message=lambda *args: self.fail('unexpected error'))
|
||||
scope = {'_removal_summary': lambda project, vmid: 'Preview',
|
||||
'_run_lifecycle': lambda *args: self.fail('must not run'),
|
||||
'translate': lambda value: value, 'sys': SimpleNamespace(executable='python3'),
|
||||
'Path': Path, 'OSError': OSError, 'ValueError': ValueError, 'KeyError': KeyError}
|
||||
self.assertFalse(extract(MENU, '_remove', scope)(Path('/inert'), ui, 101))
|
||||
self.assertEqual(calls, [('Preview', {'question': CONFIRM, 'default': False})])
|
||||
|
||||
def test_shared_bridge_and_skipped_member_are_not_reported_removed(self):
|
||||
events = []
|
||||
records = {102: {'installation_id': 'owned'}, 101: {'installation_id': 'owned'}}
|
||||
scope = {'members_of': lambda root, vmid: (101, records[101], [102, 101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: records[vmid],
|
||||
identity=lambda raw: 'reassigned' if raw == b'reassigned' else 'owned',
|
||||
location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: b'reassigned' if vmid == 102 else b'owned',
|
||||
'host_directories': lambda root, members: [], 'granted_directories': lambda *args: [], 'private_bridge': lambda primary: 'vmbr9',
|
||||
'bridge_in_use': lambda bridge, removed: True,
|
||||
'release_bridge': lambda bridge: self.fail('shared bridge release'),
|
||||
'remove_owned_host_firewall': lambda record: None,
|
||||
'run': lambda *args: events.append(('run', args)),
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
|
||||
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(('info', text)),
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
remove = extract(REMOVE, 'remove', scope)
|
||||
for skipped_config in (b'reassigned', None):
|
||||
with self.subTest(skipped_config=skipped_config):
|
||||
scope['guest_config'] = lambda vmid: skipped_config if vmid == 102 else b'owned'
|
||||
events.clear()
|
||||
remove(Path('/inert'), 101)
|
||||
self.assertEqual([x for x in events if x[0] == 'run'],
|
||||
[('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1'))])
|
||||
self.assertFalse(any(x == ('ok', 'The application was removed') for x in events))
|
||||
self.assertTrue(any('still used' in x[1] for x in events if x[0] == 'info'))
|
||||
self.assertTrue(any('no longer exists' in x[1] if skipped_config is None
|
||||
else 'belongs to another container' in x[1]
|
||||
for x in events if x[0] == 'warn'))
|
||||
# The actual main() success line is independently guarded by the expected neutral literal.
|
||||
tree = ast.parse(REMOVE.read_text())
|
||||
main = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'main')
|
||||
self.assertIn(RESULT, [n.value for n in ast.walk(main) if isinstance(n, ast.Constant) and isinstance(n.value, str)])
|
||||
|
||||
def test_actual_main_success_is_completion_not_all_members_removed(self):
|
||||
events = self.lifecycle_events(missing=True)
|
||||
self.assertIn(('warn', 'The container no longer exists: CT 102'), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def lifecycle_events(self, *, missing=False, reassigned=False, bridge='none',
|
||||
ip_rc=0, pvesh_rc=0, ip_error=False, firewall_failure=False,
|
||||
firewall_success=False, kept=False):
|
||||
events, commands = [], []
|
||||
self.last_commands = commands
|
||||
record = {'installation_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}
|
||||
if firewall_failure or firewall_success:
|
||||
record['deployment'] = {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}
|
||||
records = {101: record, 102: {'installation_id': 'owned'}}
|
||||
|
||||
def fake_command(args, **kwargs):
|
||||
commands.append(tuple(args))
|
||||
if ip_error and args[:2] == ['ip', 'link']:
|
||||
raise OSError('ip unavailable')
|
||||
if args[:2] == ['pvesh', 'get']:
|
||||
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + record['installation_id'],
|
||||
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
|
||||
'action': 'ACCEPT', 'pos': 2}]), returncode=0)
|
||||
if args[:2] == ['pvesh', 'delete'] and '/firewall/' in args[2] and firewall_failure:
|
||||
raise subprocess.CalledProcessError(1, args)
|
||||
return SimpleNamespace(returncode=ip_rc if args[:2] == ['ip', 'link'] else
|
||||
pvesh_rc if args[:2] == ['pvesh', 'delete'] else 0)
|
||||
|
||||
fake_subprocess = SimpleNamespace(run=fake_command, CalledProcessError=subprocess.CalledProcessError)
|
||||
scope = {'members_of': lambda root, vmid: (101, record, [102, 101]),
|
||||
'instances': SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
|
||||
read=lambda root, vmid: records[vmid],
|
||||
identity=lambda cfg: record['installation_id'] if cfg == b'primary' else
|
||||
'other' if cfg == b'reassigned' else 'owned',
|
||||
location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: (None if missing else b'reassigned' if reassigned else b'owned')
|
||||
if vmid == 102 else b'primary',
|
||||
'host_directories': lambda *args: ['/retained'] if kept else [], 'granted_directories': lambda *args: [],
|
||||
'private_bridge': lambda primary: 'vmbr9' if bridge != 'none' else None,
|
||||
'bridge_in_use': lambda *args: bridge == 'shared',
|
||||
'run': lambda *args: commands.append(args), 'subprocess': fake_subprocess,
|
||||
'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json, 're': re,
|
||||
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
|
||||
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None,
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(('info', text)),
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text)),
|
||||
'msg_error': lambda text: events.append(('error', text)),
|
||||
'argparse': argparse, 'os': SimpleNamespace(geteuid=lambda: 0),
|
||||
'sys': SimpleNamespace(argv=['oci_remove.py', '101'])}
|
||||
scope['release_bridge'] = extract(REMOVE, 'release_bridge', scope)
|
||||
scope['remove_owned_host_firewall'] = extract(REMOVE, 'remove_owned_host_firewall', scope)
|
||||
scope['remove'] = extract(REMOVE, 'remove', scope)
|
||||
with patch.object(sys, 'argv', ['oci_remove.py', '101']):
|
||||
self.assertEqual(extract(REMOVE, 'main', scope)(), 0)
|
||||
return events
|
||||
|
||||
def test_clean_removal_has_clear_success(self):
|
||||
events = self.lifecycle_events()
|
||||
self.assertEqual(events[-1], ('ok', 'The application was removed'))
|
||||
self.assertFalse(any(kind == 'warn' for kind, _ in events))
|
||||
|
||||
def test_skipped_identity_and_kept_bridge_have_partial_result(self):
|
||||
events = self.lifecycle_events(reassigned=True, bridge='shared')
|
||||
self.assertIn(('warn', 'The VMID belongs to another container now and is not touched: CT 102'), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_shared_bridge_intentionally_kept_does_not_taint_clean_removal(self):
|
||||
events = self.lifecycle_events(bridge='shared')
|
||||
self.assertTrue(any('still used' in text for kind, text in events if kind == 'info'))
|
||||
self.assertEqual(events[-1], ('ok', 'The application was removed'))
|
||||
|
||||
def test_failed_bridge_commands_have_partial_result(self):
|
||||
for ip_rc, pvesh_rc in ((1, 0), (0, 1), (1, 1)):
|
||||
with self.subTest(ip_rc=ip_rc, pvesh_rc=pvesh_rc):
|
||||
events = self.lifecycle_events(bridge='private', ip_rc=ip_rc, pvesh_rc=pvesh_rc)
|
||||
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
|
||||
for kind, text in events), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_successful_bridge_release_and_retained_paths_are_clean(self):
|
||||
events = self.lifecycle_events(bridge='private', kept=True)
|
||||
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||
self.assertTrue(any(kind == 'info' and '/retained' in text for kind, text in events))
|
||||
self.assertFalse(any(kind == 'warn' for kind, _ in events))
|
||||
self.assertEqual(events[-1], ('ok', SUCCESS))
|
||||
|
||||
def test_bridge_exception_is_reported_but_cleanup_continues(self):
|
||||
events = self.lifecycle_events(bridge='private', ip_error=True)
|
||||
self.assertEqual([cmd[:2] for cmd in self.last_commands],
|
||||
[('pct', 'stop'), ('pct', 'destroy'), ('pct', 'stop'),
|
||||
('pct', 'destroy'), ('ip', 'link'), ('pvesh', 'delete')])
|
||||
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
|
||||
for kind, text in events), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_successful_firewall_delete_is_clean(self):
|
||||
events = self.lifecycle_events(firewall_success=True)
|
||||
self.assertTrue(any('Host firewall rule removed:' in text for kind, text in events if kind == 'ok'))
|
||||
self.assertEqual(events[-1], ('ok', SUCCESS))
|
||||
|
||||
def test_firewall_delete_failure_has_partial_result(self):
|
||||
events = self.lifecycle_events(firewall_failure=True)
|
||||
self.assertIn(('warn', FIREWALL), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_ignored_bridge_command_failures_do_not_claim_release(self):
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
identity=lambda cfg: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: b'owned', 'host_directories': lambda *args: [], 'granted_directories': lambda *args: [],
|
||||
'private_bridge': lambda primary: 'vmbr9', 'bridge_in_use': lambda *args: False,
|
||||
'release_bridge': lambda bridge: events.append(('attempt', bridge)),
|
||||
'remove_owned_host_firewall': lambda record: None, 'run': lambda *args: None,
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
|
||||
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
|
||||
'translate': lambda text: text, 'msg_info': lambda text: None,
|
||||
'msg_ok': lambda text: events.append(('ok', text)), 'msg_warn': lambda text: None}
|
||||
extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
|
||||
self.assertIn(('attempt', 'vmbr9'), events)
|
||||
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||
self.assertNotIn(('ok', 'Private network of the application released: vmbr9'), events)
|
||||
|
||||
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
|
||||
events = []
|
||||
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
|
||||
'subprocess': subprocess, 'translate': lambda text: text,
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
|
||||
{'installation_id': 'invalid', 'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(events, [('warn', FIREWALL)])
|
||||
|
||||
def test_ambiguous_firewall_match_warns_without_deleting_other_rules(self):
|
||||
events, commands = [], []
|
||||
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
rule = {'comment': 'ProxMenux OCI firewall ' + installation_id, 'dport': '8080',
|
||||
'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in', 'action': 'ACCEPT', 'pos': 2}
|
||||
def fake_run(args, **kwargs):
|
||||
commands.append(args)
|
||||
return SimpleNamespace(stdout=json.dumps([rule, {**rule, 'pos': 3}]))
|
||||
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
|
||||
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
|
||||
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
|
||||
{'installation_id': installation_id,
|
||||
'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(events, [('warn', FIREWALL)])
|
||||
self.assertEqual([command[1] for command in commands], ['get'])
|
||||
|
||||
def test_firewall_delete_exception_has_unknown_outcome_not_unchanged_rule(self):
|
||||
events = []
|
||||
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
commands = []
|
||||
def fake_run(args, **kwargs):
|
||||
commands.append(args)
|
||||
if args[1] == 'get':
|
||||
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + installation_id,
|
||||
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
|
||||
'action': 'ACCEPT', 'pos': 2}]))
|
||||
raise subprocess.CalledProcessError(1, args)
|
||||
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
|
||||
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
|
||||
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text)), 'OSError': OSError, 'ValueError': ValueError}
|
||||
extract(REMOVE, 'remove_owned_host_firewall', scope)({'installation_id': installation_id,
|
||||
'deployment': {'host_firewall': {'port': 8080, 'source': '192.0.2.0/24'}}})
|
||||
self.assertEqual(commands[-1][1], 'delete')
|
||||
self.assertEqual(events, [('warn', FIREWALL)])
|
||||
|
||||
def test_extractor_and_runtime_lookup_fallback_and_synthetic_translation(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
found = set(generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote']))
|
||||
shell_keys = set(generator['extract_translate_texts'](ROOT / 'oci/remote'))
|
||||
self.assertTrue(set(KEYS) - {PORT} <= found, (set(KEYS) - {PORT}) - found)
|
||||
self.assertIn(PORT, shell_keys)
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||
cache = json.loads(path.read_text())
|
||||
module._language, module._cache = path.stem, cache
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key)
|
||||
module._cache = {}
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), key)
|
||||
module._language, module._cache = 'it', {key: 'IT: ' + key for key in KEYS}
|
||||
self.assertIn('IT: ' + PREVIEW, self.preview(translated=module.translate))
|
||||
self.assertIn('IT: ' + OPTIONAL, self.preview(translated=module.translate))
|
||||
|
||||
def test_actual_shell_guard_fails_for_missing_tcp_contract_not_web_role(self):
|
||||
source = INSTALL.read_text()
|
||||
function = source[source.index('validate_host_monitor_firewall() {'):source.index('\napply_host_monitor_firewall() {')]
|
||||
script = '''set -eu
|
||||
translate() { printf '%s' "$1"; }
|
||||
die() { printf '%s' "$1" >&2; exit 7; }
|
||||
jq() {
|
||||
case "$*" in
|
||||
*'.host_firewall == null'*) printf 'false\\n' ;;
|
||||
*'.host_firewall | type'*) printf 'true\\n' ;;
|
||||
*'.host_firewall.bridge'*) printf 'vmbr0\\n' ;;
|
||||
*'.host_firewall.source'*) printf '192.0.2.0/24\\n' ;;
|
||||
*'.host_firewall.port'*) printf '8080\\n' ;;
|
||||
*'.proxmox.installer_profile.host_monitor_firewall.web_port'*) printf '8080\\n' ;;
|
||||
*'.container_contract.ports'*) printf '0\\n' ;;
|
||||
*) exit 99 ;;
|
||||
esac
|
||||
}
|
||||
ip() { printf '2: vmbr0 inet 192.0.2.1/24 scope global vmbr0\\n'; }
|
||||
BRIDGE=vmbr0 HOST_MONITOR=netdata DEPLOYMENT_FILE=/inert/deployment TEMPLATE_FILE=/inert/template
|
||||
''' + function + '\nvalidate_host_monitor_firewall\n'
|
||||
result = subprocess.run(['bash', '-c', script], text=True, capture_output=True, check=False)
|
||||
self.assertEqual(result.returncode, 7, result.stderr)
|
||||
self.assertEqual(result.stderr, PORT)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Exercise the real OCI preview/import functions without importing host-management modules."""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
|
||||
ROWS = {
|
||||
'It needs a privileged container, which is not isolated from the host.':
|
||||
'This profile requires a privileged LXC, which reduces isolation from the host.',
|
||||
'It asks for capabilities or a relaxed confinement profile.':
|
||||
'A relaxed AppArmor or seccomp profile is requested; this may be optional.',
|
||||
'The image expects files that are given to it one by one:':
|
||||
'These container mount paths have an extension-like suffix:',
|
||||
'ProxMenux attaches directories, not single files, so this image cannot be installed yet.':
|
||||
'This import rejects these paths without checking whether they are files or directories.',
|
||||
}
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def extracted(name, **deps):
|
||||
node = next(n for n in ast.parse(CUSTOM.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
scope = {'Any': Any, 'Path': Path, 'yaml': yaml, 'ConversionError': ConversionError,
|
||||
'translate': lambda text: text}
|
||||
scope.update(deps)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(CUSTOM), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class SecurityMountWording(unittest.TestCase):
|
||||
def test_security_warnings_match_their_guards_without_claiming_capabilities(self):
|
||||
describe = extracted('describe', blocker_text=str)
|
||||
template = {'container_contract': {'image': {'reference': 'test:latest'},
|
||||
'volumes': [], 'ports': [], 'environment': []},
|
||||
'proxmox': {'security_profile': {}}}
|
||||
for profile, present in (
|
||||
({}, ()),
|
||||
({'optional_privileged_lxc': True}, ()),
|
||||
({'requires_privileged_lxc': True}, (ROWS['It needs a privileged container, which is not isolated from the host.'],)),
|
||||
({'requires_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
|
||||
({'source_requests_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
|
||||
({'requires_privileged_lxc': True, 'source_requests_relaxed_confinement': True},
|
||||
(ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
ROWS['It asks for capabilities or a relaxed confinement profile.'])),
|
||||
):
|
||||
with self.subTest(profile=profile):
|
||||
template['proxmox']['security_profile'] = profile
|
||||
text = describe(template)
|
||||
for warning in (ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
ROWS['It asks for capabilities or a relaxed confinement profile.']):
|
||||
if warning in present:
|
||||
self.assertIn(' ' + warning, text)
|
||||
else:
|
||||
self.assertNotIn(warning, text)
|
||||
template['proxmox']['security_profile'] = {}
|
||||
template['proxmox']['installer_profile'] = {'security': {'required_capabilities': ['NET_ADMIN']}}
|
||||
text = describe(template)
|
||||
self.assertNotIn(ROWS['It asks for capabilities or a relaxed confinement profile.'], text)
|
||||
|
||||
def test_suffix_hit_rejects_dotted_directory_without_claiming_file_type(self):
|
||||
fn = extracted('template_from_compose',
|
||||
_services=lambda d: d['services'], _check_mounts=lambda s: None,
|
||||
normalize_app_id=lambda s: s, _published_port=lambda s: None,
|
||||
_dump=lambda s: '', _keep_reference=lambda *a: None,
|
||||
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
|
||||
convert_casaos_compose=lambda *args: {'container_contract': {
|
||||
'image': {'reference': 'example:latest'},
|
||||
'volumes': [{'container_path': '/srv/data.v2', 'installation_choice': ['managed-volume', 'host-bind']}]},
|
||||
'proxmox': {}})
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
fn('services:\n app:\n image: example:latest\n')
|
||||
self.assertEqual(str(caught.exception),
|
||||
ROWS['The image expects files that are given to it one by one:'] + ' /srv/data.v2. ' +
|
||||
ROWS['ProxMenux attaches directories, not single files, so this image cannot be installed yet.'])
|
||||
|
||||
def test_suffix_free_path_still_accepted(self):
|
||||
fn = extracted('template_from_compose',
|
||||
_services=lambda d: d['services'], _check_mounts=lambda s: None,
|
||||
normalize_app_id=lambda s: s, _published_port=lambda s: None,
|
||||
_dump=lambda s: '', _keep_reference=lambda *a: None,
|
||||
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
|
||||
convert_casaos_compose=lambda *args: {'container_contract': {
|
||||
'image': {'reference': 'example:latest'},
|
||||
'volumes': [{'container_path': '/srv/config', 'installation_choice': ['managed-volume', 'host-bind']}]},
|
||||
'proxmox': {}})
|
||||
self.assertEqual(fn('services:\n app:\n image: example:latest\n')['container_contract']['volumes'][0]['container_path'], '/srv/config')
|
||||
|
||||
def test_all_four_keys_extract_and_fallback_in_seven_shipped_locales(self):
|
||||
found = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(ROWS), 4)
|
||||
self.assertTrue(set(ROWS.values()) <= set(found), set(ROWS.values()) - set(found))
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for new in ROWS.values():
|
||||
self.assertEqual(module.translate(new), cache.get(new) or new, (path.name, new))
|
||||
setattr(module, '_cache', {})
|
||||
for new in ROWS.values():
|
||||
self.assertEqual(module.translate(new), new, (path.name, new, 'synthetic missing'))
|
||||
module._language, module._cache = 'it', {new: 'IT: ' + new for new in ROWS.values()}
|
||||
self.assertIn('IT: ' + ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
extracted('describe', translate=module.translate, blocker_text=str)({
|
||||
'container_contract': {'image': {'reference': 'test:latest'}, 'volumes': [], 'ports': [], 'environment': []},
|
||||
'proxmox': {'security_profile': {'requires_privileged_lxc': True}}}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Inert extracted OCI selection/setup consumers; no admin module imports or scripts sourced."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
OLD_EMPTY = 'No OCI instances are registered.'
|
||||
EMPTY = 'No registered OCI containers are available for selection on this host.'
|
||||
OLD_REPLAY = 'This stack requires replaying specific rootfs adaptations. Coordinated updates are not yet enabled for it.'
|
||||
NO_MEMBERS = 'This stack has no saved members to update.'
|
||||
REPLAY = 'This stack needs rootfs adaptations that coordinated updates cannot replay yet.'
|
||||
HTTP = 'The application did not pass its HTTP check:'
|
||||
|
||||
|
||||
def extracted(name, **dependencies):
|
||||
node = next(n for n in ast.parse(MENU.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
# Drop only infrastructure imports, retaining all selection, guard, and return logic.
|
||||
node.body = [n for n in node.body if not (isinstance(n, ast.Import) and
|
||||
any(a.name == 'oci_instances' or a.name == 'oci_stack_replay' for a in n.names))]
|
||||
for n in ast.walk(node):
|
||||
if isinstance(n, (ast.If, ast.With)):
|
||||
n.body = [x for x in n.body if not (isinstance(x, ast.Import) and
|
||||
any(a.name in ('oci_instances', 'oci_stack_replay') for a in x.names))]
|
||||
scope = dict(dependencies)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||
str(MENU), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class SelectionSetupWording(TestCase):
|
||||
def test_selector_empty_explains_availability_not_registry_absence(self):
|
||||
ui = SimpleNamespace(message=Mock(), choose=Mock())
|
||||
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
|
||||
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
|
||||
_clean_orphans=lambda project: None,
|
||||
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
|
||||
carry_records=lambda project, **options: {},
|
||||
translate=lambda s: s)
|
||||
fn(Path('/fixture'), ui)
|
||||
ui.message.assert_called_once_with(EMPTY, 'OCI management')
|
||||
ui.choose.assert_not_called()
|
||||
|
||||
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
|
||||
class Entry:
|
||||
name = '101'
|
||||
class Lock:
|
||||
def __enter__(self): return None
|
||||
def __exit__(self, *_): return False
|
||||
fake = SimpleNamespace(ROOT=SimpleNamespace(iterdir=lambda: [Entry()]),
|
||||
locked=lambda _: Lock(), has_contract=lambda *args: True,
|
||||
guest_exists=lambda _: False, read=Mock())
|
||||
fn = extracted('saved_inventory', sys=SimpleNamespace(path=[]),
|
||||
instances=fake, public_row=Mock())
|
||||
self.assertEqual(fn(Path('/fixture')), [])
|
||||
fake.read.assert_not_called()
|
||||
|
||||
def test_stack_without_members_is_not_diagnosed_as_replay_failure(self):
|
||||
self._stack({}, NO_MEMBERS).choose.assert_not_called()
|
||||
|
||||
def test_stack_unsupported_replay_retains_distinct_verdict(self):
|
||||
# It cannot be updated, but it can still be changed or removed.
|
||||
ui = self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
|
||||
ui.choose.assert_called_once()
|
||||
self.assertEqual([tag for tag, _ in ui.choose.call_args.args[1]], ['modify', 'watchdog', 'remove'])
|
||||
|
||||
def _stack(self, stack, expected):
|
||||
record = {'stack': stack}
|
||||
fake = SimpleNamespace(ROOT=Path('/fixture'), read=lambda *args: record)
|
||||
replay = SimpleNamespace(**{n + '_menu_ready': lambda _: False
|
||||
for n in ('nextcloud', 'paperless', 'tandoor', 'immich')})
|
||||
ui = SimpleNamespace(message=Mock(), choose=Mock(return_value=None), review=Mock())
|
||||
fn = extracted('_manage_stack', sys=SimpleNamespace(path=[]), instances=fake,
|
||||
oci_stack_replay=replay, translate=lambda s: s)
|
||||
self.assertFalse(fn(Path('/fixture'), ui, {'vmid': 101}))
|
||||
ui.message.assert_called_once_with(expected, 'OCI stack management')
|
||||
ui.review.assert_not_called()
|
||||
return ui
|
||||
|
||||
def test_actual_http_failure_seams_preserve_app_and_log_redirect(self):
|
||||
for app, port in (('paperless', ':8000'), ('tandoor', '')):
|
||||
with self.subTest(app=app):
|
||||
path = ROOT / f'oci/remote/install_{app}_stack.sh'
|
||||
lines = path.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('curl -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
|
||||
# Actual Bash failure expression, never source the parent installer.
|
||||
harness = '''set -u
|
||||
APPLICATION_LAN_IP=192.0.2.1
|
||||
APPLICATION_ID=101
|
||||
OCI_LOG=/dev/null
|
||||
curl() { return 7; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
print_first_boot_diagnostics() { :; }
|
||||
die() { printf 'ERROR:%s\\n' "$1"; }
|
||||
''' + command + '\n'
|
||||
run = subprocess.run(['bash', '-c', harness], text=True,
|
||||
capture_output=True, timeout=3)
|
||||
self.assertEqual(run.returncode, 0, run.stderr)
|
||||
self.assertEqual(run.stdout.strip(),
|
||||
f'ERROR:{HTTP} {"Paperless-ngx" if app == "paperless" else "Tandoor"}')
|
||||
self.assertIn('2>>"$OCI_LOG"', command)
|
||||
|
||||
def test_nextcloud_explicit_setup_guard_kept(self):
|
||||
source = (ROOT / 'oci/remote/install_nextcloud_stack.sh').read_text()
|
||||
self.assertIn("jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false'", source)
|
||||
self.assertIn('die "$(translate "The application did not complete its initial setup:") Nextcloud"', source)
|
||||
|
||||
def test_shell_lookup_shipped_missing_and_synthetic_translation(self):
|
||||
source = (ROOT / 'oci/remote/oci_ui.sh').read_text()
|
||||
start = source.index('translate() {')
|
||||
fn = source[start:source.index('\n}', start) + 2]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
missing = Path(tmp) / 'missing.json'
|
||||
missing.write_text('{}')
|
||||
translated = Path(tmp) / 'translated.json'
|
||||
translated.write_text(json.dumps({HTTP: 'VERIFICA HTTP FALLITA:'}))
|
||||
for path in [*sorted((ROOT / 'lang').glob('*.json')), missing, translated]:
|
||||
cache = json.loads(path.read_text())
|
||||
bash = '_OCI_LANGUAGE=it\n_OCI_LANG_FILE="$1"\n' + fn + '\ntranslate "$2"\n'
|
||||
result = subprocess.run(['bash', '-c', bash, 'fixture', str(path), HTTP],
|
||||
text=True, capture_output=True, timeout=3)
|
||||
self.assertEqual(result.returncode, 0, (path, result.stderr))
|
||||
self.assertEqual(result.stdout, cache.get(HTTP) or HTTP, path)
|
||||
|
||||
def test_extraction_and_real_lookup_all_locales(self):
|
||||
import runpy
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
extracted_keys = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
for key in (EMPTY, NO_MEMBERS, REPLAY):
|
||||
self.assertIn(key, extracted_keys)
|
||||
shell_keys = generator['extract_translate_texts'](ROOT / 'oci/remote')
|
||||
self.assertIn(HTTP, shell_keys)
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||
cache = json.loads(path.read_text())
|
||||
module._language, module._cache = path.stem, cache
|
||||
for key in (EMPTY, NO_MEMBERS, REPLAY, HTTP):
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key)
|
||||
module._cache = {}
|
||||
self.assertEqual(module.translate(EMPTY), EMPTY)
|
||||
module._cache = {EMPTY: 'LOCALIZED SELECTOR'}
|
||||
self.assertEqual(module.translate(EMPTY), 'LOCALIZED SELECTOR')
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Inert source-as-key checks for OCI update and recreation; no host modules imported."""
|
||||
import ast
|
||||
from contextlib import nullcontext
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
RECREATE = ROOT / 'oci/src/proxmenux_oci/recreation.py'
|
||||
MANAGE = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
UPDATE = ROOT / 'oci/remote/oci_update_current.py'
|
||||
OLD_CONFIRM = 'Apply the options from the current catalog template? Your data and configuration are kept.'
|
||||
CONFIRM = ('Apply options from the current catalog template? New required paths and settings may be requested. '
|
||||
'Review the resulting configuration before recreating the CT.')
|
||||
OLD_GUARD = 'The current template changes the image or identity; an explicit migration is required'
|
||||
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
|
||||
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
|
||||
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
|
||||
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with '
|
||||
'the new image to update the container. If the update fails after the container was changed, the backup '
|
||||
'is restored. An update that stops halfway is recovered from the OCI management menu.')
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class RecreationWording(unittest.TestCase):
|
||||
def refresh(self, mutate, proceed=True):
|
||||
old = {'id': 'app-old', 'container_contract': {'image': {'repository': 'example/app', 'reference': 'example/app:v1'},
|
||||
'volumes': [], 'environment': []},
|
||||
'proxmox': {'installer_profile': {}}, 'catalog_ui': {'title': 'old'}}
|
||||
latest = copy.deepcopy(old)
|
||||
mutate(latest)
|
||||
candidate = {'template': old, 'deployment': {'mounts': [], 'environment': [], 'security': {}}}
|
||||
events = []
|
||||
catalog_module = ModuleType('proxmenux_oci.catalog')
|
||||
class Catalog:
|
||||
def __init__(self, root):
|
||||
pass
|
||||
def load_index(self):
|
||||
return {'applications': [{'template_id': old['id'], 'id': 'app'}]}
|
||||
def compose(self, name):
|
||||
return latest
|
||||
catalog_module.Catalog = Catalog
|
||||
ui = SimpleNamespace(confirm=lambda message, default: events.append(('confirm', message, default)) or proceed,
|
||||
info=lambda message: events.append(('info', message)))
|
||||
scope = {'__package__': 'proxmenux_oci', '__file__': str(RECREATE), 'Path': Path, 're': re,
|
||||
'translate': lambda text: text, 'copy': copy}
|
||||
with patch.dict(sys.modules, {'proxmenux_oci.catalog': catalog_module}):
|
||||
try:
|
||||
extracted(RECREATE, 'refresh_template', scope)(candidate, ui)
|
||||
except ValueError as error:
|
||||
events.append(('error', str(error)))
|
||||
return candidate, events
|
||||
|
||||
def test_confirmation_describes_followup_without_preservation_guarantee(self):
|
||||
candidate, events = self.refresh(lambda latest: latest['catalog_ui'].update(title='new'), proceed=False)
|
||||
self.assertEqual(events, [('confirm', CONFIRM, True)])
|
||||
self.assertEqual(candidate['template']['catalog_ui']['title'], 'old')
|
||||
self.assertNotIn(OLD_CONFIRM, str(events))
|
||||
|
||||
def test_guard_names_exact_rejected_identity_and_repository_changes(self):
|
||||
for change in (lambda latest: latest.update(id='app-new'),
|
||||
lambda latest: latest['container_contract']['image'].update(repository='other/app')):
|
||||
with self.subTest(change=change):
|
||||
candidate, events = self.refresh(change)
|
||||
self.assertEqual(events, [('confirm', CONFIRM, True), ('error', GUARD)])
|
||||
self.assertEqual(candidate['template']['id'], 'app-old')
|
||||
|
||||
def test_same_repository_new_tag_does_not_trigger_migration_guard(self):
|
||||
candidate, events = self.refresh(lambda latest: latest['container_contract']['image'].update(reference='example/app:v2'))
|
||||
self.assertEqual(events, [('confirm', CONFIRM, True)])
|
||||
self.assertEqual(candidate['template']['container_contract']['image']['reference'], 'example/app:v2')
|
||||
|
||||
|
||||
class UpdateWording(unittest.TestCase):
|
||||
def test_actual_native_backup_verification_succeeds_or_fails_before_replacement(self):
|
||||
transaction = ROOT / 'oci/remote/oci_instance_transaction.py'
|
||||
for failures in (1, 2):
|
||||
with self.subTest(failed_integrity_checks=failures), tempfile.TemporaryDirectory() as folder:
|
||||
directory = Path(folder)
|
||||
archive = directory / 'vzdump-lxc-101.tar.zst'
|
||||
calls = []
|
||||
def inert_run(*args):
|
||||
calls.append(args)
|
||||
if args[0] == 'vzdump':
|
||||
archive.write_bytes(b'inert fixture')
|
||||
elif args[0] == 'zstd' and sum(call[0] == 'zstd' for call in calls) <= failures:
|
||||
raise RuntimeError('invalid backup')
|
||||
scope = {'run': inert_run, 'log': lambda text: None,
|
||||
'msg_warn': lambda text: None, 'translate': lambda text: text}
|
||||
backup = extracted(transaction, 'verified_backup', scope)
|
||||
if failures == 2:
|
||||
with self.assertRaisesRegex(RuntimeError, 'invalid backup'):
|
||||
backup(101, directory, 'zstd', 'missing backup')
|
||||
else:
|
||||
self.assertEqual(backup(101, directory, 'zstd', 'missing backup'), archive)
|
||||
self.assertEqual([call[0] for call in calls], ['vzdump', 'zstd', 'vzdump', 'zstd'])
|
||||
self.assertTrue(all(call[call.index('--mode') + 1] == 'stop'
|
||||
for call in calls if call[0] == 'vzdump'))
|
||||
apply_source = transaction.read_text()
|
||||
apply_node = next(n for n in ast.parse(apply_source).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == 'apply')
|
||||
calls = [(n.lineno, n.func.id) for n in ast.walk(apply_node) if isinstance(n, ast.Call)
|
||||
and isinstance(n.func, ast.Name) and n.func.id in ('verified_backup', 'checkpoint', 'install_candidate')]
|
||||
backup_line = next(line for line, name in calls if name == 'verified_backup')
|
||||
ready_line = next(line for line, name in calls if name == 'checkpoint' and line > backup_line)
|
||||
install_line = next(line for line, name in calls if name == 'install_candidate')
|
||||
self.assertLess(backup_line, ready_line)
|
||||
self.assertLess(ready_line, install_line)
|
||||
|
||||
def test_preview_blocks_lifecycle_when_cancelled_and_keeps_command_on_accept(self):
|
||||
events = []
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: {'status': 'installed'}
|
||||
instances.command = lambda *args: b'description: owned'
|
||||
reconcile = ModuleType('oci_instance_reconcile')
|
||||
reconcile.propose = lambda *args: None
|
||||
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
|
||||
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'carry_records': lambda project, **options: {},
|
||||
'_work_backup_ready': lambda project, ui, vmid: True,
|
||||
'_run_lifecycle': lambda command, title: events.append(('run', command, title)) or True}
|
||||
row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
|
||||
decision = False
|
||||
def review(message, title, **kwargs):
|
||||
events.append(('review', message, title, kwargs))
|
||||
return decision
|
||||
ui = SimpleNamespace(review=review, unattended=True)
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_instance_reconcile': reconcile}):
|
||||
manage = extracted(MANAGE, 'manage_instance', scope)
|
||||
self.assertFalse(manage(ROOT / 'oci', ui, row, action='update'))
|
||||
self.assertEqual(events, [('review', PREVIEW, 'Update OCI', {'question': 'Update now?', 'default': True})])
|
||||
decision = True
|
||||
self.assertTrue(manage(ROOT / 'oci', ui, row, action='update'))
|
||||
self.assertEqual(events[-1], ('run', ['python3', str(ROOT / 'oci/remote/oci_update_current.py'), '101',
|
||||
'--acknowledge-external-data'], 'Update OCI'))
|
||||
self.assertNotIn(OLD_PREVIEW, str(events))
|
||||
|
||||
def test_noop_image_digest_returns_before_any_backup_or_apply(self):
|
||||
events = []
|
||||
record = {'status': 'installed', 'observed': {'image': {'manifest_digest': 'sha256:current'}}}
|
||||
instances = SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
|
||||
read=lambda root, vmid: record, command=lambda *args: b'arch: amd64')
|
||||
transaction = SimpleNamespace(candidate_contract=lambda *args: {'deployment': {}},
|
||||
external_changes=lambda *args: {}, preflight=lambda *args: None,
|
||||
require_backup_space=lambda *args: self.fail('backup space checked on no-op'),
|
||||
apply=lambda *args, **kwargs: self.fail('apply on no-op'))
|
||||
scope = {'instances': instances, 'transaction': transaction,
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(text), 'msg_ok': lambda text: events.append(text),
|
||||
'resolve_archive': lambda desired, config, current, check: (None, current),
|
||||
'kept_settings': lambda *args: self.fail('kept settings on no-op')}
|
||||
extracted(UPDATE, 'update', scope)(101, keep_backup='not-a-real-storage')
|
||||
self.assertIn('The image is already up to date; nothing was changed.', events)
|
||||
|
||||
def test_new_keys_extracted_and_shipped_and_missing_locale_fallback(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
for key in (CONFIRM, GUARD, PREVIEW):
|
||||
self.assertIn(key, found)
|
||||
scope = {'json': json, 'Path': Path, 'BASE_DIR': ROOT,
|
||||
'_language': 'it', '_cache': None}
|
||||
lookup = extracted(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', scope)
|
||||
scope['language'] = lambda: scope['_language']
|
||||
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||
scope['_language'] = path.stem
|
||||
scope['_cache'] = None
|
||||
values = json.loads(path.read_text())
|
||||
for key in (CONFIRM, GUARD, PREVIEW):
|
||||
self.assertEqual(lookup(key), values.get(key) or key, (path.name, key))
|
||||
scope['_language'] = 'it'
|
||||
for key in (CONFIRM, GUARD, PREVIEW):
|
||||
scope['_cache'] = {}
|
||||
self.assertEqual(lookup(key), key, ('missing', key))
|
||||
scope['_cache'] = {key: 'LOCALIZED: ' + key}
|
||||
self.assertEqual(lookup(key), 'LOCALIZED: ' + key, ('synthetic', key))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,231 @@
|
||||
"""OCI validation record: generated list, record checks, credited authors and the report bot."""
|
||||
import datetime
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
spec = importlib.util.spec_from_file_location("oci_validation", ROOT / ".github/scripts/oci_validation.py")
|
||||
validation = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(validation)
|
||||
|
||||
TODAY = datetime.date(2026, 9, 28)
|
||||
REPORT = "https://github.com/MacRimi/ProxMenux/issues/400"
|
||||
INDEX = {"applications": [
|
||||
{"id": "glances", "title": "Glances", "category_label": "Monitoring", "automatic_install_candidate": True},
|
||||
{"id": "2fauth", "title": "2FAuth", "category_label": "Security", "automatic_install_candidate": True},
|
||||
{"id": "legacy", "title": "Legacy | App", "category_label": "Other", "automatic_install_candidate": False},
|
||||
{"id": "hidden", "title": "Hidden", "hidden": True},
|
||||
]}
|
||||
|
||||
|
||||
def form(**answers):
|
||||
fields = {"Application": "Glances", "Image version": "4.3", "Image digest": "2026-09-06 · B1F339CF",
|
||||
"Proxmox VE version": "9.1", "Install mode": "Default configuration",
|
||||
"Data storage": "Volume on Proxmox storage", "Network": "DHCP", "GPU": "No GPU",
|
||||
"Result": "Works", "Notes": "_No response_"}
|
||||
fields.update(answers)
|
||||
return "\n\n".join(f"### {label}\n\n{value}" for label, value in fields.items())
|
||||
|
||||
|
||||
def event(body=None, labels=("oci-validation", "validated"), user="Tester-1"):
|
||||
return {"issue": {"number": 400, "html_url": REPORT, "user": {"login": user}, "body": form() if body is None else body,
|
||||
"labels": [{"name": name} for name in labels]}}
|
||||
|
||||
|
||||
class Render(unittest.TestCase):
|
||||
def test_lists_community_and_installable_lab_entries(self):
|
||||
record = {"applications": {
|
||||
"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}},
|
||||
"2fauth": {"status": "laboratory-validated"},
|
||||
"legacy": {"status": "laboratory-validated"}}}
|
||||
text = validation.render(record, INDEX)
|
||||
self.assertIn("2 of 3 applications", text)
|
||||
self.assertIn("| Glances | [@Vaso73](https://github.com/Vaso73) | 2026-09-27 | — | — | [Report](" + REPORT + ") |", text)
|
||||
self.assertIn("| 2FAuth | Security |", text)
|
||||
# An application that cannot be installed is not listed as verified.
|
||||
self.assertNotIn("Legacy", text)
|
||||
|
||||
def test_empty_record_and_escaped_titles(self):
|
||||
text = validation.render({"applications": {}}, INDEX)
|
||||
self.assertIn("No application has been tested by the community yet.", text)
|
||||
record = {"applications": {"legacy": {"community_tested": {"by": "a", "date": "2026-09-27"}}}}
|
||||
self.assertIn("| Legacy \\| App |", validation.render(record, INDEX))
|
||||
|
||||
def test_repository_record_is_valid_and_current(self):
|
||||
record = validation.load(validation.VERIFICATION)
|
||||
index = validation.load(validation.INDEX)
|
||||
self.assertEqual(validation.problems(record, index, validation.OUTPUT.read_text(encoding="utf-8")), [])
|
||||
|
||||
|
||||
class Problems(unittest.TestCase):
|
||||
def check(self, entry):
|
||||
return validation.problems({"applications": {"glances": entry}}, INDEX, today=TODAY)
|
||||
|
||||
def test_valid_entries(self):
|
||||
self.assertEqual(self.check({"status": "laboratory-validated"}), [])
|
||||
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}}), [])
|
||||
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27",
|
||||
"report": "https://github.com/MacRimi/ProxMenux/discussions/392#discussioncomment-18623868"}}), [])
|
||||
|
||||
def test_rejected_entries(self):
|
||||
cases = [
|
||||
({"status": "works"}, "status can only be"),
|
||||
({"community_tested": {"by": "bad user", "date": "2026-09-27"}}, "GitHub user name"),
|
||||
({"community_tested": {"by": "a", "date": "27/09/2026"}}, "YYYY-MM-DD"),
|
||||
({"community_tested": {"by": "a", "date": "2027-01-01"}}, "in the future"),
|
||||
({"community_tested": {"by": "a", "date": "2026-09-27", "report": "https://example.com/x"}}, "must link"),
|
||||
({"community_tested": {"by": "a", "date": "2026-09-27", "extra": 1}}, "unknown community_tested keys"),
|
||||
({"verified": True}, "unknown keys"),
|
||||
({}, "non-empty object"),
|
||||
]
|
||||
for entry, message in cases:
|
||||
with self.subTest(entry=entry):
|
||||
self.assertTrue(any(message in problem for problem in self.check(entry)), self.check(entry))
|
||||
self.assertTrue(validation.problems({"applications": {"nope": {"status": "laboratory-validated"}}}, INDEX))
|
||||
|
||||
def test_stale_list_is_reported(self):
|
||||
record = {"applications": {"2fauth": {"status": "laboratory-validated"}}}
|
||||
self.assertTrue(any("out of date" in p for p in validation.problems(record, INDEX, "old", TODAY)))
|
||||
|
||||
|
||||
class Authors(unittest.TestCase):
|
||||
base = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}},
|
||||
"2fauth": {"status": "laboratory-validated"}}}
|
||||
|
||||
def head(self, **changes):
|
||||
data = json.loads(json.dumps(self.base))
|
||||
data["applications"].update(changes)
|
||||
return data
|
||||
|
||||
def test_author_adds_own_test(self):
|
||||
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
|
||||
self.assertEqual(validation.author_problems(self.base, head, "F3rs3n"), [])
|
||||
|
||||
def test_crediting_someone_else_is_refused(self):
|
||||
head = self.head(pocketbase={"community_tested": {"by": "Vaso73", "date": "2026-09-28"}})
|
||||
self.assertEqual(validation.author_problems(self.base, head, "f3rs3n"),
|
||||
["pocketbase: credits @Vaso73, but the pull request is from @f3rs3n"])
|
||||
|
||||
def test_lab_status_and_removals_are_for_the_maintainer(self):
|
||||
head = self.head(glances={}, pocketbase={"status": "laboratory-validated"})
|
||||
found = validation.author_problems(self.base, head, "f3rs3n")
|
||||
self.assertIn("glances: a community test is removed by the maintainer only", found)
|
||||
self.assertIn("pocketbase: the ProxMenux lab status is changed by the maintainer only", found)
|
||||
self.assertEqual(validation.author_problems(self.base, head, "MacRimi"), [])
|
||||
|
||||
def test_missing_base_file(self):
|
||||
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
|
||||
self.assertEqual(validation.author_problems({}, {"applications": {"pocketbase": head["applications"]["pocketbase"]}},
|
||||
"f3rs3n"), [])
|
||||
|
||||
|
||||
class Record(unittest.TestCase):
|
||||
def test_parse_form_reads_github_issue_form_body(self):
|
||||
fields = validation.parse_form(form(Notes="Line one\r\nLine two"))
|
||||
self.assertEqual(fields["Application"], "Glances")
|
||||
self.assertEqual(fields["Notes"], "Line one\nLine two")
|
||||
self.assertEqual(validation.parse_form(form())["Notes"], "")
|
||||
|
||||
def test_records_the_report_author(self):
|
||||
data, app, message = validation.record(event(), {"applications": {"glances": {"status": "laboratory-validated"}}},
|
||||
INDEX, TODAY)
|
||||
self.assertEqual(app, "glances")
|
||||
self.assertEqual(data["applications"]["glances"], {"status": "laboratory-validated", "community_tested": {
|
||||
"by": "Tester-1", "date": "2026-09-28", "report": REPORT, "digest": "b1f339cf",
|
||||
"scenario": "Default configuration · Volume on Proxmox storage · DHCP · No GPU · Proxmox VE 9.1 · image 4.3"}})
|
||||
self.assertIn("@Tester-1", message)
|
||||
self.assertEqual(validation.problems(data, INDEX, today=TODAY), [])
|
||||
|
||||
def test_matches_id_or_title_and_replaces_older_test(self):
|
||||
record = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}}}}
|
||||
data, app, message = validation.record(event(form(Application=" glances ")), record, INDEX, TODAY)
|
||||
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
|
||||
self.assertIn("replaces the previous one from @Vaso73", message)
|
||||
self.assertEqual(record["applications"]["glances"]["community_tested"]["by"], "Vaso73")
|
||||
|
||||
def test_refusals(self):
|
||||
for case, message in [
|
||||
(event(form(Application="Unknown")), "does not match"),
|
||||
(event(form(Result="Does not work")), "does not work"),
|
||||
(event(labels=("validated",)), "not an OCI validation report"),
|
||||
(event(user="bad user"), "could not be read"),
|
||||
(event(form(**{"Image digest": "latest"})), "does not hold an image digest"),
|
||||
]:
|
||||
with self.subTest(message=message):
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
validation.record(case, {"applications": {}}, INDEX, TODAY)
|
||||
|
||||
|
||||
class ReviewerComment(unittest.TestCase):
|
||||
allowed = frozenset({"macrimi", "vaso73", "f3rs3n"})
|
||||
|
||||
def comment(self, reviewer, author="Tester-1"):
|
||||
data = event(labels=("oci-validation",), user=author)
|
||||
data["comment"] = {"user": {"login": reviewer}, "body": "/validated"}
|
||||
return data
|
||||
|
||||
def test_listed_reviewer_validates_and_is_named(self):
|
||||
data, app, message = validation.record(self.comment("Vaso73"), {"applications": {}}, INDEX, TODAY, self.allowed)
|
||||
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
|
||||
self.assertIn("Validated by @Vaso73.", message)
|
||||
|
||||
def test_unlisted_reviewer_and_own_report_are_refused(self):
|
||||
with self.assertRaisesRegex(ValueError, "not in the list of OCI validation reviewers"):
|
||||
validation.record(self.comment("someone"), {"applications": {}}, INDEX, TODAY, self.allowed)
|
||||
with self.assertRaisesRegex(ValueError, "other than its author"):
|
||||
validation.record(self.comment("f3rs3n", author="f3rs3n"), {"applications": {}}, INDEX, TODAY, self.allowed)
|
||||
|
||||
def test_maintainer_may_validate_own_report_and_label_needs_no_list(self):
|
||||
validation.record(self.comment("MacRimi", author="MacRimi"), {"applications": {}}, INDEX, TODAY, self.allowed)
|
||||
validation.record(event(), {"applications": {}}, INDEX, TODAY)
|
||||
|
||||
def test_reviewer_list_file(self):
|
||||
self.assertEqual(validation.reviewers("# comment\nMacRimi\n Vaso73 # tester\nbad name\n"),
|
||||
{"macrimi", "vaso73"})
|
||||
listed = validation.reviewers(validation.REVIEWERS.read_text(encoding="utf-8"))
|
||||
self.assertEqual(listed, self.allowed)
|
||||
|
||||
|
||||
class ImageAndScenario(unittest.TestCase):
|
||||
def test_digest_as_the_monitor_shows_it_or_in_full(self):
|
||||
full = "sha256:" + "ab" * 32
|
||||
self.assertEqual(validation.image_digest("2026-09-06 · b1f339cf"), "b1f339cf")
|
||||
self.assertEqual(validation.image_digest(full.upper()), "ab" * 32)
|
||||
for value in ("", "b1f3", "latest", "sha256:xyz12345"):
|
||||
self.assertIsNone(validation.image_digest(value))
|
||||
|
||||
def test_scenario_names_problems_and_skips_empty_fields(self):
|
||||
fields = validation.parse_form(form(Result="Works with problems", GPU="_No response_"))
|
||||
self.assertEqual(validation.scenario(fields), "Works with problems: Default configuration · "
|
||||
"Volume on Proxmox storage · DHCP · Proxmox VE 9.1 · image 4.3")
|
||||
|
||||
def test_list_shows_image_and_scenario(self):
|
||||
record = {"applications": {"glances": {"community_tested": {
|
||||
"by": "Vaso73", "date": "2026-09-27", "digest": "b1f339cf08ae", "scenario": "Default | DHCP"}}}}
|
||||
self.assertIn("| `b1f339cf08ae` | Default \\| DHCP |", validation.render(record, INDEX))
|
||||
|
||||
def test_bad_digest_and_long_scenario_are_rejected(self):
|
||||
found = validation.problems({"applications": {"glances": {"community_tested": {
|
||||
"by": "a", "date": "2026-09-27", "digest": "XYZ", "scenario": "x" * 301}}}}, INDEX, today=TODAY)
|
||||
self.assertTrue(any("digest" in f for f in found) and any("scenario" in f for f in found), found)
|
||||
|
||||
|
||||
class IssueForm(unittest.TestCase):
|
||||
def test_form_labels_match_the_bot(self):
|
||||
text = (ROOT / ".github/ISSUE_TEMPLATE/oci-validation.yml").read_text(encoding="utf-8")
|
||||
labels = re.findall(r"^\s+label: (.+)$", text, re.M)
|
||||
self.assertIn("Application", labels)
|
||||
self.assertIn("Result", labels)
|
||||
self.assertIn("Image digest", labels)
|
||||
for name in validation.SCENARIO_FIELDS:
|
||||
self.assertIn(name, labels)
|
||||
self.assertIn("- Does not work", text)
|
||||
self.assertIn('labels: ["oci-validation"]', text)
|
||||
self.assertIn("template=oci-validation.yml", validation.FORM)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
|
||||
class OciWatchdogWiring(TestCase):
|
||||
def test_changing_it_needs_an_administrator_and_goes_through_the_engine(self):
|
||||
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
|
||||
route = re.search(r"@app\.route\('/api/lxc/<int:vmid>/oci-watchdog', methods=\['POST'\]\)\n@(\w+)\ndef (\w+)", server)
|
||||
self.assertEqual(route.group(1), 'require_admin_scope')
|
||||
body = server[route.end():server.index("@app.route('/api/vms/<int:vmid>/logs'")]
|
||||
self.assertIn("if not isinstance(enabled, bool):", body)
|
||||
self.assertIn("oci/engine/remote/oci_watchdog.py',", body)
|
||||
self.assertNotIn('shell=True', body)
|
||||
self.assertIn('"watchdog": False,', (ROOT / 'AppImage/scripts/oci_instance_info.py').read_text())
|
||||
|
||||
def test_the_toggle_is_only_offered_for_an_oci_application(self):
|
||||
modal = (ROOT / 'AppImage/components/virtual-machines.tsx').read_text()
|
||||
block = modal[modal.index('{/* Watchdog of an OCI application'):]
|
||||
self.assertTrue(block.split('\n', 3)[2].strip().startswith('{ociInstance?.oci_instance && ('))
|
||||
self.assertIn('t("vmLxc.details.watchdog")', block)
|
||||
self.assertIn('`/api/lxc/${selectedVM.vmid}/oci-watchdog`', modal)
|
||||
|
||||
def test_every_language_names_and_explains_it(self):
|
||||
for locale in LOCALES:
|
||||
details = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['vmLxc']['details']
|
||||
self.assertTrue(details['watchdog'].strip(), locale)
|
||||
self.assertTrue(details['watchdogHelp'].strip(), locale)
|
||||
|
||||
def test_the_engine_restarts_its_service_when_proxmenux_replaces_it(self):
|
||||
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
self.assertIn('systemctl try-restart proxmenux-oci-watchdog.service', (ROOT / installer).read_text(), installer)
|
||||
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
|
||||
self.assertIn("UNIT = 'proxmenux-oci-watchdog.service'", engine)
|
||||
|
||||
|
||||
class OciWatchdogInstaller(TestCase):
|
||||
CLI = (ROOT / 'oci/src/proxmenux_oci/cli.py').read_text()
|
||||
MENU = (ROOT / 'oci/src/proxmenux_oci/management.py').read_text()
|
||||
|
||||
def default(self, template):
|
||||
node = next(item for item in ast.parse(self.CLI).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'watchdog_default')
|
||||
scope = {'Any': object}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), 'cli.py', 'exec'), scope)
|
||||
return scope['watchdog_default'](template)
|
||||
|
||||
def test_the_recipe_decides_what_is_proposed(self):
|
||||
self.assertTrue(self.default({'container_contract': {'restart': 'unless-stopped'}}))
|
||||
self.assertTrue(self.default({'container_contract': {'restart': 'always'}}))
|
||||
self.assertTrue(self.default({'compose_stack': {'services': [{'compose': {}}, {'compose': {'restart': 'on-failure'}}]}}))
|
||||
self.assertFalse(self.default({'container_contract': {'restart': None}}))
|
||||
self.assertFalse(self.default({'container_contract': {'restart': 'no'}, 'compose_stack': {'services': [{'compose': {'restart': 'no'}}]}}))
|
||||
self.assertFalse(self.default({}))
|
||||
|
||||
def test_every_installation_asks_it_and_the_engine_never_receives_the_answer(self):
|
||||
install = self.CLI[self.CLI.index('def install_template('):self.CLI.index('def _rclone_mount(')]
|
||||
asked = install.index('deployment["watchdog"] = wizard.confirm(')
|
||||
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
|
||||
self.assertLess(asked, install.index('approved = wizard.review('))
|
||||
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
|
||||
self.assertIn('set_watchdog(PROJECT_ROOT, sorted(vmids), True)', install)
|
||||
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
|
||||
|
||||
def test_both_management_menus_offer_it(self):
|
||||
self.assertEqual(self.MENU.count("('watchdog', "), 2)
|
||||
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
|
||||
|
||||
def test_the_service_is_written_through_the_change_journal(self):
|
||||
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
|
||||
self.assertIn('pmx_write_file "$2" && systemctl daemon-reload && pmx_enable_service "$3"', engine)
|
||||
journal = (ROOT / 'scripts/global/pmx_journal.sh').read_text()
|
||||
for helper in ('pmx_journal_context()', 'pmx_write_file()', 'pmx_enable_service()'):
|
||||
self.assertIn(helper, journal)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Offline OCI wording seams; compile actual functions, never import operational modules."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
|
||||
TRANSACTION = ROOT / 'oci/remote/oci_instance_transaction.py'
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
CHOICE = 'Recover the previous installation'
|
||||
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
|
||||
AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
|
||||
NEW_KEYS = (
|
||||
'The Compose file describes several services:',
|
||||
'Only one service at a time can be installed this way.',
|
||||
'No docker run command was given',
|
||||
'Image selection was cancelled',
|
||||
'Could not inspect the image in its registry:',
|
||||
'Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.',
|
||||
BEFORE, AFTER,
|
||||
)
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def function(path, name, **dependencies):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
# Only read_definition's infrastructure import is omitted; keep the function intact.
|
||||
if name == 'read_definition':
|
||||
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
|
||||
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
|
||||
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
|
||||
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024)
|
||||
scope.update(dependencies)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||
str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class OciWordingTests(TestCase):
|
||||
def test_extractor_shipped_locales_and_synthetic_fallback(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
extracted = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(NEW_KEYS), len(set(NEW_KEYS)))
|
||||
self.assertTrue(set(NEW_KEYS) <= set(extracted), set(NEW_KEYS) - set(extracted))
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for key in NEW_KEYS:
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
|
||||
setattr(module, '_cache', {})
|
||||
for key in NEW_KEYS:
|
||||
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
|
||||
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
|
||||
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
|
||||
|
||||
def test_multiple_services_named_not_images(self):
|
||||
template = function(CUSTOM, 'template_from_compose',
|
||||
_services=lambda doc: doc['services'], _check_mounts=lambda _: None)
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
template(json.dumps({'services': {'alpha': {'image': 'same:latest'},
|
||||
'beta': {'image': 'same:latest'}}}))
|
||||
self.assertEqual(str(caught.exception),
|
||||
'The Compose file describes several services: alpha, beta. '
|
||||
'Only one service at a time can be installed this way.')
|
||||
|
||||
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
|
||||
for title, expected in ((None, 'No Compose file was given'),
|
||||
('docker run command of the application', 'No docker run command was given')):
|
||||
def paste(content):
|
||||
return function(CUSTOM, '_read_pasted',
|
||||
console=SimpleNamespace(show_logo=lambda: None, msg_title=lambda *_: None,
|
||||
msg_info2=lambda *_: None),
|
||||
sys=SimpleNamespace(stdin=io.StringIO(content)))
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
paste('')(None, title)
|
||||
self.assertEqual(str(caught.exception), expected)
|
||||
self.assertEqual(paste(' literal input\n')(None, title), ' literal input\n')
|
||||
|
||||
def test_cancelled_image_menu(self):
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
function(CUSTOM, 'read_definition')(SimpleNamespace(choose=lambda *_, **__: None))
|
||||
self.assertEqual(str(caught.exception), 'Image selection was cancelled')
|
||||
|
||||
def test_registry_nonzero_and_success_command_unchanged(self):
|
||||
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
|
||||
run = Mock(return_value=result)
|
||||
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run))
|
||||
self.assertEqual(report('example:latest'),
|
||||
(False, 'Could not inspect the image in its registry: example:latest'))
|
||||
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
|
||||
capture_output=True, text=True, check=False, timeout=120)
|
||||
run.reset_mock()
|
||||
run.return_value = SimpleNamespace(returncode=0,
|
||||
stdout='{"manifests":[{"platform":{"architecture":"amd64"}}]}', stderr='')
|
||||
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
|
||||
self.assertEqual(run.call_count, 1)
|
||||
|
||||
def test_failed_registry_inspection_has_neutral_advice(self):
|
||||
ui = SimpleNamespace(message=Mock())
|
||||
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
|
||||
ignored_settings=lambda _: [], template_from_compose=lambda _: {
|
||||
'container_contract': {'image': {'reference': 'example:latest'}}},
|
||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'))
|
||||
fn(ui)
|
||||
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n'
|
||||
'Check the image reference and registry access. If the registry is unavailable, '
|
||||
'try again later. A private registry needs credentials, which are not supported yet.')
|
||||
|
||||
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
|
||||
# Pin the real translated management choice, not an English AST constant alone.
|
||||
source = ast.parse(MENU.read_text())
|
||||
self.assertTrue(any(isinstance(n, ast.Tuple) and len(n.elts) == 2
|
||||
and isinstance(n.elts[0], ast.Constant) and n.elts[0].value == 'recover'
|
||||
and isinstance(n.elts[1], ast.Call) and isinstance(n.elts[1].func, ast.Name)
|
||||
and n.elts[1].func.id == 'translate' and n.elts[1].args
|
||||
and isinstance(n.elts[1].args[0], ast.Constant)
|
||||
and n.elts[1].args[0].value == CHOICE for n in ast.walk(source)))
|
||||
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
warnings = []
|
||||
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
|
||||
msg_warn=warnings.append)
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
label = module.translate(CHOICE)
|
||||
self.assertNotEqual(label, CHOICE, path.name)
|
||||
expected = [(cache.get(key) or key).replace('{choice}', label)
|
||||
if '{choice}' in (cache.get(key) or key) else key.replace('{choice}', label)
|
||||
for key in (BEFORE, AFTER)]
|
||||
self.assertEqual(warnings, expected, path.name)
|
||||
# An intentionally absent message key proves fallback independently of shipped catalogs.
|
||||
setattr(module, '_cache', {CHOICE: label})
|
||||
warnings.clear()
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
self.assertEqual(warnings, [BEFORE.replace('{choice}', label),
|
||||
AFTER.replace('{choice}', label)], (path.name, 'synthetic missing'))
|
||||
|
||||
def test_recovery_hint_uses_translated_whole_messages_and_malformed_fallback(self):
|
||||
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
module._language = 'it'
|
||||
for translation in ('Before: «{choice}».', 'Before: label missing.'):
|
||||
module._cache = {CHOICE: 'Recupero', BEFORE: translation,
|
||||
AFTER: 'After: «{choice}».'}
|
||||
warnings = []
|
||||
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
|
||||
msg_warn=warnings.append)
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
self.assertEqual(warnings, [
|
||||
('Before: «Recupero».' if '{choice}' in translation
|
||||
else BEFORE.replace('{choice}', 'Recupero')),
|
||||
'After: «Recupero».'])
|
||||
@@ -0,0 +1,71 @@
|
||||
"""A preview or a warning promises only what the code always does."""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
|
||||
def oci_menu_texts():
|
||||
tree = ast.parse((ROOT / 'oci/src/proxmenux_oci/management.py').read_text())
|
||||
return [node.args[0].value for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and getattr(node.func, 'id', '') == 'translate'
|
||||
and node.args and isinstance(node.args[0], ast.Constant)]
|
||||
|
||||
|
||||
class OutcomePromises(TestCase):
|
||||
def test_a_failure_is_not_promised_to_end_in_a_restore(self):
|
||||
texts = oci_menu_texts()
|
||||
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
|
||||
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
|
||||
self.assertEqual(len(previews), 3)
|
||||
|
||||
def test_the_recovery_preview_says_when_each_step_happens(self):
|
||||
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
|
||||
self.assertIn('If the container had already been changed', preview)
|
||||
self.assertIn('when the cleanup succeeds', preview)
|
||||
|
||||
def test_a_rclone_mount_is_not_listed_as_a_registration_problem(self):
|
||||
prompt = next(text for text in oci_menu_texts() if text.startswith('Until they are registered again'))
|
||||
self.assertIn('does not prevent it', prompt)
|
||||
self.assertIn('not started automatically', prompt)
|
||||
|
||||
def test_an_empty_retention_is_the_one_of_the_storage(self):
|
||||
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
|
||||
self.assertIn('Leave empty to use the retention of the storage.', menu)
|
||||
self.assertNotIn('keep every backup', menu)
|
||||
|
||||
def test_host_backups_are_said_to_follow_the_storage(self):
|
||||
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
|
||||
self.assertNotIn('stop running', menu)
|
||||
self.assertIn('Another job that writes to the same storage still starts them.', menu)
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
|
||||
self.assertNotIn('stop running', english['deleteAttached'] + english['storageAttached'])
|
||||
self.assertIn('scheduled runs', english['disableBody'])
|
||||
for locale in LOCALES:
|
||||
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
|
||||
for key in ('deleteAttached', 'storageAttached'):
|
||||
self.assertEqual(texts[key].count('{ids}'), 1, f'{locale} {key}')
|
||||
self.assertEqual(texts['disableBody'].count('. '), 1, f'{locale} disableBody')
|
||||
|
||||
def test_the_host_restore_notice_reports_tasks_and_does_not_call_the_node_ready(self):
|
||||
for locale in LOCALES:
|
||||
body = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['runtime']['notifications']['templates']['system_restore_completed']['body']
|
||||
self.assertEqual(body.split('\n')[-1], '{warnings_block}', locale)
|
||||
self.assertEqual(body.count('\n'), 7, locale)
|
||||
|
||||
def test_the_watchdog_failure_covers_an_application_that_did_not_start(self):
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']['oci_watchdog_failed']
|
||||
self.assertNotIn('keeps stopping', english['title'] + english['body'] + english['label'])
|
||||
self.assertIn('did not start', english['body'])
|
||||
|
||||
def test_other_wording_that_promised_too_much(self):
|
||||
texts = oci_menu_texts()
|
||||
self.assertIn('Private network kept, because other guests still use it:', texts)
|
||||
recreation = (ROOT / 'oci/src/proxmenux_oci/stack_recreation.py').read_text()
|
||||
self.assertNotIn('if anything fails', recreation)
|
||||
self.assertIn('a stopped one is left stopped', recreation)
|
||||
for script in ('scripts/post_install/customizable_post_install.sh', 'scripts/post_install/uninstall-tools.sh'):
|
||||
self.assertNotIn('leaving it unchanged', (ROOT / script).read_text(), script)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Offline actual scheduler gates; never source the administrative script."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCHEDULER = ROOT / 'scripts/backup_restore/backup_scheduler.sh'
|
||||
MESSAGES = {
|
||||
'pbs': 'No matching PVE vzdump jobs were found for the PBS backend.',
|
||||
'local': 'No matching PVE vzdump jobs were found for the local archive backend.',
|
||||
}
|
||||
HINT = 'Create one first in Datacenter → Backup, then return here to attach.'
|
||||
|
||||
|
||||
def function(text, name):
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}', start) + 2] + '\n'
|
||||
|
||||
|
||||
def consumers():
|
||||
text = SCHEDULER.read_text()
|
||||
attached = function(text, '_create_job_attached').split(' local pve_prune\n')[0] + '}\n'
|
||||
# Only the backend/attach selection gate, not ID/config writes or scheduling.
|
||||
new = function(text, '_create_job')
|
||||
new = '_create_job() {\n local id=fixture backend on_calendar\n' + new[new.index(' backend=$(dialog'):new.index(' on_calendar=$(dialog')]
|
||||
new += ' return 42\n}\n'
|
||||
return attached + new
|
||||
|
||||
|
||||
def fixture(directory, backend='pbs', consumer='attached', rows='', second=None,
|
||||
locale='en', catalog=None, real_dialog=None):
|
||||
directory = Path(directory)
|
||||
(directory / 'xx.json').write_text(json.dumps(catalog or {}))
|
||||
(directory / 'rows').write_text(rows)
|
||||
(directory / 'second').write_text(rows if second is None else second)
|
||||
bindir = directory / 'bin'
|
||||
bindir.mkdir(exist_ok=True)
|
||||
# Only jq is executable via PATH; wrapper validates all lookup arguments.
|
||||
(bindir / 'jq').symlink_to('/usr/bin/jq') if not (bindir / 'jq').exists() else None
|
||||
translation = function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
|
||||
filter_code = function((ROOT / 'scripts/backup_restore/lib_host_backup_common.sh').read_text(), 'hb_pve_list_vzdump_jobs_for_backend')
|
||||
preamble = f'''PATH={shlex.quote(str(bindir))}
|
||||
LANG_DIR={shlex.quote(str(directory))}
|
||||
LANGUAGE={shlex.quote(locale)}
|
||||
FIXTURE={shlex.quote(str(directory))}
|
||||
BACKEND={backend}
|
||||
HB_UI_MENU_H=20 HB_UI_MENU_W=84 HB_UI_MENU_LIST=10
|
||||
command_not_found_handle() {{ printf 'FORBIDDEN:%s\\n' "$*" >> "$FIXTURE/errors"; return 99; }}
|
||||
jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // .[($text | gsub("\\\\\\\\n"; "\\n"))] // empty' && "$6" == "$FIXTURE/xx.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }}
|
||||
head() {{ [[ "$*" == '-1' ]] || return 99; local first; IFS= read -r first; printf '%s\\n' "$first"; while IFS= read -r first; do :; done; }}
|
||||
hb_pve_list_vzdump_jobs() {{
|
||||
local file="$FIXTURE/rows" line
|
||||
[[ -f "$FIXTURE/read" ]] && file="$FIXTURE/second"
|
||||
printf read >> "$FIXTURE/read"
|
||||
while IFS= read -r line; do printf '%s\\n' "$line"; done < "$file"
|
||||
}}
|
||||
dialog() {{
|
||||
printf '%s\\0' "$@" >> "$FIXTURE/dialogs"
|
||||
local title='' kind='' message='' previous='' arg
|
||||
for arg in "$@"; do
|
||||
[[ "$previous" == --title ]] && title="$arg"
|
||||
[[ "$previous" == --msgbox || "$previous" == --menu ]] && message="$arg"
|
||||
[[ "$arg" == --msgbox || "$arg" == --menu ]] && kind="$arg"
|
||||
previous="$arg"
|
||||
done
|
||||
if [[ "$kind" == --msgbox ]]; then
|
||||
printf '%s' "$message" > "$FIXTURE/message"
|
||||
{'command ' + shlex.quote(real_dialog) + ' "$@"' if real_dialog else ':'}
|
||||
return 0
|
||||
fi
|
||||
case "$title" in
|
||||
"$(translate 'Backend')") printf '%s' "$BACKEND" >&2 ;;
|
||||
"$(translate 'How to schedule')") printf attach >&2 ;;
|
||||
"$(translate 'Pick PVE vzdump job')") return 1 ;;
|
||||
*) printf 'unexpected dialog:%s' "$title" >> "$FIXTURE/errors"; return 99 ;;
|
||||
esac
|
||||
}}
|
||||
'''
|
||||
return preamble + translation + filter_code + consumers() + f'\nif _create_job{"_attached fixture " + backend if consumer == "attached" else ""}; then rc=0; else rc=$?; fi\nprintf "%s" "$rc" > "$FIXTURE/status"\n'
|
||||
|
||||
|
||||
def run_case(**kwargs):
|
||||
with tempfile.TemporaryDirectory(prefix='scheduler-') as directory:
|
||||
code = fixture(directory, **kwargs)
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', code],
|
||||
env={'PATH': '/nonexistent', 'LC_ALL': 'C.UTF-8'}, capture_output=True, text=True)
|
||||
if result.returncode or result.stderr:
|
||||
raise AssertionError((result.returncode, result.stderr))
|
||||
p = Path(directory)
|
||||
if (p / 'errors').exists():
|
||||
raise AssertionError((p / 'errors').read_text())
|
||||
return {name: (p / name).read_text() if (p / name).exists() else ''
|
||||
for name in ('message', 'status', 'dialogs', 'read')}
|
||||
|
||||
|
||||
class SchedulerMessages(unittest.TestCase):
|
||||
def test_new_whole_message_and_separate_hint(self):
|
||||
for backend in MESSAGES:
|
||||
with self.subTest(backend=backend):
|
||||
result = run_case(backend=backend, consumer='new')
|
||||
self.assertEqual(result['message'], MESSAGES[backend] + '\n\n' + HINT)
|
||||
self.assertEqual(result['status'], '1')
|
||||
|
||||
def test_lookup_cache_modes_at_both_consumers(self):
|
||||
shipped = json.loads((ROOT / 'lang/it.json').read_text())
|
||||
for backend, message in MESSAGES.items():
|
||||
for consumer in ('attached', 'new'):
|
||||
for locale, catalog, expected in (
|
||||
('en', {message: 'ignored'}, message),
|
||||
('missing', {}, message),
|
||||
('xx', {'unrelated': 'unrelated'}, message),
|
||||
('xx', shipped, shipped.get(message, message)),
|
||||
('xx', {message: 'SYNTHETIC reordered backend: ' + backend},
|
||||
'SYNTHETIC reordered backend: ' + backend),
|
||||
):
|
||||
with self.subTest(backend=backend, consumer=consumer, locale=locale, expected=expected):
|
||||
result = run_case(backend=backend, consumer=consumer, locale=locale, catalog=catalog)
|
||||
suffix = '\n\n' + catalog.get(HINT, HINT) if consumer == 'new' else ''
|
||||
self.assertEqual(result['message'], expected + suffix)
|
||||
self.assertEqual(result['status'], '1')
|
||||
|
||||
def test_nonempty_menus_disabled_and_second_read_empty(self):
|
||||
for backend, storage in (('pbs', 'pbs'), ('local', 'nfs')):
|
||||
for count in (1, 3):
|
||||
rows = ''.join(f'job{i}\tstore{i}\t{storage}\tdaily\t-\t0\n' for i in range(count))
|
||||
for consumer in ('attached', 'new'):
|
||||
with self.subTest(backend=backend, count=count, consumer=consumer):
|
||||
result = run_case(backend=backend, consumer=consumer, rows=rows)
|
||||
self.assertEqual(result['message'], '')
|
||||
self.assertEqual(result['status'], '1') # cancel at actual parent picker
|
||||
for i in range(count):
|
||||
self.assertIn(f'job{i} · store{i} · daily (disabled)', result['dialogs'])
|
||||
result = run_case(backend=backend, consumer='new', rows=rows, second='')
|
||||
self.assertEqual(result['message'], MESSAGES[backend])
|
||||
self.assertEqual(result['read'], 'readread')
|
||||
self.assertEqual(result['status'], '1')
|
||||
|
||||
def test_actual_filter_types_and_enabled_states(self):
|
||||
for backend in MESSAGES:
|
||||
for storage in ('pbs', 'dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs', 'rbd', '-'):
|
||||
for enabled in ('0', '1'):
|
||||
with self.subTest(backend=backend, storage=storage, enabled=enabled):
|
||||
result = run_case(backend=backend, rows=f'job\tstore\t{storage}\tdaily\t-\t{enabled}\n')
|
||||
matches = storage == 'pbs' if backend == 'pbs' else storage in ('dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs')
|
||||
self.assertEqual(result['message'], '' if matches else MESSAGES[backend])
|
||||
if matches:
|
||||
self.assertEqual('(disabled)' in result['dialogs'], enabled == '0')
|
||||
|
||||
def test_borg_excludes_attach_gate(self):
|
||||
result = run_case(backend='borg', consumer='new')
|
||||
self.assertEqual(result['status'], '42') # stopped before real schedule/config work
|
||||
self.assertEqual(result['message'], '')
|
||||
self.assertEqual(result['read'], '')
|
||||
self.assertNotIn('How to schedule', result['dialogs'])
|
||||
|
||||
def test_actual_offline_extractor_discovers_whole_messages(self):
|
||||
spec = importlib.util.spec_from_file_location('scheduler_cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
discovered = module.extract_translate_texts(ROOT / 'scripts')
|
||||
for message in MESSAGES.values():
|
||||
self.assertIn(message, discovered)
|
||||
|
||||
def test_attached_whole_message(self):
|
||||
for backend in MESSAGES:
|
||||
with self.subTest(backend=backend):
|
||||
result = run_case(backend=backend)
|
||||
self.assertEqual(result['message'], MESSAGES[backend])
|
||||
self.assertEqual(result['status'], '1')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""A script writes a line break as \\n inside the text it translates; the
|
||||
language files store that text with a real line break."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def function(text, name):
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}\n', start) + 3]
|
||||
|
||||
|
||||
class TranslateLineBreaks(TestCase):
|
||||
def translate(self, text, catalog):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
(Path(folder) / 'xx.json').write_text(json.dumps(catalog))
|
||||
script = (f'LANGUAGE=xx LANG_DIR={folder}\n' + function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
|
||||
+ 'translate "$1"\n')
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script, 'bash', text],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual((result.returncode, result.stderr), (0, ''))
|
||||
return result.stdout.rstrip('\n')
|
||||
|
||||
def test_a_text_stored_with_a_real_line_break_is_found(self):
|
||||
catalog = {'Restore this backup?\nThe configuration is overwritten.': '¿Restaurar?\nSe sobrescribe.'}
|
||||
self.assertEqual(self.translate('Restore this backup?\\nThe configuration is overwritten.', catalog),
|
||||
'¿Restaurar?\nSe sobrescribe.')
|
||||
|
||||
def test_the_exact_text_is_preferred_and_a_missing_one_comes_back_unchanged(self):
|
||||
catalog = {'one\\ntwo': 'LITERAL', 'one\ntwo': 'REAL', 'plain': 'simple'}
|
||||
self.assertEqual(self.translate('one\\ntwo', catalog), 'LITERAL')
|
||||
self.assertEqual(self.translate('plain', catalog), 'simple')
|
||||
self.assertEqual(self.translate('not\\nthere', catalog), 'not\\nthere')
|
||||
|
||||
def test_the_shipped_texts_with_a_line_break_reach_the_spanish_file(self):
|
||||
spanish = json.loads((ROOT / 'lang/es.json').read_text())
|
||||
text = 'Are you sure you want to restore this backup?\\nCurrent configuration will be overwritten.'
|
||||
self.assertEqual(self.translate(text, spanish), spanish[text.replace('\\n', '\n')])
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Offline, bounded real Bash consumers: no administrative entrypoints are sourced."""
|
||||
import importlib.util
|
||||
import json
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / 'scripts'
|
||||
NETWORK = {
|
||||
'share/iscsi_host.sh': 1,
|
||||
'share/nfs_client.sh': 1,
|
||||
'share/nfs_host.sh': 2,
|
||||
'share/samba_client.sh': 1,
|
||||
'share/samba_host.sh': 2,
|
||||
}
|
||||
MOUNT = 'Mounting existing filesystem ({filesystem})...'
|
||||
RAID = 'If you are sure you want to use it, remove the RAID metadata or format it manually using external tools.'
|
||||
|
||||
|
||||
def function(text, name):
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}', start) + 2] + '\n'
|
||||
|
||||
|
||||
def network_blocks(path):
|
||||
lines = path.read_text().splitlines()
|
||||
blocks = []
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r'^\s*if (?:pct exec .* -- )?nc -z -w 2 ', line):
|
||||
depth = len(line) - len(line.lstrip())
|
||||
end = next(j for j in range(i + 1, len(lines))
|
||||
if lines[j].strip() == 'fi' and len(lines[j]) - len(lines[j].lstrip()) == depth)
|
||||
block = '\n'.join(lines[i:end + 1])
|
||||
assert '$(translate "Open")' in block and '$(translate "Closed")' in block or '$(translate "Probe failed")' in block
|
||||
blocks.append(block)
|
||||
return blocks
|
||||
|
||||
|
||||
def raid_dialog():
|
||||
"""The dialog that reports a RAID member, wherever it sits in the script."""
|
||||
lines = (SCRIPTS / 'storage/disk-passthrough_ct.sh').read_text().splitlines()
|
||||
message = next(i for i, line in enumerate(lines) if RAID in line)
|
||||
assert lines[message - 2].lstrip().startswith('dialog ')
|
||||
return '\n'.join(lines[message - 2:message + 1])
|
||||
|
||||
|
||||
def run(code, translations=None, statuses='1', variables='', dialog=False):
|
||||
translations = translations or {}
|
||||
with tempfile.TemporaryDirectory(prefix='tui-wording-') as directory:
|
||||
root = Path(directory)
|
||||
(root / 'xx.json').write_text(json.dumps(translations))
|
||||
body = f'''LANGUAGE=xx LANG_DIR={root!s}
|
||||
{function((SCRIPTS / 'utils.sh').read_text(), 'translate')}
|
||||
GN='\\033[32m' RD='\\033[31m' CL='\\033[0m'
|
||||
{variables}
|
||||
statuses=({statuses})
|
||||
nc() {{ local code=${{statuses[0]}}; statuses=("${{statuses[@]:1}}"); return "$code"; }}
|
||||
pct() {{ [[ "$1" == exec && "$3" == -- && "$4" == nc ]] || return 98; shift 3; nc "$@"; }}
|
||||
msg_info() {{ printf 'INFO:%s\\n' "$1"; }}
|
||||
msg_error() {{ printf 'ERROR:%s\\n' "$1"; }}
|
||||
msg_ok() {{ printf 'OK:%s\\n' "$1"; }}
|
||||
msg_title() {{ :; }}
|
||||
show_proxmenux_logo() {{ :; }}
|
||||
pmx_journal_context() {{ :; }}
|
||||
blkid() {{ [[ "$2" == TYPE ]] && printf 'ext4\\n' || printf 'UUID123\\n'; }}
|
||||
mkdir() {{ :; }}
|
||||
mount() {{ return 1; }}
|
||||
dialog() {{ printf 'DIALOG:%s\\n' "$*"; }}
|
||||
{code}
|
||||
'''
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', body],
|
||||
env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C.UTF-8'},
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if result.returncode or result.stderr:
|
||||
raise AssertionError((result.returncode, result.stderr, result.stdout))
|
||||
return result.stdout
|
||||
|
||||
|
||||
class TuiNetworkStorageWording(unittest.TestCase):
|
||||
def test_failed_tcp_probe_is_not_declared_closed_at_every_caller(self):
|
||||
for path, count in NETWORK.items():
|
||||
blocks = network_blocks(SCRIPTS / path)
|
||||
self.assertEqual(len(blocks), count, path)
|
||||
for index, block in enumerate(blocks):
|
||||
cases = [('0 1', 'Open'), ('1 1', 'Probe failed'), ('124 124', 'Probe failed')]
|
||||
if 'samba_' in path:
|
||||
cases.append(('1 0', 'Open'))
|
||||
for codes, expected in cases:
|
||||
with self.subTest(path=path, index=index, codes=codes):
|
||||
output = run(block, statuses=codes, translations={'Probe failed': 'SONDAGGIO FALLITO'})
|
||||
self.assertIn('SONDAGGIO FALLITO' if expected == 'Probe failed' else 'Open', output)
|
||||
self.assertNotIn('Closed', output)
|
||||
if expected == 'Open':
|
||||
self.assertNotIn('SONDAGGIO FALLITO', output)
|
||||
else:
|
||||
fallback = run(block, statuses=codes)
|
||||
self.assertIn('Probe failed', fallback)
|
||||
self.assertNotIn('Closed', fallback)
|
||||
|
||||
def test_mount_progress_is_whole_and_uses_real_lookup_with_fallback(self):
|
||||
source = (SCRIPTS / 'share/disk_host.sh').read_text()
|
||||
start = source.index('mount_existing_disk() {')
|
||||
portion = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\n'
|
||||
for translation, expected in [({}, MOUNT.replace('{filesystem}', 'ext4')),
|
||||
({MOUNT: 'SYNTHETIC {filesystem} in corso'}, 'SYNTHETIC ext4 in corso'),
|
||||
({MOUNT: 'SYNTHETIC malformed'}, MOUNT.replace('{filesystem}', 'ext4'))]:
|
||||
with self.subTest(translation=translation):
|
||||
output = run(portion + '\nmount_existing_disk /dev/fixture /mnt/fixture', translations=translation)
|
||||
self.assertIn('INFO:' + expected, output)
|
||||
self.assertNotIn('{filesystem}', output)
|
||||
|
||||
def test_raid_warning_is_complete_message_with_disk_identifier_and_fallback(self):
|
||||
invocation = raid_dialog() # the dialog invocation, not the raid detection command
|
||||
for translation, expected in [({}, RAID), ({RAID: 'SYNTHETIC RAID metadata remains'}, 'SYNTHETIC RAID metadata remains')]:
|
||||
with self.subTest(translation=translation):
|
||||
output = run(invocation, translations=translation,
|
||||
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture 2TiB' UI_RESULT_H=16 UI_RESULT_W=80")
|
||||
self.assertIn(expected, output)
|
||||
self.assertIn('/dev/fixture 2TiB', output)
|
||||
self.assertNotIn('remove the RAID metadata or format it manually using external tools. RAID metadata', output)
|
||||
|
||||
def test_shipped_catalogs_use_english_fallback_for_new_keys(self):
|
||||
catalogs = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertTrue(catalogs)
|
||||
block = network_blocks(SCRIPTS / 'share/iscsi_host.sh')[0]
|
||||
source = (SCRIPTS / 'share/disk_host.sh').read_text()
|
||||
start = source.index('mount_existing_disk() {')
|
||||
mount = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\nmount_existing_disk /dev/fixture /mnt/fixture'
|
||||
raid = raid_dialog()
|
||||
for path in catalogs:
|
||||
with self.subTest(locale=path.name):
|
||||
catalog = json.loads(path.read_text())
|
||||
self.assertIn(catalog.get('Probe failed') or 'Probe failed', run(block, statuses='1', translations=catalog))
|
||||
self.assertIn((catalog.get(MOUNT) or MOUNT).replace('{filesystem}', 'ext4'), run(mount, translations=catalog))
|
||||
self.assertIn(catalog.get(RAID) or RAID, run(raid, translations=catalog,
|
||||
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture' UI_RESULT_H=16 UI_RESULT_W=80"))
|
||||
|
||||
def test_extractor_discovers_whole_keys(self):
|
||||
spec = importlib.util.spec_from_file_location('cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
keys = module.extract_translate_texts(SCRIPTS)
|
||||
for key in (MOUNT, RAID, 'Probe failed'):
|
||||
self.assertTrue(key in keys, key)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Run the inert Node TUI consumer fixture in the existing offline i18n lane."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class TuiUpgradeGpuWording(unittest.TestCase):
|
||||
def test_source_extracted_node_consumer(self):
|
||||
fixture = ROOT / 'tests/test_tui_upgrade_gpu_wording.cjs'
|
||||
self.assertTrue(fixture.is_file(), 'Node fixture must be packaged with the candidate')
|
||||
node = os.environ.get('NODE_BINARY') or 'node'
|
||||
result = subprocess.run([node, str(fixture)], cwd=ROOT, capture_output=True,
|
||||
text=True, timeout=60, env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'})
|
||||
self.assertEqual(result.returncode, 0, result.stdout + '\n' + result.stderr)
|
||||
self.assertIn('PASS: extracted upgrade/GPU consumers', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""The VM/CT backup jobs feature is protected, shipped and translated."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
ROUTES = ROOT / 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
SCRIPT = ROOT / 'scripts/backup_restore/vm_backup_jobs.sh'
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
|
||||
class VmBackupJobsWiring(TestCase):
|
||||
def test_every_route_is_protected_and_every_change_needs_an_administrator(self):
|
||||
source = ROUTES.read_text()
|
||||
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
|
||||
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
|
||||
self.assertEqual(len(routes), 8)
|
||||
for path, method, guard, name in routes:
|
||||
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
|
||||
self.assertEqual(guard, expected, f'{method} {path} ({name})')
|
||||
|
||||
def test_proxmox_is_called_without_a_shell(self):
|
||||
source = ROUTES.read_text()
|
||||
self.assertNotIn('shell=True', source)
|
||||
self.assertNotIn('os.system', source)
|
||||
self.assertEqual(source.count('subprocess.run('), 1)
|
||||
self.assertIn('subprocess.run(["pvesh", *args]', source)
|
||||
|
||||
def test_the_monitor_ships_and_registers_it(self):
|
||||
self.assertIn('flask_vm_backup_routes.py', (ROOT / 'AppImage/scripts/build_appimage.sh').read_text())
|
||||
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
|
||||
self.assertIn('from flask_vm_backup_routes import vm_backup_bp', server)
|
||||
self.assertIn('app.register_blueprint(vm_backup_bp)', server)
|
||||
self.assertIn('<BackupTab ', (ROOT / 'AppImage/components/proxmox-dashboard.tsx').read_text())
|
||||
tab = (ROOT / 'AppImage/components/backup-tab.tsx').read_text()
|
||||
self.assertIn('<HostBackup />', tab)
|
||||
self.assertIn('<VmBackupJobs />', tab)
|
||||
# The backups of the host and those of the guests are separate views.
|
||||
self.assertNotIn('VmBackupJobs', (ROOT / 'AppImage/components/host-backup.tsx').read_text())
|
||||
|
||||
def test_every_text_of_the_card_exists_in_the_eight_languages(self):
|
||||
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
|
||||
used = set(re.findall(r'backup\.vmJobs\.([A-Za-z]+)', component))
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
|
||||
self.assertEqual(used - set(english), set())
|
||||
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
|
||||
for locale in LOCALES:
|
||||
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
|
||||
self.assertEqual(set(texts), set(english), locale)
|
||||
self.assertEqual(set(texts['presets']), set(english['presets']), locale)
|
||||
switch = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['viewSwitch']
|
||||
self.assertEqual(set(switch), {'ariaLabel', 'host', 'guests'}, locale)
|
||||
for key, value in english.items():
|
||||
if isinstance(value, str):
|
||||
self.assertTrue(texts[key].strip(), f'{locale} {key}')
|
||||
self.assertEqual(fields(texts[key]), fields(value), f'{locale} {key}')
|
||||
|
||||
def test_the_edit_dialog_follows_the_edit_mode_contrast_rule(self):
|
||||
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
|
||||
self.assertIn('bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background', component)
|
||||
|
||||
def test_the_menu_runs_a_job_as_a_proxmox_task_and_installs_nothing(self):
|
||||
source = SCRIPT.read_text()
|
||||
self.assertNotIn('apt-get', source)
|
||||
self.assertIn('pvesh create "/nodes/$node/vzdump"', source)
|
||||
self.assertEqual(re.findall(r'^\s*vzdump ', source, re.MULTILINE), [])
|
||||
menu = (ROOT / 'scripts/backup_restore/backup_host.sh').read_text()
|
||||
self.assertIn('bash "$LOCAL_SCRIPTS/backup_restore/vm_backup_jobs.sh"', menu)
|
||||
@@ -32,7 +32,11 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Third-party apt repos on the runner (Google Chrome, Microsoft)
|
||||
# sometimes serve a hash-mismatched index and abort the refresh.
|
||||
# They are irrelevant here, so a failed refresh must not stop the
|
||||
# build: python3 comes from the Ubuntu repos, which fetch fine.
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
@@ -42,6 +46,26 @@ jobs:
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Set up Python 3.11 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify bundled Python 3.11 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Set up Python 3.13 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Verify bundled Python 3.13 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
|
||||
@@ -32,7 +32,11 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Third-party apt repos on the runner (Google Chrome, Microsoft)
|
||||
# sometimes serve a hash-mismatched index and abort the refresh.
|
||||
# They are irrelevant here, so a failed refresh must not stop the
|
||||
# build: python3 comes from the Ubuntu repos, which fetch fine.
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
@@ -42,6 +46,26 @@ jobs:
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Set up Python 3.11 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify bundled Python 3.11 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Set up Python 3.13 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Verify bundled Python 3.13 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
|
||||
@@ -30,7 +30,11 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Third-party apt repos on the runner (Google Chrome, Microsoft)
|
||||
# sometimes serve a hash-mismatched index and abort the refresh.
|
||||
# They are irrelevant here, so a failed refresh must not stop the
|
||||
# build: python3 comes from the Ubuntu repos, which fetch fine.
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
@@ -40,6 +44,26 @@ jobs:
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Set up Python 3.11 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify bundled Python 3.11 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Set up Python 3.13 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Verify bundled Python 3.13 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
|
||||
@@ -32,7 +32,11 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Third-party apt repos on the runner (Google Chrome, Microsoft)
|
||||
# sometimes serve a hash-mismatched index and abort the refresh.
|
||||
# They are irrelevant here, so a failed refresh must not stop the
|
||||
# build: python3 comes from the Ubuntu repos, which fetch fine.
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
@@ -42,6 +46,26 @@ jobs:
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Set up Python 3.11 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify bundled Python 3.11 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Set up Python 3.13 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Verify bundled Python 3.13 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
|
||||
@@ -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,129 @@
|
||||
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'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/remote/*.sh'
|
||||
- 'oci/remote/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- '.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 \
|
||||
--extra-dir oci/remote \
|
||||
--python-dir scripts \
|
||||
--python-dir oci/src \
|
||||
--python-dir oci/remote \
|
||||
--catalog-dir oci/catalog \
|
||||
--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
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Label OCI validation pull requests
|
||||
|
||||
# A pull request that changes verification.json gets the "oci-validation"
|
||||
# label, which adds it to the ProxMenux Roadmap. The pull request's code is
|
||||
# never checked out: only its list of changed files is read.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
paths:
|
||||
- 'oci/catalog/verification.json'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh api "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels" -f "labels[]=oci-validation" --silent
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Record OCI validation
|
||||
|
||||
# When an OCI validation report is validated, the report's author is recorded
|
||||
# in oci/catalog/verification.json on develop and oci/VALIDATION.md is
|
||||
# regenerated. A report is validated by the "validated" label, which only users
|
||||
# with write access can add, or by a /validated comment from a user listed in
|
||||
# .github/oci-validation-reviewers, checked by the script. The issue body and
|
||||
# the comment are read from the event file, never interpolated into a shell.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: oci-validation-record
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
record:
|
||||
if: >-
|
||||
contains(github.event.issue.labels.*.name, 'oci-validation') && (
|
||||
(github.event_name == 'issues' && github.event.label.name == 'validated') ||
|
||||
(github.event_name == 'issue_comment' && !github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/validated')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Record the report
|
||||
id: record
|
||||
run: |
|
||||
set +e
|
||||
python3 .github/scripts/oci_validation.py record --event "$GITHUB_EVENT_PATH" --message "$RUNNER_TEMP/reply.md"
|
||||
echo "code=$?" >> "$GITHUB_OUTPUT"
|
||||
- name: Commit + push
|
||||
if: steps.record.outputs.code == '0'
|
||||
env:
|
||||
APP: ${{ steps.record.outputs.app }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add oci/catalog/verification.json oci/VALIDATION.md
|
||||
git commit -m "chore(oci): record the validation of $APP (#$NUMBER)"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin HEAD:develop; then
|
||||
exit 0
|
||||
fi
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $(( attempt * 3 ))
|
||||
done
|
||||
echo "push failed after 5 attempts"
|
||||
exit 1
|
||||
- name: Reply and close
|
||||
if: steps.record.outputs.code == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
|
||||
gh issue close "$NUMBER" --repo "$GITHUB_REPOSITORY" --reason completed
|
||||
- name: Explain why it was not recorded
|
||||
if: steps.record.outputs.code == '2'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
|
||||
if [ "$GITHUB_EVENT_NAME" = issues ]; then
|
||||
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels/validated" --silent
|
||||
fi
|
||||
- name: Fail on an unexpected error
|
||||
if: steps.record.outputs.code != '0' && steps.record.outputs.code != '2'
|
||||
run: exit 1
|
||||
@@ -0,0 +1,49 @@
|
||||
name: OCI validation record
|
||||
|
||||
# verification.json is the record of OCI applications tested for real, and
|
||||
# oci/VALIDATION.md is generated from it. A pull request only adds or changes
|
||||
# the community tests credited to its own author.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'oci/catalog/verification.json'
|
||||
- 'oci/catalog/index.json'
|
||||
- 'oci/VALIDATION.md'
|
||||
- '.github/scripts/oci_validation.py'
|
||||
- '.github/workflows/oci-validation.yml'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'oci/catalog/verification.json'
|
||||
- 'oci/catalog/index.json'
|
||||
- 'oci/VALIDATION.md'
|
||||
- '.github/scripts/oci_validation.py'
|
||||
- '.github/workflows/oci-validation.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Check the record and oci/VALIDATION.md
|
||||
run: python3 .github/scripts/oci_validation.py check
|
||||
- name: Check who the pull request credits
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
git show "$BASE:oci/catalog/verification.json" > "$RUNNER_TEMP/base.json" 2>/dev/null || : > "$RUNNER_TEMP/base.json"
|
||||
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
|
||||
@@ -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,67 @@
|
||||
name: Offline i18n tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/scripts/**'
|
||||
- '.github/workflows/test-i18n.yml'
|
||||
- '.github/workflows/build-i18n-messages.yml'
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'CONTRIBUTING.md'
|
||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'lang/*.json'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- 'oci/remote/**/*.sh'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- '.github/scripts/**'
|
||||
- '.github/workflows/test-i18n.yml'
|
||||
- '.github/workflows/build-i18n-messages.yml'
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'CONTRIBUTING.md'
|
||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'lang/*.json'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- 'oci/remote/**/*.sh'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
offline-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Check scheduler test dependencies
|
||||
run: |
|
||||
test -x /bin/bash
|
||||
test -x /usr/bin/jq
|
||||
- name: Install OCI wording test dependency
|
||||
run: python3 -m pip install 'PyYAML>=6,<7'
|
||||
- name: Run complete offline script test suite
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s .github/scripts/tests -v
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Qualified offline tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-offline-qualified.yml'
|
||||
- '.github/scripts/run_offline_qualified.py'
|
||||
- '.github/ci/offline-node/**'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'tests/**'
|
||||
- 'AppImage/package.json'
|
||||
- 'AppImage/package-lock.json'
|
||||
- 'AppImage/scripts/*.py'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
- 'AppImage/hooks/**'
|
||||
- 'AppImage/lib/**'
|
||||
- 'AppImage/messages/**'
|
||||
- 'lang/*.json'
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- '.github/workflows/test-offline-qualified.yml'
|
||||
- '.github/scripts/run_offline_qualified.py'
|
||||
- '.github/ci/offline-node/**'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'tests/**'
|
||||
- 'AppImage/package.json'
|
||||
- 'AppImage/package-lock.json'
|
||||
- 'AppImage/scripts/*.py'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
- 'AppImage/hooks/**'
|
||||
- 'AppImage/lib/**'
|
||||
- 'AppImage/messages/**'
|
||||
- 'lang/*.json'
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
offline-python:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Run eight qualified Python files
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
|
||||
|
||||
offline-node:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install only locked fixture dependencies (no lifecycle scripts)
|
||||
run: |
|
||||
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules
|
||||
- name: Run eleven qualified Node files
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane node
|
||||
@@ -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
|
||||
+8
-6
@@ -41,13 +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`.
|
||||
# Search index written by `build`: pagefind --site out produces it into
|
||||
# public/pagefind/ and the GitHub Pages CI regenerates it on every deploy,
|
||||
# so committing it would only bloat the repo and add noise to `git status`.
|
||||
/web/public/pagefind/
|
||||
|
||||
# Left ignored so the copy that `sync:scripts` used to generate disappears
|
||||
# from a working tree that still has it. The docs link to the scripts on
|
||||
# GitHub, never to a copy served from the site, and pagefind indexes HTML
|
||||
# only — so nothing read it and the static export was publishing it anyway.
|
||||
/web/public/scripts/
|
||||
|
||||
# Cache
|
||||
|
||||
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -1 +1 @@
|
||||
e0128ac327ea74b645b37bcfab03aa744f067a355f9960a822b411c6ac75cb02 ProxMenux-1.2.2.AppImage
|
||||
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -113,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>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} 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
|
||||
@@ -22,8 +23,8 @@ import { APP_VERSION } from "./release-notes-modal"
|
||||
// without re-cluttering the dashboard footer.
|
||||
|
||||
interface LinkRow {
|
||||
label: string
|
||||
description: string
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
href: string
|
||||
Icon: React.ComponentType<{ className?: string }>
|
||||
accent?: keyof typeof ACCENT_CLASSES
|
||||
@@ -42,29 +43,29 @@ const ACCENT_CLASSES = {
|
||||
|
||||
const PROJECT_LINKS: LinkRow[] = [
|
||||
{
|
||||
label: "GitHub repository",
|
||||
description: "Source code, releases and issue tracker.",
|
||||
labelKey: "about.links.repository.label",
|
||||
descriptionKey: "about.links.repository.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux",
|
||||
Icon: Github,
|
||||
accent: "gray",
|
||||
},
|
||||
{
|
||||
label: "Documentation",
|
||||
description: "Full user guide for ProxMenux and the Monitor.",
|
||||
labelKey: "about.links.documentation.label",
|
||||
descriptionKey: "about.links.documentation.description",
|
||||
href: "https://proxmenux.com",
|
||||
Icon: BookOpen,
|
||||
accent: "blue",
|
||||
},
|
||||
{
|
||||
label: "Discussions",
|
||||
description: "Ask questions, share custom AI prompts, swap ideas.",
|
||||
labelKey: "about.links.discussions.label",
|
||||
descriptionKey: "about.links.discussions.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux/discussions",
|
||||
Icon: MessageSquare,
|
||||
accent: "purple",
|
||||
},
|
||||
{
|
||||
label: "Report a bug or request a feature",
|
||||
description: "Open an issue on GitHub — bugs, ideas, regressions.",
|
||||
labelKey: "about.links.issues.label",
|
||||
descriptionKey: "about.links.issues.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux/issues",
|
||||
Icon: Bug,
|
||||
accent: "red",
|
||||
@@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [
|
||||
|
||||
const SUPPORT_LINKS: LinkRow[] = [
|
||||
{
|
||||
label: "Support the project on Ko-fi",
|
||||
description: "ProxMenux is free and open source. Donations cover hosting and dev time.",
|
||||
labelKey: "about.links.support.label",
|
||||
descriptionKey: "about.links.support.description",
|
||||
href: "https://ko-fi.com/macrimi",
|
||||
Icon: Heart,
|
||||
accent: "pink",
|
||||
@@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [
|
||||
]
|
||||
|
||||
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
|
||||
@@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{row.label}
|
||||
{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">{row.description}</p>
|
||||
<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. */}
|
||||
@@ -120,7 +123,7 @@ export function About() {
|
||||
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
|
||||
<Image
|
||||
src="/images/proxmenux-logo.png"
|
||||
alt="ProxMenux logo"
|
||||
alt={t("about.logoAlt")}
|
||||
fill
|
||||
priority
|
||||
className="object-contain"
|
||||
@@ -131,9 +134,7 @@ export function About() {
|
||||
ProxMenux Monitor
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A web dashboard and management layer for Proxmox VE — health monitoring,
|
||||
notifications, terminal, optimization tracker and more, packaged as a single
|
||||
AppImage.
|
||||
{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">
|
||||
@@ -151,7 +152,7 @@ export function About() {
|
||||
const href = isPrerelease
|
||||
? "https://github.com/MacRimi/ProxMenux/releases"
|
||||
: "https://proxmenux.com/en/changelog"
|
||||
const label = isPrerelease ? "Release notes" : "Changelog"
|
||||
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@@ -175,9 +176,9 @@ export function About() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Github className="h-4 w-4 text-muted-foreground" />
|
||||
Project
|
||||
{t("about.project.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>Repository, documentation and community channels.</CardDescription>
|
||||
<CardDescription>{t("about.project.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
@@ -195,11 +196,10 @@ export function About() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Heart className="h-4 w-4 text-pink-500" />
|
||||
Support & License
|
||||
{t("about.support.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
ProxMenux is free and open source under the GPL-3.0 license. If it's useful to
|
||||
you, a one-off contribution helps keep it that way.
|
||||
{t("about.support.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -218,11 +218,11 @@ export function About() {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
GPL-3.0 license
|
||||
{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">
|
||||
Free software — see the LICENSE file for the full text.
|
||||
{t("about.license.description")}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import { useId, useState } from "react"
|
||||
import { Info, ExternalLink, Check, Loader2, Trash2 } from "lucide-react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
import { Label } from "./ui/label"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
export type AppUpdateMethod = "none" | "helper" | "custom"
|
||||
|
||||
export function AppUpdaterEditor({ method, command, helperAvailable, helperSlug, configured, saving, changed,
|
||||
onMethodChange, onCommandChange, onSave, onCancel, onRemove }: {
|
||||
method: AppUpdateMethod
|
||||
command: string
|
||||
helperAvailable: boolean
|
||||
helperSlug?: string
|
||||
configured: boolean
|
||||
saving: boolean
|
||||
changed: boolean
|
||||
onMethodChange: (method: AppUpdateMethod) => void
|
||||
onCommandChange: (command: string) => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const commandId = useId()
|
||||
const [help, setHelp] = useState<"helper" | "custom" | null>(null)
|
||||
const scriptUrl = helperSlug && /^[a-z0-9][a-z0-9._-]*$/.test(helperSlug)
|
||||
? `https://github.com/community-scripts/ProxmoxVE/blob/main/ct/${helperSlug}.sh` : null
|
||||
// Keep the editable command readable. Download guards belong to the runner,
|
||||
// which also protects this literal launcher when saved as a custom command.
|
||||
const helperCommand = scriptUrl
|
||||
? `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${helperSlug}.sh)"`
|
||||
: null
|
||||
const valid = method === "helper" ? helperAvailable && !!helperCommand : method === "custom" && !!command.trim()
|
||||
const displayedCommand = method === "helper" ? (helperCommand || "") : command
|
||||
const editCommand = (value: string) => {
|
||||
// Edited launchers belong to the existing custom-command execution path.
|
||||
// Never leave the method as "helper": saving it would discard the edits.
|
||||
if (method === "helper") {
|
||||
if (value.trim() === (helperCommand || "").trim()) return
|
||||
onMethodChange("custom")
|
||||
}
|
||||
onCommandChange(value)
|
||||
}
|
||||
const customExamples = [
|
||||
{ title: "customScriptTitle", description: "customScriptDescription", command: "/opt/my-app/update.sh" },
|
||||
{ title: "customPackageTitle", description: "customPackageDescription", command: "apt-get update &&\napt-get install -y --only-upgrade my-package" },
|
||||
{ title: "customBinaryTitle", description: "customBinaryDescription", command: "install -b -m 0755 /tmp/my-app.new /opt/my-app/my-app &&\nsystemctl restart my-app" },
|
||||
]
|
||||
const onlineExampleCommand = `script=$(mktemp) || exit 1
|
||||
trap 'rm -f "$script"' EXIT
|
||||
curl -fsSL 'https://example.com/my-app/update.sh' -o "$script" &&
|
||||
bash "$script"`
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.updaterChoiceHint")}</p>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t("vmLxc.updates.updaterMethodLabel")}>
|
||||
{([...(helperAvailable ? ["helper" as const] : []), "custom" as const]).map((choice) => (
|
||||
<div key={choice} className="flex items-center gap-1">
|
||||
<Button type="button" size="sm" variant={method === choice ? "default" : "outline"}
|
||||
aria-pressed={method === choice} disabled={saving} onClick={() => onMethodChange(choice)}>
|
||||
{t(`vmLxc.updates.${choice === "helper" ? "helperMethod" : "customMethod"}`)}
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400" onClick={() => setHelp(choice)}
|
||||
aria-label={t(`vmLxc.updates.${choice === "helper" ? "helperMethodHelp" : "customMethodHelp"}`)}>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{helperAvailable && <p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperDetectedChoice")}</p>}
|
||||
{method === "helper" && (!helperAvailable || !helperCommand) && (
|
||||
<p className="text-xs text-amber-500">{t("vmLxc.updates.helperUnavailableChoice")}</p>
|
||||
)}
|
||||
{(method === "helper" || method === "custom") && (
|
||||
<div>
|
||||
<Label htmlFor={commandId} className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t(`vmLxc.updates.${method === "helper" ? "helperCommandLabel" : "customCommandLabel"}`)}
|
||||
</Label>
|
||||
<Textarea id={commandId} value={displayedCommand} onChange={(event) => editCommand(event.target.value)}
|
||||
placeholder={t("vmLxc.updates.customCommandPlaceholder")} disabled={saving}
|
||||
className="font-mono text-xs mt-2 min-h-[100px]" maxLength={4096} />
|
||||
{method === "helper" && helperCommand && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandEditHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>{configured && (
|
||||
<Button type="button" size="sm" variant="outline" className="text-red-400" disabled={saving} onClick={onRemove}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />{t("vmLxc.updates.disableUpdater")}
|
||||
</Button>
|
||||
)}</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" variant="outline" disabled={saving} onClick={onCancel}>{t("vmLxc.updates.cancelButton")}</Button>
|
||||
<Button type="button" size="sm" className="bg-blue-500 hover:bg-blue-600 text-white" disabled={saving || !valid || !changed} onClick={onSave}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1.5" />}
|
||||
{t("vmLxc.updates.saveButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={help !== null} onOpenChange={(open) => { if (!open) setHelp(null) }}>
|
||||
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(`vmLxc.updates.${help === "helper" ? "helperMethod" : "customMethod"}`)}</DialogTitle>
|
||||
<DialogDescription>{t(`vmLxc.updates.${help === "helper" ? "helperMethodDescription" : "customMethodDescription"}`)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{help === "helper" ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
{helperCommand && <div className="min-w-0 space-y-2">
|
||||
<p className="font-medium">{t("vmLxc.updates.helperCommandLabel")}</p>
|
||||
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{helperCommand}</code></pre>
|
||||
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandFallback")}</p>
|
||||
</div>}
|
||||
<p>{t("vmLxc.updates.updaterInstructions")}</p>
|
||||
<a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href="https://community-scripts.org/docs/tools/pve/update-apps" target="_blank" rel="noopener noreferrer">
|
||||
{t("vmLxc.updates.helperDocumentation")}<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
{scriptUrl && <div><a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href={scriptUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("vmLxc.updates.helperSource")}<ExternalLink className="h-4 w-4" />
|
||||
</a></div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="font-medium">{t("vmLxc.updates.customExamplesHeading")}</p>
|
||||
{customExamples.map((example) => <div key={example.title} className="min-w-0 space-y-2">
|
||||
<p className="font-medium">{t(`vmLxc.updates.${example.title}`)}</p>
|
||||
<p className="text-muted-foreground">{t(`vmLxc.updates.${example.description}`)}</p>
|
||||
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{example.command}</code></pre>
|
||||
{example.title === "customScriptTitle" && <>
|
||||
<p className="text-muted-foreground">{t("vmLxc.updates.customOnlineDescription")}</p>
|
||||
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{onlineExampleCommand}</code></pre>
|
||||
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.customOnlineExampleNote")}</p>
|
||||
</>}
|
||||
</div>)}
|
||||
<p className="text-muted-foreground">{t("vmLxc.updates.customMethodExample")}</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
"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"
|
||||
import { getCategoryLabel } from "../lib/category-label"
|
||||
|
||||
// ─── 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) => getCategoryLabel(t, a[0]).localeCompare(getCategoryLabel(t, b[0]))),
|
||||
[categoryCounts, t],
|
||||
)
|
||||
|
||||
// ─── 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) ||
|
||||
getCategoryLabel(t, 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 = getCategoryLabel(t, catA).localeCompare(getCategoryLabel(t, 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}>{`${getCategoryLabel(t, 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
|
||||
}) {
|
||||
const t = useT()
|
||||
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>{getCategoryLabel(t, 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, tab: "app" | "updates" = "app") {
|
||||
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
||||
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid, tab } }))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// The update icon leads to where the update is applied: the Updates
|
||||
// tab of the container.
|
||||
const goToUpdates = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (link.vmid == null) return
|
||||
openLxcModalOnAppTab(link.vmid, "updates")
|
||||
}
|
||||
|
||||
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (link.customId) onEditCustom(link.customId)
|
||||
}
|
||||
|
||||
const guestPrefix = link.guestType === "qemu" ? "VM" : "LXC"
|
||||
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 && (
|
||||
link.vmid != null && link.guestType !== "qemu" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToUpdates}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToUpdates(e) }}
|
||||
className="rounded-full flex-shrink-0 self-start mt-0.5 text-purple-400 hover:text-purple-300 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t("apps.openUpdatesAria", { name: link.appName })}
|
||||
title={t("apps.openUpdatesTitle")}
|
||||
>
|
||||
<ArrowUpCircle className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<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={getCategoryLabel(t, link.category)}
|
||||
>
|
||||
{getCategoryLabel(t, link.category)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import {
|
||||
ChevronDown, ChevronRight, FileCode, HelpCircle, Loader2, Package,
|
||||
Play, Settings2, Sparkles, Terminal, Wrench,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
/**
|
||||
* What ProxMenux changed on this host.
|
||||
*
|
||||
* The complaint this answers is not that the tool changes things: it is
|
||||
* that afterwards nobody can say what it changed. Showing the script
|
||||
* does not answer it either — a function of four hundred lines may alter
|
||||
* two values, and the reader cannot tell which two. So what is shown
|
||||
* here is the difference and nothing else.
|
||||
*
|
||||
* Two distinctions are kept in front of the reader, because both bear on
|
||||
* what they can do about what they are looking at: whether ProxMenux
|
||||
* authored the change or merely ran something the user asked for, and
|
||||
* how much of the previous state is actually known.
|
||||
*/
|
||||
|
||||
interface Change {
|
||||
id: number
|
||||
recorded_at: number
|
||||
class: string
|
||||
operation: string
|
||||
source: string
|
||||
function: string
|
||||
function_version: string
|
||||
target: string
|
||||
before_ref: string
|
||||
after_ref: string
|
||||
capture: string
|
||||
revert: string
|
||||
exactness: string
|
||||
result: string
|
||||
recoverable: boolean
|
||||
detail: Record<string, unknown>
|
||||
diff?: {
|
||||
available: boolean; reason?: string
|
||||
added?: number; removed?: number; truncated?: boolean; hunks?: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total: number
|
||||
by_class: Record<string, number>
|
||||
functions: Array<{
|
||||
function: string; source: string; version: string
|
||||
changes: number; last_change: number; first_change: number
|
||||
}>
|
||||
journal_started: number | null
|
||||
}
|
||||
|
||||
// A file that did not exist before was created, not replaced — a sysadmin
|
||||
// reading this must see "new file", not "file replaced". `capture` carries
|
||||
// that distinction: "created" for a file born here, "present" for one that
|
||||
// already had contents we captured before overwriting them.
|
||||
function operationLabelKey(change: { operation: string; capture: string }): string {
|
||||
// Two truthful labels for a file: created if it did not exist, modified if
|
||||
// it did. The diff below shows exactly what changed either way, so there is
|
||||
// no need to distinguish write/edit/append in the label.
|
||||
if (["write_file", "edit_file", "append_file"].includes(change.operation)) {
|
||||
return change.capture === "created" ? "operation.file_created" : "operation.file_modified"
|
||||
}
|
||||
return `operation.${change.operation}`
|
||||
}
|
||||
|
||||
// Undoing follows `revert`, not `exactness`: a created file is undone by
|
||||
// deleting it (there was nothing before), an overwritten one by restoring
|
||||
// what we captured.
|
||||
function undoKey(change: { revert: string; exactness: string }): string {
|
||||
if (change.revert === "remove") return "undo.remove"
|
||||
if (change.revert === "restore" && change.exactness === "exact") return "undo.restore"
|
||||
if (change.revert === "purge") return "undo.purge"
|
||||
return `exactness.${change.exactness}`
|
||||
}
|
||||
|
||||
const FN_LABEL: Record<string, string> = {
|
||||
// Internal helpers of a post-install feature show its menu name, not their
|
||||
// raw name — and never the source ("auto"/"customizable").
|
||||
_update_existing_log2ram_auto: "Install and configure Log2RAM",
|
||||
_update_existing_log2ram_custom: "Install and configure Log2RAM",
|
||||
update_snapshot_schedule: "Install ZFS auto-snapshot",
|
||||
apply_amd_fixes: "Apply AMD CPU fixes",
|
||||
apply_network_optimizations: "Apply network optimizations",
|
||||
apt_upgrade: "Update and upgrade system",
|
||||
cleanup_duplicate_repos_pve9: "Configure Proxmox APT repositories",
|
||||
configure_fastfetch: "Install and configure Fastfetch",
|
||||
configure_figurine: "Install Figurine",
|
||||
configure_kernel_panic: "Enable restart on kernel panic",
|
||||
configure_log2ram: "Install and configure Log2RAM",
|
||||
configure_pigz: "Use pigz for faster gzip compression",
|
||||
configure_time_sync: "Synchronize time automatically",
|
||||
customize_bashrc: "Customize bashrc",
|
||||
disable_rpc: "Disable portmapper/rpcbind",
|
||||
enable_ha: "Enable High Availability services",
|
||||
enable_kexec: "Enable fast reboots",
|
||||
enable_tcp_fast_open: "Enable TCP BBR/Fast Open control",
|
||||
enable_vfio_iommu: "Enable VFIO IOMMU support",
|
||||
enable_zfs_autotrim: "Enable ZFS autotrim (SSD/NVMe pools)",
|
||||
force_apt_ipv4: "Force APT to use IPv4",
|
||||
increase_system_limits: "Increase various system limits",
|
||||
install_ceph: "Add latest Ceph support",
|
||||
install_guest_agent: "Install relevant guest agent",
|
||||
install_log2ram: "Install and configure Log2RAM",
|
||||
install_log2ram_auto: "Install and configure Log2RAM",
|
||||
install_openvswitch: "Install Open vSwitch",
|
||||
install_ovh_rtm: "Install OVH Real Time Monitoring",
|
||||
install_system_utils: "Install common system utilities",
|
||||
install_zfs_auto_snapshot: "Install ZFS auto-snapshot",
|
||||
optimize_journald: "Optimize journald",
|
||||
optimize_logrotate: "Optimize logrotate",
|
||||
optimize_memory_settings: "Optimize Memory",
|
||||
optimize_vzdump: "Increase vzdump backup speed",
|
||||
optimize_zfs_arc: "Optimize ZFS ARC size",
|
||||
remove_subscription_banner: "Remove subscription banner",
|
||||
setup_motd: "Set up custom MOTD banner",
|
||||
setup_persistent_network: "Interface Names (persistent)",
|
||||
setup_proxmox_repositories: "Configure Proxmox APT repositories",
|
||||
skip_apt_languages: "Skip downloading additional languages",
|
||||
update_pve8: "Update and upgrade system",
|
||||
update_pve9: "Update and upgrade system",
|
||||
update_pve_appliance_manager: "Update Proxmox VE Appliance Manager",
|
||||
}
|
||||
|
||||
// Post-install functions run from the auto/customizable scripts; everything
|
||||
// else is a general host script (nvidia/tpu installers, PVE update, vfio…).
|
||||
const POST_INSTALL_SOURCES = new Set(["auto", "customizable"])
|
||||
|
||||
// A few sources deserve a friendly name instead of a raw script identifier;
|
||||
// everything else shows the script it came from.
|
||||
const FRIENDLY_SOURCE = new Set(["install_proxmenux", "monitor"])
|
||||
|
||||
// Which of the three sections a change belongs to: installations are their
|
||||
// own block, post-install optimizations another, general scripts the rest.
|
||||
function blockOf(c: { class: string; source: string }): "installs" | "postInstall" | "scripts" {
|
||||
if (c.class === "installation") return "installs"
|
||||
if (POST_INSTALL_SOURCES.has(c.source)) return "postInstall"
|
||||
return "scripts"
|
||||
}
|
||||
|
||||
// A post-install function shows its menu name; anything else shows the script
|
||||
// that made the change.
|
||||
function groupLabel(fn: string, source: string): string {
|
||||
// A post-install change shows its feature's menu name; if the function is
|
||||
// not a known optimization, its own name — never the bare source.
|
||||
return FN_LABEL[fn] || fn || source || "—"
|
||||
}
|
||||
|
||||
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof Settings2 }> = {
|
||||
configuration: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Settings2 },
|
||||
installation: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: Package },
|
||||
execution: { chip: "bg-muted text-muted-foreground border-border", Icon: Play },
|
||||
registration: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
|
||||
}
|
||||
|
||||
function ChangeCard({ change, expanded, onToggle, t, when }: {
|
||||
change: Change; expanded: boolean; onToggle: () => void
|
||||
t: (k: string, params?: Record<string, string>) => string
|
||||
when: (n: number) => string
|
||||
}) {
|
||||
const style = CLASS_STYLE[change.class] || CLASS_STYLE.registration
|
||||
const Icon = style.Icon
|
||||
const installed = String(change.detail?.installed || "")
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="w-full text-left p-3 flex flex-wrap items-center gap-2
|
||||
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<Badge variant="outline" className={`${style.chip} gap-1.5 shrink-0`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.changes.${operationLabelKey(change)}`)}
|
||||
</Badge>
|
||||
<span className="min-w-0 font-mono text-sm text-foreground break-all">
|
||||
{change.target}
|
||||
</span>
|
||||
{change.diff?.available && (
|
||||
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
|
||||
+{change.diff.added} −{change.diff.removed}
|
||||
</Badge>
|
||||
)}
|
||||
{change.capture === "unknown" && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{t("audit.changes.capture.unknown")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{when(change.recorded_at)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<CardContent className="pt-0 pl-10 space-y-3">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("audit.changes.source")}:{" "}
|
||||
<span className="font-mono">{change.source || "—"}</span></span>
|
||||
{change.revert && change.revert !== "none" && (
|
||||
<span>{t("audit.changes.reversibility")}:{" "}
|
||||
{t(`audit.changes.${undoKey(change)}`)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(change.operation === "enable_service" || change.operation === "disable_service")
|
||||
&& Boolean(change.detail?.before_state || change.detail?.after_state) && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{String(change.detail?.before_state || "—").replace(/\s+/g, " ")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="font-mono text-foreground">
|
||||
{String(change.detail?.after_state || "—").replace(/\s+/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installed && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.changes.packagesAdded")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.split(/\s+/).filter(Boolean).map((pkg) => (
|
||||
<Badge key={pkg} variant="outline" className="font-mono text-xs">
|
||||
{pkg}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{change.class === "execution" && Boolean(change.detail?.command) && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.changes.commandRun")}
|
||||
</p>
|
||||
<pre className="text-xs font-mono bg-background border border-border
|
||||
rounded-md p-2.5 overflow-x-auto">
|
||||
{String(change.detail.command)}
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("audit.changes.executionNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{change.diff && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.changes.difference")}
|
||||
</p>
|
||||
{change.diff.available ? (
|
||||
<>
|
||||
<pre className="text-xs font-mono bg-background border border-border
|
||||
rounded-md p-2.5 overflow-x-auto">
|
||||
{(change.diff.hunks || []).map((line: string, i: number) => (
|
||||
<div key={i} className={
|
||||
line.startsWith("+") ? "text-green-500"
|
||||
: line.startsWith("-") ? "text-red-400"
|
||||
: line.startsWith("@@") ? "text-blue-400" : ""
|
||||
}>{line}</div>
|
||||
))}
|
||||
</pre>
|
||||
{change.diff.truncated && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("audit.changes.diffTruncated")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.changes.diffUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{change.capture === "unknown" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.changes.unknownNote")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupSection({ title, icon: Icon, iconClass, groups, openFn, toggleFn, open, toggle, t, when }: {
|
||||
title: string
|
||||
icon: typeof Settings2
|
||||
iconClass: string
|
||||
groups: { key: string; label: string; version: string; last: number; items: Change[] }[]
|
||||
openFn: Set<string>; toggleFn: (k: string) => void
|
||||
open: Set<number>; toggle: (id: number) => void
|
||||
t: (k: string, params?: Record<string, string>) => string
|
||||
when: (n: number) => string
|
||||
}) {
|
||||
if (groups.length === 0) return null
|
||||
return (
|
||||
// Padding on the child: the container's space-y-4 overrides any mt-*.
|
||||
<div className="space-y-2 pt-2">
|
||||
{/* Same type and icon size as CardTitle, so a block heading here reads
|
||||
exactly like a card heading on the Settings page. */}
|
||||
<h3 className="px-1 flex items-center gap-2 text-2xl font-semibold
|
||||
leading-none tracking-tight text-foreground">
|
||||
<Icon className={`h-5 w-5 shrink-0 ${iconClass}`} />
|
||||
{title}
|
||||
</h3>
|
||||
{groups.map((g) => {
|
||||
const fnOpen = openFn.has(g.key)
|
||||
return (
|
||||
<Card key={g.key} className="bg-card border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFn(g.key)}
|
||||
aria-expanded={fnOpen}
|
||||
className="w-full text-left p-3 flex flex-wrap items-center gap-2
|
||||
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{fnOpen
|
||||
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<FileCode className="h-4 w-4 shrink-0 text-blue-400" />
|
||||
<span className="min-w-0 text-sm font-medium text-foreground break-words">
|
||||
{g.label}
|
||||
</span>
|
||||
{g.version && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">v{g.version}</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
|
||||
{t("audit.changes.count", { count: String(g.items.length) })}
|
||||
</Badge>
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{when(g.last)}
|
||||
</span>
|
||||
</button>
|
||||
{fnOpen && (
|
||||
<CardContent className="pt-0 pl-10 pr-3 space-y-2">
|
||||
{g.items.map((change) => (
|
||||
<ChangeCard key={change.id} change={change}
|
||||
expanded={open.has(change.id)} onToggle={() => toggle(change.id)}
|
||||
t={t} when={when} />
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditChanges() {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const [changes, setChanges] = useState<Change[]>([])
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState<Set<number>>(new Set())
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await fetchApi("/api/audit/changes?limit=500")
|
||||
if (res?.success) {
|
||||
setChanges(res.changes || [])
|
||||
setSummary(res.summary || null)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(res?.message || t("audit.changes.failed"))
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const toggle = (id: number) => setOpen((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const [openFn, setOpenFn] = useState<Set<string>>(new Set())
|
||||
const toggleFn = (fn: string) => setOpenFn((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(fn) ? next.delete(fn) : next.add(fn)
|
||||
return next
|
||||
})
|
||||
|
||||
// A sysadmin reads this in three sections: what ProxMenux optimized
|
||||
// (post-install), what its other scripts changed, and what it installed.
|
||||
// Within each, changes are grouped under a card that opens to reveal them.
|
||||
type Group = { key: string; label: string; version: string; last: number; items: Change[] }
|
||||
const blocks = useMemo(() => {
|
||||
const mk = () => new Map<string, Group>()
|
||||
const post = mk(), scripts = mk(), installs = mk()
|
||||
const pick = (b: string) => b === "installs" ? installs : b === "postInstall" ? post : scripts
|
||||
for (const c of changes) {
|
||||
const b = blockOf(c)
|
||||
const target = pick(b)
|
||||
// Post-install groups by function under its menu name; the script and
|
||||
// install sections group by the script that made the change, and show
|
||||
// that script's name — not a per-function label.
|
||||
// The same script can appear in more than one section (it changed
|
||||
// config and also installed a package), so the accordion key is scoped
|
||||
// by section — otherwise opening one card opens its twin elsewhere.
|
||||
const rawKey = b === "postInstall" ? groupLabel(c.function, c.source) : (c.source || c.function || "—")
|
||||
const key = `${b}:${rawKey}`
|
||||
const label = b === "postInstall"
|
||||
? groupLabel(c.function, c.source)
|
||||
: FRIENDLY_SOURCE.has(c.source)
|
||||
? t(`audit.changes.scriptLabel.${c.source}`)
|
||||
: (c.source || c.function || "—")
|
||||
const g = target.get(key) || { key, label, version: "", last: 0, items: [] }
|
||||
g.items.push(c)
|
||||
if (c.recorded_at > g.last) g.last = c.recorded_at
|
||||
if (b === "postInstall" && c.function_version) g.version = c.function_version
|
||||
target.set(key, g)
|
||||
}
|
||||
const sort = (m: Map<string, Group>) => Array.from(m.values()).sort((a, b) => b.last - a.last)
|
||||
return { post: sort(post), scripts: sort(scripts), installs: sort(installs) }
|
||||
}, [changes, t])
|
||||
|
||||
const when = (epoch: number) => new Date(epoch * 1000).toLocaleString(language)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.changes.loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="py-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("audit.changes.intro")}</p>
|
||||
{/* A host with nothing recorded should say why, rather than
|
||||
looking like a host nothing has touched. */}
|
||||
{summary && summary.total === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("audit.changes.empty")}</p>
|
||||
)}
|
||||
{summary && summary.journal_started && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.changes.since", { date: when(summary.journal_started) })}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<GroupSection title={t("audit.changes.section.postInstall")}
|
||||
icon={Sparkles} iconClass="text-orange-500"
|
||||
groups={blocks.post} openFn={openFn} toggleFn={toggleFn}
|
||||
open={open} toggle={toggle} t={t} when={when} />
|
||||
<GroupSection title={t("audit.changes.section.scripts")}
|
||||
icon={Terminal} iconClass="text-cyan-500"
|
||||
groups={blocks.scripts} openFn={openFn} toggleFn={toggleFn}
|
||||
open={open} toggle={toggle} t={t} when={when} />
|
||||
<GroupSection title={t("audit.changes.section.installs")}
|
||||
icon={Wrench} iconClass="text-purple-400"
|
||||
groups={blocks.installs} openFn={openFn} toggleFn={toggleFn}
|
||||
open={open} toggle={toggle} t={t} when={when} />
|
||||
{summary && summary.total > 0
|
||||
&& blocks.post.length + blocks.scripts.length + blocks.installs.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground px-1">{t("audit.changes.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import {
|
||||
ArrowDownRight, ArrowUpRight, ChevronDown, ChevronRight, Flag, Loader2,
|
||||
MinusCircle, PlusCircle, ShieldOff, TrendingUp,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
/**
|
||||
* How this assessment differs from an earlier one.
|
||||
*
|
||||
* A single run says what the host is like now. It cannot say whether
|
||||
* that is better or worse than last week, which is the question anyone
|
||||
* maintaining a machine actually asks — and the one that turns an audit
|
||||
* from a snapshot into a record.
|
||||
*
|
||||
* The distinction the engine draws and this view keeps: a finding that
|
||||
* stopped being reported because the host was fixed is not the same as
|
||||
* one that stopped because somebody accepted it. Both leave the list;
|
||||
* only the first is progress, and merging them would tell the reader a
|
||||
* problem went away when the decision was to live with it.
|
||||
*
|
||||
* The same care applies to a finding that is still reported. One that
|
||||
* was already failing has not appeared now, so it is shown as having got
|
||||
* worse or better with where it came from, rather than as new — reading
|
||||
* "new" against work that lowered a critical to a warning would punish
|
||||
* exactly the reader who fixed something.
|
||||
*
|
||||
* It sits inside the assessment rather than in a view of its own,
|
||||
* because "what changed since last time" is context for the run being
|
||||
* read, not a separate place to visit.
|
||||
*/
|
||||
|
||||
interface Finding {
|
||||
check_id: string
|
||||
area: string
|
||||
classification: string
|
||||
// Only the findings that moved carry where they came from.
|
||||
previous_classification?: string
|
||||
previous_affected?: number
|
||||
affected_count?: number
|
||||
}
|
||||
|
||||
interface Comparison {
|
||||
from: string
|
||||
to: string
|
||||
new: Finding[]
|
||||
worse: Finding[]
|
||||
better: Finding[]
|
||||
resolved: Finding[]
|
||||
accepted: Finding[]
|
||||
unchanged: Finding[]
|
||||
retired: Finding[]
|
||||
unverified: Finding[]
|
||||
}
|
||||
|
||||
/** What moved, in the reader's terms: the gravity when that is what
|
||||
* changed, otherwise how many objects the finding now covers. */
|
||||
function movement(f: Finding, t: (k: string) => string): string | null {
|
||||
if (!f.previous_classification) return null
|
||||
if (f.previous_classification !== f.classification) {
|
||||
return `${t(`audit.classifications.${f.previous_classification}`)} → ${t(
|
||||
`audit.classifications.${f.classification}`,
|
||||
)}`
|
||||
}
|
||||
if (f.previous_affected === undefined || f.affected_count === undefined) return null
|
||||
return `${f.previous_affected} → ${f.affected_count}`
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{ key: "new", Icon: PlusCircle, tone: "text-amber-500" },
|
||||
{ key: "worse", Icon: ArrowUpRight, tone: "text-red-400" },
|
||||
{ key: "better", Icon: ArrowDownRight, tone: "text-emerald-400" },
|
||||
{ key: "resolved", Icon: MinusCircle, tone: "text-green-500" },
|
||||
{ key: "accepted", Icon: ShieldOff, tone: "text-indigo-400" },
|
||||
{ key: "retired", Icon: Flag, tone: "text-muted-foreground" },
|
||||
] as const
|
||||
|
||||
export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
|
||||
runId: string
|
||||
isBaseline: boolean
|
||||
onBaselineSet: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const [comparison, setComparison] = useState<Comparison | null>(null)
|
||||
const [baseline, setBaseline] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// fetchApi turns a non-2xx response into an Error whose message is the
|
||||
// backend's own English prose and whose `body` carries the parsed
|
||||
// payload. Both paths therefore go through here: only the reason code
|
||||
// crosses into a view that exists in eight languages.
|
||||
const reason = (source: any): string => {
|
||||
const code = String(source?.reason ?? source?.body?.reason ?? "")
|
||||
const key = `audit.comparison.reasons.${code}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : t("audit.comparison.failed")
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Asked for separately: a host with a single run answers the
|
||||
// comparison with an error, and inside a Promise.all that error
|
||||
// takes the status with it — leaving no way to tell a first
|
||||
// assessment from a comparison that genuinely failed, which is
|
||||
// how "two runs are required" reached a reader who had simply
|
||||
// never chosen a reference.
|
||||
const status: any = await fetchApi("/api/audit/status").catch(() => null)
|
||||
setBaseline(status?.baseline || null)
|
||||
|
||||
// With no reference chosen there is nothing to compare against,
|
||||
// and asking anyway answered 400 — an error in the browser console
|
||||
// for the ordinary state of a host assessed for the first time.
|
||||
let diff: any = null
|
||||
let failure: unknown = null
|
||||
if (status?.baseline) {
|
||||
try {
|
||||
diff = await fetchApi(`/api/audit/compare?to=${encodeURIComponent(runId)}`)
|
||||
} catch (e) {
|
||||
failure = e
|
||||
}
|
||||
}
|
||||
setComparison(diff?.success ? diff : null)
|
||||
// Having no reference run yet is the ordinary state of a host
|
||||
// assessed for the first time, and the view already says so.
|
||||
const failed = failure ?? (diff?.success === false ? diff : null)
|
||||
setError(failed && status?.baseline ? reason(failed) : null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const markBaseline = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res: any = await fetchApi("/api/audit/baseline", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
if (res?.success) { onBaselineSet(); await load() }
|
||||
else setError(reason(res))
|
||||
} catch (e) {
|
||||
setError(reason(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t("audit.comparison.loading")}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const counts = comparison
|
||||
? GROUPS.map((g) => ({ ...g, n: (comparison[g.key] || []).length }))
|
||||
.filter((g) => g.n > 0)
|
||||
: []
|
||||
const comparable = comparison && comparison.from !== comparison.to
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
{!comparable ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full flex-wrap items-center gap-2 rounded-md -mx-2 px-2 py-1
|
||||
text-left hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||
<TrendingUp className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("audit.comparison.since", {
|
||||
date: baseline?.started_at
|
||||
? new Date(baseline.started_at * 1000).toLocaleDateString(language)
|
||||
: t("audit.comparison.previousRun"),
|
||||
})}
|
||||
</span>
|
||||
{counts.length === 0 ? (
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{t("audit.comparison.noChange")}
|
||||
</Badge>
|
||||
) : counts.map(({ key, Icon, tone, n }) => (
|
||||
<Badge key={key} variant="outline" className={`text-sm gap-1.5 ${tone}`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.comparison.${key}`)}
|
||||
<span className="tabular-nums font-semibold">{n}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 pl-6">
|
||||
{GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map(
|
||||
({ key, Icon, tone }) => (
|
||||
<div key={key}>
|
||||
<p className={`flex items-center gap-1.5 text-sm font-medium mb-1 ${tone}`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.comparison.${key}`)}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— {t(`audit.comparison.${key}Note`)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(comparison[key] || []).map((f) => {
|
||||
const moved = movement(f, t)
|
||||
return (
|
||||
<Badge key={f.check_id} variant="outline" className="text-xs">
|
||||
{t(`audit.checks.${f.check_id}.title`)}
|
||||
{moved && (
|
||||
<span className="ml-1.5 font-normal text-muted-foreground tabular-nums">
|
||||
{moved}
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{(comparison.unchanged || []).length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("audit.comparison.unchanged", {
|
||||
count: String(comparison.unchanged.length),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Choosing a reference is what makes every later run comparable,
|
||||
so the action lives beside the comparison it enables. */}
|
||||
{!isBaseline && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={markBaseline}
|
||||
disabled={saving}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{saving
|
||||
? <Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||
: <Flag className="h-3 w-3 mr-1.5" />}
|
||||
{t("audit.comparison.setBaseline")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { parseEvidence, type EvidenceBlock } from "../lib/evidence-format"
|
||||
|
||||
/**
|
||||
* Renders a finding's evidence as tables and labelled values.
|
||||
*
|
||||
* The evidence exists so a reader can verify the conclusion for
|
||||
* themselves. A JSON dump technically contains the same facts but asks
|
||||
* the reader to parse it first, which is the part they came here to
|
||||
* avoid.
|
||||
*/
|
||||
export function AuditEvidence({ evidence, locale }: {
|
||||
evidence: string | null
|
||||
locale: string
|
||||
}) {
|
||||
const blocks = parseEvidence(evidence, locale)
|
||||
if (blocks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => <Block key={i} block={block} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Block({ block }: { block: EvidenceBlock }) {
|
||||
const title = block.title
|
||||
? <p className="text-xs font-medium text-foreground mb-1">{block.title}</p>
|
||||
: null
|
||||
|
||||
if (block.kind === "table") {
|
||||
return (
|
||||
<div>
|
||||
{title}
|
||||
{/* Wide evidence scrolls inside its own box so the page itself
|
||||
never scrolls sideways. */}
|
||||
{/* Evidence is the widest thing on the page; on a narrow screen
|
||||
it stacks like the rest rather than scrolling sideways. */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{block.rows.map((row, i) => (
|
||||
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
||||
{row.map((cell, j) => cell === "—" || cell === "" ? null : (
|
||||
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{block.columns[j]}</span>
|
||||
<span className="text-xs text-foreground tabular-nums break-words min-w-0">
|
||||
{cell}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/50">
|
||||
{block.columns.map((c) => (
|
||||
<th key={c} className="text-left font-medium px-3 py-1.5
|
||||
text-muted-foreground whitespace-nowrap">
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-border">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="px-3 py-1.5 align-top tabular-nums">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.kind === "pairs") {
|
||||
return (
|
||||
<div>
|
||||
{title}
|
||||
<dl className="grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1 text-xs
|
||||
rounded-md border border-border px-3 py-2">
|
||||
{block.entries.map(([label, value]) => (
|
||||
<div key={label} className="contents">
|
||||
<dt className="text-muted-foreground whitespace-nowrap">{label}</dt>
|
||||
<dd className="text-foreground break-words tabular-nums">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{title}
|
||||
{block.lines.length > 0 && (
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 rounded-md
|
||||
border border-border px-3 py-2">
|
||||
{block.lines.map((line, i) => (
|
||||
<li key={i} className="break-words">{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { presentFinding, type PresentedFinding, type AuditTranslate } from "../lib/audit-presentation"
|
||||
|
||||
export function AuditFindingData({ finding, t, locale }: {
|
||||
finding: PresentedFinding; t: AuditTranslate; locale: string
|
||||
}) {
|
||||
return <div className="space-y-4">{presentFinding(finding, t, locale).map((group, index) =>
|
||||
<section key={index}>
|
||||
<p className="text-sm font-medium mb-2">{group.title}</p>
|
||||
{group.note && <p className="text-sm text-muted-foreground mb-2">{group.note}</p>}
|
||||
{/* Wide on a screen that has the width; stacked where it does
|
||||
not, so a heading stays beside the value it belongs to instead
|
||||
of scrolling away from it. */}
|
||||
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-muted-foreground"><tr>{group.columns.map((column, i) =>
|
||||
<th key={i} className="px-3 py-2 text-left font-medium">{column}</th>)}</tr></thead>
|
||||
<tbody>{group.rows.map((row, i) => <tr key={i} className="border-t border-border">
|
||||
{row.cells.map((cell, j) => <td key={j} className="px-3 py-2 align-top break-words tabular-nums">{cell}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sm:hidden space-y-2">{group.rows.map((row, i) =>
|
||||
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
||||
{row.cells.map((cell, j) => cell === "" || cell === "—" ? null : (
|
||||
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{group.columns[j]}</span>
|
||||
<span className="text-sm text-foreground tabular-nums break-words min-w-0">{cell}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>)}</div>
|
||||
</section>)}</div>
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import {
|
||||
Activity, Boxes, ChevronDown, ChevronRight, CircuitBoard, Cpu, HardDrive,
|
||||
Loader2, MemoryStick, Network, Package, Plug, Server, Share2, Wrench,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
import { subscriptionLabel } from "../lib/audit-presentation"
|
||||
|
||||
interface UplinkHop { kind: string; id: string; mode?: string; role?: string }
|
||||
interface GuestDisk {
|
||||
slot: string; storage: string | null; volume: string
|
||||
size: string; passthrough?: boolean
|
||||
}
|
||||
interface GuestNic {
|
||||
slot: string; name: string; bridge: string; mac: string; vlan: string
|
||||
uplink: UplinkHop[] | null
|
||||
}
|
||||
interface GuestBackup { job: string; storage: string; schedule: string; retention: string }
|
||||
interface Guest {
|
||||
vmid: number; type: string; name: string; cores: string; memory: string
|
||||
ostype: string; onboot: boolean; tags: string; protected: boolean
|
||||
unprivileged: boolean | null; features: string | null
|
||||
agent: boolean | null; cpu: string | null
|
||||
disks: GuestDisk[]; interfaces: GuestNic[]; backups: GuestBackup[]
|
||||
}
|
||||
interface Inventory {
|
||||
collected_at: number
|
||||
node: string
|
||||
unavailable: Record<string, string>
|
||||
sections: {
|
||||
identity?: Record<string, string | null>
|
||||
cluster?: any
|
||||
hardware?: any
|
||||
storages?: any[]
|
||||
guests?: Guest[]
|
||||
passthrough?: any[]
|
||||
applications?: any[]
|
||||
custom_links?: any[]
|
||||
proxmenux?: { optimizations: any[]; pending_updates: any[] }
|
||||
network?: { bridges: Record<string, any> } | null
|
||||
latency?: { window: string; targets: any[] } | null
|
||||
}
|
||||
}
|
||||
|
||||
const GiB = 1024 ** 3
|
||||
|
||||
function bytes(value: number | null | undefined): string {
|
||||
if (!value || value <= 0) return "—"
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
|
||||
let n = value, i = 0
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||
return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
if (value === null || value === undefined || value === "") return null
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm text-foreground break-words">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One table shape for the whole view.
|
||||
*
|
||||
* The inventory is read across as much as down — a disk's model beside
|
||||
* its bus beside its wear — so the sections that enumerate things share
|
||||
* a single table rather than each inventing its own row layout.
|
||||
*
|
||||
* On a narrow screen the same rows are stacked instead. A disk table is
|
||||
* eight columns wide; sideways scrolling technically fits it on a phone,
|
||||
* but reading a row then means dragging back and forth to pair each
|
||||
* value with its heading. Stacked, the heading travels with the value.
|
||||
*/
|
||||
function DataTable({ columns, rows }: {
|
||||
columns: string[]
|
||||
rows: React.ReactNode[][]
|
||||
}) {
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
{columns.map((c, i) => (
|
||||
<th key={i} className="pb-2 pr-4 font-medium whitespace-nowrap">{c}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-border">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="py-2 pr-4 align-top tabular-nums">{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-2">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
||||
{row.map((cell, j) => {
|
||||
// A cell with nothing in it would leave a heading standing
|
||||
// alone, which reads as missing data rather than as absent.
|
||||
if (cell === null || cell === undefined || cell === "" || cell === "—") return null
|
||||
return (
|
||||
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{columns[j]}</span>
|
||||
<span className="text-sm text-foreground tabular-nums break-words min-w-0">
|
||||
{cell}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Mono({ children }: { children: React.ReactNode }) {
|
||||
return <span className="font-mono text-xs text-muted-foreground">{children}</span>
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon, title, count, children, note,
|
||||
}: {
|
||||
icon: React.ReactNode; title: string; count?: number
|
||||
children: React.ReactNode; note?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 rounded-md -mx-2 -my-1 px-2 py-1
|
||||
text-left hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-base font-semibold text-foreground">
|
||||
{icon}<span className="min-w-0 break-words">{title}</span>
|
||||
</CardTitle>
|
||||
{count !== undefined && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0 text-xs tabular-nums">{count}</Badge>
|
||||
)}
|
||||
</button>
|
||||
</CardHeader>
|
||||
{open && (
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
{note && <p className="text-xs text-muted-foreground">{note}</p>}
|
||||
{children}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** A heading inside a section, matching the printed document's. */
|
||||
function Sub({ icon, title, note }: {
|
||||
icon: React.ReactNode; title: string; note?: string
|
||||
}) {
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-foreground mt-4 mb-1 first:mt-0">
|
||||
{icon}{title}
|
||||
{note && <span className="font-normal text-muted-foreground">— {note}</span>}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// The uplink is the point of the network section: a bridge on its own
|
||||
// says nothing, the path from it to the wire is what an operator needs.
|
||||
function Uplink({ hops }: { hops: UplinkHop[] | null }) {
|
||||
const t = useT()
|
||||
if (hops === null) {
|
||||
return <span className="text-xs text-muted-foreground italic">{t("audit.inventory.unresolved")}</span>
|
||||
}
|
||||
if (hops.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">{t("audit.inventory.noUplink")}</span>
|
||||
}
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1 text-xs">
|
||||
{hops.map((h, i) => (
|
||||
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-muted-foreground">→</span>}
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{h.id}{h.mode ? ` · ${h.mode}` : ""}
|
||||
</Badge>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditInventory({ profile = "full" }: { profile?: string }) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const [data, setData] = useState<Inventory | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [openGuest, setOpenGuest] = useState<Set<number>>(new Set())
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await fetchApi(
|
||||
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
|
||||
if (res?.success) { setData(res.inventory); setError(null) }
|
||||
else setError(res?.message || t("audit.inventory.failed"))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally { setLoading(false) }
|
||||
}, [t, profile])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const apps = useMemo(() => {
|
||||
const byGuest = new Map<number, any[]>()
|
||||
for (const a of data?.sections.applications || []) {
|
||||
if (!byGuest.has(a.vmid)) byGuest.set(a.vmid, [])
|
||||
byGuest.get(a.vmid)!.push(a)
|
||||
}
|
||||
return byGuest
|
||||
}, [data])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.inventory.loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
|
||||
if (!data) return null
|
||||
|
||||
const s = data.sections
|
||||
const hw = s.hardware || {}
|
||||
const mem = hw.memory || {}
|
||||
const when = (value: number | string | null | undefined) => {
|
||||
if (!value) return "—"
|
||||
const date = typeof value === "number"
|
||||
? new Date(value * 1000)
|
||||
: new Date(/[Z+]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value}Z`)
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(language)
|
||||
}
|
||||
const toggle = (vmid: number) => setOpenGuest((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(vmid) ? next.delete(vmid) : next.add(vmid)
|
||||
return next
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground px-1">
|
||||
{t("audit.inventory.collectedAt", {
|
||||
when: new Date(data.collected_at * 1000).toLocaleString(language),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* Sections that could not be read are named, so an empty list is
|
||||
never mistaken for a section that was read and found nothing. */}
|
||||
{Object.keys(data.unavailable || {}).length > 0 && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="py-3 space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("audit.inventory.unavailable")}
|
||||
</p>
|
||||
{Object.entries(data.unavailable).map(([k, v]) => (
|
||||
<p key={k} className="text-xs text-muted-foreground">
|
||||
<span className="font-mono">{k}</span> — {v}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{s.identity && (
|
||||
<Section icon={<Server className="h-4 w-4 text-blue-500" />} title={t("audit.inventory.identity")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("audit.inventory.node")} value={s.identity?.node} />
|
||||
<Field label={t("audit.inventory.pveVersion")} value={s.identity?.pve_version} />
|
||||
<Field label={t("audit.inventory.kernel")} value={s.identity?.kernel} />
|
||||
<Field label={t("audit.inventory.subscription")} value={subscriptionLabel(t, s.identity?.subscription)} />
|
||||
<Field label={t("audit.inventory.cluster")}
|
||||
value={s.identity?.cluster || t("audit.inventory.standalone")} />
|
||||
<Field label={t("audit.document.system")}
|
||||
value={[hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ")} />
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{"cluster" in s && (
|
||||
<Section icon={<Share2 className="h-4 w-4 text-cyan-500" />} title={t("audit.document.cluster")}
|
||||
count={s.cluster ? (s.cluster.nodes || []).length : undefined}
|
||||
note={s.cluster ? undefined : t("audit.document.standaloneNote")}>
|
||||
{s.cluster && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field label={t("audit.inventory.cluster")} value={s.cluster.name} />
|
||||
<Field label={t("audit.document.quorum")}
|
||||
value={s.cluster.quorate == null ? "—" : (
|
||||
<Badge variant="outline" className={s.cluster.quorate
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"}>
|
||||
{t(s.cluster.quorate ? "audit.document.quorate"
|
||||
: "audit.document.inquorate")}
|
||||
</Badge>
|
||||
)} />
|
||||
<Field label={t("audit.document.votes")}
|
||||
value={`${s.cluster.total_votes ?? "—"} / ${s.cluster.expected_votes ?? "—"}`} />
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[t("audit.document.nodeName"), "nodeid", "ring0", "ring1",
|
||||
t("audit.document.state")]}
|
||||
rows={(s.cluster.nodes || []).map((n: any) => [
|
||||
<span className="font-medium text-foreground">
|
||||
{n.name}
|
||||
{n.local && <span className="text-muted-foreground">
|
||||
{" "}({t("audit.document.thisNode")})</span>}
|
||||
</span>,
|
||||
<Mono>{n.nodeid || "—"}</Mono>,
|
||||
<Mono>{n.ring0_addr || "—"}</Mono>,
|
||||
<Mono>{n.ring1_addr || "—"}</Mono>,
|
||||
n.online === false
|
||||
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
||||
{t("audit.document.unreachable")}</Badge>
|
||||
: n.online === true
|
||||
? <Badge variant="outline">{t("audit.document.member")}</Badge>
|
||||
: "—",
|
||||
])}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{s.hardware && (
|
||||
<Section icon={<Cpu className="h-4 w-4 text-indigo-500" />} title={t("audit.document.architecture")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("audit.inventory.cpu")} value={hw.cpu?.model} />
|
||||
<Field label={t("audit.inventory.topology")}
|
||||
value={hw.cpu ? t("audit.inventory.cpuLayout", {
|
||||
sockets: String(hw.cpu.sockets), cores: String(hw.cpu.cores_per_socket),
|
||||
threads: String(hw.cpu.threads),
|
||||
}) : ""} />
|
||||
<Field label={t("audit.inventory.memory")}
|
||||
value={hw.memory_bytes ? `${(hw.memory_bytes / GiB).toFixed(0)} GiB` : ""} />
|
||||
<Field label={t("audit.inventory.virtualisation")} value={hw.cpu?.virtualisation} />
|
||||
<Field label={t("audit.inventory.serial")} value={hw.system?.serial} />
|
||||
<Field label={t("audit.document.board")}
|
||||
value={[hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ")} />
|
||||
<Field label={t("audit.inventory.bios")}
|
||||
value={[hw.bios?.vendor, hw.bios?.version, hw.bios?.date].filter(Boolean).join(" · ")} />
|
||||
<Field label={t("audit.inventory.iommuGroups")} value={hw.iommu_groups} />
|
||||
</div>
|
||||
|
||||
{(mem.modules || []).length > 0 && (
|
||||
<>
|
||||
<Sub icon={<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.document.memoryModules")}
|
||||
note={t("audit.document.slotsFilled", {
|
||||
used: String(mem.populated ?? 0),
|
||||
total: String(mem.slots ?? mem.populated ?? 0),
|
||||
})} />
|
||||
<DataTable
|
||||
columns={[t("audit.document.slot"), t("audit.document.size"),
|
||||
t("audit.document.type"), t("audit.document.formFactor"),
|
||||
t("audit.document.speed"), t("audit.document.manufacturer")]}
|
||||
rows={(mem.modules || []).map((m: any) => [
|
||||
<Mono>{m.locator || "—"}</Mono>, m.size || "—", m.type || "—",
|
||||
m.form_factor || "—", m.speed || "—",
|
||||
[m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—",
|
||||
])}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(hw.controllers || []).length > 0 && (
|
||||
<>
|
||||
<Sub icon={<CircuitBoard className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.document.controllers")} />
|
||||
<DataTable
|
||||
columns={["PCI", t("audit.document.class"), t("audit.document.device")]}
|
||||
rows={(hw.controllers || []).map((c: any) => [
|
||||
<Mono>{c.slot}</Mono>, c.class, c.name,
|
||||
])}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(hw.disks || []).length > 0 && (
|
||||
<Section icon={<HardDrive className="h-4 w-4 text-amber-500" />} title={t("audit.document.storageDevices")}
|
||||
count={(hw.disks || []).length}>
|
||||
<DataTable
|
||||
columns={[t("audit.document.device"), t("audit.document.model"),
|
||||
t("audit.document.serial"), t("audit.document.size"),
|
||||
t("audit.document.bus"), "SMART", t("audit.document.serviceLife"),
|
||||
t("audit.document.events")]}
|
||||
rows={(hw.disks || []).map((d: any) => {
|
||||
const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase())
|
||||
return [
|
||||
<span className="font-medium text-foreground">{d.name}</span>,
|
||||
d.model || "—",
|
||||
<Mono>{d.serial || "—"}</Mono>,
|
||||
bytes(d.size_bytes),
|
||||
`${(d.bus || "—").toUpperCase()} · ${d.rotational ? "HDD" : "SSD"}`,
|
||||
ok
|
||||
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{t("audit.document.healthy")}</Badge>
|
||||
: d.health && d.health !== "unknown"
|
||||
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
||||
{d.health}</Badge>
|
||||
: "—",
|
||||
typeof d.power_on_hours === "number" && d.power_on_hours > 0
|
||||
? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) })
|
||||
: "—",
|
||||
(d.observations || []).length
|
||||
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
|
||||
{d.observations.length}</Badge>
|
||||
: <span className="text-muted-foreground">—</span>,
|
||||
]
|
||||
})}
|
||||
/>
|
||||
|
||||
{(hw.disks || []).some((d: any) => (d.observations || []).length > 0) && (
|
||||
<>
|
||||
<Sub icon={<Activity className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.document.observations")}
|
||||
note={t("audit.document.observationsNote")} />
|
||||
{(hw.disks || []).filter((d: any) => (d.observations || []).length).map((d: any) => (
|
||||
<div key={d.name} className="mt-2">
|
||||
<p className="text-xs font-medium text-foreground mb-1">
|
||||
{d.name} <span className="font-normal text-muted-foreground">{d.model}</span>
|
||||
</p>
|
||||
<DataTable
|
||||
columns={[t("audit.document.event"), t("audit.document.severity"),
|
||||
t("audit.document.occurrences"), t("audit.document.firstSeen"),
|
||||
t("audit.document.lastSeen"), t("audit.document.detail")]}
|
||||
rows={d.observations.map((o: any) => [
|
||||
o.type || "—",
|
||||
<Badge variant="outline" className={o.severity === "critical"
|
||||
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"}>
|
||||
{o.severity
|
||||
? t(`audit.classifications.${
|
||||
o.severity === "critical" ? "critical" : "warning"}`)
|
||||
: "—"}</Badge>,
|
||||
String(o.count ?? "—"),
|
||||
when(o.first_seen), when(o.last_seen),
|
||||
<span className="text-muted-foreground break-words">{o.message || ""}</span>,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(s.network || (hw.adapters || []).length > 0) && (
|
||||
<Section icon={<Network className="h-4 w-4 text-green-500" />} title={t("audit.inventory.network")}
|
||||
count={Object.keys(s.network?.bridges || {}).length || undefined}>
|
||||
{(hw.adapters || []).length > 0 && (
|
||||
<>
|
||||
<Sub icon={<Plug className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.document.physicalAdapters")} />
|
||||
<DataTable
|
||||
columns={[t("audit.document.interface"), t("audit.document.state"),
|
||||
t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"]}
|
||||
rows={(hw.adapters || []).map((a: any) => [
|
||||
<span className="font-medium text-foreground">{a.name}</span>,
|
||||
a.state === "up"
|
||||
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{a.state}</Badge>
|
||||
: <Badge variant="outline">{a.state || "—"}</Badge>,
|
||||
a.speed_mbps
|
||||
? (a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`)
|
||||
: "—",
|
||||
<Mono>{a.mac || "—"}</Mono>, a.driver || "—", <Mono>{a.pci || "—"}</Mono>,
|
||||
])}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{s.network && (
|
||||
<>
|
||||
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.document.bridges")} />
|
||||
<div className="space-y-2">
|
||||
{Object.entries(s.network.bridges || {}).map(([id, b]: [string, any]) => (
|
||||
<div key={id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="font-mono">{id}</Badge>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Uplink hops={b.uplink} />
|
||||
{b.vlan_interface && (
|
||||
<Badge variant="outline" className="text-xs">VLAN {b.vlan_interface}</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{(s.guests || []).filter((g) =>
|
||||
(g.interfaces || []).some((n) => n.bridge === id)).length}
|
||||
{" "}{t("audit.inventory.guests").toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{s.latency?.targets?.length ? (
|
||||
<Section icon={<Activity className="h-4 w-4 text-sky-500" />} title={t("audit.document.latency")}
|
||||
note={t("audit.document.latencyNote")}>
|
||||
<DataTable
|
||||
columns={[t("audit.document.target.label"), t("audit.document.minimum"),
|
||||
t("audit.document.average"), t("audit.document.maximum"),
|
||||
t("audit.document.packetLoss"), t("audit.document.samples")]}
|
||||
rows={s.latency.targets.map((target: any) => [
|
||||
<span className="font-medium text-foreground">
|
||||
{t(`audit.document.target.${target.target}`)}</span>,
|
||||
target.min_ms != null ? `${target.min_ms} ms` : "—",
|
||||
target.avg_ms != null ? `${target.avg_ms} ms` : "—",
|
||||
target.max_ms != null ? `${target.max_ms} ms` : "—",
|
||||
target.packet_loss != null ? `${target.packet_loss} %` : "—",
|
||||
String(target.samples),
|
||||
])}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{s.storages && (
|
||||
<Section icon={<HardDrive className="h-4 w-4 text-purple-500" />} title={t("audit.inventory.storage")}
|
||||
count={(s.storages || []).length}>
|
||||
<DataTable
|
||||
columns={[t("audit.inventory.name"), t("audit.inventory.type"),
|
||||
t("audit.inventory.content"), t("audit.inventory.shared"),
|
||||
t("audit.document.location"), t("audit.inventory.guests")]}
|
||||
rows={(s.storages || []).map((st: any) => [
|
||||
<Mono>{st.id}</Mono>, st.type,
|
||||
<span className="text-muted-foreground text-xs">{st.content}</span>,
|
||||
st.shared
|
||||
? <Badge variant="outline">{t("audit.inventory.yes")}</Badge>
|
||||
: <span className="text-muted-foreground">—</span>,
|
||||
<span className="text-muted-foreground text-xs break-all">
|
||||
{st.server || st.path || "—"}</span>,
|
||||
String((s.guests || []).filter((g) =>
|
||||
(g.disks || []).some((d) => d.storage === st.id)).length),
|
||||
])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{s.guests && (
|
||||
<Section icon={<Boxes className="h-4 w-4 text-emerald-500" />} title={t("audit.inventory.guests")}
|
||||
count={(s.guests || []).length}>
|
||||
<div className="space-y-2">
|
||||
{(s.guests || []).map((g) => {
|
||||
const open = openGuest.has(g.vmid)
|
||||
const guestApps = apps.get(g.vmid) || []
|
||||
return (
|
||||
<div key={g.vmid} className="rounded-md border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.vmid)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full flex-wrap items-center gap-2 rounded-md p-3 text-left hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<Badge variant="outline" className="font-mono text-xs">{g.vmid}</Badge>
|
||||
<span className="font-medium text-foreground">{g.name || "—"}</span>
|
||||
<Badge variant="outline" className="text-xs uppercase">{g.type}</Badge>
|
||||
{/* Whether a guest needs a backup is not visible from
|
||||
here, so its absence is stated, not flagged. */}
|
||||
{g.backups.length === 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("audit.inventory.noBackup")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{g.cores}c · {g.memory}MB
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border p-3 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<Field label={t("audit.inventory.ostype")} value={g.ostype} />
|
||||
<Field label={t("audit.inventory.onboot")}
|
||||
value={g.onboot ? t("audit.inventory.yes") : t("audit.inventory.no")} />
|
||||
<Field label={t("audit.inventory.tags")} value={g.tags} />
|
||||
{g.type === "lxc" && (
|
||||
<Field label={t("audit.inventory.privilege")}
|
||||
value={g.unprivileged ? t("audit.inventory.unprivileged")
|
||||
: t("audit.inventory.privileged")} />
|
||||
)}
|
||||
{g.type === "lxc" && <Field label={t("audit.inventory.features")} value={g.features} />}
|
||||
{g.type === "qemu" && (
|
||||
<Field label={t("audit.inventory.agent")}
|
||||
value={g.agent ? t("audit.inventory.yes") : t("audit.inventory.no")} />
|
||||
)}
|
||||
{g.type === "qemu" && <Field label={t("audit.inventory.cpuModel")} value={g.cpu} />}
|
||||
</div>
|
||||
|
||||
{g.disks.length > 0 && (
|
||||
<div>
|
||||
<Sub icon={<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.inventory.disks")} />
|
||||
<DataTable
|
||||
columns={[t("audit.document.slot"), t("audit.document.storage"),
|
||||
t("audit.inventory.name"), t("audit.document.size")]}
|
||||
rows={g.disks.map((d) => [
|
||||
<Mono>{d.slot}</Mono>,
|
||||
d.storage
|
||||
? <Badge variant="outline" className="font-mono text-xs">{d.storage}</Badge>
|
||||
: <Badge variant="outline" className="text-xs">
|
||||
{t("audit.inventory.passthrough")}</Badge>,
|
||||
<span className="font-mono text-xs break-all">{d.volume}</span>,
|
||||
d.size || "—",
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{g.interfaces.length > 0 && (
|
||||
<div>
|
||||
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.inventory.interfaces")} />
|
||||
<div className="space-y-1">
|
||||
{g.interfaces.map((n) => (
|
||||
<div key={n.slot} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<Badge variant="outline" className="font-mono">{n.slot}</Badge>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Badge variant="outline" className="font-mono">{n.bridge}</Badge>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Uplink hops={n.uplink} />
|
||||
{n.vlan && <Badge variant="outline">VLAN {n.vlan}</Badge>}
|
||||
{n.mac && <Mono>{n.mac}</Mono>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Sub icon={<Package className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.inventory.protection")} />
|
||||
{g.backups.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.inventory.noBackupDetail")}</p>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={[t("audit.document.backup"), t("audit.document.storage"),
|
||||
t("audit.inventory.type"), t("audit.document.content")]}
|
||||
rows={g.backups.map((b) => [
|
||||
<Mono>{b.job}</Mono>,
|
||||
<Badge variant="outline" className="font-mono text-xs">{b.storage}</Badge>,
|
||||
b.schedule || "—", b.retention || "—",
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{guestApps.length > 0 && (
|
||||
<div>
|
||||
<Sub icon={<Wrench className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
title={t("audit.inventory.applications")} />
|
||||
<div className="space-y-1">
|
||||
{guestApps.map((a: any, i: number) => (
|
||||
<div key={`${a.slug}-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-foreground">{a.name}</span>
|
||||
<Mono>{a.version || t("audit.inventory.versionUnknown")}</Mono>
|
||||
{a.update_available && (
|
||||
<Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
|
||||
{a.available}
|
||||
</Badge>
|
||||
)}
|
||||
{(a.ports || []).map((p: any, j: number) => (
|
||||
<Badge key={j} variant="outline" className="font-mono">
|
||||
{p.scheme}:{p.port}{p.path}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(s.passthrough || []).length > 0 && (
|
||||
<Section icon={<Plug className="h-4 w-4 text-violet-500" />} title={t("audit.inventory.passthroughTitle")}
|
||||
count={(s.passthrough || []).length}>
|
||||
<DataTable
|
||||
columns={[t("audit.document.vmid"), t("audit.document.name"),
|
||||
t("audit.document.slot"), t("audit.document.device"),
|
||||
t("audit.document.iommuGroup"), t("audit.inventory.sharedGroup")]}
|
||||
rows={(s.passthrough || []).map((p: any) => [
|
||||
<Mono>{p.vmid}</Mono>,
|
||||
p.guest || "—",
|
||||
<Mono>{p.slot || "—"}</Mono>,
|
||||
<Mono>{p.address || "—"}</Mono>,
|
||||
(p.iommu_groups || []).join(", ") || "—",
|
||||
// Everything in a group moves together, so a shared group is
|
||||
// what decides whether the passthrough is possible.
|
||||
(p.shared_group_devices || []).length
|
||||
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
|
||||
{p.shared_group_devices.length}</Badge>
|
||||
: <span className="text-muted-foreground">—</span>,
|
||||
])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{s.proxmenux && (
|
||||
<Section icon={<Wrench className="h-4 w-4 text-orange-500" />} title={t("audit.inventory.proxmenux")}
|
||||
count={(s.proxmenux.optimizations || []).length}>
|
||||
<DataTable
|
||||
columns={[t("audit.document.name"), t("audit.document.version"),
|
||||
t("audit.document.state")]}
|
||||
rows={(s.proxmenux.optimizations || []).map((o: any) => {
|
||||
const pending = (s.proxmenux!.pending_updates || [])
|
||||
.find((u: any) => u.key === o.key)
|
||||
return [
|
||||
<Mono>{o.key}</Mono>,
|
||||
o.version || "—",
|
||||
pending
|
||||
? <Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
|
||||
{t("audit.document.updateAvailable", { version: String(pending.available) })}
|
||||
</Badge>
|
||||
: <Badge variant="outline">{t("audit.document.current")}</Badge>,
|
||||
]
|
||||
})}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Boxes, CheckCircle2, HardDrive, Loader2, Settings2, SlidersHorizontal } from "lucide-react"
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
/**
|
||||
* Declares what is expected of this host.
|
||||
*
|
||||
* An assessment can see what the host does; it cannot see what it is
|
||||
* for. Everything on this page answers a question the host has no way of
|
||||
* answering itself — does this guest need a backup, must this one come
|
||||
* back by itself, is this storage essential — and each answer is what
|
||||
* turns an observation in the report into a warning, or takes it out of
|
||||
* the count entirely.
|
||||
*
|
||||
* Nothing here is required. A host with no declaration produces a
|
||||
* complete report; it just describes rather than judges.
|
||||
*/
|
||||
|
||||
interface Guest { vmid: number; name: string; type: string }
|
||||
interface Storage { id: string; type: string }
|
||||
interface GuestRule {
|
||||
backup?: string; autostart?: string
|
||||
recovery_objective_hours?: number; note?: string
|
||||
}
|
||||
interface Policy {
|
||||
guests: Record<string, GuestRule>
|
||||
storages: Record<string, { role?: string }>
|
||||
defaults: Record<string, unknown>
|
||||
thresholds: Record<string, number>
|
||||
}
|
||||
interface Vocabulary {
|
||||
expectations: string[]
|
||||
roles: string[]
|
||||
thresholds: Record<string, number>
|
||||
}
|
||||
|
||||
const EMPTY: Policy = { guests: {}, storages: {}, defaults: {}, thresholds: {} }
|
||||
|
||||
function PolicySelect({ value, options, prefix, onChange, inherited, inheritedKey, label, disabled }: {
|
||||
value: string; options: string[]; prefix: string
|
||||
onChange: (value: string) => void; inherited: string; inheritedKey?: string
|
||||
label: (key: string) => string; disabled?: boolean
|
||||
}) {
|
||||
// One component behind every dropdown on this tab, so it is also the
|
||||
// one place that decides they look like the rest of the interface.
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="w-full min-w-0 text-foreground sm:w-[12.5rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">{inherited}</SelectItem>
|
||||
{/* Declaring here what the default already says would be the same
|
||||
entry twice, reading the same. */}
|
||||
{options.filter((option) => option !== inheritedKey).map((option) => (
|
||||
<SelectItem key={option} value={option}>{label(`${prefix}.${option}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditPolicy() {
|
||||
const t = useT()
|
||||
const [policy, setPolicy] = useState<Policy>(EMPTY)
|
||||
const [vocabulary, setVocabulary] = useState<Vocabulary | null>(null)
|
||||
const [guests, setGuests] = useState<Guest[]>([])
|
||||
const [storages, setStorages] = useState<Storage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [revision, setRevision] = useState<string | null>(null)
|
||||
const [conflict, setConflict] = useState(false)
|
||||
// The declaration is what turns an observation into a warning, so the
|
||||
// form stays locked until the reader says they are changing it.
|
||||
const [editing, setEditing] = useState(false)
|
||||
const locked = !editing || saving || !revision || conflict
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setRevision(null)
|
||||
try {
|
||||
const [current, inventory]: any[] = await Promise.all([
|
||||
fetchApi("/api/audit/policy"),
|
||||
// The declaration is about this host's own guests and storages,
|
||||
// so they are listed rather than typed in by identifier.
|
||||
fetchApi("/api/audit/inventory?profile=inventory"),
|
||||
])
|
||||
if (current?.success) {
|
||||
setPolicy({ ...EMPTY, ...current.policy })
|
||||
setVocabulary(current.vocabulary)
|
||||
setRevision(current.summary.revision)
|
||||
setDirty(false); setSaved(false); setConflict(false)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(current?.message || t("audit.policy.failed"))
|
||||
}
|
||||
const sections = inventory?.inventory?.sections
|
||||
setGuests((sections?.guests || []).map((g: any) => ({
|
||||
vmid: g.vmid, name: g.name, type: g.type,
|
||||
})))
|
||||
setStorages((sections?.storages || []).map((s: any) => ({
|
||||
id: s.id, type: s.type,
|
||||
})))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const setGuestRule = (vmid: number, field: keyof GuestRule, value: unknown) => {
|
||||
setPolicy((prev) => {
|
||||
const guests = { ...prev.guests }
|
||||
const rule: GuestRule = { ...(guests[String(vmid)] || {}) }
|
||||
// Absence inherits; explicit "unspecified" overrides the site default.
|
||||
if (value === "inherit" || value === "" || value === undefined) {
|
||||
delete rule[field]
|
||||
} else {
|
||||
;(rule as Record<string, unknown>)[field] = value
|
||||
}
|
||||
if (Object.keys(rule).length === 0) delete guests[String(vmid)]
|
||||
else guests[String(vmid)] = rule
|
||||
return { ...prev, guests }
|
||||
})
|
||||
setDirty(true); setSaved(false)
|
||||
}
|
||||
|
||||
const setStorageRole = (id: string, role: string) => {
|
||||
setPolicy((prev) => {
|
||||
const storages = { ...prev.storages }
|
||||
if (role === "inherit" || !role) delete storages[id]
|
||||
else storages[id] = { role }
|
||||
return { ...prev, storages }
|
||||
})
|
||||
setDirty(true); setSaved(false)
|
||||
}
|
||||
|
||||
const setThreshold = (name: string, raw: string) => {
|
||||
setPolicy((prev) => {
|
||||
const thresholds = { ...prev.thresholds }
|
||||
const value = Number(raw)
|
||||
if (!raw.trim()) delete thresholds[name]
|
||||
else thresholds[name] = value
|
||||
return { ...prev, thresholds }
|
||||
})
|
||||
setDirty(true); setSaved(false)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!revision || saving || conflict) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res: any = await fetchApi("/api/audit/policy", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...policy, expected_revision: revision }),
|
||||
})
|
||||
if (res?.success) {
|
||||
setRevision(res.summary.revision)
|
||||
setDirty(false); setSaved(true); setError(null); setEditing(false)
|
||||
}
|
||||
else setError(res?.message || t("audit.policy.failed"))
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status === 409) {
|
||||
setConflict(true)
|
||||
setError(t("audit.policy.conflict"))
|
||||
} else setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const declared = useMemo(
|
||||
() => Object.keys(policy.guests).length + Object.keys(policy.storages).length
|
||||
+ Object.keys(policy.thresholds).length + Object.keys(policy.defaults).length,
|
||||
[policy],
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.policy.loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const expectations = vocabulary?.expectations || ["required", "not_required", "unspecified"]
|
||||
const roles = vocabulary?.roles || ["essential", "optional", "unspecified"]
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => { event.preventDefault(); void save() }}>
|
||||
{error && <p className="text-sm text-red-400 px-1">{error}</p>}
|
||||
{(conflict || !revision) && <Button type="button" onClick={() => void load()}>
|
||||
{t("audit.policy.reload")}
|
||||
</Button>}
|
||||
<Card className={editing
|
||||
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: "bg-card border-border"}>
|
||||
<CardContent className="py-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("audit.policy.intro")}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("audit.policy.declaredCount", { count: String(declared) })}
|
||||
</Badge>
|
||||
{saved && <span className="text-sm text-green-500">{t("audit.policy.saved")}</span>}
|
||||
{editing ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background
|
||||
hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={() => { setEditing(false); void load() }}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <CheckCircle2 className="h-3 w-3" />}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto 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={() => { setEditing(true); setSaved(false) }}
|
||||
disabled={!revision || conflict}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<fieldset disabled={locked} className="space-y-4 min-w-0">
|
||||
|
||||
|
||||
<Card className={editing
|
||||
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: "bg-card border-border"}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<Boxes className="h-4 w-4" />{t("audit.inventory.guests")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t("audit.policy.guestsNote")}</p>
|
||||
{guests.map((guest) => {
|
||||
const rule = policy.guests[String(guest.vmid)] || {}
|
||||
return (
|
||||
<div key={guest.vmid}
|
||||
className="rounded-md border border-border p-3 space-y-2
|
||||
sm:flex sm:flex-wrap sm:items-center sm:gap-3 sm:space-y-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs shrink-0">
|
||||
{guest.vmid}
|
||||
</Badge>
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{guest.name || "—"}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs uppercase shrink-0">
|
||||
{guest.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.backup")}</span>
|
||||
<PolicySelect label={t} disabled={locked}
|
||||
inherited={policy.defaults.backup
|
||||
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.backup}`) })
|
||||
: t("audit.policy.inheritUnset")}
|
||||
inheritedKey={policy.defaults.backup ? undefined : "unspecified"}
|
||||
value={rule.backup || "inherit"}
|
||||
options={expectations}
|
||||
prefix="audit.policy.expectation"
|
||||
onChange={(v) => setGuestRule(guest.vmid, "backup", v)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.autostart")}</span>
|
||||
<PolicySelect label={t} disabled={locked}
|
||||
inherited={policy.defaults.autostart
|
||||
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.autostart}`) })
|
||||
: t("audit.policy.inheritUnset")}
|
||||
inheritedKey={policy.defaults.autostart ? undefined : "unspecified"}
|
||||
value={rule.autostart || "inherit"}
|
||||
options={expectations}
|
||||
prefix="audit.policy.expectation"
|
||||
onChange={(v) => setGuestRule(guest.vmid, "autostart", v)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.objective")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.000001}
|
||||
step="any"
|
||||
inputMode="numeric"
|
||||
value={rule.recovery_objective_hours ?? ""}
|
||||
placeholder={policy.defaults.recovery_objective_hours
|
||||
? String(policy.defaults.recovery_objective_hours) : t("audit.policy.objectivePlaceholder")}
|
||||
onChange={(e) => setGuestRule(
|
||||
guest.vmid, "recovery_objective_hours",
|
||||
e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full min-w-0 rounded-md border border-border bg-background
|
||||
px-2 py-1.5 text-sm text-foreground focus:outline-none
|
||||
focus:ring-1 focus:ring-ring sm:w-24"
|
||||
/>
|
||||
{rule.recovery_objective_hours == null && policy.defaults.recovery_objective_hours != null && (
|
||||
<span>{t("audit.policy.inherit", { value: `${policy.defaults.recovery_objective_hours} ${t("audit.policy.objectivePlaceholder")}` })}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{guests.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("audit.policy.noGuests")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={editing
|
||||
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: "bg-card border-border"}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<HardDrive className="h-4 w-4" />{t("audit.inventory.storage")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t("audit.policy.storagesNote")}</p>
|
||||
{storages.map((storage) => (
|
||||
<div key={storage.id}
|
||||
className="rounded-md border border-border p-3 space-y-2
|
||||
sm:flex sm:items-center sm:gap-3 sm:space-y-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs shrink-0">
|
||||
{storage.id}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{storage.type}</span>
|
||||
</div>
|
||||
<PolicySelect label={t} disabled={locked}
|
||||
inherited={policy.defaults.storage_role
|
||||
? t("audit.policy.inherit", { value: t(`audit.policy.role.${policy.defaults.storage_role}`) })
|
||||
: t("audit.policy.inheritUnset")}
|
||||
inheritedKey={policy.defaults.storage_role ? undefined : "unspecified"}
|
||||
value={policy.storages[storage.id]?.role || "inherit"}
|
||||
options={roles}
|
||||
prefix="audit.policy.role"
|
||||
onChange={(v) => setStorageRole(storage.id, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={editing
|
||||
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: "bg-card border-border"}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<SlidersHorizontal className="h-4 w-4" />{t("audit.policy.thresholds")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t("audit.policy.thresholdsNote")}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(vocabulary?.thresholds || {}).map(([name, shipped]) => (
|
||||
<label key={name}
|
||||
className="flex flex-col items-end gap-1.5 rounded-md border
|
||||
border-border p-2.5 sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="w-full min-w-0 text-left text-sm text-foreground sm:flex-1">
|
||||
{t(`audit.policy.threshold.${name}`)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.000001}
|
||||
max={name.endsWith("_percent") ? 100 : undefined}
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={policy.thresholds[name] ?? ""}
|
||||
placeholder={String(shipped)}
|
||||
onChange={(e) => setThreshold(name, e.target.value)}
|
||||
className="w-24 shrink-0 rounded-md border border-border bg-background
|
||||
px-2 py-1.5 text-sm text-foreground tabular-nums
|
||||
focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</fieldset>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Button } from "./ui/button"
|
||||
import { Badge } from "./ui/badge"
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog"
|
||||
import {
|
||||
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
|
||||
FileText, HelpCircle, Info, Loader2, MinusCircle, Play, RotateCcw,
|
||||
ShieldOff, XCircle,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { AuditPolicy } from "./audit-policy"
|
||||
import { AuditChanges } from "./audit-changes"
|
||||
import { AuditComparison } from "./audit-comparison"
|
||||
import { Label } from "./ui/label"
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "./ui/select"
|
||||
import { unreadSources } from "../lib/audit-presentation"
|
||||
import { openAuditDocument, openReportWindow } from "../lib/audit-document"
|
||||
import { AuditEvidence } from "./audit-evidence"
|
||||
import { AuditFindingData } from "./audit-finding-data"
|
||||
import { affectedDescription, resultBreakdown } from "../lib/audit-presentation"
|
||||
import { useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface Finding {
|
||||
check_id: string
|
||||
area: string
|
||||
severity: string
|
||||
classification: string
|
||||
decision?: string
|
||||
summary_key: string | null
|
||||
summary_params: Record<string, string | number>
|
||||
affected: Array<Record<string, unknown>>
|
||||
evidence: string | null
|
||||
remediable_by: string | null
|
||||
incomplete?: boolean
|
||||
collected_at?: number
|
||||
check_version?: number
|
||||
sources?: Array<{ source: string; collected_at: number; error?: string }>
|
||||
exception?: {
|
||||
reason: string; accepted_by: string; accepted_at: number
|
||||
expires_at?: number | null
|
||||
// Asks for the decision to be looked at again on this date. It does
|
||||
// not withdraw it: an acceptance can stand indefinitely and still
|
||||
// come back for review.
|
||||
review_at?: number | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface Run {
|
||||
run_id: string
|
||||
profile: string
|
||||
started_at: number
|
||||
finished_at: number | null
|
||||
status: string
|
||||
checks_total: number
|
||||
checks_expected: number
|
||||
error?: string | null
|
||||
is_baseline?: number | boolean
|
||||
// Recorded by the engine: the sources it read and the declaration it
|
||||
// judged against. The document states the latter in its scope.
|
||||
metadata?: { policy?: {
|
||||
declared?: boolean; guests_declared?: number
|
||||
storages_declared?: number; thresholds_declared?: string[]
|
||||
} } | null
|
||||
}
|
||||
|
||||
// Findings are ordered by how much they demand attention, not by area.
|
||||
// Someone triaging wants the worst thing first regardless of where it
|
||||
// lives; grouping by area is the reading order of the printed document.
|
||||
//
|
||||
// One scale, worst first. There is no second ordering by severity any
|
||||
// more: gravity is the classification, so a finding cannot be a critical
|
||||
// observation or an informational failure.
|
||||
const CLASS_RANK: Record<string, number> = {
|
||||
critical: 0, warning: 1, observation: 2, unverified: 3,
|
||||
accepted: 4, conformant: 5, not_applicable: 6,
|
||||
}
|
||||
|
||||
// Only the first two are problems. An observation is drawn in a neutral
|
||||
// tone on purpose: colouring planning information like a fault is what
|
||||
// made ordinary configurations read as defects.
|
||||
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
|
||||
critical: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
|
||||
warning: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
|
||||
observation: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Info },
|
||||
unverified: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
|
||||
accepted: { chip: "bg-indigo-500/10 text-indigo-400 border-indigo-400/20", Icon: ShieldOff },
|
||||
conformant: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 },
|
||||
not_applicable: { chip: "bg-muted text-muted-foreground border-border", Icon: MinusCircle },
|
||||
}
|
||||
|
||||
/** What a finding reads as once the reader's decision is applied. */
|
||||
function shownAs(f: { classification: string; decision?: string }): string {
|
||||
return f.decision === "accepted" ? "accepted" : f.classification
|
||||
}
|
||||
|
||||
// An assessment older than this stops describing the current system, so
|
||||
// the age is surfaced before any count rather than as a footnote.
|
||||
const STALE_AFTER_DAYS = 30
|
||||
const SUMMARY_BADGE_CLASS = "h-6 gap-1.5 whitespace-nowrap px-2.5 py-0 text-xs"
|
||||
|
||||
export function AuditReport() {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
// Assessment and inventory answer different questions and are
|
||||
// read differently: one is triaged, the other is read through.
|
||||
const [view, setView] = useState<"assessment" | "changes" | "policy">("assessment")
|
||||
const [running, setRunning] = useState(false)
|
||||
const [latest, setLatest] = useState<Run | null>(null)
|
||||
const [findings, setFindings] = useState<Finding[]>([])
|
||||
const [summary, setSummary] = useState<Record<string, number>>({})
|
||||
const [areaFilter, setAreaFilter] = useState<string>("all")
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accepting, setAccepting] = useState<Finding | null>(null)
|
||||
const [reason, setReason] = useState("")
|
||||
const [expiryDays, setExpiryDays] = useState<string>("")
|
||||
const [reviewDays, setReviewDays] = useState<string>("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [progress, setProgress] = useState({ completed: 0, total: 0 })
|
||||
// The profile decides which question the page answers, so it governs
|
||||
// both what an assessment runs and what the inventory documents.
|
||||
const [profile, setProfile] = useState("full")
|
||||
const [profiles, setProfiles] = useState<Array<{ id: string; runs_checks: boolean; areas: string[] | null; include: string[] }>>([])
|
||||
// Set when a Lynis-bearing assessment is about to run and the stored
|
||||
// report is missing or stale: the user decides whether to run Lynis now.
|
||||
const [lynisPrompt, setLynisPrompt] = useState<null | { ageDays: number | null; stale: boolean }>(null)
|
||||
const [building, setBuilding] = useState(false)
|
||||
|
||||
const loadRun = useCallback(async (runId: string) => {
|
||||
try {
|
||||
const data: any = await fetchApi(`/api/audit/runs/${runId}?effective=1`)
|
||||
if (data?.success) setFindings(data.findings || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data: any = await fetchApi("/api/audit/status")
|
||||
if (!data?.success) return
|
||||
setRunning(Boolean(data.running))
|
||||
setProgress({ completed: data.progress?.completed || 0, total: data.progress?.total || 0 })
|
||||
setSummary(data.summary || {})
|
||||
setLatest(data.latest || null)
|
||||
if (data.latest?.run_id) await loadRun(data.latest.run_id)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadRun])
|
||||
|
||||
useEffect(() => {
|
||||
fetchApi("/api/audit/profiles")
|
||||
.then((d: any) => { if (d?.success) setProfiles(d.profiles || []) })
|
||||
.catch(() => { /* the page works on the default profile */ })
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
// Expiry changes a decision, not the assessment. One local timer and
|
||||
// a focus refresh keep it current without periodic scans or idle polling.
|
||||
useEffect(() => {
|
||||
const now = Date.now() / 1000
|
||||
const due = findings.flatMap((f) => [f.exception?.expires_at, f.exception?.review_at]
|
||||
.filter((t): t is number => !!t && t > now))
|
||||
if (!due.length) return
|
||||
const delay = Math.max(100, Math.min(2147483647, Math.min(...due) * 1000 - Date.now() + 100))
|
||||
const id = setTimeout(refresh, delay)
|
||||
return () => clearTimeout(id)
|
||||
}, [findings, refresh])
|
||||
useEffect(() => {
|
||||
const onFocus = () => { void refresh() }
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [refresh])
|
||||
|
||||
// While an assessment is in flight the page polls; once it settles the
|
||||
// interval is dropped so an idle tab does not keep waking the backend.
|
||||
useEffect(() => {
|
||||
if (!running) return
|
||||
const id = setInterval(refresh, 2000)
|
||||
return () => clearInterval(id)
|
||||
}, [running, refresh])
|
||||
|
||||
const doRun = async (runLynis: boolean) => {
|
||||
setLynisPrompt(null)
|
||||
setError(null)
|
||||
try {
|
||||
const data: any = await fetchApi("/api/audit/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profile, run_lynis: runLynis }),
|
||||
})
|
||||
if (data?.success) setRunning(true)
|
||||
else setError(data?.message || t("audit.errors.runFailed"))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that includes the Lynis check asks the user before running,
|
||||
// since producing a fresh Lynis report takes a few minutes. When a
|
||||
// recent report already exists — or Lynis is not installed — the run
|
||||
// starts straight away and reuses it.
|
||||
const startRun = async () => {
|
||||
setError(null)
|
||||
const spec = profiles.find((p) => p.id === profile)
|
||||
const runsLynis = !!spec && (spec.areas === null || spec.areas.includes("security"))
|
||||
if (runsLynis) {
|
||||
try {
|
||||
const r: any = await fetchApi("/api/audit/lynis-readiness")
|
||||
if (r?.success && r.installed && (!r.has_report || r.stale)) {
|
||||
setLynisPrompt({ ageDays: r.age_days ?? null, stale: !!r.stale })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* Readiness is advisory; on failure run without Lynis rather than block. */
|
||||
}
|
||||
}
|
||||
doRun(false)
|
||||
}
|
||||
|
||||
// Accepting or revoking changes which findings are active, so the run
|
||||
// is re-read afterwards rather than patched in place: the stored
|
||||
// finding is what the next report will show.
|
||||
const submitAcceptance = async () => {
|
||||
if (!accepting || !reason.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
check_id: accepting.check_id,
|
||||
run_id: latest?.run_id,
|
||||
reason: reason.trim(),
|
||||
}
|
||||
if (expiryDays) body.expires_in_days = Number(expiryDays)
|
||||
if (reviewDays) body.review_in_days = Number(reviewDays)
|
||||
const data: any = await fetchApi("/api/audit/exceptions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!data?.success) throw new Error(data?.message || "")
|
||||
setAccepting(null)
|
||||
setReason("")
|
||||
setExpiryDays("")
|
||||
setReviewDays("")
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const revokeAcceptance = async (checkId: string) => {
|
||||
try {
|
||||
await fetchApi(`/api/audit/exceptions/${checkId}`, { method: "DELETE" })
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const areas = useMemo(
|
||||
() => Array.from(new Set(findings.map((f) => f.area))).sort(),
|
||||
[findings],
|
||||
)
|
||||
|
||||
// Every check is listed, worst first. Hiding what passed made the
|
||||
// reader guess whether a check was clean or had not run, which is
|
||||
// exactly the distinction this page exists to keep.
|
||||
const visible = useMemo(() =>
|
||||
findings
|
||||
.filter((f) => areaFilter === "all" || f.area === areaFilter)
|
||||
.sort((a, b) =>
|
||||
(CLASS_RANK[shownAs(a)] ?? 9) - (CLASS_RANK[shownAs(b)] ?? 9) ||
|
||||
a.check_id.localeCompare(b.check_id)),
|
||||
[findings, areaFilter])
|
||||
|
||||
const acceptedCount = summary.accepted || 0
|
||||
const reviewDueCount = findings.filter(
|
||||
(f) => f.exception?.review_at && f.exception.review_at * 1000 <= Date.now(),
|
||||
).length
|
||||
const unverifiedChecks = findings.filter(
|
||||
(f) => f.classification === "unverified" || f.incomplete)
|
||||
const ageDays = latest?.finished_at
|
||||
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
|
||||
: null
|
||||
const stale = ageDays !== null && ageDays >= STALE_AFTER_DAYS
|
||||
|
||||
// The backend stores which sentence applies and its numbers, not the
|
||||
// sentence itself, so a finding recorded under one language still reads
|
||||
// correctly under another. A check that failed to evaluate has no
|
||||
// per-check entry, hence the shared fallback.
|
||||
const summaryOf = (f: Finding) => {
|
||||
if (f.check_id === "backup.last_backup_age" && f.affected.length) return resultBreakdown(f, t)
|
||||
if (!f.summary_key) return ""
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]),
|
||||
)
|
||||
const key = `audit.checks.${f.check_id}.summary.${f.summary_key}`
|
||||
const text = t(key, params)
|
||||
return text === key ? t("audit.summaryFallback") : text
|
||||
}
|
||||
|
||||
// A check's plain-language texts are optional. Those that do not carry
|
||||
// them yet render nothing, rather than the raw key a missing lookup
|
||||
// returns, so the section can gain them one area at a time.
|
||||
const optional = (key: string) => {
|
||||
const text = t(key)
|
||||
return text === key ? null : text
|
||||
}
|
||||
|
||||
// What to do about a finding depends on what was found, not on what was
|
||||
// checked: an outcome that is not a problem has no next step, and one
|
||||
// that could not be evaluated has nothing to act on either.
|
||||
const nextStepOf = (f: Finding) => {
|
||||
if (f.classification === "not_applicable") return null
|
||||
if (f.classification === "unverified" || f.incomplete) return t("audit.couldNotEvaluate")
|
||||
if (f.classification !== "critical" && f.classification !== "warning") {
|
||||
return t("audit.noActionNeeded")
|
||||
}
|
||||
return f.summary_key ? optional(`audit.checks.${f.check_id}.nextStep.${f.summary_key}`) : null
|
||||
}
|
||||
|
||||
const notApplicableText = (f: Finding) => {
|
||||
if (f.summary_key) return ""
|
||||
return f.classification === "not_applicable" ? t("audit.notApplicableScope") : ""
|
||||
}
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
{t("audit.loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The document carries both halves, so the inventory is fetched at the
|
||||
// moment it is produced rather than kept in memory for a button that
|
||||
// may never be pressed.
|
||||
const generateDocument = async () => {
|
||||
// The window is opened on the click itself, before the inventory is
|
||||
// fetched, so the popup blocker sees the user gesture. It shows a
|
||||
// spinner while the document is composed.
|
||||
const target = openReportWindow(t("audit.document.building"))
|
||||
setBuilding(true)
|
||||
try {
|
||||
const inv: any = await fetchApi(
|
||||
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
|
||||
// A focused report shows only the checks its profile runs, even
|
||||
// when the findings on screen came from a full assessment: the
|
||||
// report is scoped to its question, not to whichever run produced
|
||||
// the data. `areas: null` (full, diagnostic) keeps everything.
|
||||
const spec = profiles.find((p) => p.id === profile)
|
||||
const scopedFindings = !spec || spec.areas === null
|
||||
? findings
|
||||
: findings.filter((f) => spec.areas!.includes(f.area) || spec.include.includes(f.check_id))
|
||||
openAuditDocument({
|
||||
profile,
|
||||
run: latest,
|
||||
findings: scopedFindings,
|
||||
inventory: inv?.success ? inv.inventory : null,
|
||||
t,
|
||||
locale: language,
|
||||
}, target)
|
||||
} catch (e) {
|
||||
target?.close()
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBuilding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const documentButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={generateDocument}
|
||||
disabled={building}
|
||||
// Related to running an assessment, and secondary to it: the same
|
||||
// hue as that button, at the translucent weight the chips use.
|
||||
// Green is taken — here it means a conformant result, and this
|
||||
// report may be full of critical ones.
|
||||
className="shrink-0 border-blue-500/20 bg-blue-500/10 text-blue-500
|
||||
hover:bg-blue-500/20 hover:text-blue-500"
|
||||
>
|
||||
{building
|
||||
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
: <FileText className="h-4 w-4 mr-2" />}
|
||||
{/* The icon carries the meaning where the width is short. */}
|
||||
<span className="sm:hidden">{t("audit.document.actionShort")}</span>
|
||||
<span className="hidden sm:inline">{t("audit.document.action")}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const profilePicker = profiles.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm sm:w-auto sm:flex-none">
|
||||
<Label htmlFor="audit-profile" className="hidden shrink-0 text-muted-foreground sm:inline">
|
||||
{t("audit.profile.label")}
|
||||
</Label>
|
||||
<Select value={profile} onValueChange={setProfile}>
|
||||
<SelectTrigger id="audit-profile" className="min-w-0 flex-1 sm:w-56 sm:flex-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{t(`audit.profile.${p.id}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const viewTabs = (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("audit.viewSwitch.ariaLabel")}
|
||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||
sm:inline-flex sm:w-auto"
|
||||
>
|
||||
{(["assessment", "changes", "policy"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={view === key}
|
||||
onClick={() => setView(key)}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
|
||||
view === key
|
||||
? "bg-blue-500 text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`audit.viewSwitch.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Changes and policy carry no profile: one is what was done to this
|
||||
// host, the other what is expected of it, and neither narrows by report.
|
||||
if (view === "changes") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||
sm:items-center sm:gap-3">
|
||||
{viewTabs}
|
||||
</div>
|
||||
</div>
|
||||
<AuditChanges />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (view === "policy") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||
sm:items-center sm:gap-3">
|
||||
{viewTabs}
|
||||
</div>
|
||||
</div>
|
||||
<AuditPolicy />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||
sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
{profilePicker}{documentButton}
|
||||
</div>
|
||||
{viewTabs}
|
||||
</div>
|
||||
</div>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
{/* Stated before any count: an assessment nobody has run, or
|
||||
one run months ago, does not describe this host today. */}
|
||||
{!latest ? (
|
||||
<p className="text-sm text-muted-foreground">{t("audit.neverRun")}</p>
|
||||
) : (
|
||||
<p className={`text-sm ${stale ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
{t("audit.lastRun", {
|
||||
when: new Date((latest.finished_at || latest.started_at) * 1000)
|
||||
.toLocaleString(),
|
||||
})}
|
||||
{stale && ` — ${t("audit.stale", { days: String(ageDays) })}`}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
|
||||
{running && <p role="status" className="text-sm text-muted-foreground">
|
||||
{t("audit.progress", { completed: String(progress.completed), total: String(progress.total) })}
|
||||
</p>}
|
||||
{/* A reading that could not be taken is information, not an
|
||||
alarm: it says the report is narrower than usual, and
|
||||
colouring it like a finding puts it above warnings the
|
||||
reader has to act on. A run that failed outright is the
|
||||
one thing here that does interrupt. */}
|
||||
{latest && (latest.status === "partial" || latest.status === "failed") && (
|
||||
<div
|
||||
{...(latest.status === "failed" ? { role: "alert" as const } : {})}
|
||||
className={`space-y-1 text-sm ${
|
||||
latest.status === "failed" ? "text-amber-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<p>{t(`audit.runStates.${latest.status}`)}</p>
|
||||
{unverifiedChecks.length > 0 && <p className="text-xs">
|
||||
{t("audit.unverifiedChecks", { checks: unverifiedChecks
|
||||
.map((f) => t(`audit.checks.${f.check_id}.title`)).join(" · ") })}
|
||||
</p>}
|
||||
</div>
|
||||
)}
|
||||
{latest?.error && <p className="text-xs text-amber-500">{latest.error}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={startRun}
|
||||
disabled={running}
|
||||
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
|
||||
>
|
||||
{running
|
||||
? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t("audit.running")}</>
|
||||
: <><Play className="h-4 w-4 mr-2" />{t("audit.run")}</>}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
{latest && (
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
{/* One row of counters on one scale. Gravity is the
|
||||
classification itself, so there is nothing left to
|
||||
reconcile between two sets of numbers. */}
|
||||
<div role="group" aria-label={t("audit.results")}
|
||||
className="flex max-w-full flex-wrap items-center gap-2">
|
||||
{(["critical", "warning", "observation", "unverified",
|
||||
"accepted", "conformant", "not_applicable"] as const)
|
||||
.filter((c) => summary[c])
|
||||
.map((c) => {
|
||||
const { chip, Icon } = CLASS_STYLE[c]
|
||||
return (
|
||||
<Badge key={c} variant="outline" className={`${SUMMARY_BADGE_CLASS} ${chip}`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.classifications.${c}`)}
|
||||
<span className="tabular-nums font-semibold">{summary[c]}</span>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* What changed since a reference run: context for the
|
||||
assessment being read, not a place of its own. */}
|
||||
<AuditComparison
|
||||
runId={latest.run_id}
|
||||
isBaseline={Boolean(latest.is_baseline)}
|
||||
onBaselineSet={refresh}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAreaFilter("all")}
|
||||
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||
areaFilter === "all"
|
||||
? "bg-blue-500 text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t("audit.areas.all")}
|
||||
</button>
|
||||
{areas.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => setAreaFilter(a)}
|
||||
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||
areaFilter === a
|
||||
? "bg-blue-500 text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`audit.areas.${a}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The count of accepted risks stays visible even when the
|
||||
findings themselves are filtered out of the list, so a
|
||||
decision to live with something is never silently lost. */}
|
||||
{acceptedCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.acceptedNotice", { count: String(acceptedCount) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* A decision that asked to be revisited says so here, where it
|
||||
is read without going to look for it. The acceptance still
|
||||
stands; this is a reminder, not a lapse. */}
|
||||
{reviewDueCount > 0 && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t("audit.reviewDueNotice", { count: String(reviewDueCount) })}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-400 px-1">{error}</p>
|
||||
)}
|
||||
|
||||
{latest && visible.length === 0 && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="py-10 text-center text-muted-foreground">
|
||||
{t("audit.noFindings")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{visible.map((f) => {
|
||||
const shown = shownAs(f)
|
||||
const { chip, Icon } = CLASS_STYLE[shown] || CLASS_STYLE.not_applicable
|
||||
const open = expanded.has(f.check_id)
|
||||
const muted = shown === "accepted" || shown === "not_applicable"
|
||||
return (
|
||||
<Card
|
||||
key={f.check_id}
|
||||
className={`bg-card border-border ${muted ? "opacity-70" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(f.check_id)}
|
||||
aria-expanded={open}
|
||||
className="w-full text-left p-4 flex max-[359px]:flex-wrap items-start gap-3 rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{open
|
||||
? <ChevronDown className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />}
|
||||
<Badge variant="outline" className={`${chip} gap-1.5 shrink-0`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.classifications.${shown}`)}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1 max-[359px]:basis-full">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">
|
||||
{t(`audit.checks.${f.check_id}.title`)}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t(`audit.areas.${f.area}`)}
|
||||
</Badge>
|
||||
{f.incomplete && <Badge variant="outline" className="text-xs text-amber-500">
|
||||
{t("audit.incomplete")}
|
||||
</Badge>}
|
||||
{f.affected.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs tabular-nums">
|
||||
{affectedDescription(f, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(f.summary_key || notApplicableText(f)) && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{f.summary_key ? summaryOf(f) : notApplicableText(f)}
|
||||
</p>
|
||||
)}
|
||||
{/* "Could not be evaluated" describes the assessment, not
|
||||
the host. What could not be read is recorded against
|
||||
each source, and belongs here rather than two
|
||||
collapsed panels below. */}
|
||||
{unreadSources(f.sources, t) && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{unreadSources(f.sources, t)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<CardContent className="pt-0 pl-11 space-y-4">
|
||||
{optional(`audit.checks.${f.check_id}.explanation`) && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.detail.whatItMeans")}
|
||||
</p>
|
||||
<p className="text-sm text-foreground">
|
||||
{optional(`audit.checks.${f.check_id}.explanation`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.detail.why")}
|
||||
</p>
|
||||
<p className="text-sm text-foreground">
|
||||
{t(`audit.checks.${f.check_id}.rationale`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{nextStepOf(f) && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.detail.whatToDo")}
|
||||
</p>
|
||||
<p className="text-sm text-foreground">{nextStepOf(f)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{f.exception && (
|
||||
<div className="rounded-md border border-border bg-background p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.detail.acceptedRisk")}
|
||||
</p>
|
||||
<p className="text-sm text-foreground">{f.exception.reason}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{f.exception.accepted_by} ·{" "}
|
||||
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
|
||||
{f.exception.expires_at && <> · {t("audit.expires", {
|
||||
when: new Date(f.exception.expires_at * 1000).toLocaleString(),
|
||||
})}</>}
|
||||
{f.exception.review_at && <> · {t("audit.reviewOn", {
|
||||
when: new Date(f.exception.review_at * 1000).toLocaleDateString(),
|
||||
})}</>}
|
||||
</p>
|
||||
{!!f.exception.review_at && f.exception.review_at * 1000 <= Date.now() && (
|
||||
<p className="text-xs text-amber-500 mt-1">{t("audit.reviewDue")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Some checks carry a useful, structured positive reading
|
||||
in their evidence even when nothing is affected. The
|
||||
presenter returns no groups for checks without such a
|
||||
view, so rendering it unconditionally does not create
|
||||
empty space. */}
|
||||
<AuditFindingData finding={f} t={t} locale={language} />
|
||||
|
||||
{f.evidence && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground mb-2">
|
||||
{t("audit.presentation.technical")}
|
||||
</summary>
|
||||
<AuditEvidence evidence={f.evidence} locale={language} />
|
||||
</details>
|
||||
)}
|
||||
{f.sources && f.sources.length > 0 && <details className="text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">{t("audit.detail.sources")}</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{f.sources.map((source) => <li key={source.source} className="break-all">
|
||||
{source.source} · {new Date(source.collected_at * 1000).toLocaleString()}
|
||||
{source.error && <span className="text-amber-500"> · {source.error}</span>}
|
||||
</li>)}
|
||||
</ul>
|
||||
</details>}
|
||||
|
||||
{/* Only an active finding can be accepted, and only an
|
||||
accepted one can be returned to the active set. */}
|
||||
{(f.classification === "critical" || f.classification === "warning")
|
||||
&& !f.decision && !f.incomplete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setAccepting(f); setReason(""); setExpiryDays(""); setReviewDays("") }}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4 mr-2" />
|
||||
{t("audit.acceptRisk.action")}
|
||||
</Button>
|
||||
)}
|
||||
{f.decision === "accepted" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revokeAcceptance(f.check_id)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
{t("audit.acceptRisk.revoke")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Dialog open={lynisPrompt !== null} onOpenChange={(o) => !o && setLynisPrompt(null)}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("audit.lynis.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{lynisPrompt?.stale
|
||||
? t("audit.lynis.bodyStale", { days: String(lynisPrompt?.ageDays ?? "") })
|
||||
: t("audit.lynis.bodyNotRun")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
<Button variant="outline" onClick={() => setLynisPrompt(null)}>
|
||||
{t("audit.lynis.cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => doRun(false)}>
|
||||
{t("audit.lynis.withoutLynis")}
|
||||
</Button>
|
||||
<Button onClick={() => doRun(true)}>
|
||||
{t("audit.lynis.withLynis")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={accepting !== null} onOpenChange={(o) => !o && setAccepting(null)}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("audit.acceptRisk.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{accepting && t(`audit.checks.${accepting.check_id}.title`)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="audit-reason" className="text-sm font-medium text-foreground">
|
||||
{t("audit.acceptRisk.reasonLabel")}
|
||||
</label>
|
||||
{/* The reason is required, not encouraged. An acceptance
|
||||
without one cannot be told apart later from having
|
||||
silenced the check. */}
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
{t("audit.acceptRisk.reasonHelp")}
|
||||
</p>
|
||||
<textarea
|
||||
id="audit-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("audit.acceptRisk.reasonPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="audit-expiry" className="text-sm font-medium text-foreground">
|
||||
{t("audit.acceptRisk.expiryLabel")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
{t("audit.acceptRisk.expiryHelp")}
|
||||
</p>
|
||||
<Select value={expiryDays || "none"}
|
||||
onValueChange={(v) => setExpiryDays(v === "none" ? "" : v)}>
|
||||
<SelectTrigger id="audit-expiry" className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("audit.acceptRisk.expiryNever")}</SelectItem>
|
||||
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
|
||||
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
|
||||
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Separate from the expiry on purpose: this one asks to look at
|
||||
the decision again without withdrawing it, so "remind me in a
|
||||
year" no longer has to be spelled as "stop accepting this in a
|
||||
year". */}
|
||||
<div>
|
||||
<label htmlFor="audit-review" className="text-sm font-medium text-foreground">
|
||||
{t("audit.acceptRisk.reviewLabel")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
{t("audit.acceptRisk.reviewHelp")}
|
||||
</p>
|
||||
<Select value={reviewDays || "none"}
|
||||
onValueChange={(v) => setReviewDays(v === "none" ? "" : v)}>
|
||||
<SelectTrigger id="audit-review" className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("audit.acceptRisk.reviewNever")}</SelectItem>
|
||||
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
|
||||
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
|
||||
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAccepting(null)}>
|
||||
{t("audit.acceptRisk.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitAcceptance}
|
||||
disabled={!reason.trim() || saving}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
|
||||
>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{t("audit.acceptRisk.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
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("")
|
||||
@@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to skip authentication")
|
||||
throw new Error(data.error || t("authSetup.skipFailed"))
|
||||
}
|
||||
|
||||
if (data.auth_declined) {
|
||||
@@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("Auth skip error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to save preference")
|
||||
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to setup authentication")
|
||||
throw new Error(data.error || t("authSetup.setupFailed"))
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
@@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("Auth setup error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
||||
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -214,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">
|
||||
@@ -222,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}
|
||||
@@ -240,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">
|
||||
@@ -252,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 && (
|
||||
@@ -266,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"
|
||||
@@ -285,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"
|
||||
@@ -312,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"
|
||||
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
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">
|
||||
Profile · optional
|
||||
{t("authSetup.profileOptional")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="display-name" className="text-sm">
|
||||
Display name
|
||||
{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="Shown above the username in the menu"
|
||||
placeholder={t("authSetup.displayNamePlaceholder")}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
maxLength={64}
|
||||
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Leave empty to render the username itself. Up to 64 characters.
|
||||
{t("authSetup.displayNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Avatar</Label>
|
||||
<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
|
||||
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Upload className="h-3 w-3 mr-1.5" />
|
||||
{avatarFile ? "Change" : "Choose image"}
|
||||
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
|
||||
</Button>
|
||||
{avatarFile && (
|
||||
<Button
|
||||
@@ -419,12 +421,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
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" />
|
||||
Clear
|
||||
{t("authSetup.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
|
||||
{t("authSetup.avatarHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface AuthStatus {
|
||||
auth_enabled?: boolean
|
||||
@@ -57,6 +58,8 @@ interface AvatarMenuProps {
|
||||
* 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
|
||||
@@ -201,7 +204,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
<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="Open user menu"
|
||||
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
|
||||
@@ -248,7 +251,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
<div className="text-xs text-muted-foreground truncate">{username}</div>
|
||||
)}
|
||||
{!profile?.display_name && (
|
||||
<div className="text-xs text-muted-foreground truncate">Signed in</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
{onOpenProfile && (
|
||||
<DropdownMenuItem onClick={onOpenProfile}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
View profile
|
||||
{t("account.viewProfile")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onOpenSecurity && (
|
||||
<DropdownMenuItem onClick={onOpenSecurity}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Security
|
||||
{t("account.security")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
|
||||
@@ -272,7 +275,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
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" />
|
||||
Sign out
|
||||
{t("account.signOut")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
// The Backup tab: the backups of the host and the backup jobs of the VMs
|
||||
// and LXCs, each in its own view.
|
||||
|
||||
import { useState } from "react"
|
||||
import { DatabaseBackup } from "lucide-react"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type BackupView = "host" | "guests"
|
||||
|
||||
export function BackupTab() {
|
||||
const t = useT()
|
||||
const [view, setView] = useState<BackupView>("host")
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseBackup className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("navigation.backup")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("backup.viewSwitch.ariaLabel")}
|
||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||
sm:inline-flex sm:w-auto"
|
||||
>
|
||||
{(["host", "guests"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={view === key}
|
||||
onClick={() => setView(key)}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
|
||||
view === key
|
||||
? "bg-blue-500 text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`backup.viewSwitch.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* The host view stays mounted: it follows a running backup or restore. */}
|
||||
<div className={view === "host" ? "" : "hidden"}>
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { getCategoryLabel } from "../lib/category-label"
|
||||
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}>{getCategoryLabel(t, 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" : "LXC"} {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="outline" className={cancelButtonClass} 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,264 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Check, HardDrive, Info, Loader2, Settings2 } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface DiskEntry {
|
||||
name: string
|
||||
key: string
|
||||
model: string
|
||||
serial: string
|
||||
size_bytes: number
|
||||
transport: string
|
||||
rotational: boolean
|
||||
excluded: boolean
|
||||
excluded_at: string | null
|
||||
idle: boolean
|
||||
present: boolean
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return ""
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
||||
let value = bytes
|
||||
let i = 0
|
||||
while (value >= 1000 && i < units.length - 1) {
|
||||
value /= 1000
|
||||
i++
|
||||
}
|
||||
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// Disks the user wants left alone. An excluded disk is never read on a
|
||||
// schedule — no temperature, no SMART refresh, not even a power-mode query —
|
||||
// so it can spin down on its own timer. Rotational disks with no I/O are
|
||||
// already left alone automatically; this is for the ones that should never
|
||||
// be touched at all, such as a drive handed whole to a VM.
|
||||
export function DiskExclusions() {
|
||||
const t = useT()
|
||||
const [disks, setDisks] = useState<DiskEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [pending, setPending] = useState<Map<string, boolean>>(new Map())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchApi<{ disks: DiskEntry[] }>("/api/health/disks")
|
||||
setDisks(data.disks || [])
|
||||
} catch {
|
||||
setDisks([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const cancel = () => {
|
||||
setPending(new Map())
|
||||
setError("")
|
||||
setEditMode(false)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (pending.size === 0) {
|
||||
setEditMode(false)
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError("")
|
||||
try {
|
||||
for (const [key, excluded] of pending.entries()) {
|
||||
const disk = disks.find((d) => d.key === key)
|
||||
if (!disk) continue
|
||||
if (excluded) {
|
||||
await fetchApi("/api/health/disk-exclusions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
disk_key: disk.key,
|
||||
disk_name: disk.name,
|
||||
model: disk.model,
|
||||
serial: disk.serial,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
await fetchApi(`/api/health/disk-exclusions/${encodeURIComponent(disk.key)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
}
|
||||
setPending(new Map())
|
||||
setEditMode(false)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
await load()
|
||||
} catch {
|
||||
setError(t("settings.diskExclusions.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const transportLabel = (disk: DiskEntry) => {
|
||||
const tr = disk.transport.toLowerCase()
|
||||
if (tr === "usb") return "USB"
|
||||
if (tr === "nvme") return "NVMe"
|
||||
if (tr === "sata" || tr === "ata") return disk.rotational ? "HDD" : "SSD"
|
||||
if (tr) return tr.toUpperCase()
|
||||
return disk.rotational ? "HDD" : "SSD"
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-amber-500" />
|
||||
<CardTitle>{t("settings.diskExclusions.title")}</CardTitle>
|
||||
</div>
|
||||
{!loading && disks.length > 0 && (
|
||||
<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={cancel}
|
||||
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={save}
|
||||
disabled={saving || pending.size === 0}
|
||||
>
|
||||
{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 flex items-center gap-1.5"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>{t("settings.diskExclusions.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className={editMode ? "bg-accent" : undefined}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : disks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<HardDrive className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground">{t("settings.diskExclusions.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
<div className="grid grid-cols-[1fr_auto] gap-4 pb-2 mb-1 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("settings.diskExclusions.disk")}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-24">
|
||||
{t("settings.diskExclusions.periodicReads")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border/50">
|
||||
{disks.map((disk) => {
|
||||
const excluded = pending.has(disk.key) ? pending.get(disk.key)! : disk.excluded
|
||||
return (
|
||||
<div key={disk.key} className="grid grid-cols-[1fr_auto] gap-4 py-3 items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`font-medium ${excluded ? "text-muted-foreground" : ""}`}>
|
||||
{disk.name || "—"}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{transportLabel(disk)}
|
||||
</Badge>
|
||||
{excluded && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
|
||||
{t("settings.diskExclusions.excluded")}
|
||||
</Badge>
|
||||
)}
|
||||
{!excluded && disk.idle && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground"
|
||||
title={t("storage.idleTitle")}
|
||||
>
|
||||
{t("storage.idle")}
|
||||
</Badge>
|
||||
)}
|
||||
{!disk.present && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
|
||||
{t("settings.diskExclusions.notConnected")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground break-all">
|
||||
{[disk.model, formatSize(disk.size_bytes), disk.serial].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center w-24">
|
||||
<Switch
|
||||
checked={!excluded}
|
||||
disabled={!editMode || saving}
|
||||
onCheckedChange={(checked) => {
|
||||
setPending((m) => {
|
||||
const next = new Map(m)
|
||||
if (!checked === disk.excluded) next.delete(disk.key)
|
||||
else next.set(disk.key, !checked)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex items-start gap-2 mt-3 pt-3 border-t 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.diskExclusions.help")}
|
||||
<br />
|
||||
{t("settings.diskExclusions.idleHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Thermometer } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { AreaChart, Area, ResponsiveContainer, Tooltip } from "recharts"
|
||||
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
|
||||
@@ -24,11 +25,11 @@ interface DiskTemperatureCardProps {
|
||||
// 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, t: { warn: number; hot: number }) {
|
||||
if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
|
||||
if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
|
||||
if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
|
||||
return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
|
||||
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) => {
|
||||
@@ -55,6 +56,7 @@ export function DiskTemperatureCard({
|
||||
diskType,
|
||||
onOpenDetail,
|
||||
}: DiskTemperatureCardProps) {
|
||||
const t = useT()
|
||||
const [data, setData] = useState<TempPoint[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const cancelled = useRef(false)
|
||||
@@ -64,8 +66,11 @@ export function DiskTemperatureCard({
|
||||
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=hour`,
|
||||
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=day`,
|
||||
)
|
||||
if (cancelled.current) return
|
||||
setData(result?.data || [])
|
||||
@@ -95,7 +100,7 @@ export function DiskTemperatureCard({
|
||||
})()
|
||||
const status = statusFor(liveTemperature, dt)
|
||||
const lineColor = status.color
|
||||
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A"
|
||||
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
|
||||
const samples = data.length
|
||||
|
||||
const interactive = !!onOpenDetail
|
||||
@@ -109,11 +114,11 @@ export function DiskTemperatureCard({
|
||||
"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 ? "Open temperature history" : undefined}
|
||||
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">Temperature</p>
|
||||
<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>
|
||||
@@ -121,7 +126,7 @@ export function DiskTemperatureCard({
|
||||
<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`}>
|
||||
{status.label}
|
||||
{t(status.labelKey)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +136,7 @@ export function DiskTemperatureCard({
|
||||
<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">
|
||||
Collecting samples — chart populates after ~2 minutes
|
||||
{t("details.temperature.collectingSamples")}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -142,6 +147,18 @@ export function DiskTemperatureCard({
|
||||
<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"
|
||||
|
||||
@@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
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", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ 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 {
|
||||
@@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string {
|
||||
}
|
||||
|
||||
function statusInfoFor(temp: number, t: DiskTempThreshold) {
|
||||
if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
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({
|
||||
@@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({
|
||||
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 })
|
||||
@@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -181,24 +183,24 @@ export function DiskTemperatureDetailModal({
|
||||
|
||||
<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">Current</div>
|
||||
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}</div>
|
||||
<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" /> 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>
|
||||
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
|
||||
<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 yet for this disk</p>
|
||||
<p className="text-sm mt-1">Samples are collected every 60 seconds</p>
|
||||
<p>{t("details.temperature.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name="Temperature"
|
||||
name={t("details.temperature.seriesName")}
|
||||
stroke={chartColor}
|
||||
strokeWidth={2}
|
||||
fill={`url(#diskTempGradient-${diskName})`}
|
||||
|
||||
@@ -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,9 +72,11 @@ 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 (
|
||||
@@ -124,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>
|
||||
|
||||
@@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
{t("hardware.gpuSwitch.lxc")}
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
@@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
{t("hardware.gpuSwitch.lxc")}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
{t("hardware.gpuSwitch.vm")}
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
@@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
{t("hardware.gpuSwitch.vm")}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
@@ -363,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>
|
||||
|
||||
+431
-362
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
ArrowUpCircle,
|
||||
X,
|
||||
Clock,
|
||||
BellOff,
|
||||
@@ -39,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
|
||||
@@ -102,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[]>([])
|
||||
@@ -122,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> = {}
|
||||
@@ -137,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([])
|
||||
@@ -200,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)
|
||||
@@ -219,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])
|
||||
@@ -274,21 +279,159 @@ 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> = {
|
||||
"No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"),
|
||||
"Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"),
|
||||
"Temperature elevated": t("healthStatus.details.temperatureElevated"),
|
||||
"No swap configured": t("healthStatus.details.noSwapConfigured"),
|
||||
"High RAM usage sustained": t("healthStatus.details.ramHighSustained"),
|
||||
"Swap nearly full with RAM tight": t("healthStatus.details.swapRamPressure"),
|
||||
"Failed to check for updates (apt-get error)": t("healthStatus.details.updatesAptError"),
|
||||
"apt-get timed out - repository may be unreachable": t("healthStatus.details.updatesAptTimeout"),
|
||||
"Unknown": t("healthStatus.details.unknownDetail"),
|
||||
"No failed login attempts in 24h": t("healthStatus.details.noFailedLogins"),
|
||||
"Fail2Ban installed but service not active": t("healthStatus.details.fail2banInactive"),
|
||||
"Unable to determine uptime": t("healthStatus.details.uptimeUnavailable"),
|
||||
"Unable to check login attempts": t("healthStatus.details.loginCheckUnavailable"),
|
||||
"Certificate expired": t("healthStatus.details.certificateExpired"),
|
||||
"Certificate check inconclusive": t("healthStatus.details.certificateInconclusive"),
|
||||
"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 (Object.prototype.hasOwnProperty.call(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(/^([\d.]+)% used$/)
|
||||
if (match) return t("healthStatus.details.storageUsage", { percent: match[1] })
|
||||
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] })
|
||||
match = value.match(/^CPU >(\d+(?:\.\d+)?)% sustained for (\d+)s$/)
|
||||
if (match) return t("healthStatus.details.cpuSustained", { threshold: match[1], seconds: match[2] })
|
||||
match = value.match(/^CPU check failed: ([\s\S]*)$/)
|
||||
if (match) return t("healthStatus.details.cpuCheckFailed", { error: match[1] })
|
||||
match = value.match(/^Sensor temperature (\d+(?:\.\d+)?)°C >80°C; high samples span ((?:\d+m )?\d+s)$/)
|
||||
if (match) return t("healthStatus.details.sensorHighSamplesSpan", { temperature: match[1], duration: match[2] })
|
||||
match = value.match(/^RAM >(\d+(?:\.\d+)?)% sustained for (\d+)s$/)
|
||||
if (match) return t("healthStatus.details.ramSustained", { threshold: match[1], seconds: match[2] })
|
||||
match = value.match(/^Memory pressure: swap (\d+)% used and only (\d+)% RAM available$/)
|
||||
if (match) return t("healthStatus.details.memoryPressure", { swapPercent: match[1], ramAvailablePercent: match[2] })
|
||||
match = value.match(/^Memory check failed: ([\s\S]*)$/)
|
||||
if (match) return t("healthStatus.details.memoryCheckFailed", { error: match[1] })
|
||||
match = value.match(/^(\d+) security update\(s\) pending$/)
|
||||
if (match) return t("healthStatus.details.pendingSecurityUpdates", { count: match[1] })
|
||||
match = value.match(/^(\d+) security update\(s\) pending \((\d+) days unpatched\)$/)
|
||||
if (match) return t("healthStatus.details.pendingSecurityUpdatesUnpatched", { count: match[1], days: match[2] })
|
||||
match = value.match(/^(\d+) security update\(s\) pending for (\d+) days$/)
|
||||
if (match) return t("healthStatus.details.pendingSecurityUpdatesDays", { count: match[1], days: match[2] })
|
||||
match = value.match(/^System not updated in (\d+) days \(>18 months\)$/)
|
||||
if (match) return t("healthStatus.details.systemNotUpdatedMonths", { days: match[1] })
|
||||
match = value.match(/^System not updated in (\d+) days \(>1 year\)$/)
|
||||
if (match) return t("healthStatus.details.systemNotUpdatedYear", { days: match[1] })
|
||||
match = value.match(/^Kernel update available for running ([^\s()]+)$/)
|
||||
if (match) return t("healthStatus.details.runningKernelUpdate", { kernel: match[1] })
|
||||
match = value.match(/^(\d+) kernel \+ (\d+) Proxmox update\(s\) available$/)
|
||||
if (match) return t("healthStatus.details.kernelPveUpdates", { kernelCount: match[1], pveCount: match[2] })
|
||||
match = value.match(/^(\d+) package update\(s\) pending$/)
|
||||
if (match) return t("healthStatus.details.pendingPackageUpdates", { count: match[1] })
|
||||
match = value.match(/^([a-z0-9][a-z0-9+.-]+(?::[a-z0-9]+)?(?: [^\s()]+ -> [^\s()]+)?) \(\+(\d+) more\)$/)
|
||||
if (match) return t("healthStatus.details.kernelPackagesMore", { technicalIdentity: match[1], count: match[2] })
|
||||
match = value.match(/^(\d+) kernel update\(s\) available \(none for running kernel( [^\s()]+)?\)$/)
|
||||
if (match) return t("healthStatus.details.nonRunningKernelUpdates", { count: match[1], optionalKernel: match[2] || "" })
|
||||
match = value.match(/^PVE ([^\s()]+) -> ([^\s()]+) available$/)
|
||||
if (match) return t("healthStatus.details.pveVersionAvailable", { current: match[1], new: match[2] })
|
||||
match = value.match(/^Updates check unavailable: ([\s\S]*)$/)
|
||||
if (match) return t("healthStatus.details.updatesCheckUnavailable", { error: match[1] })
|
||||
match = value.match(/^Unable to check Fail2Ban: ([\s\S]*)$/)
|
||||
if (match) return t("healthStatus.details.fail2banCheckUnavailable", { error: match[1] })
|
||||
match = value.match(/^Uptime (\d+) days \(>1 year, consider updating kernel\/system\)$/)
|
||||
if (match) return t("healthStatus.details.uptimeKernelAdvice", { days: match[1] })
|
||||
match = value.match(/^(\d+) failed attempts in 24h \(within threshold\)$/)
|
||||
if (match) return t("healthStatus.details.failedLoginsWithinThreshold", { count: match[1] })
|
||||
match = value.match(/^Security check unavailable: ([\s\S]*)$/)
|
||||
if (match) return t("healthStatus.details.securityCheckUnavailable", { error: match[1] })
|
||||
match = value.match(/^Certificate expires in (\d+) days$/)
|
||||
if (match) return t("healthStatus.details.certificateExpiresDays", { days: 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)
|
||||
@@ -441,11 +584,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) => {
|
||||
@@ -468,49 +611,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, " ")
|
||||
@@ -540,15 +649,15 @@ 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 && (
|
||||
checkData.permanent ? (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
|
||||
Permanent
|
||||
{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">
|
||||
Dismissed
|
||||
{t("healthStatus.dismissed")}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
@@ -560,6 +669,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
handleAcknowledge(checkData.error_key || checkKey, hours)
|
||||
}
|
||||
busy={dismissingKey === (checkData.error_key || checkKey)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -579,12 +689,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" />
|
||||
@@ -602,7 +712,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>
|
||||
)}
|
||||
@@ -613,47 +723,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
|
||||
@@ -674,7 +784,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})
|
||||
@@ -687,7 +797,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 ${
|
||||
@@ -710,6 +820,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
handleAcknowledge(`category_${key}_unknown`, hours)
|
||||
}
|
||||
busy={dismissingKey === `category_${key}_unknown`}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -719,7 +830,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>
|
||||
@@ -738,12 +866,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 (
|
||||
@@ -758,34 +886,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>
|
||||
@@ -801,30 +923,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}
|
||||
@@ -834,7 +946,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>
|
||||
@@ -842,12 +954,29 @@ 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>
|
||||
)
|
||||
}
|
||||
@@ -859,9 +988,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
function DismissDropdown({
|
||||
onSelect,
|
||||
busy,
|
||||
t,
|
||||
}: {
|
||||
onSelect: (suppressionHours: number) => void
|
||||
busy: boolean
|
||||
t: ReturnType<typeof useT>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -878,27 +1009,27 @@ function DismissDropdown({
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 sm:mr-0.5" />
|
||||
<span className="hidden sm:inline">Dismiss</span>
|
||||
<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">
|
||||
Silence this alert for
|
||||
{t("healthStatus.silenceFor")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
|
||||
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 24 hours
|
||||
<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" /> 7 days
|
||||
<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" /> Permanently
|
||||
<BellOff className="h-3 w-3 mr-2" /> {t("healthStatus.permanently")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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
|
||||
@@ -80,7 +81,7 @@ interface ThresholdLeaf {
|
||||
|
||||
interface ThresholdsTree {
|
||||
cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_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 }
|
||||
@@ -149,7 +150,8 @@ const SECTIONS: SectionDef[] = [
|
||||
fields: [
|
||||
{ path: ["memory", "warning"], label: "Memory warning" },
|
||||
{ path: ["memory", "critical"], label: "Memory critical" },
|
||||
{ path: ["memory", "swap_critical"], label: "Swap critical" },
|
||||
{ path: ["memory", "swap_high"], label: "Swap high" },
|
||||
{ path: ["memory", "available_min"], label: "Memory available minimum" },
|
||||
],
|
||||
},
|
||||
// ── Heat ────────────────────────────────────────────────────────
|
||||
@@ -250,9 +252,43 @@ 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)
|
||||
@@ -270,7 +306,7 @@ export function HealthThresholds() {
|
||||
)
|
||||
if (res?.success && res.thresholds) setTree(res.thresholds)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load thresholds")
|
||||
setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -293,7 +329,7 @@ export function HealthThresholds() {
|
||||
if (trimmed === "") continue
|
||||
const num = Number(trimmed)
|
||||
if (!isFinite(num)) {
|
||||
setError(`Invalid value for ${key}: must be a number`)
|
||||
setError(t("settings.healthThresholds.invalidValue", { key }))
|
||||
return null
|
||||
}
|
||||
// Walk into payload mirroring the path
|
||||
@@ -333,7 +369,7 @@ export function HealthThresholds() {
|
||||
{ method: "PUT", body: JSON.stringify(payload) },
|
||||
)
|
||||
if (!data.success || !data.thresholds) {
|
||||
setError(data.message || "Save failed")
|
||||
setError(data.message || t("status.saveFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
@@ -342,14 +378,16 @@ export function HealthThresholds() {
|
||||
setSavedFlash(true)
|
||||
setTimeout(() => setSavedFlash(false), 2000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Network error while saving")
|
||||
setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetSection = async (sectionId: string) => {
|
||||
if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`))
|
||||
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 }>(
|
||||
@@ -357,7 +395,7 @@ export function HealthThresholds() {
|
||||
{ method: "POST" },
|
||||
)
|
||||
if (!data.success || !data.thresholds) {
|
||||
setError(data.message || "Reset failed")
|
||||
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
@@ -371,52 +409,36 @@ export function HealthThresholds() {
|
||||
return next
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Network error while resetting")
|
||||
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetAll = async () => {
|
||||
if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return
|
||||
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 || "Reset failed")
|
||||
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
setPending({})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Network error while resetting")
|
||||
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)
|
||||
// Visual rules (rebuilt — the original used /40 opacity borders +
|
||||
// a blue ring stacked on top of the colour border, both of which
|
||||
// were nearly invisible in read-only mode and stacked weirdly when
|
||||
// a value was customised):
|
||||
//
|
||||
// • Read-only mode (editMode=false): keep severity colour on the
|
||||
// border at a higher opacity (/70 instead of /40) and on the
|
||||
// background (/10) so the field is clearly readable, and
|
||||
// restore foreground colour (no `opacity-70` washout). This is
|
||||
// the default state the user sees most of the time — it must
|
||||
// match the visual weight of the rest of the Settings page.
|
||||
// • Edit mode + value matches the recommended default: severity
|
||||
// border + soft severity bg, same as read-only.
|
||||
// • Edit mode + value customised: ONE border in blue, replacing
|
||||
// (not stacking on top of) the severity border. This is the
|
||||
// single signal that "this value differs from recommended".
|
||||
//
|
||||
// `swap_critical` and any other `*_critical` leaf falls into the
|
||||
// red bucket via the substring check.
|
||||
const last = path[path.length - 1] || ""
|
||||
const isCritical = last.toLowerCase().includes("critical")
|
||||
const isWarning = last.toLowerCase().includes("warning")
|
||||
@@ -428,7 +450,7 @@ export function HealthThresholds() {
|
||||
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 = `Recommended: ${leaf.recommended}${leaf.unit}`
|
||||
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">
|
||||
@@ -456,20 +478,220 @@ export function HealthThresholds() {
|
||||
)
|
||||
}
|
||||
|
||||
// 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>Health Monitor Thresholds</CardTitle>
|
||||
<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" />
|
||||
Saved
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
@@ -479,7 +701,7 @@ export function HealthThresholds() {
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
{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"
|
||||
@@ -491,7 +713,7 @@ export function HealthThresholds() {
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Save
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -499,17 +721,17 @@ export function HealthThresholds() {
|
||||
<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="Reset every threshold to its recommended value"
|
||||
title={t("settings.healthThresholds.resetAllTitle")}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset all
|
||||
{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" />
|
||||
Edit
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -517,19 +739,23 @@ export function HealthThresholds() {
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
The Health Monitor and notifications fire when these thresholds are crossed.
|
||||
Amber inputs are warning levels, red inputs are critical levels. A blue ring
|
||||
marks a value you've customised away from the recommended default — hover the
|
||||
field to see the recommendation, or use Reset to restore it.
|
||||
{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">Failed to load thresholds.</div>
|
||||
<div className="text-sm text-muted-foreground">{t("settings.healthThresholds.loadFailed")}</div>
|
||||
) : (
|
||||
<div>
|
||||
{error && (
|
||||
@@ -554,13 +780,13 @@ export function HealthThresholds() {
|
||||
<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">{section.title}</h4>
|
||||
<h4 className="text-sm font-medium">{tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title)}</h4>
|
||||
</div>
|
||||
{!editMode && (
|
||||
{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="Reset this section to recommended"
|
||||
title={t("settings.healthThresholds.resetSectionTitle")}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -568,21 +794,70 @@ export function HealthThresholds() {
|
||||
</div>
|
||||
{section.description && (
|
||||
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
|
||||
{section.description}
|
||||
{tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}
|
||||
</p>
|
||||
)}
|
||||
<div className="divide-y divide-border/40">
|
||||
{section.rowGroups
|
||||
? section.rowGroups.map((group) => (
|
||||
<div key={group.subKey} className="py-1.5">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-0.5 px-1">
|
||||
{group.label}
|
||||
</div>
|
||||
{renderField([section.id, group.subKey, "warning"], "Warning")}
|
||||
{renderField([section.id, group.subKey, "critical"], "Critical")}
|
||||
<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>
|
||||
))
|
||||
: section.fields.map((f) => renderField(f.path, f.label))}
|
||||
{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>
|
||||
)
|
||||
|
||||
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; }
|
||||
@@ -298,10 +363,12 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
.top-bar-title { font-weight: 600; }
|
||||
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
|
||||
.top-bar button {
|
||||
background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.top-bar button:hover { background: #0891b2; }
|
||||
.top-bar .btn-group { display: flex; gap: 8px; }
|
||||
.top-bar button svg { width: 18px; height: 18px; display: block; }
|
||||
@media (min-width: 640px) {
|
||||
.top-bar { padding: 12px 24px; }
|
||||
.top-bar-subtitle { display: block; }
|
||||
@@ -463,11 +530,14 @@ 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>
|
||||
<div class="btn-group">
|
||||
<button onclick="window.print()" title="Print" aria-label="Print"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg></button>
|
||||
<button onclick="window.print()" title="Save as PDF" aria-label="Save as PDF"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -475,21 +545,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 +578,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 +621,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 +662,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 +670,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 +713,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 +733,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 +780,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 +957,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 +972,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 +998,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 +1013,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 +1023,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 +1032,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 +1052,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 +1067,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 +1075,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 +1100,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 +1133,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 +1150,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 +1182,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 +1198,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 +1208,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,15 @@
|
||||
|
||||
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 { APP_VERSION } from "../lib/version"
|
||||
import Image from "next/image"
|
||||
|
||||
interface LoginProps {
|
||||
@@ -16,6 +18,7 @@ interface LoginProps {
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: LoginProps) {
|
||||
const t = useT()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [totpCode, setTotpCode] = useState("")
|
||||
@@ -24,6 +27,7 @@ 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
|
||||
@@ -51,17 +55,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
|
||||
}
|
||||
|
||||
@@ -74,20 +79,26 @@ 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)
|
||||
@@ -107,12 +118,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">
|
||||
@@ -139,8 +166,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>
|
||||
|
||||
@@ -157,14 +184,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"
|
||||
@@ -176,14 +203,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"
|
||||
@@ -214,7 +241,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>
|
||||
</>
|
||||
@@ -223,29 +250,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>
|
||||
|
||||
@@ -260,18 +287,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.2</p>
|
||||
<p className="text-center text-sm text-muted-foreground">{`ProxMenux Monitor v${APP_VERSION}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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