mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-04 20:02:07 +00:00
Compare commits
685
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 | ||
|
|
11e030b9de | ||
|
|
c5d717c85a | ||
|
|
e73ec8e24c | ||
|
|
b577c154ee | ||
|
|
ec129b8262 | ||
|
|
698c89fd3b | ||
|
|
7bbccef81e | ||
|
|
b45799d319 | ||
|
|
17d00f2db7 | ||
|
|
58a28dd81e | ||
|
|
42b4aa2489 | ||
|
|
0beeb7a68b | ||
|
|
34b8c47415 | ||
|
|
c25f72febe | ||
|
|
f0c8e73446 | ||
|
|
157ab50f9b | ||
|
|
9eb576f8f3 | ||
|
|
f6da553aeb | ||
|
|
cf0c21eff3 | ||
|
|
99ad69e8cf | ||
|
|
452902bd9a | ||
|
|
e1fb203e46 | ||
|
|
ac2c28ad2c | ||
|
|
8481e588b2 | ||
|
|
d9944edf5e | ||
|
|
037421d762 | ||
|
|
3fe9a49ebc | ||
|
|
805003d811 | ||
|
|
bc88a34390 | ||
|
|
ef859d5ec3 | ||
|
|
55a95a2346 | ||
|
|
c81f552ed0 | ||
|
|
d2c7be9a32 | ||
|
|
bf6c580877 | ||
|
|
3dc263f9fb | ||
|
|
05e5277ae5 | ||
|
|
c2ba648ebe | ||
|
|
2bfa84d5e0 | ||
|
|
d6162af020 | ||
|
|
c4b8ad65da | ||
|
|
a056d7e0dc | ||
|
|
87bda3bbb6 | ||
|
|
c1bd4b3403 | ||
|
|
f2420d9978 | ||
|
|
2901b22be6 | ||
|
|
7b2d9a3268 | ||
|
|
9dae948a7c | ||
|
|
66a1781298 | ||
|
|
504d34a3ee | ||
|
|
3d83d9d561 | ||
|
|
5c33852a65 | ||
|
|
a2740b6873 | ||
|
|
547942cac8 | ||
|
|
8a5f7a4cb8 | ||
|
|
d2a3720ec4 | ||
|
|
732f1e8ef7 | ||
|
|
e05b4e94dc | ||
|
|
05476b99c6 | ||
|
|
06f41f5792 | ||
|
|
fb8b41b445 | ||
|
|
eeb39e6e0f | ||
|
|
fda41ae8d5 | ||
|
|
3a7e450b55 | ||
|
|
7d781d570c | ||
|
|
e581aa0121 | ||
|
|
ef53fa0827 | ||
|
|
86517860ff | ||
|
|
d62fc29581 | ||
|
|
39e7a7d2db | ||
|
|
88f1d09cb8 | ||
|
|
2109cf2508 | ||
|
|
04a028245b | ||
|
|
f10ff18abc | ||
|
|
359a5c269c | ||
|
|
2a23cc9079 | ||
|
|
7fcbc467df | ||
|
|
f2d9c9fb8e | ||
|
|
2c05c800d9 | ||
|
|
89fa4e73a5 | ||
|
|
c4a574422b | ||
|
|
e7a9e3a5b6 | ||
|
|
cbb46cb70a | ||
|
|
37189ed67e | ||
|
|
ebd41b8a46 | ||
|
|
42ca256bac | ||
|
|
3d3b1fc902 |
@@ -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
|
||||
+1136
-449
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())
|
||||
@@ -16,6 +16,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -28,12 +30,252 @@ from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt")
|
||||
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(
|
||||
@@ -86,6 +328,93 @@ def extract_translate_texts(
|
||||
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
|
||||
@@ -97,7 +426,10 @@ def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
|
||||
|
||||
translator = Translator()
|
||||
full_text = f"{context} {text}".strip()
|
||||
return translator.translate(full_text, dest=dest_lang).text
|
||||
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:
|
||||
@@ -163,8 +495,23 @@ def translate_appimage(
|
||||
|
||||
def clean_translation(value: str) -> str:
|
||||
separator = r"[\s\u00a0]*[::]"
|
||||
translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
|
||||
context_labels = "Context|Contexto|Contexte|Kontext|Contesto"
|
||||
# `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}",
|
||||
"",
|
||||
@@ -186,6 +533,20 @@ def clean_translation(value: str) -> str:
|
||||
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,
|
||||
@@ -194,15 +555,24 @@ def translate_text(
|
||||
timeout: int,
|
||||
appimage_path: Path,
|
||||
) -> str:
|
||||
if provider == "googletrans":
|
||||
translated = translate_googletrans(text, dest_lang, context)
|
||||
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(text, dest_lang, context, timeout)
|
||||
translated = translate_google_web(protected_text, dest_lang, context, timeout)
|
||||
elif provider == "appimage":
|
||||
translated = translate_appimage(text, dest_lang, context, timeout, appimage_path)
|
||||
translated = translate_appimage(
|
||||
protected_text, dest_lang, context, timeout, appimage_path
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
return clean_translation(translated) or text
|
||||
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]:
|
||||
@@ -217,6 +587,35 @@ def load_language_cache(path: Path) -> dict[str, str]:
|
||||
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")
|
||||
@@ -232,6 +631,30 @@ def build_arg_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",
|
||||
@@ -260,11 +683,11 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--languages",
|
||||
default=",".join(DEFAULT_LANGUAGES),
|
||||
help="Comma-separated destination languages. Default: es,fr,de,it,pt",
|
||||
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("appimage", "googletrans", "google-web"),
|
||||
choices=("argos", "appimage", "googletrans", "google-web"),
|
||||
default="appimage",
|
||||
help="Translation provider to use. Default: appimage",
|
||||
)
|
||||
@@ -277,10 +700,21 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
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="Translate all entries again instead of reusing existing cache values.",
|
||||
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",
|
||||
@@ -319,17 +753,45 @@ def main() -> int:
|
||||
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
|
||||
}
|
||||
next_by_lang: dict[str, dict[str, str]] = {lang: {} 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
|
||||
@@ -340,7 +802,13 @@ def main() -> int:
|
||||
|
||||
for index, text in enumerate(texts, start=1):
|
||||
done += 1
|
||||
if not args.refresh and existing.get(text):
|
||||
# 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:
|
||||
@@ -348,20 +816,51 @@ def main() -> int:
|
||||
continue
|
||||
|
||||
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
|
||||
try:
|
||||
next_by_lang[lang][text] = translate_text(
|
||||
text,
|
||||
lang,
|
||||
args.provider,
|
||||
args.context,
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
print(f" => {next_by_lang[lang][text][:100]}", flush=True)
|
||||
except Exception as exc:
|
||||
next_by_lang[lang][text] = existing.get(text, text)
|
||||
failures.append((text, lang, str(exc)))
|
||||
print(f" failed: {exc}", file=sys.stderr, flush=True)
|
||||
# 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)
|
||||
@@ -373,12 +872,20 @@ def main() -> int:
|
||||
write_language_cache(output_dir / f"{lang}.json", cache)
|
||||
|
||||
if failures:
|
||||
print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True)
|
||||
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)
|
||||
return 2
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +47,26 @@ jobs:
|
||||
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
|
||||
working-directory: AppImage
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +47,26 @@ jobs:
|
||||
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
|
||||
working-directory: AppImage
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +45,26 @@ jobs:
|
||||
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
|
||||
working-directory: AppImage
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +47,26 @@ jobs:
|
||||
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
|
||||
working-directory: AppImage
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
name: Build i18n messages
|
||||
|
||||
# Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||
# against the English source whenever the source changes.
|
||||
#
|
||||
# The Monitor's i18n layer (AppImage/lib/i18n/provider.tsx) does its own
|
||||
# runtime fallback (locale → en → key), so this workflow doesn't break
|
||||
# anything if it misses a key: it just eliminates the visible-English
|
||||
# blocks in non-en locales.
|
||||
#
|
||||
# Guardrails baked into build_i18n_messages.py:
|
||||
# - Never overwrites a key whose target value differs from EN (i.e.
|
||||
# already translated by a human). This is what makes it safe to
|
||||
# include sk in the default set: Vaso73's curated strings are
|
||||
# protected end-to-end; auto only fills keys still on the EN
|
||||
# fallback.
|
||||
# - `{placeholder}` tokens are protected end-to-end.
|
||||
#
|
||||
# Triggers:
|
||||
# - push to develop touching AppImage/messages/en/common.json
|
||||
# - manual via workflow_dispatch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
# Only en/ triggers the run — it's the source of truth. Other
|
||||
# locales are destinations, and the bot auto-commits them at
|
||||
# the end of every run; if they were in the trigger too, each
|
||||
# auto-commit would fire another (empty) run.
|
||||
# Manual edits to a curated locale that empty a key for the
|
||||
# workflow to refill are the exception — dispatch this
|
||||
# workflow manually from Actions in that case, or piggy-back
|
||||
# a trivial en/ change onto the commit.
|
||||
- 'AppImage/messages/en/common.json'
|
||||
- '.github/scripts/build_i18n_messages.py'
|
||||
- '.github/workflows/build-i18n-messages.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
refresh:
|
||||
description: 'Re-translate every key (overwrites human translations!)'
|
||||
type: boolean
|
||||
default: false
|
||||
languages:
|
||||
description: 'Comma-separated locales. Default: es,de,fr,it,pt,sk,sv (guardrail protects Vaso73 sk).'
|
||||
default: 'es,de,fr,it,pt,sk,sv'
|
||||
|
||||
# Prevent two runs from racing on the same branch and fighting over the
|
||||
# auto-commit. cancel-in-progress:false because a full first-run may take
|
||||
# ~30 min for the initial bootstrap and interrupting mid-flight would
|
||||
# waste the calls already made.
|
||||
concurrency:
|
||||
group: build-i18n-messages-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # auto-commit AppImage/messages/*/common.json to develop
|
||||
# First-run bootstrap of ~5 locales × 3.8k keys can take a while at
|
||||
# 0.15s/call with rate-limit backoffs. 90 min headroom.
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
# Full history so the auto-commit doesn't drift when another
|
||||
# push landed between trigger and this job start.
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install googletrans
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Same pinning as build-translation-cache.yml so the two
|
||||
# workflows share behavior. Bump both in lockstep.
|
||||
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||
|
||||
- name: Translate missing keys
|
||||
run: |
|
||||
REFRESH_FLAG=""
|
||||
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||
REFRESH_FLAG="--refresh"
|
||||
fi
|
||||
LANGS="${{ github.event.inputs.languages }}"
|
||||
# Keep this fallback in sync with the workflow_dispatch input
|
||||
# default above AND with DEFAULT_LANGUAGES in the Python script —
|
||||
# `push` triggers hit this branch (inputs are empty on push).
|
||||
LANGS="${LANGS:-es,de,fr,it,pt,sk,sv}"
|
||||
python .github/scripts/build_i18n_messages.py \
|
||||
--source AppImage/messages/en/common.json \
|
||||
--messages-dir AppImage/messages \
|
||||
--languages "$LANGS" \
|
||||
--provider googletrans \
|
||||
$REFRESH_FLAG
|
||||
|
||||
- name: Commit + push if changed
|
||||
run: |
|
||||
if git diff --quiet -- AppImage/messages/; then
|
||||
echo "No translation changes — skipping commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add AppImage/messages/
|
||||
git commit -m "chore(i18n): auto-fill missing translations in messages/{locale}/common.json
|
||||
|
||||
Source: ${GITHUB_SHA::7}
|
||||
Triggered by: ${{ github.event_name }}"
|
||||
# Rebase-and-retry against develop: between the initial
|
||||
# checkout and this push another workflow (e.g.
|
||||
# build-translation-cache auto-commit) or a manual push can
|
||||
# land on develop first, and a naked push fails with
|
||||
# non-fast-forward — losing the freshly generated translation
|
||||
# commit. Rebasing the local i18n commit on top of the newer
|
||||
# HEAD is safe: paths don't overlap with cache/script
|
||||
# workflows, and the same-branch collisions between two i18n
|
||||
# runs are already blocked by the concurrency group above.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin develop; then
|
||||
echo "push succeeded on attempt ${attempt}"
|
||||
exit 0
|
||||
fi
|
||||
echo "push attempt ${attempt} failed, retrying..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $(( attempt * 3 ))
|
||||
done
|
||||
echo "push failed after 5 attempts"
|
||||
exit 1
|
||||
@@ -15,9 +15,15 @@ on:
|
||||
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:
|
||||
@@ -79,6 +85,11 @@ jobs:
|
||||
--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
|
||||
@@ -96,4 +107,23 @@ jobs:
|
||||
|
||||
Source: ${GITHUB_SHA::7}
|
||||
Triggered by: ${{ github.event_name }}"
|
||||
git push origin develop
|
||||
# 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"
|
||||
@@ -22,5 +22,6 @@ jobs:
|
||||
title: ProxMenux growth
|
||||
output: images/project-growth.svg
|
||||
history: .github/repo-growth/history.json
|
||||
metrics: stars,forks
|
||||
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.
@@ -1 +1 @@
|
||||
d3aedf08d50d332161a3c1f7f58f4f546af46ee2b7a4bd193ff6b2669b7cd5e8 ProxMenux-1.2.4.AppImage
|
||||
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
|
||||
|
||||
@@ -201,6 +201,20 @@ After setting up your password, you can enable 2FA using any TOTP authenticator
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
|
||||
@@ -43,13 +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 />
|
||||
<PwaInstallPrompt />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Badge } from "./ui/badge"
|
||||
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useDiskTempThresholds } from "@/lib/health-thresholds"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface TempPoint {
|
||||
timestamp: number
|
||||
@@ -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)
|
||||
@@ -98,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
|
||||
@@ -112,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>
|
||||
@@ -124,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>
|
||||
@@ -134,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%">
|
||||
|
||||
@@ -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>
|
||||
|
||||
+411
-312
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Download,
|
||||
ArrowUpCircle,
|
||||
X,
|
||||
Clock,
|
||||
BellOff,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
@@ -104,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[]>([])
|
||||
@@ -146,7 +148,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
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([])
|
||||
@@ -203,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)
|
||||
@@ -277,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)
|
||||
@@ -444,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) => {
|
||||
@@ -471,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, " ")
|
||||
@@ -543,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>
|
||||
)
|
||||
)}
|
||||
@@ -563,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>
|
||||
@@ -582,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" />
|
||||
@@ -605,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>
|
||||
)}
|
||||
@@ -616,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
|
||||
@@ -677,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})
|
||||
@@ -690,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 ${
|
||||
@@ -713,6 +820,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
handleAcknowledge(`category_${key}_unknown`, hours)
|
||||
}
|
||||
busy={dismissingKey === `category_${key}_unknown`}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -722,7 +830,7 @@ 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
|
||||
@@ -737,8 +845,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
Update Now
|
||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
{t("healthStatus.updateNow")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -758,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 (
|
||||
@@ -778,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>
|
||||
@@ -821,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}
|
||||
@@ -854,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>
|
||||
@@ -862,7 +954,7 @@ 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>
|
||||
@@ -882,8 +974,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
params={{
|
||||
EXECUTION_MODE: "web",
|
||||
}}
|
||||
title="Proxmox System Update"
|
||||
description="Runs apt-get update + dist-upgrade and post-update cleanup on the host."
|
||||
title={t("healthStatus.updateTerminalTitle")}
|
||||
description={t("healthStatus.updateTerminalDescription")}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -896,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>
|
||||
@@ -915,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 ────────────────────────────────────────────────────────
|
||||
@@ -282,6 +284,11 @@ function computeVisualRange(
|
||||
// ─── 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)
|
||||
@@ -299,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)
|
||||
}
|
||||
@@ -322,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
|
||||
@@ -362,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)
|
||||
@@ -371,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 }>(
|
||||
@@ -386,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)
|
||||
@@ -400,25 +409,25 @@ 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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,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">
|
||||
@@ -524,12 +533,12 @@ export function HealthThresholds() {
|
||||
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={`Recommended: ${leaf.recommended}${unit}`}
|
||||
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>OK < {val}{unit}</span>
|
||||
<span className="text-right">{severity === "critical" ? "CRIT" : "WARN"} > {val}{unit}</span>
|
||||
<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>
|
||||
)
|
||||
@@ -641,7 +650,7 @@ export function HealthThresholds() {
|
||||
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={`Warning (recommended: ${wLeaf.recommended}${unit})`}
|
||||
title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
@@ -652,7 +661,7 @@ export function HealthThresholds() {
|
||||
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={`Critical (recommended: ${cLeaf.recommended}${unit})`}
|
||||
title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -660,9 +669,9 @@ export function HealthThresholds() {
|
||||
"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>OK < {wVal}{unit}</span>
|
||||
<span className="text-center">WARN {wVal}–{cVal}{unit}</span>
|
||||
<span className="text-right">CRIT > {cVal}{unit}</span>
|
||||
<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>
|
||||
@@ -675,14 +684,14 @@ export function HealthThresholds() {
|
||||
<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 ? (
|
||||
@@ -692,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"
|
||||
@@ -704,7 +713,7 @@ export function HealthThresholds() {
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Save
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -712,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>
|
||||
</>
|
||||
)}
|
||||
@@ -730,19 +739,23 @@ export function HealthThresholds() {
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
The Health Monitor and notifications fire when these thresholds are crossed.
|
||||
Drag the amber handle to set the warning level and the red handle to set the
|
||||
critical level. Values that differ from the recommended default appear in blue —
|
||||
hover a handle 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 && (
|
||||
@@ -767,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 && (
|
||||
<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>
|
||||
@@ -781,7 +794,7 @@ 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>
|
||||
@@ -800,20 +813,36 @@ export function HealthThresholds() {
|
||||
))
|
||||
) : section.id === "memory" ? (
|
||||
// Memory & Swap is special: warn/crit pair for
|
||||
// RAM, plus a single Swap threshold that has no
|
||||
// companion (it's a "critical only" metric).
|
||||
// Both use sliders so the section reads as one
|
||||
// visual language end to end.
|
||||
// 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">
|
||||
RAM
|
||||
{t("settings.healthThresholds.ram")}
|
||||
</div>
|
||||
{renderThresholdRange(["memory"])}
|
||||
<div className="border-t border-border/40">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1 pt-1.5">
|
||||
Swap (critical only)
|
||||
<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>
|
||||
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
|
||||
</div>
|
||||
</>
|
||||
) : section.fields.length === 2 &&
|
||||
|
||||
+955
-819
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.4</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
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTit
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface LxcTerminalModalProps {
|
||||
open: boolean
|
||||
@@ -51,33 +52,35 @@ interface CheatSheetResult {
|
||||
examples: string[]
|
||||
}
|
||||
|
||||
const proxmoxCommands = [
|
||||
{ cmd: "ls -la", desc: "List all files with details" },
|
||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
||||
{ cmd: "cat filename", desc: "Display file contents" },
|
||||
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
|
||||
{ cmd: "find . -name 'file'", desc: "Find files by name" },
|
||||
{ cmd: "df -h", desc: "Show disk usage" },
|
||||
{ cmd: "du -sh *", desc: "Show directory sizes" },
|
||||
{ cmd: "free -h", desc: "Show memory usage" },
|
||||
{ cmd: "top", desc: "Show running processes" },
|
||||
{ cmd: "ps aux | grep process", desc: "Find running process" },
|
||||
{ cmd: "systemctl status service", desc: "Check service status" },
|
||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
||||
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
|
||||
{ cmd: "apt install package", desc: "Install package" },
|
||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
|
||||
{ cmd: "chmod 755 file", desc: "Change file permissions" },
|
||||
{ cmd: "chown user:group file", desc: "Change file owner" },
|
||||
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
|
||||
{ cmd: "docker ps", desc: "List running containers" },
|
||||
{ cmd: "docker images", desc: "List Docker images" },
|
||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
||||
{ cmd: "history", desc: "Show command history" },
|
||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
||||
]
|
||||
const LXC_COMMANDS = [
|
||||
{ cmd: "ls -la", descKey: "listFiles" },
|
||||
{ cmd: "cd /path/to/dir", descKey: "changeDirectory" },
|
||||
{ cmd: "cat filename", descKey: "displayFile" },
|
||||
{ cmd: "grep 'pattern' file", descKey: "searchPattern" },
|
||||
{ cmd: "find . -name 'file'", descKey: "findFiles" },
|
||||
{ cmd: "df -h", descKey: "diskUsage" },
|
||||
{ cmd: "du -sh *", descKey: "directorySizes" },
|
||||
{ cmd: "free -h", descKey: "memoryUsage" },
|
||||
{ cmd: "top", descKey: "runningProcesses" },
|
||||
{ cmd: "ps aux | grep process", descKey: "findProcess" },
|
||||
{ cmd: "systemctl status service", descKey: "serviceStatus" },
|
||||
{ cmd: "systemctl restart service", descKey: "restartService" },
|
||||
{ cmd: "apt update && apt upgrade", descKey: "updatePackages" },
|
||||
{ cmd: "apt install package", descKey: "installPackage" },
|
||||
{ cmd: "tail -f /var/log/syslog", descKey: "followLog" },
|
||||
{ cmd: "chmod 755 file", descKey: "changePermissions" },
|
||||
{ cmd: "chown user:group file", descKey: "changeOwner" },
|
||||
{ cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
|
||||
{ cmd: "docker ps", descKey: "listContainers" },
|
||||
{ cmd: "docker images", descKey: "listImages" },
|
||||
{ cmd: "ip addr show", descKey: "showIpAddresses" },
|
||||
{ cmd: "ping host", descKey: "testConnectivity" },
|
||||
{ cmd: "curl -I url", descKey: "httpHeaders" },
|
||||
{ cmd: "history", descKey: "commandHistory" },
|
||||
{ cmd: "clear", descKey: "clearScreen" },
|
||||
] as const
|
||||
|
||||
type LocalCommand = { cmd: string; desc: string }
|
||||
|
||||
function getWebSocketUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -101,6 +104,7 @@ export function LxcTerminalModal({
|
||||
vmid,
|
||||
vmName,
|
||||
}: LxcTerminalModalProps) {
|
||||
const t = useT()
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
@@ -121,12 +125,18 @@ export function LxcTerminalModal({
|
||||
// Search state
|
||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
||||
const localCommands = useMemo<LocalCommand[]>(
|
||||
() => LXC_COMMANDS.map((item) => ({ cmd: item.cmd, desc: t(`lxcTerminal.commands.${item.descKey}`) })),
|
||||
[t],
|
||||
)
|
||||
const [filteredCommands, setFilteredCommands] = useState<LocalCommand[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||
const [useOnline, setUseOnline] = useState(true)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredCommands(localCommands)
|
||||
}, [localCommands])
|
||||
|
||||
// Detect mobile/tablet
|
||||
useEffect(() => {
|
||||
@@ -168,8 +178,7 @@ export function LxcTerminalModal({
|
||||
// `cancelled` short-circuits the async init if the modal closes
|
||||
// before the dynamic xterm import resolves. Without this, we'd
|
||||
// construct a Terminal instance, attach it to a now-stale ref, and
|
||||
// open a WebSocket that nobody listens to. Audit Tier 6 — useEffect
|
||||
// con `import("xterm")` sin cancelación.
|
||||
// open a WebSocket that nobody listens to.
|
||||
let cancelled = false
|
||||
|
||||
// Small delay to ensure Dialog content is rendered
|
||||
@@ -278,7 +287,7 @@ export function LxcTerminalModal({
|
||||
// through Number without losing fidelity.
|
||||
const id = Number(vmid)
|
||||
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
|
||||
term.writeln('\r\n\x1b[31m[ERROR] Invalid VMID — refusing to execute pct enter\x1b[0m')
|
||||
term.writeln(`\r\n\x1b[31m[ERROR] ${t("lxcTerminal.errors.invalidVmid")}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
ws.send(`pct enter ${id}\r`)
|
||||
@@ -287,7 +296,7 @@ export function LxcTerminalModal({
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||||
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
@@ -295,7 +304,7 @@ export function LxcTerminalModal({
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||||
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
@@ -395,7 +404,7 @@ export function LxcTerminalModal({
|
||||
termRef.current.dispose()
|
||||
}
|
||||
}
|
||||
}, [isOpen, vmid])
|
||||
}, [isOpen, vmid, t])
|
||||
|
||||
// Resize handling
|
||||
useEffect(() => {
|
||||
@@ -478,7 +487,7 @@ export function LxcTerminalModal({
|
||||
const searchCheatSh = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localCommands)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,7 +500,7 @@ export function LxcTerminalModal({
|
||||
})
|
||||
|
||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||
throw new Error("No examples found")
|
||||
throw new Error(t("terminal.noExamplesFound"))
|
||||
}
|
||||
|
||||
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
||||
@@ -503,7 +512,7 @@ export function LxcTerminalModal({
|
||||
setUseOnline(true)
|
||||
setSearchResults(formattedResults)
|
||||
} catch (error) {
|
||||
const filtered = proxmoxCommands.filter(
|
||||
const filtered = localCommands.filter(
|
||||
(item) =>
|
||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||
@@ -521,12 +530,12 @@ export function LxcTerminalModal({
|
||||
searchCheatSh(searchQuery)
|
||||
} else {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localCommands)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchQuery])
|
||||
}, [searchQuery, localCommands, t])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (termRef.current) {
|
||||
@@ -565,7 +574,7 @@ export function LxcTerminalModal({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||
<DialogTitle className="text-sm font-medium text-white">
|
||||
Terminal: {vmName} (ID: {vmid})
|
||||
{t("lxcTerminal.title", { name: vmName, id: vmid })}
|
||||
</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -576,7 +585,7 @@ export function LxcTerminalModal({
|
||||
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClear}
|
||||
@@ -586,7 +595,7 @@ export function LxcTerminalModal({
|
||||
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,29 +682,29 @@ export function LxcTerminalModal({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||
<span className="text-muted-foreground text-xs">Search history</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||
<span className="text-xs">Copy selection</span>
|
||||
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||
<span className="text-xs">Paste</span>
|
||||
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -716,7 +725,7 @@ export function LxcTerminalModal({
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
|
||||
<span className="text-xs text-zinc-400">{t(`scriptTerminal.${connectionStatus}`)}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
@@ -725,7 +734,7 @@ export function LxcTerminalModal({
|
||||
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Close</span>
|
||||
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -734,22 +743,22 @@ export function LxcTerminalModal({
|
||||
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
||||
<SearchDialogTitle className="text-xl font-semibold">Search Commands</SearchDialogTitle>
|
||||
<SearchDialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</SearchDialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
|
||||
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription>
|
||||
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search commands... (e.g., tar, docker, systemctl)"
|
||||
placeholder={t("terminal.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
||||
@@ -763,7 +772,7 @@ export function LxcTerminalModal({
|
||||
{isSearching && (
|
||||
<div className="text-center py-4 text-zinc-400">
|
||||
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
||||
<p className="text-sm">Searching cheat.sh...</p>
|
||||
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -790,7 +799,7 @@ export function LxcTerminalModal({
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-zinc-500">
|
||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||
Powered by cheat.sh
|
||||
{t("terminal.poweredByCheatSh")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -816,13 +825,13 @@ export function LxcTerminalModal({
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching && !searchQuery && !useOnline ? (
|
||||
proxmoxCommands.map((item, index) => (
|
||||
localCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToTerminal(item.cmd)}
|
||||
@@ -843,7 +852,7 @@ export function LxcTerminalModal({
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -854,17 +863,17 @@ export function LxcTerminalModal({
|
||||
<>
|
||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<div>
|
||||
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
||||
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<div>
|
||||
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
|
||||
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
|
||||
<div className="text-sm text-zinc-500 space-y-1">
|
||||
<p>Try searching for:</p>
|
||||
<p>{t("terminal.trySearchingFor")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
||||
<code
|
||||
@@ -881,7 +890,7 @@ export function LxcTerminalModal({
|
||||
{useOnline && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Powered by cheat.sh</span>
|
||||
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -890,13 +899,11 @@ export function LxcTerminalModal({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Tip: Search for any Linux command</span>
|
||||
{useOnline && searchResults.length > 0 && (
|
||||
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
|
||||
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
|
||||
</div>
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface DetectionResponse {
|
||||
success: boolean
|
||||
@@ -14,6 +15,7 @@ interface DetectionResponse {
|
||||
}
|
||||
|
||||
export function LxcUpdateDetection() {
|
||||
const t = useT()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [enabled, setEnabled] = useState<boolean>(true)
|
||||
@@ -32,11 +34,11 @@ export function LxcUpdateDetection() {
|
||||
setEnabled(data.enabled)
|
||||
setPending(data.enabled)
|
||||
} else {
|
||||
setError(data.message || "Failed to load setting")
|
||||
setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setError(String(e))
|
||||
if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
@@ -77,7 +79,7 @@ export function LxcUpdateDetection() {
|
||||
body: JSON.stringify({ enabled: pending }),
|
||||
})
|
||||
if (!data.success) {
|
||||
setError(data.message || "Failed to save setting")
|
||||
setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
|
||||
return
|
||||
}
|
||||
setEnabled(pending)
|
||||
@@ -95,7 +97,7 @@ export function LxcUpdateDetection() {
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
setError(t("settings.lxcUpdateDetection.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -111,14 +113,14 @@ export function LxcUpdateDetection() {
|
||||
breakpoint thanks to `items-center` + leading-tight title. */}
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
|
||||
<CardTitle className="leading-tight">LXC Update Detection</CardTitle>
|
||||
<CardTitle className="leading-tight">{t("settings.lxcUpdateDetection.title")}</CardTitle>
|
||||
{enabled ? (
|
||||
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
||||
Active
|
||||
{t("status.active")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
|
||||
Disabled
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -126,7 +128,7 @@ export function LxcUpdateDetection() {
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{error && !editMode && (
|
||||
@@ -134,7 +136,7 @@ export function LxcUpdateDetection() {
|
||||
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
|
||||
title={error}
|
||||
>
|
||||
Save failed: {error}
|
||||
{t("status.saveFailed")}: {error}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
@@ -144,7 +146,7 @@ export function LxcUpdateDetection() {
|
||||
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"
|
||||
@@ -152,7 +154,7 @@ export function LxcUpdateDetection() {
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||
Save
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -162,20 +164,25 @@ export function LxcUpdateDetection() {
|
||||
disabled={loading}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
Edit
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates
|
||||
(<code>apt list --upgradable</code> / <code>apk list -u</code>) and surface them on the dashboard. The
|
||||
corresponding notification toggle in <strong>Notifications → Services</strong> appears only while detection
|
||||
is enabled.
|
||||
{t("settings.lxcUpdateDetection.descriptionStart")}{" "}
|
||||
(<code>apt list --upgradable</code> / <code>apk list -u</code>)
|
||||
{t("settings.lxcUpdateDetection.descriptionEnd")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<CardContent
|
||||
className={`space-y-5${
|
||||
editMode
|
||||
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* ── Enable/Disable ── single-line label + toggle. The description
|
||||
paragraph was removed because the CardDescription above already
|
||||
covers the behaviour; on mobile that second paragraph forced
|
||||
@@ -185,7 +192,7 @@ export function LxcUpdateDetection() {
|
||||
<Boxes
|
||||
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">Enable LXC update detection</span>
|
||||
<span className="text-sm font-medium truncate">{t("settings.lxcUpdateDetection.enableLabel")}</span>
|
||||
</div>
|
||||
<button
|
||||
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
|
||||
@@ -195,7 +202,7 @@ export function LxcUpdateDetection() {
|
||||
disabled={!editMode || saving}
|
||||
role="switch"
|
||||
aria-checked={pending}
|
||||
aria-label="Enable LXC update detection"
|
||||
aria-label={t("settings.lxcUpdateDetection.enableLabel")}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
@@ -209,8 +216,7 @@ export function LxcUpdateDetection() {
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
|
||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the
|
||||
next scan cycle.
|
||||
{t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user