mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-04 20:02:07 +00:00
Compare commits
482
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 |
@@ -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
|
||||||
@@ -4,10 +4,19 @@ Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
|||||||
against the English source (AppImage/messages/en/common.json).
|
against the English source (AppImage/messages/en/common.json).
|
||||||
|
|
||||||
Guardrails:
|
Guardrails:
|
||||||
- Keys already translated in a target locale are PRESERVED. A key is
|
- Non-empty string translations are PRESERVED, including values equal
|
||||||
considered "already translated" when the target value is non-empty
|
to English. --refresh explicitly opts into replacing valid strings.
|
||||||
AND differs from the English source. This protects human-curated
|
- Arrays, objects and non-string leaves retain their JSON types. Array
|
||||||
locales (Vaso73's sk) from being overwritten.
|
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.)
|
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
|
||||||
are extracted before translation and restored afterwards, so the
|
are extracted before translation and restored afterwards, so the
|
||||||
interpolation contract stays intact regardless of what the
|
interpolation contract stays intact regardless of what the
|
||||||
@@ -16,6 +25,13 @@ Guardrails:
|
|||||||
Vaso73 has curated by hand; auto-translation only fills the keys
|
Vaso73 has curated by hand; auto-translation only fills the keys
|
||||||
that are still on the English fallback in sk.
|
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
|
Reuses the same translation providers as build_translation_cache.py so
|
||||||
the CI environment (googletrans pinning, AppImage provider) stays
|
the CI environment (googletrans pinning, AppImage provider) stays
|
||||||
identical.
|
identical.
|
||||||
@@ -58,36 +74,38 @@ DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard
|
|||||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
||||||
|
|
||||||
|
|
||||||
def flatten(node: dict, prefix: str = "") -> dict[str, str]:
|
CatalogPath = tuple[str | int, ...]
|
||||||
"""Depth-first flatten of a nested dict into ``{"a.b.c": "value"}``.
|
|
||||||
Non-string leaves are coerced to str (should not happen in messages
|
|
||||||
catalogs, but keeps the function total)."""
|
def flatten(node: object, prefix: CatalogPath = ()) -> dict[CatalogPath, object]:
|
||||||
out: dict[str, str] = {}
|
"""Keep typed path segments and empty container markers, without coercion."""
|
||||||
for key, value in node.items():
|
if isinstance(node, dict):
|
||||||
path = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
|
out = {prefix: {}}
|
||||||
if isinstance(value, dict):
|
children = node.items()
|
||||||
out.update(flatten(value, path))
|
elif isinstance(node, list):
|
||||||
elif value is None:
|
out = {prefix: []}
|
||||||
out[path] = ""
|
children = enumerate(node)
|
||||||
else:
|
else:
|
||||||
out[path] = str(value)
|
return {prefix: node}
|
||||||
|
for key, value in children:
|
||||||
|
out.update(flatten(value, prefix + (key,)))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def unflatten(flat: dict[str, str]) -> dict:
|
def unflatten(flat: dict[CatalogPath, object]) -> object:
|
||||||
"""Inverse of ``flatten``: rebuild nested structure from dotted keys."""
|
"""Rebuild a tree from typed paths; parents precede children by depth."""
|
||||||
out: dict = {}
|
nodes = {}
|
||||||
for path, value in flat.items():
|
for path in sorted(flat, key=len):
|
||||||
parts = path.split(".")
|
value = flat[path]
|
||||||
cursor = out
|
nodes[path] = {} if isinstance(value, dict) else [] if isinstance(value, list) else value
|
||||||
for part in parts[:-1]:
|
if path:
|
||||||
existing = cursor.get(part)
|
parent = nodes[path[:-1]]
|
||||||
if not isinstance(existing, dict):
|
key = path[-1]
|
||||||
existing = {}
|
if isinstance(parent, list):
|
||||||
cursor[part] = existing
|
while len(parent) <= key:
|
||||||
cursor = existing
|
parent.append(None)
|
||||||
cursor[parts[-1]] = value
|
parent[key] = nodes[path]
|
||||||
return out
|
return nodes[()]
|
||||||
|
|
||||||
|
|
||||||
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
||||||
@@ -98,7 +116,9 @@ def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
|||||||
|
|
||||||
def _swap(match: re.Match) -> str:
|
def _swap(match: re.Match) -> str:
|
||||||
placeholders.append(match.group(0))
|
placeholders.append(match.group(0))
|
||||||
return f"__PMX_PH_{len(placeholders) - 1}__"
|
# 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
|
return PLACEHOLDER_RE.sub(_swap, text), placeholders
|
||||||
|
|
||||||
@@ -109,10 +129,29 @@ def restore_placeholders(text: str, placeholders: list[str]) -> str:
|
|||||||
fallback assignment (target = existing or EN) upstream catches
|
fallback assignment (target = existing or EN) upstream catches
|
||||||
the worst case."""
|
the worst case."""
|
||||||
for i, original in enumerate(placeholders):
|
for i, original in enumerate(placeholders):
|
||||||
text = text.replace(f"__PMX_PH_{i}__", original)
|
text = text.replace(f"PMXPH{i:03d}", original)
|
||||||
return text
|
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(
|
def translate_one(
|
||||||
text: str,
|
text: str,
|
||||||
lang: str,
|
lang: str,
|
||||||
@@ -121,7 +160,7 @@ def translate_one(
|
|||||||
timeout: int,
|
timeout: int,
|
||||||
appimage_path: Path,
|
appimage_path: Path,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Dispatch to the correct provider. Reuses the same three
|
"""Dispatch to the correct provider. Reuses the same
|
||||||
implementations as build_translation_cache.py so there is exactly
|
implementations as build_translation_cache.py so there is exactly
|
||||||
one place to fix if a provider changes upstream."""
|
one place to fix if a provider changes upstream."""
|
||||||
if provider == "googletrans":
|
if provider == "googletrans":
|
||||||
@@ -130,6 +169,8 @@ def translate_one(
|
|||||||
raw = translate_google_web(text, lang, context, timeout)
|
raw = translate_google_web(text, lang, context, timeout)
|
||||||
elif provider == "appimage":
|
elif provider == "appimage":
|
||||||
raw = translate_appimage(text, lang, context, timeout, appimage_path)
|
raw = translate_appimage(text, lang, context, timeout, appimage_path)
|
||||||
|
elif provider == "argos":
|
||||||
|
raw = translate_argos(text, lang)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown provider: {provider}")
|
raise ValueError(f"Unknown provider: {provider}")
|
||||||
return clean_translation(raw) or text
|
return clean_translation(raw) or text
|
||||||
@@ -182,9 +223,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--provider",
|
"--provider",
|
||||||
choices=("appimage", "googletrans", "google-web"),
|
choices=("argos", "appimage", "googletrans", "google-web"),
|
||||||
default="googletrans",
|
default="googletrans",
|
||||||
help="Translation provider. Default matches build_translation_cache.",
|
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(
|
parser.add_argument(
|
||||||
"--appimage-path",
|
"--appimage-path",
|
||||||
@@ -198,9 +243,16 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||||||
"--refresh",
|
"--refresh",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=(
|
help=(
|
||||||
"Re-translate EVERY key, ignoring existing translations. "
|
"Retranslate the keys named by --refresh-keys. On its own it has "
|
||||||
"Dangerous: this DOES overwrite human-curated strings. "
|
"no effect: an existing translation is never replaced."
|
||||||
"Use only when you know what you are doing."
|
),
|
||||||
|
)
|
||||||
|
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(
|
parser.add_argument(
|
||||||
@@ -240,11 +292,53 @@ def main() -> int:
|
|||||||
print(f"Provider: {args.provider}", flush=True)
|
print(f"Provider: {args.provider}", flush=True)
|
||||||
print(f"Sleep between calls: {args.sleep}s", flush=True)
|
print(f"Sleep between calls: {args.sleep}s", flush=True)
|
||||||
|
|
||||||
total_failures: list[tuple[str, str, str]] = []
|
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:
|
for lang in languages:
|
||||||
locale_path = messages_dir / lang / "common.json"
|
locale_path = messages_dir / lang / "common.json"
|
||||||
target_flat = flatten(read_json(locale_path))
|
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
|
# Decide what needs translating. Same rule as
|
||||||
# build_translation_cache.py: only touch keys whose target
|
# build_translation_cache.py: only touch keys whose target
|
||||||
@@ -261,14 +355,25 @@ def main() -> int:
|
|||||||
# 3. Auto-fills from prior runs whose output happened to
|
# 3. Auto-fills from prior runs whose output happened to
|
||||||
# match EN — leaving them alone is the intended
|
# match EN — leaving them alone is the intended
|
||||||
# steady-state, not a bug.
|
# steady-state, not a bug.
|
||||||
# When someone genuinely wants to redo everything, --refresh
|
# An existing translation is never replaced. --refresh only reaches
|
||||||
# is still available (and is destructive by design).
|
# the keys named in --refresh-keys, so an overwrite is always one
|
||||||
missing: list[str] = []
|
# 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():
|
for key, en_value in en_flat.items():
|
||||||
if not en_value:
|
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
|
continue
|
||||||
existing = target_flat.get(key, "")
|
existing = target_flat.get(key, "")
|
||||||
if args.refresh or not existing:
|
if not existing or ".".join(str(part) for part in key) in refresh_keys:
|
||||||
missing.append(key)
|
missing.append(key)
|
||||||
|
|
||||||
if args.limit > 0:
|
if args.limit > 0:
|
||||||
@@ -276,10 +381,12 @@ def main() -> int:
|
|||||||
|
|
||||||
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
|
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
|
||||||
if not missing:
|
if not missing:
|
||||||
print(f" {lang}: nothing to do", flush=True)
|
if target_flat != original_flat:
|
||||||
|
write_json(locale_path, unflatten(target_flat))
|
||||||
|
print(f" {lang}: nothing to translate", flush=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
failures_for_lang: list[tuple[str, str, str]] = []
|
failures_for_lang: list[tuple[str, CatalogPath, str]] = []
|
||||||
|
|
||||||
for index, key in enumerate(missing, start=1):
|
for index, key in enumerate(missing, start=1):
|
||||||
en_value = en_flat[key]
|
en_value = en_flat[key]
|
||||||
@@ -336,7 +443,18 @@ def main() -> int:
|
|||||||
# Previously we returned 2, which failed the whole run and
|
# Previously we returned 2, which failed the whole run and
|
||||||
# discarded 36 out of 38 successful translations because
|
# discarded 36 out of 38 successful translations because
|
||||||
# 2 googletrans timeouts hit sv at the start of the burst.
|
# 2 googletrans timeouts hit sv at the start of the burst.
|
||||||
return 0
|
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)
|
print("\ni18n messages generated successfully.", flush=True)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ PROTECTED_TECHNICAL_TERMS = (
|
|||||||
"pveproxy",
|
"pveproxy",
|
||||||
"apt-get",
|
"apt-get",
|
||||||
"Frigate",
|
"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",
|
"Docker",
|
||||||
"Coral",
|
"Coral",
|
||||||
"gasket",
|
"gasket",
|
||||||
@@ -69,6 +79,40 @@ PROTECTED_TECHNICAL_TERMS = (
|
|||||||
"ZFS",
|
"ZFS",
|
||||||
"SSH",
|
"SSH",
|
||||||
"fork",
|
"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(
|
TECHNICAL_TERM_RE = re.compile(
|
||||||
"|".join(
|
"|".join(
|
||||||
@@ -81,26 +125,159 @@ TRANSLATE_CALL_RE = re.compile(
|
|||||||
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
|
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
|
||||||
re.VERBOSE | re.DOTALL,
|
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]]:
|
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
|
||||||
"""Replace glossary terms with stable tokens before translation."""
|
"""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] = []
|
protected: list[str] = []
|
||||||
|
|
||||||
def _swap(match: re.Match[str]) -> str:
|
def _swap(match: re.Match[str]) -> str:
|
||||||
protected.append(match.group(0))
|
protected.append(match.group(0))
|
||||||
return f"__PMX_TERM_{len(protected) - 1}__"
|
return f"PMXTERM{len(protected) - 1:03d}"
|
||||||
|
|
||||||
|
text = STRUCTURAL_LITERAL_RE.sub(_swap, text)
|
||||||
return TECHNICAL_TERM_RE.sub(_swap, text), protected
|
return TECHNICAL_TERM_RE.sub(_swap, text), protected
|
||||||
|
|
||||||
|
|
||||||
def restore_technical_terms(text: str, protected: list[str]) -> str:
|
def restore_technical_terms(text: str, protected: list[str]) -> str:
|
||||||
"""Restore glossary terms exactly as they appeared in the source."""
|
"""Restore glossary terms exactly as they appeared in the source."""
|
||||||
for index, original in enumerate(protected):
|
for index, original in enumerate(protected):
|
||||||
text = text.replace(f"__PMX_TERM_{index}__", original)
|
text = text.replace(f"PMXTERM{index:03d}", original)
|
||||||
return text
|
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(
|
def iter_script_files(
|
||||||
scripts_dir: Path, extra_files: Iterable[Path] = ()
|
scripts_dir: Path, extra_files: Iterable[Path] = ()
|
||||||
) -> Iterable[Path]:
|
) -> Iterable[Path]:
|
||||||
@@ -151,6 +328,93 @@ def extract_translate_texts(
|
|||||||
return sorted(found)
|
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:
|
def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
|
||||||
try:
|
try:
|
||||||
from googletrans import Translator # type: ignore
|
from googletrans import Translator # type: ignore
|
||||||
@@ -269,6 +533,20 @@ def clean_translation(value: str) -> str:
|
|||||||
return value.strip()
|
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(
|
def translate_text(
|
||||||
text: str,
|
text: str,
|
||||||
dest_lang: str,
|
dest_lang: str,
|
||||||
@@ -277,8 +555,11 @@ def translate_text(
|
|||||||
timeout: int,
|
timeout: int,
|
||||||
appimage_path: Path,
|
appimage_path: Path,
|
||||||
) -> str:
|
) -> str:
|
||||||
protected_text, protected_terms = protect_technical_terms(text)
|
protected_text, placeholders = protect_placeholders(text)
|
||||||
if provider == "googletrans":
|
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)
|
translated = translate_googletrans(protected_text, dest_lang, context)
|
||||||
elif provider == "google-web":
|
elif provider == "google-web":
|
||||||
translated = translate_google_web(protected_text, dest_lang, context, timeout)
|
translated = translate_google_web(protected_text, dest_lang, context, timeout)
|
||||||
@@ -289,7 +570,9 @@ def translate_text(
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown provider: {provider}")
|
raise ValueError(f"Unknown provider: {provider}")
|
||||||
translated = restore_technical_terms(clean_translation(translated), protected_terms)
|
translated = restore_technical_terms(clean_translation(translated), protected_terms)
|
||||||
return translated or text
|
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]:
|
def load_language_cache(path: Path) -> dict[str, str]:
|
||||||
@@ -304,6 +587,35 @@ def load_language_cache(path: Path) -> dict[str, str]:
|
|||||||
return {str(text): str(value) for text, value in data.items()}
|
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:
|
def write_language_cache(path: Path, cache: dict[str, str]) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||||
@@ -319,6 +631,30 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||||||
description="Extract translate calls from scripts/ and build json/cache.json."
|
description="Extract translate calls from scripts/ and build json/cache.json."
|
||||||
)
|
)
|
||||||
parser.add_argument("--scripts-dir", default="scripts", type=Path)
|
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(
|
parser.add_argument(
|
||||||
"--extra-file",
|
"--extra-file",
|
||||||
action="append",
|
action="append",
|
||||||
@@ -351,7 +687,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--provider",
|
"--provider",
|
||||||
choices=("appimage", "googletrans", "google-web"),
|
choices=("argos", "appimage", "googletrans", "google-web"),
|
||||||
default="appimage",
|
default="appimage",
|
||||||
help="Translation provider to use. Default: appimage",
|
help="Translation provider to use. Default: appimage",
|
||||||
)
|
)
|
||||||
@@ -364,10 +700,21 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||||
parser.add_argument("--timeout", default=30, type=int)
|
parser.add_argument("--timeout", default=30, type=int)
|
||||||
parser.add_argument("--sleep", default=0.15, type=float)
|
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(
|
parser.add_argument(
|
||||||
"--refresh",
|
"--refresh",
|
||||||
action="store_true",
|
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(
|
parser.add_argument(
|
||||||
"--extract-only",
|
"--extract-only",
|
||||||
@@ -406,17 +753,45 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
texts = extract_translate_texts(scripts_dir, args.extra_file)
|
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:
|
if args.limit > 0:
|
||||||
texts = texts[: args.limit]
|
texts = texts[: args.limit]
|
||||||
existing_by_lang = {
|
existing_by_lang = {
|
||||||
lang: load_language_cache(output_dir / f"{lang}.json")
|
lang: load_language_cache(output_dir / f"{lang}.json")
|
||||||
for lang in languages
|
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"Found {len(texts)} unique translate strings.", flush=True)
|
||||||
print(f"Output directory: {output_dir}", flush=True)
|
print(f"Output directory: {output_dir}", flush=True)
|
||||||
print(f"Languages: {', '.join(languages)}", 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]] = []
|
failures: list[tuple[str, str, str]] = []
|
||||||
total = len(texts) * len(languages)
|
total = len(texts) * len(languages)
|
||||||
done = 0
|
done = 0
|
||||||
@@ -427,7 +802,13 @@ def main() -> int:
|
|||||||
|
|
||||||
for index, text in enumerate(texts, start=1):
|
for index, text in enumerate(texts, start=1):
|
||||||
done += 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]
|
next_by_lang[lang][text] = existing[text]
|
||||||
continue
|
continue
|
||||||
if args.extract_only:
|
if args.extract_only:
|
||||||
@@ -435,20 +816,51 @@ def main() -> int:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
|
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
|
||||||
try:
|
# A rate limit is a "come back later", not an answer. Retrying with
|
||||||
next_by_lang[lang][text] = translate_text(
|
# a growing wait recovers it; giving up on the first one is what
|
||||||
text,
|
# left thousands of strings untranslated.
|
||||||
lang,
|
value, last_error = None, None
|
||||||
args.provider,
|
for attempt in range(1, args.retries + 1):
|
||||||
args.context,
|
unchanged = False
|
||||||
args.timeout,
|
try:
|
||||||
args.appimage_path,
|
value = translate_text(
|
||||||
)
|
text,
|
||||||
print(f" => {next_by_lang[lang][text][:100]}", flush=True)
|
lang,
|
||||||
except Exception as exc:
|
args.provider,
|
||||||
next_by_lang[lang][text] = existing.get(text, text)
|
args.context,
|
||||||
failures.append((text, lang, str(exc)))
|
args.timeout,
|
||||||
print(f" failed: {exc}", file=sys.stderr, flush=True)
|
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:
|
if args.save_every > 0 and index % args.save_every == 0:
|
||||||
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
|
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
|
||||||
time.sleep(args.sleep)
|
time.sleep(args.sleep)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
|
|||||||
from build_translation_cache import ( # noqa: E402
|
from build_translation_cache import ( # noqa: E402
|
||||||
clean_translation,
|
clean_translation,
|
||||||
translate_appimage,
|
translate_appimage,
|
||||||
|
translate_argos,
|
||||||
translate_google_web,
|
translate_google_web,
|
||||||
translate_googletrans,
|
translate_googletrans,
|
||||||
)
|
)
|
||||||
@@ -86,6 +87,7 @@ PROTECTED_PATTERNS = (
|
|||||||
re.compile(r"(?<![\w-])--[A-Za-z0-9][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"(?<![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"\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(
|
LITERAL_TAG_RE = re.compile(
|
||||||
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
||||||
@@ -100,6 +102,11 @@ TERM_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
|
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_.-]*\}")
|
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||||
NON_TRANSLATABLE_KEYS = {
|
NON_TRANSLATABLE_KEYS = {
|
||||||
"command",
|
"command",
|
||||||
@@ -206,13 +213,13 @@ def needs_translation(
|
|||||||
source: str,
|
source: str,
|
||||||
target: Any,
|
target: Any,
|
||||||
path: tuple[str | int, ...],
|
path: tuple[str | int, ...],
|
||||||
refresh: bool,
|
|
||||||
forced_tokens: set[str] | None = None,
|
forced_tokens: set[str] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if should_copy(source, path):
|
if should_copy(source, path):
|
||||||
return False
|
return False
|
||||||
if refresh:
|
# An existing translation is never replaced. Only a leaf whose token was
|
||||||
return True
|
# 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:
|
if forced_tokens and leaf_token(path) in forced_tokens:
|
||||||
return True
|
return True
|
||||||
return not isinstance(target, str) or not target.strip() or target == source
|
return not isinstance(target, str) or not target.strip() or target == source
|
||||||
@@ -279,6 +286,17 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
|||||||
for match in TERM_RE.finditer(text)
|
for match in TERM_RE.finditer(text)
|
||||||
if not overlaps_literal(match.start(), match.end())
|
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])))
|
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
|
||||||
|
|
||||||
selected: list[tuple[int, int]] = []
|
selected: list[tuple[int, int]] = []
|
||||||
@@ -296,7 +314,12 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
|||||||
# sit directly beside inline markup (for example an opening <em>
|
# sit directly beside inline markup (for example an opening <em>
|
||||||
# token followed by translated prose). Triple brackets remain opaque
|
# token followed by translated prose). Triple brackets remain opaque
|
||||||
# in that position and preserve the complete rich-text contract.
|
# in that position and preserve the complete rich-text contract.
|
||||||
token = f"[[[PMXDOC{index:04d}]]]"
|
#
|
||||||
|
# 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(text[cursor:start])
|
||||||
chunks.append(token)
|
chunks.append(token)
|
||||||
mapping[token] = text[start:end]
|
mapping[token] = text[start:end]
|
||||||
@@ -317,13 +340,20 @@ def restore_text(text: str, mapping: dict[str, str]) -> str:
|
|||||||
def validate_contract(source: str, target: str) -> None:
|
def validate_contract(source: str, target: str) -> None:
|
||||||
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
|
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
|
||||||
raise ValueError("rich-text tag contract changed")
|
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)):
|
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||||
raise ValueError("placeholder contract changed")
|
raise ValueError("placeholder contract changed")
|
||||||
|
|
||||||
|
|
||||||
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||||
def translate(text: str, language: str) -> str:
|
def translate(text: str, language: str) -> str:
|
||||||
if args.provider == "googletrans":
|
if args.provider == "argos":
|
||||||
|
raw = translate_argos(text, language)
|
||||||
|
elif args.provider == "googletrans":
|
||||||
raw = translate_googletrans(text, language, args.context)
|
raw = translate_googletrans(text, language, args.context)
|
||||||
elif args.provider == "google-web":
|
elif args.provider == "google-web":
|
||||||
raw = translate_google_web(text, language, args.context, args.timeout)
|
raw = translate_google_web(text, language, args.context, args.timeout)
|
||||||
@@ -446,7 +476,6 @@ def collect_memory(source_root: Path, messages_root: Path, language: str) -> dic
|
|||||||
def pending_leaves(
|
def pending_leaves(
|
||||||
source: Any,
|
source: Any,
|
||||||
target: Any,
|
target: Any,
|
||||||
refresh: bool,
|
|
||||||
forced_tokens: set[str] | None = None,
|
forced_tokens: set[str] | None = None,
|
||||||
) -> list[Leaf]:
|
) -> list[Leaf]:
|
||||||
return [
|
return [
|
||||||
@@ -456,7 +485,6 @@ def pending_leaves(
|
|||||||
leaf.source,
|
leaf.source,
|
||||||
get_at_path(target, leaf.path),
|
get_at_path(target, leaf.path),
|
||||||
leaf.path,
|
leaf.path,
|
||||||
refresh,
|
|
||||||
forced_tokens,
|
forced_tokens,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -533,13 +561,14 @@ def translate_file(
|
|||||||
memory: dict[str, str],
|
memory: dict[str, str],
|
||||||
translate: Callable[[str, str], str],
|
translate: Callable[[str, str], str],
|
||||||
args: argparse.Namespace,
|
args: argparse.Namespace,
|
||||||
) -> tuple[Any | None, list[str], int]:
|
) -> tuple[Any | None, list[str], int, set[str]]:
|
||||||
resolved: dict[tuple[str | int, ...], str] = {}
|
resolved: dict[tuple[str | int, ...], str] = {}
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
|
failed_tokens: set[str] = set()
|
||||||
jobs: dict[str, list[tuple[str | int, ...]]] = {}
|
jobs: dict[str, list[tuple[str | int, ...]]] = {}
|
||||||
|
|
||||||
for leaf in leaves:
|
for leaf in leaves:
|
||||||
if not args.refresh and leaf.source in memory:
|
if leaf.source in memory:
|
||||||
resolved[leaf.path] = memory[leaf.source]
|
resolved[leaf.path] = memory[leaf.source]
|
||||||
continue
|
continue
|
||||||
jobs.setdefault(leaf.source, []).append(leaf.path)
|
jobs.setdefault(leaf.source, []).append(leaf.path)
|
||||||
@@ -566,12 +595,17 @@ def translate_file(
|
|||||||
resolved[path] = translated
|
resolved[path] = translated
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failures.append(f"{text[:90]}: {exc}")
|
failures.append(f"{text[:90]}: {exc}")
|
||||||
|
failed_tokens.update(leaf_token(path) for path in jobs[text])
|
||||||
if args.sleep:
|
if args.sleep:
|
||||||
time.sleep(args.sleep)
|
time.sleep(args.sleep)
|
||||||
|
|
||||||
if failures:
|
# What did translate is kept. merge_tree leaves a string it has no
|
||||||
return None, failures, len(jobs)
|
# translation for as whatever the locale already held, or as the English
|
||||||
return merge_tree(source, target, resolved), [], len(jobs)
|
# 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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
@@ -586,8 +620,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--provider",
|
"--provider",
|
||||||
choices=("google-web", "googletrans", "appimage"),
|
choices=("argos", "google-web", "googletrans", "appimage"),
|
||||||
default="googletrans",
|
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("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -623,7 +661,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
global BARE_SENTINELS
|
||||||
args = build_parser().parse_args()
|
args = build_parser().parse_args()
|
||||||
|
BARE_SENTINELS = args.provider == "argos"
|
||||||
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
|
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)
|
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
@@ -646,7 +686,8 @@ def main() -> int:
|
|||||||
print("No target languages selected.", file=sys.stderr)
|
print("No target languages selected.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
if args.refresh:
|
if args.refresh:
|
||||||
print("WARNING: --refresh overwrites existing translations in the selected scope.")
|
print("--refresh no longer overwrites existing translations; "
|
||||||
|
"name the leaves with --tokens instead.")
|
||||||
|
|
||||||
translate = provider_function(args)
|
translate = provider_function(args)
|
||||||
state = load_source_state(source_state_path)
|
state = load_source_state(source_state_path)
|
||||||
@@ -716,7 +757,7 @@ def main() -> int:
|
|||||||
pending_tokens.update(changed_tokens)
|
pending_tokens.update(changed_tokens)
|
||||||
pending_tokens.update(
|
pending_tokens.update(
|
||||||
leaf_token(leaf.path)
|
leaf_token(leaf.path)
|
||||||
for leaf in pending_leaves(source, target, args.refresh)
|
for leaf in pending_leaves(source, target)
|
||||||
)
|
)
|
||||||
set_state_pending_tokens(state, language, relative, pending_tokens)
|
set_state_pending_tokens(state, language, relative, pending_tokens)
|
||||||
|
|
||||||
@@ -742,6 +783,11 @@ def main() -> int:
|
|||||||
|
|
||||||
total_failures = 0
|
total_failures = 0
|
||||||
total_written = 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)}")
|
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
|
||||||
|
|
||||||
for language in languages:
|
for language in languages:
|
||||||
@@ -773,7 +819,7 @@ def main() -> int:
|
|||||||
source = file_info[relative_key][1]
|
source = file_info[relative_key][1]
|
||||||
target = read_json(target_path)
|
target = read_json(target_path)
|
||||||
queued_tokens = state_pending_tokens(state, language, relative_key)
|
queued_tokens = state_pending_tokens(state, language, relative_key)
|
||||||
leaves = pending_leaves(source, target, args.refresh, queued_tokens)
|
leaves = pending_leaves(source, target, queued_tokens)
|
||||||
total_strings += len(iter_leaves(source))
|
total_strings += len(iter_leaves(source))
|
||||||
missing_strings += len(leaves)
|
missing_strings += len(leaves)
|
||||||
schema_changed = not schema_matches(source, target)
|
schema_changed = not schema_matches(source, target)
|
||||||
@@ -816,7 +862,7 @@ def main() -> int:
|
|||||||
f"({len(leaves)} strings{suffix})",
|
f"({len(leaves)} strings{suffix})",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
built, failures, calls = translate_file(
|
built, failures, calls, failed_tokens = translate_file(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
leaves,
|
leaves,
|
||||||
@@ -827,18 +873,18 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
if failures:
|
if failures:
|
||||||
total_failures += len(failures)
|
total_failures += len(failures)
|
||||||
set_state_pending_tokens(state, language, relative_key, queued_tokens)
|
|
||||||
write_json(source_state_path, state)
|
|
||||||
print(
|
print(
|
||||||
f" skipped atomically after {len(failures)} failures "
|
f" {len(failures)} strings left untranslated ({calls} calls)",
|
||||||
f"({calls} calls)",
|
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
for failure in failures[:5]:
|
for failure in failures[:5]:
|
||||||
print(f" - {failure}", file=sys.stderr)
|
print(f" - {failure}", file=sys.stderr)
|
||||||
continue
|
|
||||||
write_json(target_path, built)
|
write_json(target_path, built)
|
||||||
set_state_pending_tokens(state, language, relative_key, set())
|
# 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)
|
write_json(source_state_path, state)
|
||||||
total_written += 1
|
total_written += 1
|
||||||
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
|
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def fetch_directory_json(api_url: str) -> list[dict]:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
if not isinstance(data, list):
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
directory = fetch_directory_json(API_URL)
|
directory = fetch_directory_json(API_URL)
|
||||||
except Exception as e:
|
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
|
return 1
|
||||||
|
|
||||||
cache: list[dict] = []
|
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()
|
||||||
@@ -58,7 +58,7 @@ class WebDocsI18nTests(unittest.TestCase):
|
|||||||
def test_human_translation_is_not_pending(self):
|
def test_human_translation_is_not_pending(self):
|
||||||
source = {"title": "Updates", "url": "https://example.com"}
|
source = {"title": "Updates", "url": "https://example.com"}
|
||||||
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
||||||
self.assertEqual(MODULE.pending_leaves(source, target, refresh=False), [])
|
self.assertEqual(MODULE.pending_leaves(source, target), [])
|
||||||
|
|
||||||
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
||||||
source = {"title": "Updated installation guidance"}
|
source = {"title": "Updated installation guidance"}
|
||||||
@@ -67,7 +67,6 @@ class WebDocsI18nTests(unittest.TestCase):
|
|||||||
leaves = MODULE.pending_leaves(
|
leaves = MODULE.pending_leaves(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
refresh=False,
|
|
||||||
forced_tokens={token},
|
forced_tokens={token},
|
||||||
)
|
)
|
||||||
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
|
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
|
||||||
|
|||||||
@@ -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
|
- name: Install Python dependencies
|
||||||
run: |
|
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
|
sudo apt-get install -y python3 python3-pip python3-venv
|
||||||
|
|
||||||
- name: Make build script executable
|
- name: Make build script executable
|
||||||
@@ -42,6 +46,26 @@ jobs:
|
|||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
working-directory: AppImage
|
working-directory: AppImage
|
||||||
run: ./scripts/build_appimage.sh
|
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
|
- name: Get version from package.json
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
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
|
sudo apt-get install -y python3 python3-pip python3-venv
|
||||||
|
|
||||||
- name: Make build script executable
|
- name: Make build script executable
|
||||||
@@ -42,6 +46,26 @@ jobs:
|
|||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
working-directory: AppImage
|
working-directory: AppImage
|
||||||
run: ./scripts/build_appimage.sh
|
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
|
- name: Get version from package.json
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
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
|
sudo apt-get install -y python3 python3-pip python3-venv
|
||||||
|
|
||||||
- name: Make build script executable
|
- name: Make build script executable
|
||||||
@@ -40,6 +44,26 @@ jobs:
|
|||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
working-directory: AppImage
|
working-directory: AppImage
|
||||||
run: ./scripts/build_appimage.sh
|
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
|
- name: Get version from package.json
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
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
|
sudo apt-get install -y python3 python3-pip python3-venv
|
||||||
|
|
||||||
- name: Make build script executable
|
- name: Make build script executable
|
||||||
@@ -42,6 +46,26 @@ jobs:
|
|||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
working-directory: AppImage
|
working-directory: AppImage
|
||||||
run: ./scripts/build_appimage.sh
|
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
|
- name: Get version from package.json
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ on:
|
|||||||
branches: [develop]
|
branches: [develop]
|
||||||
paths:
|
paths:
|
||||||
- 'scripts/**/*.sh'
|
- 'scripts/**/*.sh'
|
||||||
|
- 'scripts/**/*.func'
|
||||||
|
- 'scripts/**/*.py'
|
||||||
- 'menu'
|
- 'menu'
|
||||||
- 'install_proxmenux.sh'
|
- 'install_proxmenux.sh'
|
||||||
- 'install_proxmenux_beta.sh'
|
- 'install_proxmenux_beta.sh'
|
||||||
|
- 'oci/src/**/*.py'
|
||||||
|
- 'oci/remote/*.sh'
|
||||||
|
- 'oci/remote/*.py'
|
||||||
|
- 'oci/catalog/**/*.json'
|
||||||
- '.github/scripts/build_translation_cache.py'
|
- '.github/scripts/build_translation_cache.py'
|
||||||
- '.github/workflows/build-translation-cache.yml'
|
- '.github/workflows/build-translation-cache.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -79,6 +85,11 @@ jobs:
|
|||||||
--extra-file menu \
|
--extra-file menu \
|
||||||
--extra-file install_proxmenux.sh \
|
--extra-file install_proxmenux.sh \
|
||||||
--extra-file install_proxmenux_beta.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 \
|
--output-dir lang \
|
||||||
--provider googletrans \
|
--provider googletrans \
|
||||||
$REFRESH_FLAG
|
$REFRESH_FLAG
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Label OCI validation pull requests
|
||||||
|
|
||||||
|
# A pull request that changes verification.json gets the "oci-validation"
|
||||||
|
# label, which adds it to the ProxMenux Roadmap. The pull request's code is
|
||||||
|
# never checked out: only its list of changed files is read.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
paths:
|
||||||
|
- 'oci/catalog/verification.json'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 2
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
run: gh api "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels" -f "labels[]=oci-validation" --silent
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
name: Record OCI validation
|
||||||
|
|
||||||
|
# When an OCI validation report is validated, the report's author is recorded
|
||||||
|
# in oci/catalog/verification.json on develop and oci/VALIDATION.md is
|
||||||
|
# regenerated. A report is validated by the "validated" label, which only users
|
||||||
|
# with write access can add, or by a /validated comment from a user listed in
|
||||||
|
# .github/oci-validation-reviewers, checked by the script. The issue body and
|
||||||
|
# the comment are read from the event file, never interpolated into a shell.
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [labeled]
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: oci-validation-record
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
record:
|
||||||
|
if: >-
|
||||||
|
contains(github.event.issue.labels.*.name, 'oci-validation') && (
|
||||||
|
(github.event_name == 'issues' && github.event.label.name == 'validated') ||
|
||||||
|
(github.event_name == 'issue_comment' && !github.event.issue.pull_request &&
|
||||||
|
startsWith(github.event.comment.body, '/validated')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: develop
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Record the report
|
||||||
|
id: record
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
python3 .github/scripts/oci_validation.py record --event "$GITHUB_EVENT_PATH" --message "$RUNNER_TEMP/reply.md"
|
||||||
|
echo "code=$?" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Commit + push
|
||||||
|
if: steps.record.outputs.code == '0'
|
||||||
|
env:
|
||||||
|
APP: ${{ steps.record.outputs.app }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}
|
||||||
|
run: |
|
||||||
|
git config user.name "ProxMenuxBot"
|
||||||
|
git config user.email "bot@proxmenux.local"
|
||||||
|
git add oci/catalog/verification.json oci/VALIDATION.md
|
||||||
|
git commit -m "chore(oci): record the validation of $APP (#$NUMBER)"
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
git fetch origin develop
|
||||||
|
if git rebase origin/develop && git push origin HEAD:develop; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git rebase --abort 2>/dev/null || true
|
||||||
|
sleep $(( attempt * 3 ))
|
||||||
|
done
|
||||||
|
echo "push failed after 5 attempts"
|
||||||
|
exit 1
|
||||||
|
- name: Reply and close
|
||||||
|
if: steps.record.outputs.code == '0'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}
|
||||||
|
run: |
|
||||||
|
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
|
||||||
|
gh issue close "$NUMBER" --repo "$GITHUB_REPOSITORY" --reason completed
|
||||||
|
- name: Explain why it was not recorded
|
||||||
|
if: steps.record.outputs.code == '2'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}
|
||||||
|
run: |
|
||||||
|
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
|
||||||
|
if [ "$GITHUB_EVENT_NAME" = issues ]; then
|
||||||
|
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels/validated" --silent
|
||||||
|
fi
|
||||||
|
- name: Fail on an unexpected error
|
||||||
|
if: steps.record.outputs.code != '0' && steps.record.outputs.code != '2'
|
||||||
|
run: exit 1
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: OCI validation record
|
||||||
|
|
||||||
|
# verification.json is the record of OCI applications tested for real, and
|
||||||
|
# oci/VALIDATION.md is generated from it. A pull request only adds or changes
|
||||||
|
# the community tests credited to its own author.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'oci/catalog/verification.json'
|
||||||
|
- 'oci/catalog/index.json'
|
||||||
|
- 'oci/VALIDATION.md'
|
||||||
|
- '.github/scripts/oci_validation.py'
|
||||||
|
- '.github/workflows/oci-validation.yml'
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
paths:
|
||||||
|
- 'oci/catalog/verification.json'
|
||||||
|
- 'oci/catalog/index.json'
|
||||||
|
- 'oci/VALIDATION.md'
|
||||||
|
- '.github/scripts/oci_validation.py'
|
||||||
|
- '.github/workflows/oci-validation.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Check the record and oci/VALIDATION.md
|
||||||
|
run: python3 .github/scripts/oci_validation.py check
|
||||||
|
- name: Check who the pull request credits
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
env:
|
||||||
|
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
BASE: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
git show "$BASE:oci/catalog/verification.json" > "$RUNNER_TEMP/base.json" 2>/dev/null || : > "$RUNNER_TEMP/base.json"
|
||||||
|
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
|
||||||
@@ -0,0 +1,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
|
||||||
+8
-6
@@ -41,13 +41,15 @@ Thumbs.db
|
|||||||
/web/.next
|
/web/.next
|
||||||
/web/out
|
/web/out
|
||||||
|
|
||||||
# Build artifacts generated by web's prebuild + build scripts.
|
# Search index written by `build`: pagefind --site out produces it into
|
||||||
# `prebuild` runs `sync:scripts` which rsyncs ../scripts/ into
|
# public/pagefind/ and the GitHub Pages CI regenerates it on every deploy,
|
||||||
# public/scripts/. `build` runs pagefind --site out which writes the
|
# so committing it would only bloat the repo and add noise to `git status`.
|
||||||
# search index into public/pagefind/. Both are regenerated fresh by
|
|
||||||
# the GitHub Pages CI on every deploy; committing them would just
|
|
||||||
# bloat the repo and produce constant noise in `git status`.
|
|
||||||
/web/public/pagefind/
|
/web/public/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/
|
/web/public/scripts/
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { ThemeAwareLogo } from "./lxc-app-panel"
|
|||||||
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
|
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
||||||
|
import { getCategoryLabel } from "../lib/category-label"
|
||||||
|
|
||||||
// ─── Local subset of /api/vms shape ─────────────────────────────
|
// ─── Local subset of /api/vms shape ─────────────────────────────
|
||||||
// Kept narrow on purpose — this component only needs what feeds a
|
// Kept narrow on purpose — this component only needs what feeds a
|
||||||
@@ -248,8 +249,8 @@ export function AppsDashboard() {
|
|||||||
return map
|
return map
|
||||||
}, [links, t])
|
}, [links, t])
|
||||||
const sortedCategoryEntries = useMemo(
|
const sortedCategoryEntries = useMemo(
|
||||||
() => Array.from(categoryCounts.entries()).sort((a, b) => a[0].localeCompare(b[0])),
|
() => Array.from(categoryCounts.entries()).sort((a, b) => getCategoryLabel(t, a[0]).localeCompare(getCategoryLabel(t, b[0]))),
|
||||||
[categoryCounts],
|
[categoryCounts, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Controls state ────────────────────────────────────────────
|
// ─── Controls state ────────────────────────────────────────────
|
||||||
@@ -327,7 +328,8 @@ export function AppsDashboard() {
|
|||||||
l.appName.toLowerCase().includes(q) ||
|
l.appName.toLowerCase().includes(q) ||
|
||||||
(l.ctName || "").toLowerCase().includes(q) ||
|
(l.ctName || "").toLowerCase().includes(q) ||
|
||||||
(l.vmid != null && String(l.vmid).includes(q)) ||
|
(l.vmid != null && String(l.vmid).includes(q)) ||
|
||||||
(l.category || "").toLowerCase().includes(q)
|
(l.category || "").toLowerCase().includes(q) ||
|
||||||
|
getCategoryLabel(t, l.category || "").toLowerCase().includes(q)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const sorted = [...filtered]
|
const sorted = [...filtered]
|
||||||
@@ -344,7 +346,7 @@ export function AppsDashboard() {
|
|||||||
// category — grouped alphabetically, then by app name inside
|
// category — grouped alphabetically, then by app name inside
|
||||||
const catA = a.category || uncatKey
|
const catA = a.category || uncatKey
|
||||||
const catB = b.category || uncatKey
|
const catB = b.category || uncatKey
|
||||||
const c = catA.localeCompare(catB)
|
const c = getCategoryLabel(t, catA).localeCompare(getCategoryLabel(t, catB))
|
||||||
return c !== 0 ? c : a.appName.localeCompare(b.appName)
|
return c !== 0 ? c : a.appName.localeCompare(b.appName)
|
||||||
})
|
})
|
||||||
return sorted
|
return sorted
|
||||||
@@ -432,7 +434,7 @@ export function AppsDashboard() {
|
|||||||
>
|
>
|
||||||
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
|
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
|
||||||
{sortedCategoryEntries.map(([cat, n]) => (
|
{sortedCategoryEntries.map(([cat, n]) => (
|
||||||
<option key={cat} value={cat}>{`${cat} · ${n}`}</option>
|
<option key={cat} value={cat}>{`${getCategoryLabel(t, cat)} · ${n}`}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -532,6 +534,7 @@ function CardsGrid({
|
|||||||
editMode: boolean
|
editMode: boolean
|
||||||
onEditCustom: (customId: string) => void
|
onEditCustom: (customId: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT()
|
||||||
if (!links.length) {
|
if (!links.length) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
@@ -572,7 +575,7 @@ function CardsGrid({
|
|||||||
{groups.map(([cat, items]) => (
|
{groups.map(([cat, items]) => (
|
||||||
<div key={`grp-${cat}`} className="contents">
|
<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">
|
<h3 className="col-span-full uppercase text-xs tracking-wider text-muted-foreground font-semibold pt-3 pb-1.5 border-b border-border/60 flex items-center gap-2">
|
||||||
<span>{cat}</span>
|
<span>{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>
|
<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>
|
</h3>
|
||||||
{items.map((link) => (
|
{items.map((link) => (
|
||||||
@@ -592,9 +595,9 @@ function CardsGrid({
|
|||||||
// `changeTab` switches the outer tab (dashboard-level) and
|
// `changeTab` switches the outer tab (dashboard-level) and
|
||||||
// `openLxcAppModal` tells VirtualMachines which guest to open and on
|
// `openLxcAppModal` tells VirtualMachines which guest to open and on
|
||||||
// which inner tab to land. Both fire in the same tick.
|
// which inner tab to land. Both fire in the same tick.
|
||||||
function openLxcModalOnAppTab(vmid: number) {
|
function openLxcModalOnAppTab(vmid: number, tab: "app" | "updates" = "app") {
|
||||||
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
||||||
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } }))
|
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid, tab } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same pattern for a QEMU guest: land on the modal's Status tab
|
// Same pattern for a QEMU guest: land on the modal's Status tab
|
||||||
@@ -634,13 +637,22 @@ function AppCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (link.customId) onEditCustom(link.customId)
|
if (link.customId) onEditCustom(link.customId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const guestPrefix = link.guestType === "qemu" ? "VM" : "CT"
|
const guestPrefix = link.guestType === "qemu" ? "VM" : "LXC"
|
||||||
const hasBinding = link.vmid != null
|
const hasBinding = link.vmid != null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -679,7 +691,20 @@ function AppCard({
|
|||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
) : link.updateAvailable && (
|
) : link.updateAvailable && (
|
||||||
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -715,9 +740,9 @@ function AppCard({
|
|||||||
<span
|
<span
|
||||||
style={categoryChipStyle(link.category, isLightTheme)}
|
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%]"
|
className="ml-auto px-1.5 py-0.5 border rounded text-[10px] font-medium flex-shrink-0 truncate max-w-[45%]"
|
||||||
title={link.category}
|
title={getCategoryLabel(t, link.category)}
|
||||||
>
|
>
|
||||||
{link.category}
|
{getCategoryLabel(t, link.category)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { cancelButtonClass } from "../lib/utils"
|
||||||
import { Trash2 } from "lucide-react"
|
import { Trash2 } from "lucide-react"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { getCategoryLabel } from "../lib/category-label"
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Input } from "./ui/input"
|
import { Input } from "./ui/input"
|
||||||
@@ -237,7 +239,7 @@ export function CustomLinkEditor({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
|
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
|
||||||
{categoryPresets.map((c) => (
|
{categoryPresets.map((c) => (
|
||||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
<SelectItem key={c} value={c}>{getCategoryLabel(t, c)}</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
|
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -258,7 +260,7 @@ export function CustomLinkEditor({
|
|||||||
<SelectItem value={UNBOUND_KEY}>{t("apps.customLinkBindingNone")}</SelectItem>
|
<SelectItem value={UNBOUND_KEY}>{t("apps.customLinkBindingNone")}</SelectItem>
|
||||||
{sortedGuests.map((g) => (
|
{sortedGuests.map((g) => (
|
||||||
<SelectItem key={`${g.type}:${g.vmid}`} value={`${g.type}:${g.vmid}`}>
|
<SelectItem key={`${g.type}:${g.vmid}`} value={`${g.type}:${g.vmid}`}>
|
||||||
{g.type === "qemu" ? "VM" : "CT"} {g.vmid} · {g.name}
|
{g.type === "qemu" ? "VM" : "LXC"} {g.vmid} · {g.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -288,7 +290,7 @@ export function CustomLinkEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
) : <div />}
|
) : <div />}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
<Button variant="outline" className={cancelButtonClass} onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
{t("apps.customLinkCancel")}
|
{t("apps.customLinkCancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -629,7 +629,7 @@ export default function Hardware() {
|
|||||||
const hasRealtimeData = (): boolean => {
|
const hasRealtimeData = (): boolean => {
|
||||||
if (!realtimeGPUData) return false
|
if (!realtimeGPUData) return false
|
||||||
|
|
||||||
// Esto permite mostrar datos incluso cuando la GPU está inactiva (valores en 0 o null)
|
// Show the data even while the GPU is idle, when values are 0 or null
|
||||||
return realtimeGPUData.has_monitoring_tool === true
|
return realtimeGPUData.has_monitoring_tool === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
const translateHealthText = (value?: string): string => {
|
const translateHealthText = (value?: string): string => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
const exact: Record<string, string> = {
|
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"),
|
"All systems operational": t("healthStatus.details.allOperational"),
|
||||||
"Normal": t("healthStatus.details.normal"),
|
"Normal": t("healthStatus.details.normal"),
|
||||||
"No I/O errors in dmesg": t("healthStatus.details.noIoErrors"),
|
"No I/O errors in dmesg": t("healthStatus.details.noIoErrors"),
|
||||||
@@ -321,7 +336,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
"No VM startup failures": t("healthStatus.details.noVmFailures"),
|
"No VM startup failures": t("healthStatus.details.noVmFailures"),
|
||||||
"Dismissed by user": t("healthStatus.details.dismissedByUser"),
|
"Dismissed by user": t("healthStatus.details.dismissedByUser"),
|
||||||
}
|
}
|
||||||
if (exact[value]) return exact[value]
|
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
|
||||||
|
|
||||||
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
|
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
|
||||||
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
|
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
|
||||||
@@ -347,6 +362,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
if (match) return t("healthStatus.details.startupStorageUnavailable", { storage: match[1] })
|
if (match) return t("healthStatus.details.startupStorageUnavailable", { storage: match[1] })
|
||||||
match = value.match(/^\[Startup\] Storage '(.+)' has status: (.+)\. \(checking\.\.\.\)$/)
|
match = value.match(/^\[Startup\] Storage '(.+)' has status: (.+)\. \(checking\.\.\.\)$/)
|
||||||
if (match) return t("healthStatus.details.startupStorageStatus", { storage: match[1], status: match[2] })
|
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$/)
|
match = value.match(/^(.+) storage available$/)
|
||||||
if (match) return t("healthStatus.details.storageAvailable", { type: match[1] })
|
if (match) return t("healthStatus.details.storageAvailable", { type: match[1] })
|
||||||
match = value.match(/^(.+) mount reachable$/)
|
match = value.match(/^(.+) mount reachable$/)
|
||||||
@@ -359,6 +376,52 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] })
|
if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] })
|
||||||
match = value.match(/^(\d+) remote mount\(s\) healthy$/)
|
match = value.match(/^(\d+) remote mount\(s\) healthy$/)
|
||||||
if (match) return t("healthStatus.details.remoteMountsHealthy", { count: match[1] })
|
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
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { ScriptTerminalModal } from "./script-terminal-modal"
|
|||||||
import { RestoreProgressCard } from "./restore-progress-card"
|
import { RestoreProgressCard } from "./restore-progress-card"
|
||||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||||
import { formatStorage, formatBytes } from "../lib/utils"
|
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
||||||
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
||||||
import { useI18n, useT } from "../lib/i18n/provider"
|
import { useI18n, useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
@@ -618,8 +618,11 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
|||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{t("backup.keyfileActions.pveKeyDescriptionBefore")} <code className="font-mono text-[10.5px]">{pveMatch.name}</code> {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "}
|
{t("backup.keyfileActions.pveKeyDescription").split(/(\{storage\}|\{path\})/).map((part, index) =>
|
||||||
<code className="font-mono text-[10.5px] break-all">{pveMatch.path}</code>. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
|
part === "{storage}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatch.name}</code>
|
||||||
|
: part === "{path}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatch.path}</code>
|
||||||
|
: part
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1300,7 +1303,7 @@ export function HostBackup() {
|
|||||||
</div>
|
</div>
|
||||||
) : unifiedArchives.length === 0 ? (
|
) : unifiedArchives.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-4">
|
<div className="text-sm text-muted-foreground py-4">
|
||||||
{t("backup.archives.emptyBefore")} <span className="font-medium">{t("backup.manual.run")}</span> {t("backup.archives.emptyAfter")}
|
{t("backup.archives.emptyMessage")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1510,7 +1513,7 @@ export function HostBackup() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline" className={cancelButtonClass}
|
||||||
onClick={() => setJobToDelete(null)}
|
onClick={() => setJobToDelete(null)}
|
||||||
disabled={busyJobId === jobToDelete?.id}
|
disabled={busyJobId === jobToDelete?.id}
|
||||||
>
|
>
|
||||||
@@ -2131,8 +2134,11 @@ function InspectModal({
|
|||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{t("backup.keyfileActions.pveKeyDescriptionBefore")} <code className="font-mono text-[10.5px]">{pveMatchInspect.name}</code> {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "}
|
{t("backup.keyfileActions.pveKeyDescription").split(/(\{storage\}|\{path\})/).map((part, index) =>
|
||||||
<code className="font-mono text-[10.5px] break-all">{pveMatchInspect.path}</code>. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
|
part === "{storage}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatchInspect.name}</code>
|
||||||
|
: part === "{path}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatchInspect.path}</code>
|
||||||
|
: part
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2302,7 +2308,7 @@ function InspectModal({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{kf && <KeyfileErrorBlock err={kf} />}
|
{kf && <KeyfileErrorBlock err={kf} />}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button variant="ghost" onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
|
<Button variant="outline" className={cancelButtonClass} onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -2408,7 +2414,7 @@ function InspectModal({
|
|||||||
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
|
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
|
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={deleteArchive} disabled={deletingArchive}>
|
<Button variant="destructive" onClick={deleteArchive} disabled={deletingArchive}>
|
||||||
@@ -2439,7 +2445,7 @@ function InspectModal({
|
|||||||
{archiveLog?.content ?? ""}
|
{archiveLog?.content ?? ""}
|
||||||
</pre>
|
</pre>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button variant="ghost" onClick={() => setShowArchiveFullLog(false)}>{t("actions.close")}</Button>
|
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowArchiveFullLog(false)}>{t("actions.close")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -3879,7 +3885,14 @@ function CreateJobDialog({
|
|||||||
{t("backup.encryption.encryptBackups")}
|
{t("backup.encryption.encryptBackups")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
{t("backup.encryption.pbsHelpBefore")} <code className="font-mono">--keyfile</code> {t("backup.encryption.pbsHelpMiddle")} <code className="font-mono">proxmox-backup-client backup</code>. {t("backup.encryption.pbsHelpAfter")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) {t("backup.encryption.pbsHelpEnd")}
|
{t("backup.encryption.pbsHelp").split(/(\{keyfile\}|\{option\}|\{mode\})/).map((part, index) => {
|
||||||
|
const literals: Record<string, string> = {
|
||||||
|
"{keyfile}": "/usr/local/share/proxmenux/pbs-key.conf",
|
||||||
|
"{option}": "--keyfile",
|
||||||
|
"{mode}": "0600",
|
||||||
|
}
|
||||||
|
return literals[part] ? <code key={index} className="font-mono break-all">{literals[part]}</code> : part
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -4089,7 +4102,7 @@ function CreateJobDialog({
|
|||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("backup.destinations.localAttachHelpBefore")} <code className="font-mono">/dump</code> {t("backup.destinations.localAttachHelpMiddle")} <code className="font-mono">/var/lib/vz/dump</code>.
|
{t("backup.destinations.localAttachHelpMessage")}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -4303,7 +4316,7 @@ function CreateJobDialog({
|
|||||||
{/* Footer navigation */}
|
{/* Footer navigation */}
|
||||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline" className={cancelButtonClass}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
@@ -5200,7 +5213,7 @@ function ManualBackupDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>
|
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={submitting}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -5709,13 +5722,13 @@ function DestinationsSection({
|
|||||||
)}
|
)}
|
||||||
{backups > 0 && (
|
{backups > 0 && (
|
||||||
<div className="px-3 py-2 rounded-md border border-blue-500/40 bg-blue-500/5">
|
<div className="px-3 py-2 rounded-md border border-blue-500/40 bg-blue-500/5">
|
||||||
{t("backup.destinations.backupsKeptBefore", { count: backups })} <span className="font-medium">{t("backup.destinations.kept")}</span>.
|
{t("backup.destinations.backupsKeptMessage")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={() => setConfirmingDest(null)}>
|
<Button variant="outline" className={cancelButtonClass} onClick={() => setConfirmingDest(null)}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDeleteDest}>
|
<Button variant="destructive" onClick={confirmDeleteDest}>
|
||||||
@@ -6039,7 +6052,7 @@ function ConfigureDestinationWizard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button variant="ghost" onClick={onClose}>{t("actions.cancel")}</Button>
|
<Button variant="outline" className={cancelButtonClass} onClick={onClose}>{t("actions.cancel")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -6177,7 +6190,13 @@ function AddDestinationDialog({
|
|||||||
// back to the URL match; default 22 when neither is set.
|
// back to the URL match; default 22 when neither is set.
|
||||||
const port = editing.ssh_port ?? (ssh[3] ? Number(ssh[3]) : 22)
|
const port = editing.ssh_port ?? (ssh[3] ? Number(ssh[3]) : 22)
|
||||||
setBorgSshPort(String(port || 22))
|
setBorgSshPort(String(port || 22))
|
||||||
setBorgSshRemotePath(`/${ssh[4]}`)
|
// `./path` (relative to the SSH user's home) and `~/path` are Borg
|
||||||
|
// path forms of their own — only an absolute path gets the slash
|
||||||
|
// the URL dropped.
|
||||||
|
const remotePath = ssh[4]
|
||||||
|
setBorgSshRemotePath(
|
||||||
|
remotePath.startsWith("./") || remotePath.startsWith("~/") ? remotePath : `/${remotePath}`,
|
||||||
|
)
|
||||||
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
|
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
|
||||||
setBorgRepo("")
|
setBorgRepo("")
|
||||||
} else {
|
} else {
|
||||||
@@ -6429,7 +6448,7 @@ function AddDestinationDialog({
|
|||||||
<Label htmlFor="borgSshUser">{t("backup.fields.sshUser")}</Label>
|
<Label htmlFor="borgSshUser">{t("backup.fields.sshUser")}</Label>
|
||||||
<Input id="borgSshUser" value={borgSshUser} onChange={(e) => setBorgSshUser(e.target.value)} className="font-mono mt-1" placeholder="borg" />
|
<Input id="borgSshUser" value={borgSshUser} onChange={(e) => setBorgSshUser(e.target.value)} className="font-mono mt-1" placeholder="borg" />
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{t("backup.destinations.sshUserHelpBefore")} <code className="font-mono">borg serve</code>. {t("backup.destinations.sshUserHelpAfter")} <code className="font-mono">borg</code>, {t("backup.destinations.not")} <code className="font-mono">root</code>.
|
{t("backup.destinations.sshUserHelpMessage")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[1fr_100px] gap-3">
|
<div className="grid grid-cols-[1fr_100px] gap-3">
|
||||||
@@ -6458,14 +6477,13 @@ function AddDestinationDialog({
|
|||||||
<Label htmlFor="borgKeyPath">{t("backup.fields.sshKeyPath")}</Label>
|
<Label htmlFor="borgKeyPath">{t("backup.fields.sshKeyPath")}</Label>
|
||||||
<Input id="borgKeyPath" value={borgSshKeyPath} onChange={(e) => setBorgSshKeyPath(e.target.value)} className="font-mono mt-1" />
|
<Input id="borgKeyPath" value={borgSshKeyPath} onChange={(e) => setBorgSshKeyPath(e.target.value)} className="font-mono mt-1" />
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{t("backup.destinations.sshKeyHelpBefore")}
|
{t("backup.destinations.sshKeyHelpMessage", { action: t("backup.actions.prepareSshKey") })}
|
||||||
{" "}{t("backup.destinations.sshKeyHelpMiddle")} <span className="font-medium text-foreground">{t("backup.actions.generateKey")}</span> {t("backup.destinations.sshKeyHelpAfter")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-border bg-card p-3 space-y-2">
|
<div className="rounded-md border border-border bg-card p-3 space-y-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs font-medium">{t("backup.destinations.generateNewSshKey")}</span>
|
<span className="text-xs font-medium">{t("backup.destinations.sshKeySetupTitle")}</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -6475,13 +6493,13 @@ function AddDestinationDialog({
|
|||||||
onClick={generateBorgKey}
|
onClick={generateBorgKey}
|
||||||
>
|
>
|
||||||
{generatingKey ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
|
{generatingKey ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
|
||||||
{generatedKey ? t("backup.actions.regenerate") : t("backup.actions.generateKey")}
|
{t("backup.actions.prepareSshKey")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{generatedKey ? (
|
{generatedKey ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{t("backup.destinations.appendAuthorizedKeyBefore")} <code className="font-mono">~{borgSshUser}/.ssh/authorized_keys</code>:
|
{t("backup.destinations.sshAuthorizedKeyHelp", { user: borgSshUser })}
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
readOnly
|
||||||
@@ -6492,7 +6510,7 @@ function AddDestinationDialog({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{t("backup.destinations.createsSshKeyBefore")} <code className="font-mono">borg serve</code> {t("backup.destinations.createsSshKeyAfter")}
|
{t("backup.destinations.sshKeySetupHelp")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -6595,7 +6613,7 @@ function AddDestinationDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-3 border-t border-border">
|
<div className="flex justify-end gap-2 pt-3 border-t border-border">
|
||||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
|
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!canSubmit || submitting}
|
disabled={!canSubmit || submitting}
|
||||||
@@ -6844,7 +6862,7 @@ function UsbPicker({
|
|||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline" className={cancelButtonClass}
|
||||||
onClick={() => { setFormatTarget(null); setFormatTyped("") }}
|
onClick={() => { setFormatTarget(null); setFormatTyped("") }}
|
||||||
disabled={busyKey === formatTarget?.path_or_device}
|
disabled={busyKey === formatTarget?.path_or_device}
|
||||||
>
|
>
|
||||||
@@ -7267,7 +7285,7 @@ function UsbDrivesSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
<Button variant="ghost" onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
|
<Button variant="outline" className={cancelButtonClass} onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -7763,7 +7781,7 @@ function JobDetailModal({
|
|||||||
{detail?.id}
|
{detail?.id}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={() => setShowDisableConfirm(false)}>
|
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowDisableConfirm(false)}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -8146,7 +8164,7 @@ function PbsKeyfileRecoveryDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={busy}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -9021,7 +9039,7 @@ function RestoreOptionsModal({
|
|||||||
{t("backup.actions.back")}
|
{t("backup.actions.back")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="outline" className={cancelButtonClass} onClick={onClose}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
{step === "custom" && (
|
{step === "custom" && (
|
||||||
|
|||||||
@@ -363,10 +363,12 @@ const generateLatencyReport = (report: ReportData, t: TFunction) => {
|
|||||||
.top-bar-title { font-weight: 600; }
|
.top-bar-title { font-weight: 600; }
|
||||||
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
|
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
|
||||||
.top-bar button {
|
.top-bar button {
|
||||||
background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
|
background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px;
|
||||||
font-size: 14px; font-weight: 600; cursor: pointer;
|
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 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) {
|
@media (min-width: 640px) {
|
||||||
.top-bar { padding: 12px 24px; }
|
.top-bar { padding: 12px 24px; }
|
||||||
.top-bar-subtitle { display: block; }
|
.top-bar-subtitle { display: block; }
|
||||||
@@ -532,7 +534,10 @@ const generateLatencyReport = (report: ReportData, t: TFunction) => {
|
|||||||
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
|
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</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>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Checkbox } from "./ui/checkbox"
|
|||||||
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { APP_VERSION } from "../lib/version"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
@@ -297,7 +298,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
|
<p className="text-center text-sm text-muted-foreground">{`ProxMenux Monitor v${APP_VERSION}`}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ import { Label } from "./ui/label"
|
|||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached } from "../lib/lxc-apps-cache"
|
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache"
|
||||||
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
||||||
|
import { getCategoryLabel } from "../lib/category-label"
|
||||||
import { useT } from "@/lib/i18n/provider"
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
// installed_via is optional now — an empty value means "register only,
|
// installed_via is optional now — an empty value means "register only,
|
||||||
@@ -42,7 +43,7 @@ import { useT } from "@/lib/i18n/provider"
|
|||||||
// apps and casual "just want a link" registrations use this default.
|
// apps and casual "just want a link" registrations use this default.
|
||||||
type InstalledVia = "" | "dpkg" | "apk" | "file" | "binary" |
|
type InstalledVia = "" | "dpkg" | "apk" | "file" | "binary" |
|
||||||
"python_dist" | "docker_label" | "docker_exec" |
|
"python_dist" | "docker_label" | "docker_exec" |
|
||||||
"command" | "manual"
|
"command" | "manual" | "oci_image"
|
||||||
type GithubSource = "releases" | "tags"
|
type GithubSource = "releases" | "tags"
|
||||||
|
|
||||||
interface PortEntry {
|
interface PortEntry {
|
||||||
@@ -92,10 +93,14 @@ interface AppConfig {
|
|||||||
health_path?: string
|
health_path?: string
|
||||||
logo_url?: string
|
logo_url?: string
|
||||||
helper_slug?: string
|
helper_slug?: string
|
||||||
|
// Set when the application runs inside Docker: registered for its identity
|
||||||
|
// and installed version, updated by the image it comes from.
|
||||||
|
update_via?: string
|
||||||
// Preserved here even though this editor does not execute updates.
|
// Preserved here even though this editor does not execute updates.
|
||||||
// The backend uses full-record replacement, so omitting these when
|
// The backend uses full-record replacement, so omitting these when
|
||||||
// editing ports/tracking would silently erase the Updates-tab setup.
|
// editing ports/tracking would silently erase the Updates-tab setup.
|
||||||
update_command?: string
|
update_command?: string
|
||||||
|
update_method?: "none" | "helper" | "custom"
|
||||||
hide_no_updater_notice?: boolean
|
hide_no_updater_notice?: boolean
|
||||||
// Per-app opt-out for the `app_update_available` notification.
|
// Per-app opt-out for the `app_update_available` notification.
|
||||||
// Absent / true = notify; false = silenced. Set from the bell
|
// Absent / true = notify; false = silenced. Set from the bell
|
||||||
@@ -106,15 +111,30 @@ interface AppConfig {
|
|||||||
exclude_from_badge?: boolean
|
exclude_from_badge?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A port as the installation record states it: the application answers on
|
||||||
|
// the first one, and any other is a service of its own (go2rtc next to Frigate).
|
||||||
|
interface PortDetail {
|
||||||
|
port: number
|
||||||
|
scheme: string | null
|
||||||
|
path: string | null
|
||||||
|
description: string
|
||||||
|
logo_url: string
|
||||||
|
}
|
||||||
|
|
||||||
interface DetectedApp {
|
interface DetectedApp {
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
logo_url?: string | null
|
logo_url?: string | null
|
||||||
default_ports?: number[]
|
default_ports?: number[]
|
||||||
|
port_details?: PortDetail[] | null
|
||||||
// Categoría preset from helpers_cache.category_names[0] — used to
|
// Categoría preset from helpers_cache.category_names[0] — used to
|
||||||
// auto-fill the Web Link editor when the user clicks "Register".
|
// auto-fill the Web Link editor when the user clicks "Register".
|
||||||
category?: string | null
|
category?: string | null
|
||||||
tracking_suggestion?: TrackingSuggestion | null
|
tracking_suggestion?: TrackingSuggestion | null
|
||||||
|
// The scheme the application is served on, when it is known rather than
|
||||||
|
// guessed. A ProxMenux OCI install records it; Chromium answers on 3001
|
||||||
|
// over https, which no port heuristic can tell from 3000 over http.
|
||||||
|
scheme?: "http" | "https" | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
@@ -124,12 +144,40 @@ interface AppState {
|
|||||||
update_available: boolean | null
|
update_available: boolean | null
|
||||||
error: string | null
|
error: string | null
|
||||||
checked_at: string | null
|
checked_at: string | null
|
||||||
|
// An application installed from an OCI image: the image it runs and the
|
||||||
|
// one its registry publishes today. The image decides whether there is an
|
||||||
|
// update; the version above only says which application it carries.
|
||||||
|
installed_digest?: string | null
|
||||||
|
latest_digest?: string | null
|
||||||
|
image_created?: string | null
|
||||||
|
latest_image_created?: string | null
|
||||||
|
image_reference?: string | null
|
||||||
|
image_repository?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The repository of an OCI image, shown the way the repository of a tracked
|
||||||
|
// application is: owner/name for GitHub, the image name for Docker Hub.
|
||||||
|
export const ociRepoLabel = (url: string) =>
|
||||||
|
url.replace(/^https?:\/\/(www\.)?/, "")
|
||||||
|
.replace(/^github\.com\//, "")
|
||||||
|
.replace(/^hub\.docker\.com\/(r|_)\//, "")
|
||||||
|
.replace(/\/$/, "")
|
||||||
|
|
||||||
|
// "2026-09-24 · a3f21c08": an image as a reader can compare two of them.
|
||||||
|
export const ociImageLabel = (created?: string | null, digest?: string | null) =>
|
||||||
|
[created ? created.slice(0, 10) : null, digest ? digest.split(":").pop()!.slice(0, 8) : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
|
||||||
interface AppEntry extends AppConfig {
|
interface AppEntry extends AppConfig {
|
||||||
id: string
|
id: string
|
||||||
state?: AppState
|
state?: AppState
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
// Resolved server-side from the image a delegated app updates with. It is
|
||||||
|
// not the app's own upstream — the app deliberately has none — so it is
|
||||||
|
// kept in its own field rather than folded into state.latest_version.
|
||||||
|
docker_available_version?: string | null
|
||||||
|
docker_update_available?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidecarResponse {
|
interface SidecarResponse {
|
||||||
@@ -209,20 +257,54 @@ interface DockerWebLinkSuggestion {
|
|||||||
logo_url?: string | null
|
logo_url?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identity of a container ProxMenux installed from an OCI image. It comes
|
||||||
|
// from the installation record rather than a probe, so it names the
|
||||||
|
// application even when the guest also ships something else — CT 152 runs
|
||||||
|
// Chromium and carries a docker client the probe reported instead.
|
||||||
|
interface OciInstance {
|
||||||
|
template_id: string | null
|
||||||
|
image_reference: string | null
|
||||||
|
repository: string | null
|
||||||
|
scheme: string | null
|
||||||
|
port: number | null
|
||||||
|
path: string | null
|
||||||
|
website: string | null
|
||||||
|
// The ports of an application with no web address: a service that is
|
||||||
|
// reached on them and has no page to open.
|
||||||
|
service_ports?: { port: number; protocol: string }[] | null
|
||||||
|
}
|
||||||
|
|
||||||
interface Suggestions {
|
interface Suggestions {
|
||||||
ready?: boolean
|
ready?: boolean
|
||||||
name_suggestion: string | null
|
name_suggestion: string | null
|
||||||
helper_slug: string | null
|
helper_slug: string | null
|
||||||
|
oci_instance?: OciInstance | null
|
||||||
port_suggestions: number[]
|
port_suggestions: number[]
|
||||||
web_path_hint: string | null
|
web_path_hint: string | null
|
||||||
tracking_suggestion?: TrackingSuggestion | null
|
tracking_suggestion?: TrackingSuggestion | null
|
||||||
default_ports?: number[]
|
default_ports?: number[]
|
||||||
|
port_details?: PortDetail[] | null
|
||||||
logo_url?: string | null
|
logo_url?: string | null
|
||||||
category?: string | null
|
category?: string | null
|
||||||
extras?: DetectedApp[]
|
extras?: DetectedApp[]
|
||||||
|
docker_workloads?: DockerWorkload[]
|
||||||
docker_web_links?: DockerWebLinkSuggestion[]
|
docker_web_links?: DockerWebLinkSuggestion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A registrable application inside Docker. Its image remains the updater;
|
||||||
|
// registration adds identity and version tracking, not a second update path.
|
||||||
|
interface DockerWorkload {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
logo_url?: string | null
|
||||||
|
container_name?: string
|
||||||
|
installed_version?: string | null
|
||||||
|
installed_via?: string | null
|
||||||
|
tracking_suggestion?: TrackingSuggestion | null
|
||||||
|
default_ports?: number[]
|
||||||
|
category?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// Compact catalog entry — one row for every registerable app the
|
// Compact catalog entry — one row for every registerable app the
|
||||||
// picker can offer. Fetched once from /api/apps/catalog on panel
|
// picker can offer. Fetched once from /api/apps/catalog on panel
|
||||||
// mount, filtered client-side while the user types.
|
// mount, filtered client-side while the user types.
|
||||||
@@ -264,6 +346,10 @@ interface Props {
|
|||||||
ctIp?: string | null
|
ctIp?: string | null
|
||||||
onChange?: () => void
|
onChange?: () => void
|
||||||
managed?: ManagedAppInfo | null
|
managed?: ManagedAppInfo | null
|
||||||
|
// What the OCI record says about this container, known without probing
|
||||||
|
// it: whether ProxMenux installed it from an image, and whether it is a
|
||||||
|
// secondary container of a multi-container application.
|
||||||
|
oci?: { instance: boolean; memberOf?: { vmid: number; label: string } | null } | null
|
||||||
// Optional seed payload from the parent's cross-open ref cache. When
|
// Optional seed payload from the parent's cross-open ref cache. When
|
||||||
// supplied, the panel renders with real content on the very first
|
// supplied, the panel renders with real content on the very first
|
||||||
// frame and only revalidates silently in the background — no
|
// frame and only revalidates silently in the background — no
|
||||||
@@ -339,7 +425,8 @@ function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?:
|
|||||||
const custom = (customUrl || "").trim()
|
const custom = (customUrl || "").trim()
|
||||||
if (custom) return custom
|
if (custom) return custom
|
||||||
if (!ip || ip === "DHCP" || !port) return null
|
if (!ip || ip === "DHCP" || !port) return null
|
||||||
return `${scheme || defaultSchemeFor(port)}://${ip}:${port}`
|
const host = ip.includes(":") && !ip.startsWith("[") ? `[${ip}]` : ip
|
||||||
|
return `${scheme || defaultSchemeFor(port)}://${host}:${port}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggest a dpkg/apk package name from a friendly app name — lowercase,
|
// Suggest a dpkg/apk package name from a friendly app name — lowercase,
|
||||||
@@ -364,7 +451,7 @@ function parseArgvInput(value: string): string[] {
|
|||||||
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) {
|
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const isLightTheme = useIsLightTheme()
|
const isLightTheme = useIsLightTheme()
|
||||||
// Seed from `initialData` first, then fall back to the shared cache
|
// Seed from `initialData` first, then fall back to the shared cache
|
||||||
@@ -375,6 +462,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
const [loading, setLoading] = useState(!seed)
|
const [loading, setLoading] = useState(!seed)
|
||||||
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
|
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
|
||||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||||
|
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
const [searchingApplications, setSearchingApplications] = useState(false)
|
||||||
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
||||||
@@ -457,6 +545,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [vmid, managed])
|
}, [vmid, managed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (managed) return
|
||||||
|
return subscribeLxcApps(vmid, bundle => {
|
||||||
|
setSidecar(bundle.sidecar)
|
||||||
|
setSuggestions(bundle.suggestions)
|
||||||
|
})
|
||||||
|
}, [vmid, managed])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
// Live Docker Hub preview. Debounced so typing an image/regex does not
|
// Live Docker Hub preview. Debounced so typing an image/regex does not
|
||||||
@@ -515,6 +611,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
if (lower.includes("github rate limited")) {
|
if (lower.includes("github rate limited")) {
|
||||||
return t("vmLxc.appEditor.upstreamErrorGithubRateLimit")
|
return t("vmLxc.appEditor.upstreamErrorGithubRateLimit")
|
||||||
}
|
}
|
||||||
|
if (lower.startsWith("registry unreachable")) {
|
||||||
|
return t("vmLxc.appEditor.upstreamErrorRegistry")
|
||||||
|
}
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,14 +655,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
const detectedList: DetectedApp[] = useMemo(() => {
|
const detectedList: DetectedApp[] = useMemo(() => {
|
||||||
if (!suggestions) return []
|
if (!suggestions) return []
|
||||||
const out: DetectedApp[] = []
|
const out: DetectedApp[] = []
|
||||||
if (suggestions.helper_slug && suggestions.name_suggestion) {
|
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
||||||
|
// community script — so its template id identifies it instead.
|
||||||
|
const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id
|
||||||
|
if (primarySlug && suggestions.name_suggestion) {
|
||||||
out.push({
|
out.push({
|
||||||
slug: suggestions.helper_slug,
|
slug: primarySlug,
|
||||||
name: suggestions.name_suggestion,
|
name: suggestions.name_suggestion,
|
||||||
logo_url: suggestions.logo_url,
|
logo_url: suggestions.logo_url,
|
||||||
default_ports: suggestions.default_ports,
|
default_ports: suggestions.default_ports,
|
||||||
|
port_details: suggestions.port_details,
|
||||||
category: suggestions.category,
|
category: suggestions.category,
|
||||||
tracking_suggestion: suggestions.tracking_suggestion,
|
tracking_suggestion: suggestions.tracking_suggestion,
|
||||||
|
scheme: suggestions.oci_instance?.scheme === "https" ? "https"
|
||||||
|
: suggestions.oci_instance?.scheme === "http" ? "http" : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const seen = new Set(out.map((d) => d.slug))
|
const seen = new Set(out.map((d) => d.slug))
|
||||||
@@ -594,12 +699,58 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
)
|
)
|
||||||
// Alias for pre-existing consumers (post-registration chip strip).
|
// Alias for pre-existing consumers (post-registration chip strip).
|
||||||
const unregisteredDetected = visibleDetected
|
const unregisteredDetected = visibleDetected
|
||||||
|
// Applications running inside Docker. They are filtered the same way, and
|
||||||
|
// deliberately NOT nested inside the Docker chip: that chip disappears the
|
||||||
|
// moment Docker is registered, which is exactly when these become
|
||||||
|
// registrable.
|
||||||
|
const visibleWorkloads = (suggestions?.docker_workloads || []).filter(
|
||||||
|
(w) => !registeredSlugs.has(w.slug) && !dismissedSlugs.has(w.slug),
|
||||||
|
)
|
||||||
// Detections the user hid and could restore from the Register-a-
|
// Detections the user hid and could restore from the Register-a-
|
||||||
// different-app panel. Not affected by registration state.
|
// different-app panel. Not affected by registration state.
|
||||||
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
|
const hiddenDetections = [...detectedList, ...(suggestions?.docker_workloads || [])]
|
||||||
|
.filter((d, index, items) => dismissedSlugs.has(d.slug) && items.findIndex(item => item.slug === d.slug) === index)
|
||||||
|
|
||||||
|
// A container installed from an OCI image holds exactly the application its
|
||||||
|
// record names, so there is nothing to search for and nothing else to add.
|
||||||
|
// What can go stale is what the record and the registry say, and this
|
||||||
|
// reads both again.
|
||||||
|
const isOciInstall = !!suggestions?.oci_instance || !!oci?.instance
|
||||||
|
const ociMemberOf = oci?.memberOf ?? null
|
||||||
|
const isOciAdguard = suggestions?.oci_instance?.template_id === "image-adguard-home"
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOciAdguard) return
|
||||||
|
let cancelled = false
|
||||||
|
fetchApi<{ available: boolean }>(`/api/vms/${vmid}/apps/adguard-setup`)
|
||||||
|
.then((result) => { if (!cancelled) setAdguardSetupAvailable(result.available === true) })
|
||||||
|
.catch(() => { if (!cancelled) setAdguardSetupAvailable(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [vmid, isOciAdguard])
|
||||||
|
const refreshOciData = async () => {
|
||||||
|
setSearchingApplications(true)
|
||||||
|
setDetectionNotice(null)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
setSuggestions(result)
|
||||||
|
let next = sidecar
|
||||||
|
if (apps.length > 0) {
|
||||||
|
next = await fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
|
||||||
|
setSidecar(next)
|
||||||
|
}
|
||||||
|
if (next) setLxcAppsCached(vmid, next, result)
|
||||||
|
onChange?.()
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || t("vmLxc.appEditor.checkFailed"))
|
||||||
|
} finally {
|
||||||
|
setSearchingApplications(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const searchInstalledApplications = async () => {
|
const searchInstalledApplications = async () => {
|
||||||
const before = new Set(visibleDetected.map((item) => item.slug))
|
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
||||||
setSearchingApplications(true)
|
setSearchingApplications(true)
|
||||||
setDetectionNotice(null)
|
setDetectionNotice(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -613,6 +764,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
const detected = new Set<string>()
|
const detected = new Set<string>()
|
||||||
if (result.helper_slug) detected.add(result.helper_slug)
|
if (result.helper_slug) detected.add(result.helper_slug)
|
||||||
for (const item of result.extras || []) detected.add(item.slug)
|
for (const item of result.extras || []) detected.add(item.slug)
|
||||||
|
for (const item of result.docker_workloads || []) detected.add(item.slug)
|
||||||
const visible = [...detected].filter(
|
const visible = [...detected].filter(
|
||||||
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
||||||
)
|
)
|
||||||
@@ -680,7 +832,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
health_path: existing.health_path || "",
|
health_path: existing.health_path || "",
|
||||||
logo_url: existing.logo_url || "",
|
logo_url: existing.logo_url || "",
|
||||||
helper_slug: existing.helper_slug || "",
|
helper_slug: existing.helper_slug || "",
|
||||||
|
// Preserved explicitly: saving replaces the whole record, so dropping
|
||||||
|
// it here would silently un-delegate the app the first time someone
|
||||||
|
// edits one of its ports.
|
||||||
|
update_via: (existing as { update_via?: string }).update_via || "",
|
||||||
update_command: existing.update_command || "",
|
update_command: existing.update_command || "",
|
||||||
|
update_method: existing.update_method || (existing.update_command ? "custom" : "none"),
|
||||||
hide_no_updater_notice: existing.hide_no_updater_notice === true,
|
hide_no_updater_notice: existing.hide_no_updater_notice === true,
|
||||||
notifications_enabled: existing.notifications_enabled !== false,
|
notifications_enabled: existing.notifications_enabled !== false,
|
||||||
exclude_from_badge: existing.exclude_from_badge === true,
|
exclude_from_badge: existing.exclude_from_badge === true,
|
||||||
@@ -709,10 +866,26 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
// Docker endpoints come from the real published host-port mappings
|
// Docker endpoints come from the real published host-port mappings
|
||||||
// listed under Web links. Do not pre-save a catalog default such as
|
// listed under Web links. Do not pre-save a catalog default such as
|
||||||
// 9000; the user explicitly chooses which workload links to add.
|
// 9000; the user explicitly chooses which workload links to add.
|
||||||
if (p.slug !== "docker" && p.default_ports?.length) {
|
const suggestedPorts = isOciAdguard && p.slug === "image-adguard-home"
|
||||||
seed.ports = p.default_ports.map((port) => ({
|
? [80, ...(adguardSetupAvailable ? [3000] : [])]
|
||||||
|
: p.default_ports || []
|
||||||
|
if (p.port_details?.length && !(isOciAdguard && p.slug === "image-adguard-home")) {
|
||||||
|
seed.ports = p.port_details.map((d) => ({
|
||||||
|
port: d.port,
|
||||||
|
...(d.description ? { description: d.description } : {}),
|
||||||
|
scheme: d.scheme === "https" ? "https" as const
|
||||||
|
: d.scheme === "http" ? "http" as const : defaultSchemeFor(d.port),
|
||||||
|
web_path: d.path || s?.web_path_hint || "",
|
||||||
|
...(d.logo_url ? { logo_url: d.logo_url } : {}),
|
||||||
|
...(p.category ? { category: p.category } : {}),
|
||||||
|
}))
|
||||||
|
} else if (p.slug !== "docker" && suggestedPorts.length) {
|
||||||
|
seed.ports = suggestedPorts.map((port) => ({
|
||||||
port,
|
port,
|
||||||
scheme: defaultSchemeFor(port),
|
...(isOciAdguard && port === 3000 ? { description: "Config" } : {}),
|
||||||
|
// A recorded scheme beats the port heuristic: getting this wrong
|
||||||
|
// hands the user a link that cannot connect.
|
||||||
|
scheme: p.scheme || defaultSchemeFor(port),
|
||||||
web_path: s?.web_path_hint || "",
|
web_path: s?.web_path_hint || "",
|
||||||
// Auto-fill Categoría from helpers_cache.category_names[0]
|
// Auto-fill Categoría from helpers_cache.category_names[0]
|
||||||
// when the catalog entry carries one. User can still change
|
// when the catalog entry carries one. User can still change
|
||||||
@@ -745,6 +918,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
docker_image: (t as any).docker_image || "",
|
docker_image: (t as any).docker_image || "",
|
||||||
tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)",
|
tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)",
|
||||||
}
|
}
|
||||||
|
// A containerised workload delegates its update to its image, and
|
||||||
|
// the backend refuses a delegation that also carries an upstream —
|
||||||
|
// so the upstream fields seeded above are cleared, not just ignored.
|
||||||
|
if ((t as { update_via?: string }).update_via === "docker") {
|
||||||
|
seed = {
|
||||||
|
...seed,
|
||||||
|
update_via: "docker",
|
||||||
|
upstream_type: "",
|
||||||
|
repo: "",
|
||||||
|
upstream_url: "",
|
||||||
|
upstream_json_path: "",
|
||||||
|
docker_image: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
setShowAdvanced(true)
|
setShowAdvanced(true)
|
||||||
} else {
|
} else {
|
||||||
setShowAdvanced(false)
|
setShowAdvanced(false)
|
||||||
@@ -761,7 +948,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
setEditing({ appId: existing?.id || null, draft: seed })
|
setEditing({ appId: existing?.id || null, draft: seed })
|
||||||
setDetectorTest(null)
|
setDetectorTest(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
}, [suggestions, vmid, sidecar])
|
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
|
||||||
|
|
||||||
const closeEditor = () => {
|
const closeEditor = () => {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
@@ -1145,27 +1332,42 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
web_path: link.web_path || "/",
|
web_path: link.web_path || "/",
|
||||||
logo_url: link.logo_url || "",
|
logo_url: link.logo_url || "",
|
||||||
}
|
}
|
||||||
const last = draft.ports[draft.ports.length - 1]
|
setDetectorTest(null)
|
||||||
const indexAfterAdd = (last && last.port === "" && !last.description)
|
setEditing((current) => {
|
||||||
? draft.ports.length - 1
|
if (!current) return current
|
||||||
: draft.ports.length
|
const currentPorts = current.draft.ports
|
||||||
if (indexAfterAdd === draft.ports.length) {
|
// The buttons disappear after a port is added, but guard the state
|
||||||
setField({ ports: [...draft.ports, entry] })
|
// update too so a double click can never create duplicate links.
|
||||||
} else {
|
if (currentPorts.some((port) => port.port === link.host_port)) return current
|
||||||
const ports = [...draft.ports]
|
const ports = [...currentPorts]
|
||||||
ports[indexAfterAdd] = entry
|
const last = ports[ports.length - 1]
|
||||||
setField({ ports })
|
if (last && last.port === "" && !last.description) ports[ports.length - 1] = entry
|
||||||
}
|
else ports.push(entry)
|
||||||
|
return { ...current, draft: { ...current.draft, ports } }
|
||||||
|
})
|
||||||
// Ask the backend whether this service_name has a known catalog
|
// Ask the backend whether this service_name has a known catalog
|
||||||
// category and, if so, patch the just-inserted port so the user
|
// category and, if so, patch the just-inserted port so the user
|
||||||
// finds it pre-selected instead of having to open the dropdown.
|
// finds it pre-selected instead of having to open the dropdown.
|
||||||
// Non-blocking — the port is already visible either way.
|
// This must be a functional update: the response can arrive after the
|
||||||
|
// user has added or edited more links, and must never restore the old
|
||||||
|
// draft captured by this render.
|
||||||
const q = (link.service_name || "").trim()
|
const q = (link.service_name || "").trim()
|
||||||
if (q) {
|
if (q) {
|
||||||
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
|
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r?.category) return
|
if (!r?.category) return
|
||||||
setPort(indexAfterAdd, { category: r.category })
|
setEditing((current) => {
|
||||||
|
if (!current) return current
|
||||||
|
let changed = false
|
||||||
|
const ports = current.draft.ports.map((port) => {
|
||||||
|
if (port.port !== link.host_port || port.category) return port
|
||||||
|
changed = true
|
||||||
|
return { ...port, category: r.category || undefined }
|
||||||
|
})
|
||||||
|
return changed
|
||||||
|
? { ...current, draft: { ...current.draft, ports } }
|
||||||
|
: current
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch(() => { /* non-fatal — user can pick manually */ })
|
.catch(() => { /* non-fatal — user can pick manually */ })
|
||||||
}
|
}
|
||||||
@@ -1175,15 +1377,32 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
const usedPorts = new Set(draft.ports.map((p) => p.port))
|
const usedPorts = new Set(draft.ports.map((p) => p.port))
|
||||||
const isDockerDraft = draft.helper_slug === "docker" ||
|
const isDockerDraft = draft.helper_slug === "docker" ||
|
||||||
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
|
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
|
||||||
|
// A recognised Docker workload belongs in one place at a time. Keep its
|
||||||
|
// links out of Docker while it is offered (or already registered) as an
|
||||||
|
// independent app. Dismissing that detection makes the links available
|
||||||
|
// under Docker again.
|
||||||
|
const independentDockerWorkloadSlugs = new Set(
|
||||||
|
(suggestions?.docker_workloads || [])
|
||||||
|
.filter((workload) => !dismissedSlugs.has(workload.slug))
|
||||||
|
.map((workload) => workload.slug),
|
||||||
|
)
|
||||||
const suggestableDockerLinks = isDockerDraft
|
const suggestableDockerLinks = isDockerDraft
|
||||||
? (suggestions?.docker_web_links || []).filter((link) => !usedPorts.has(link.host_port))
|
? (suggestions?.docker_web_links || []).filter(
|
||||||
|
(link) =>
|
||||||
|
!usedPorts.has(link.host_port) &&
|
||||||
|
(!link.service_slug || !independentDockerWorkloadSlugs.has(link.service_slug)),
|
||||||
|
)
|
||||||
: []
|
: []
|
||||||
// A Docker registration uses structured container → published-port
|
// Keep the generic ss/netstat probe available for Docker too. It covers
|
||||||
// suggestions below. Suppress the generic ss/netstat chips in that case
|
// host-networked services and listeners that Docker does not expose in
|
||||||
// so the same endpoint is not presented twice without its workload name.
|
// NetworkSettings.Ports. Published ports already represented by a named
|
||||||
const suggestable = isDockerDraft
|
// Docker workload stay deduplicated from the generic chips.
|
||||||
? []
|
const dockerPublishedPorts = new Set(
|
||||||
: (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
|
(suggestions?.docker_web_links || []).map((link) => link.host_port),
|
||||||
|
)
|
||||||
|
const suggestable = (suggestions?.port_suggestions || []).filter(
|
||||||
|
(port) => !usedPorts.has(port) && (!isDockerDraft || !dockerPublishedPorts.has(port)),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1512,7 +1731,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
|
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
|
||||||
{categoryPresets.map((c) => (
|
{categoryPresets.map((c) => (
|
||||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
<SelectItem key={c} value={c}>{getCategoryLabel(t, c)}</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
|
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -1589,14 +1808,34 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
|
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
|
||||||
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
|
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
|
||||||
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
|
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
|
||||||
<SelectItem value="docker_label" disabled>{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
|
{/* Chosen by detection for a Docker workload; kept only so an
|
||||||
|
application already using it shows its method. */}
|
||||||
|
{method === "docker_label" && (
|
||||||
|
<SelectItem value="docker_label">{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
|
||||||
|
)}
|
||||||
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
|
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
|
||||||
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
|
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
|
||||||
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
|
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
|
||||||
|
{/* Only meaningful on a container ProxMenux installed
|
||||||
|
from an image: the record it reads exists nowhere else. */}
|
||||||
|
{(method === "oci_image" || suggestions?.oci_instance) && (
|
||||||
|
<SelectItem value="oci_image">{t("vmLxc.appEditor.methodOciImage")}</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{method === "oci_image" && (
|
||||||
|
<div className="sm:col-span-2 text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{t("vmLxc.appEditor.ociImageHelp")}
|
||||||
|
{suggestions?.oci_instance?.image_reference && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<code className="text-foreground/80">{suggestions.oci_instance.image_reference}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPackaged && (
|
{isPackaged && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
|
<Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
|
||||||
@@ -1799,7 +2038,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{method && (() => {
|
{/* The registry of the image is the upstream of an OCI
|
||||||
|
install, and the record already names it. */}
|
||||||
|
{method && method !== "oci_image" && (() => {
|
||||||
// Upstream source selector — 3 methods (github,
|
// Upstream source selector — 3 methods (github,
|
||||||
// http_json, docker_hub). Legacy sidecars with a
|
// http_json, docker_hub). Legacy sidecars with a
|
||||||
// `repo` set but no `upstream_type` default to
|
// `repo` set but no `upstream_type` default to
|
||||||
@@ -1825,6 +2066,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
if (!t) patch.tag_regex = ""
|
if (!t) patch.tag_regex = ""
|
||||||
setField(patch)
|
setField(patch)
|
||||||
}
|
}
|
||||||
|
// A delegated app has no upstream of its own on purpose:
|
||||||
|
// the image it runs on resolves the available version.
|
||||||
|
// Showing an empty "none" dropdown reads as a setting the
|
||||||
|
// user forgot, so say what is happening instead.
|
||||||
|
if (editing.draft.update_via === "docker") {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border/60 bg-background/40 p-3">
|
||||||
|
<div className="text-xs font-medium text-foreground">
|
||||||
|
{t("vmLxc.appEditor.upstreamDelegatedTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1 leading-relaxed">
|
||||||
|
{t("vmLxc.appEditor.upstreamDelegatedHelp")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -2227,6 +2484,67 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A containerised application. Same chip language as a native detection,
|
||||||
|
// with the update path spelled out: it is registered for what it is, not
|
||||||
|
// for an updater it does not have.
|
||||||
|
const renderWorkloadChip = (w: DockerWorkload) => (
|
||||||
|
<div key={`workload-${w.slug}`} className="p-3 rounded-md border border-border/60 bg-background/40">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
{w.logo_url && (
|
||||||
|
<ThemeAwareLogo
|
||||||
|
src={w.logo_url}
|
||||||
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-foreground truncate">{w.name}</div>
|
||||||
|
<div className="text-xs text-emerald-400/90">
|
||||||
|
{w.installed_version
|
||||||
|
? t("vmLxc.appEditor.versionDetected", { version: w.installed_version })
|
||||||
|
: t("vmLxc.appEditor.detectedInContainer")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-0.5 truncate">
|
||||||
|
{t("vmLxc.appEditor.runsInsideDocker")}
|
||||||
|
{w.container_name ? ` · ${w.container_name}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEditor(undefined, {
|
||||||
|
withTracking: !!w.tracking_suggestion,
|
||||||
|
preset: {
|
||||||
|
slug: w.slug,
|
||||||
|
name: w.name,
|
||||||
|
logo_url: w.logo_url || "",
|
||||||
|
default_ports: w.default_ports || [],
|
||||||
|
category: w.category || null,
|
||||||
|
tracking_suggestion: w.tracking_suggestion || null,
|
||||||
|
} as unknown as DetectedApp,
|
||||||
|
})}
|
||||||
|
className="flex-[3] sm:flex-none bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{t("vmLxc.appEditor.registerButton")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => dismissDetection(w.slug, w.name)}
|
||||||
|
aria-label={`${t("vmLxc.appEditor.hideButton")}: ${w.name}`}
|
||||||
|
title={t("vmLxc.appEditor.hidePermanentlyTooltip")}
|
||||||
|
className="flex-1 sm:flex-none bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<EyeOff className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">{t("vmLxc.appEditor.hideButton")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Browse panel — surfaces hidden detections with Restore
|
{/* Browse panel — surfaces hidden detections with Restore
|
||||||
@@ -2276,7 +2594,36 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
many detections there are (0, 1, or many). Below the chips,
|
many detections there are (0, 1, or many). Below the chips,
|
||||||
a single "Register a different app" button lets the user
|
a single "Register a different app" button lets the user
|
||||||
add something the auto-detector doesn't know about. */}
|
add something the auto-detector doesn't know about. */}
|
||||||
{apps.length === 0 && (
|
{/* A database or a cache of a multi-container application has no
|
||||||
|
application of its own: it is reached from the main container. */}
|
||||||
|
{apps.length === 0 && ociMemberOf && (
|
||||||
|
<Card className="border border-border bg-card/50">
|
||||||
|
<CardContent className="p-6 space-y-3">
|
||||||
|
<div className="mx-auto p-2 rounded-full bg-blue-500/10 w-fit">
|
||||||
|
<Info className="h-5 w-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground text-center">
|
||||||
|
{t("vmLxc.appEditor.stackMemberTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
|
||||||
|
{t("vmLxc.appEditor.stackMemberBody", { primary: ociMemberOf.label })}
|
||||||
|
</p>
|
||||||
|
<div className="pt-1 flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent("openLxcAppModal", {
|
||||||
|
detail: { vmid: ociMemberOf.vmid, tab: "app" },
|
||||||
|
}))}
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4 mr-1.5" />
|
||||||
|
{t("vmLxc.ociUpdates.openPrimary")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apps.length === 0 && !ociMemberOf && (
|
||||||
<Card className="border border-border bg-card/50">
|
<Card className="border border-border bg-card/50">
|
||||||
<CardContent className="p-6 space-y-3">
|
<CardContent className="p-6 space-y-3">
|
||||||
<div className="mx-auto p-2 rounded-full bg-emerald-500/10 w-fit">
|
<div className="mx-auto p-2 rounded-full bg-emerald-500/10 w-fit">
|
||||||
@@ -2288,24 +2635,28 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
|
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
|
||||||
{t("vmLxc.appEditor.noAppsBody")}
|
{t("vmLxc.appEditor.noAppsBody")}
|
||||||
</p>
|
</p>
|
||||||
{visibleDetected.length > 0 && (
|
{(visibleDetected.length > 0 || visibleWorkloads.length > 0) && (
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
{visibleDetected.map(renderDetectionChip)}
|
{visibleDetected.map(renderDetectionChip)}
|
||||||
|
{visibleWorkloads.map(renderWorkloadChip)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="pt-1 flex flex-wrap justify-center gap-2">
|
<div className="pt-1 flex flex-wrap justify-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={searchInstalledApplications}
|
onClick={isOciInstall ? refreshOciData : searchInstalledApplications}
|
||||||
disabled={searchingApplications}
|
disabled={searchingApplications}
|
||||||
className="bg-blue-500 hover:bg-blue-600 text-white"
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
>
|
>
|
||||||
{searchingApplications
|
{searchingApplications
|
||||||
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||||
: <Search className="h-4 w-4 mr-1.5" />}
|
: isOciInstall ? <RefreshCw className="h-4 w-4 mr-1.5" /> : <Search className="h-4 w-4 mr-1.5" />}
|
||||||
{searchingApplications
|
{isOciInstall
|
||||||
? t("vmLxc.appEditor.searchingApplications")
|
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
|
||||||
: t("vmLxc.appEditor.searchApplications")}
|
: searchingApplications
|
||||||
|
? t("vmLxc.appEditor.searchingApplications")
|
||||||
|
: t("vmLxc.appEditor.searchApplications")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isOciInstall && (
|
||||||
<Button
|
<Button
|
||||||
onClick={openBrowseOrEditor}
|
onClick={openBrowseOrEditor}
|
||||||
className="bg-blue-500 hover:bg-blue-600 text-white"
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
@@ -2318,6 +2669,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{detectionNotice && (
|
{detectionNotice && (
|
||||||
<p className={`text-xs text-center ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
|
<p className={`text-xs text-center ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
|
||||||
@@ -2353,6 +2705,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
{app.installed_via === "apk" && app.package && <>apk · <code className="text-foreground/80">{app.package}</code></>}
|
{app.installed_via === "apk" && app.package && <>apk · <code className="text-foreground/80">{app.package}</code></>}
|
||||||
{app.installed_via === "file" && app.file_path && <>file · <code className="text-foreground/80">{app.file_path}</code></>}
|
{app.installed_via === "file" && app.file_path && <>file · <code className="text-foreground/80">{app.file_path}</code></>}
|
||||||
{app.installed_via === "binary" && app.binary_path && <>binary · <code className="text-foreground/80">{app.binary_path}</code></>}
|
{app.installed_via === "binary" && app.binary_path && <>binary · <code className="text-foreground/80">{app.binary_path}</code></>}
|
||||||
|
{app.installed_via === "oci_image" && st?.image_reference && <>OCI · <code className="text-foreground/80 break-all">{st.image_reference}</code></>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tracking && st?.checked_at && (
|
{tracking && st?.checked_at && (
|
||||||
@@ -2364,6 +2717,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
stack below Checked, full-width so long repo
|
stack below Checked, full-width so long repo
|
||||||
names wrap cleanly instead of competing with
|
names wrap cleanly instead of competing with
|
||||||
the top-right on narrow screens. */}
|
the top-right on narrow screens. */}
|
||||||
|
{app.installed_via === "oci_image" && tracking && st?.image_repository && (
|
||||||
|
<a
|
||||||
|
href={st.image_repository}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="md:hidden mt-1 text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 min-w-0"
|
||||||
|
>
|
||||||
|
<span className="truncate">{ociRepoLabel(st.image_repository)}</span>
|
||||||
|
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{app.repo && tracking && (
|
{app.repo && tracking && (
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/${app.repo}`}
|
href={`https://github.com/${app.repo}`}
|
||||||
@@ -2380,6 +2744,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
{/* Desktop-only repo link: same row as the title on md+,
|
{/* Desktop-only repo link: same row as the title on md+,
|
||||||
hidden on mobile where the stacked variant above
|
hidden on mobile where the stacked variant above
|
||||||
handles it. */}
|
handles it. */}
|
||||||
|
{app.installed_via === "oci_image" && tracking && st?.image_repository && (
|
||||||
|
<a
|
||||||
|
href={st.image_repository}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hidden md:inline-flex text-xs text-muted-foreground hover:text-foreground items-center gap-1 flex-shrink-0 mt-1"
|
||||||
|
>
|
||||||
|
{ociRepoLabel(st.image_repository)}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{app.repo && tracking && (
|
{app.repo && tracking && (
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/${app.repo}`}
|
href={`https://github.com/${app.repo}`}
|
||||||
@@ -2394,8 +2769,49 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const hasUpstream = !!(app.repo || app.upstream_type)
|
// An application installed from an OCI image changes when its
|
||||||
const hasUpdate = st?.update_available === true
|
// image does. Both are shown — the application version it
|
||||||
|
// carries and the image it runs — and the image decides.
|
||||||
|
if (app.installed_via === "oci_image") {
|
||||||
|
if (!tracking) return null
|
||||||
|
const newImage = st?.update_available === true
|
||||||
|
const appMoved = !!(st?.latest_version && st.latest_version !== st.installed_version)
|
||||||
|
return (
|
||||||
|
<div className="mb-3 grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||||
|
<div className="p-3 rounded-md bg-muted/40">
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.ociAppVersion")}</div>
|
||||||
|
<div className="text-lg font-semibold font-mono text-foreground break-all">
|
||||||
|
{st?.installed_version || "—"}
|
||||||
|
</div>
|
||||||
|
{appMoved && (
|
||||||
|
<div className="text-xs font-mono text-purple-400 mt-1 break-all">→ {st!.latest_version}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-md bg-muted/40">
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.ociImage")}</div>
|
||||||
|
<div className="text-sm font-semibold font-mono text-foreground">
|
||||||
|
{ociImageLabel(st?.image_created, st?.installed_digest) || "—"}
|
||||||
|
</div>
|
||||||
|
{newImage ? (
|
||||||
|
<div className="text-sm font-semibold font-mono text-purple-400 mt-1 flex items-center gap-2">
|
||||||
|
{t("vmLxc.appEditor.ociNewImage")}: {ociImageLabel(st?.latest_image_created, st?.latest_digest)}
|
||||||
|
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
||||||
|
</div>
|
||||||
|
) : st?.latest_digest ? (
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.ociImageCurrent")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A delegated app has no upstream of its own; the version to
|
||||||
|
// compare against comes from the image it updates with.
|
||||||
|
const delegated = app.update_via === "docker"
|
||||||
|
const hasUpstream = !!(app.repo || app.upstream_type) || (delegated && !!app.docker_available_version)
|
||||||
|
const hasUpdate = delegated
|
||||||
|
? app.docker_update_available === true
|
||||||
|
: st?.update_available === true
|
||||||
|
const latestVersion = delegated ? app.docker_available_version : st?.latest_version
|
||||||
if (!tracking || !(st?.installed_version || hasUpstream)) return null
|
if (!tracking || !(st?.installed_version || hasUpstream)) return null
|
||||||
return (
|
return (
|
||||||
<div className={"mb-3 grid gap-3 " + (st?.installed_version && hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
|
<div className={"mb-3 grid gap-3 " + (st?.installed_version && hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
|
||||||
@@ -2413,8 +2829,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
{t("vmLxc.appEditor.latestUpstream")}
|
{t("vmLxc.appEditor.latestUpstream")}
|
||||||
</div>
|
</div>
|
||||||
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
|
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
|
||||||
{st?.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
|
{latestVersion || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
|
||||||
{hasUpdate && st?.latest_version && (
|
{hasUpdate && latestVersion && (
|
||||||
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2437,9 +2853,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
Logo is optional (per-port `logo_url`); when absent
|
Logo is optional (per-port `logo_url`); when absent
|
||||||
the row indents naturally to align with the text.
|
the row indents naturally to align with the text.
|
||||||
If we can't resolve an IP for the CT we hide the row. */}
|
If we can't resolve an IP for the CT we hide the row. */}
|
||||||
{app.ports && app.ports.length > 0 && (
|
{(isOciAdguard ? true : !!app.ports?.length) && (
|
||||||
<div className="mb-3 space-y-4">
|
<div className="mb-3 space-y-4">
|
||||||
{app.ports.map((p) => {
|
{(isOciAdguard
|
||||||
|
? [
|
||||||
|
{ port: 80, description: app.name, scheme: "http" as const },
|
||||||
|
...(adguardSetupAvailable ? [{ port: 3000, description: "Config", scheme: "http" as const }] : []),
|
||||||
|
]
|
||||||
|
: app.ports).map((p) => {
|
||||||
const url = buildWebUrl(ctIp, p.port, p.scheme, p.custom_url)
|
const url = buildWebUrl(ctIp, p.port, p.scheme, p.custom_url)
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
const label = p.description || app.name
|
const label = p.description || app.name
|
||||||
@@ -2473,9 +2894,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
<span
|
<span
|
||||||
style={categoryChipStyle(p.category, isLightTheme)}
|
style={categoryChipStyle(p.category, isLightTheme)}
|
||||||
className="flex-shrink-0 px-1.5 py-0.5 border rounded text-[10px] font-medium truncate max-w-[40%]"
|
className="flex-shrink-0 px-1.5 py-0.5 border rounded text-[10px] font-medium truncate max-w-[40%]"
|
||||||
title={p.category}
|
title={getCategoryLabel(t, p.category)}
|
||||||
>
|
>
|
||||||
{p.category}
|
{getCategoryLabel(t, p.category)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2486,6 +2907,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* A service has no page to open: its address is shown as
|
||||||
|
text, on the ports of its container. */}
|
||||||
|
{!app.ports?.length && app.installed_via === "oci_image" && !!ctIp && ctIp !== "DHCP"
|
||||||
|
&& !!suggestions?.oci_instance?.service_ports?.length && (
|
||||||
|
<div className="mb-3 flex flex-col gap-1">
|
||||||
|
{suggestions.oci_instance.service_ports.map((p) => (
|
||||||
|
<span key={`${p.port}/${p.protocol}`} className="font-mono text-sm text-foreground">
|
||||||
|
{ctIp.split("/")[0]}:{p.port}{p.protocol === "udp" ? "/udp" : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer with per-card actions — only rendered in the
|
{/* Footer with per-card actions — only rendered in the
|
||||||
global edit mode (toggled from the "Edit" button next
|
global edit mode (toggled from the "Edit" button next
|
||||||
to Add another application). View mode keeps cards
|
to Add another application). View mode keeps cards
|
||||||
@@ -2561,12 +2995,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
hasn't been registered yet is shown as a chip with a
|
hasn't been registered yet is shown as a chip with a
|
||||||
one-click Register button. Filtered against the sidecar's
|
one-click Register button. Filtered against the sidecar's
|
||||||
`helper_slug` field so a registered app never re-appears. */}
|
`helper_slug` field so a registered app never re-appears. */}
|
||||||
{apps.length > 0 && unregisteredDetected.length > 0 && (
|
{apps.length > 0 && (unregisteredDetected.length > 0 || visibleWorkloads.length > 0) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
{t("vmLxc.appEditor.alsoDetectedContainer")}
|
{t("vmLxc.appEditor.alsoDetectedContainer")}
|
||||||
</div>
|
</div>
|
||||||
{unregisteredDetected.map(renderDetectionChip)}
|
{unregisteredDetected.map(renderDetectionChip)}
|
||||||
|
{visibleWorkloads.map(renderWorkloadChip)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2588,22 +3023,27 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={searchInstalledApplications}
|
onClick={isOciInstall ? refreshOciData : searchInstalledApplications}
|
||||||
disabled={searchingApplications || editMode}
|
disabled={searchingApplications || editMode}
|
||||||
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
|
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
|
||||||
aria-label={searchingApplications
|
aria-label={isOciInstall
|
||||||
? t("vmLxc.appEditor.searchingApplications")
|
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
|
||||||
: t("vmLxc.appEditor.searchApplications")}
|
: searchingApplications
|
||||||
|
? t("vmLxc.appEditor.searchingApplications")
|
||||||
|
: t("vmLxc.appEditor.searchApplications")}
|
||||||
>
|
>
|
||||||
{searchingApplications
|
{searchingApplications
|
||||||
? <Loader2 className="h-4 w-4 sm:mr-1.5 animate-spin" />
|
? <Loader2 className="h-4 w-4 sm:mr-1.5 animate-spin" />
|
||||||
: <Search className="h-4 w-4 sm:mr-1.5" />}
|
: isOciInstall ? <RefreshCw className="h-4 w-4 sm:mr-1.5" /> : <Search className="h-4 w-4 sm:mr-1.5" />}
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">
|
||||||
{searchingApplications
|
{isOciInstall
|
||||||
? t("vmLxc.appEditor.searchingApplications")
|
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
|
||||||
: t("vmLxc.appEditor.searchApplications")}
|
: searchingApplications
|
||||||
|
? t("vmLxc.appEditor.searchingApplications")
|
||||||
|
: t("vmLxc.appEditor.searchApplications")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isOciInstall && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -2622,6 +3062,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditMode((v) => !v)}
|
onClick={() => setEditMode((v) => !v)}
|
||||||
|
|||||||
@@ -178,8 +178,7 @@ export function LxcTerminalModal({
|
|||||||
// `cancelled` short-circuits the async init if the modal closes
|
// `cancelled` short-circuits the async init if the modal closes
|
||||||
// before the dynamic xterm import resolves. Without this, we'd
|
// before the dynamic xterm import resolves. Without this, we'd
|
||||||
// construct a Terminal instance, attach it to a now-stale ref, and
|
// construct a Terminal instance, attach it to a now-stale ref, and
|
||||||
// open a WebSocket that nobody listens to. Audit Tier 6 — useEffect
|
// open a WebSocket that nobody listens to.
|
||||||
// con `import("xterm")` sin cancelación.
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
// Small delay to ensure Dialog content is rendered
|
// Small delay to ensure Dialog content is rendered
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useI18n } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
export function MetricsCacheNotice({ lastChecked }: { lastChecked: number | null }) {
|
||||||
|
const { language, t } = useI18n()
|
||||||
|
if (lastChecked === null) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p role="status" className="text-xs text-amber-600 dark:text-amber-400 mb-2">
|
||||||
|
{t("overview.metricsCachedWarning", {
|
||||||
|
time: new Date(lastChecked * 1000).toLocaleString(language),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -665,9 +665,8 @@ function renderVertical(data: NetworkFlowData, labels: FlowLabels, unit: Network
|
|||||||
const NIC_PATH_START_OFFSET = 46 // clears SUB_OFFSET_Y + text height
|
const NIC_PATH_START_OFFSET = 46 // clears SUB_OFFSET_Y + text height
|
||||||
const NIC_VERTICAL_LEG = 56
|
const NIC_VERTICAL_LEG = 56
|
||||||
const HOST_GAP_FROM_CONVERGE = 56
|
const HOST_GAP_FROM_CONVERGE = 56
|
||||||
const GUEST_ROW_H = 100 // más separación vertical entre
|
const GUEST_ROW_H = 100 // enough vertical room that one guest's
|
||||||
// guests para que sub no toque
|
// sub clears the next guest's circle
|
||||||
// el círculo del siguiente
|
|
||||||
const BRIDGE_PITCH_PAD = 36
|
const BRIDGE_PITCH_PAD = 36
|
||||||
// Vertical positions of the label and the sub (rate) BELOW each
|
// Vertical positions of the label and the sub (rate) BELOW each
|
||||||
// node's circle. Both grew when the font went up to 12.5 px; this
|
// node's circle. Both grew when the font went up to 12.5 px; this
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Loader2 } from 'lucide-react'
|
|||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { getNetworkUnit } from "../lib/format-network"
|
import { getNetworkUnit } from "../lib/format-network"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { MetricsCacheNotice } from "./metrics-cache-notice"
|
||||||
|
|
||||||
interface NetworkMetricsData {
|
interface NetworkMetricsData {
|
||||||
time: string
|
time: string
|
||||||
@@ -55,12 +56,13 @@ export function NetworkTrafficChart({
|
|||||||
const [data, setData] = useState<NetworkMetricsData[]>([])
|
const [data, setData] = useState<NetworkMetricsData[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [staleAt, setStaleAt] = useState<number | null>(null)
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||||
const [visibleLines, setVisibleLines] = useState({
|
const [visibleLines, setVisibleLines] = useState({
|
||||||
netIn: true,
|
netIn: true,
|
||||||
netOut: true,
|
netOut: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(
|
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(
|
||||||
networkUnitProp || getNetworkUnit()
|
networkUnitProp || getNetworkUnit()
|
||||||
)
|
)
|
||||||
@@ -87,133 +89,147 @@ export function NetworkTrafficChart({
|
|||||||
}, [networkUnitProp])
|
}, [networkUnitProp])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
let hasData = false
|
||||||
|
let lastChecked: number | null = null
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
setIsInitialLoad(true)
|
setIsInitialLoad(true)
|
||||||
fetchMetrics()
|
setLoading(true)
|
||||||
}, [timeframe, interfaceName, networkUnit])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (refreshInterval > 0) {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchMetrics()
|
|
||||||
}, refreshInterval)
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [timeframe, interfaceName, refreshInterval, networkUnit]) // Added networkUnit to dependencies
|
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
|
||||||
if (isInitialLoad) {
|
|
||||||
setLoading(true)
|
|
||||||
}
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setStaleAt(null)
|
||||||
|
setData([])
|
||||||
|
|
||||||
try {
|
const fetchMetrics = async () => {
|
||||||
const apiPath = interfaceName
|
|
||||||
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
try {
|
||||||
: `/api/node/metrics?timeframe=${timeframe}`
|
const apiPath = interfaceName
|
||||||
|
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
||||||
|
: `/api/node/metrics?timeframe=${timeframe}`
|
||||||
|
|
||||||
|
|
||||||
const result = await fetchApi<any>(apiPath)
|
const result = await fetchApi<any>(apiPath)
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
if (!result.data || !Array.isArray(result.data)) {
|
if (!result.data || !Array.isArray(result.data)) {
|
||||||
throw new Error(t("network.chart.invalidDataFormat"))
|
throw new Error(t("network.chart.invalidDataFormat"))
|
||||||
}
|
|
||||||
|
|
||||||
if (result.data.length === 0) {
|
|
||||||
setData([])
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const transformedData = result.data.map((item: any, index: number) => {
|
|
||||||
const date = new Date(item.time * 1000)
|
|
||||||
let timeLabel = ""
|
|
||||||
|
|
||||||
if (timeframe === "hour") {
|
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
} else if (timeframe === "day") {
|
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
} else if (timeframe === "week") {
|
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
} else if (timeframe === "year") {
|
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let intervalSeconds = 60
|
setError(null)
|
||||||
if (index > 0) {
|
lastChecked = result.last_checked || Date.now() / 1000
|
||||||
intervalSeconds = item.time - result.data[index - 1].time
|
setStaleAt(result.cache_status === "stale" ? lastChecked : null)
|
||||||
|
hasData = result.data.length > 0
|
||||||
|
|
||||||
|
if (result.data.length === 0) {
|
||||||
|
setData([])
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const netInBytes = (item.netin || 0) * intervalSeconds
|
const transformedData = result.data.map((item: any, index: number) => {
|
||||||
const netOutBytes = (item.netout || 0) * intervalSeconds
|
const date = new Date(item.time * 1000)
|
||||||
|
let timeLabel = ""
|
||||||
|
|
||||||
|
if (timeframe === "hour") {
|
||||||
|
timeLabel = date.toLocaleString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
} else if (timeframe === "day") {
|
||||||
|
timeLabel = date.toLocaleString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
} else if (timeframe === "week") {
|
||||||
|
timeLabel = date.toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
} else if (timeframe === "year") {
|
||||||
|
timeLabel = date.toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
timeLabel = date.toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let intervalSeconds = 60
|
||||||
|
if (index > 0) {
|
||||||
|
intervalSeconds = item.time - result.data[index - 1].time
|
||||||
|
}
|
||||||
|
|
||||||
|
const netInBytes = (item.netin || 0) * intervalSeconds
|
||||||
|
const netOutBytes = (item.netout || 0) * intervalSeconds
|
||||||
|
|
||||||
|
if (networkUnit === "Bits") {
|
||||||
|
return {
|
||||||
|
time: timeLabel,
|
||||||
|
timestamp: item.time,
|
||||||
|
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
|
||||||
|
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (networkUnit === "Bits") {
|
|
||||||
return {
|
return {
|
||||||
time: timeLabel,
|
time: timeLabel,
|
||||||
timestamp: item.time,
|
timestamp: item.time,
|
||||||
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
|
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
|
||||||
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
|
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setData(transformedData)
|
||||||
|
|
||||||
|
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
|
||||||
|
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
|
||||||
|
const netInBytes = (item.netin || 0) * intervalSeconds
|
||||||
|
return sum + (netInBytes / 1024 / 1024 / 1024)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
|
||||||
|
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
|
||||||
|
const netOutBytes = (item.netout || 0) * intervalSeconds
|
||||||
|
return sum + (netOutBytes / 1024 / 1024 / 1024)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
if (onTotalsCalculated) {
|
||||||
|
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
time: timeLabel,
|
} catch (err: any) {
|
||||||
timestamp: item.time,
|
if (!active) return
|
||||||
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
|
console.error("Error fetching network metrics:", err)
|
||||||
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
|
if (hasData) {
|
||||||
|
setStaleAt(lastChecked)
|
||||||
|
} else {
|
||||||
|
setError(err?.body?.code === "metrics_timeout"
|
||||||
|
? t("overview.metricsTimeout")
|
||||||
|
: err.message || t("network.chart.loadError"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false)
|
||||||
|
setIsInitialLoad(false)
|
||||||
|
if (refreshInterval > 0) refreshTimer = setTimeout(fetchMetrics, refreshInterval)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
setData(transformedData)
|
|
||||||
|
|
||||||
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
|
|
||||||
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
|
|
||||||
const netInBytes = (item.netin || 0) * intervalSeconds
|
|
||||||
return sum + (netInBytes / 1024 / 1024 / 1024)
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
|
|
||||||
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
|
|
||||||
const netOutBytes = (item.netout || 0) * intervalSeconds
|
|
||||||
return sum + (netOutBytes / 1024 / 1024 / 1024)
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
if (onTotalsCalculated) {
|
|
||||||
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInitialLoad) {
|
|
||||||
setIsInitialLoad(false)
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Error fetching network metrics:", err)
|
|
||||||
setError(err.message || t("network.chart.loadError"))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
void fetchMetrics()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
clearTimeout(refreshTimer)
|
||||||
|
}
|
||||||
|
}, [timeframe, interfaceName, refreshInterval, networkUnit])
|
||||||
|
|
||||||
const tickInterval = Math.ceil(data.length / 8)
|
const tickInterval = Math.ceil(data.length / 8)
|
||||||
|
|
||||||
@@ -272,6 +288,8 @@ export function NetworkTrafficChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<MetricsCacheNotice lastChecked={staleAt} />
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -327,5 +345,6 @@ export function NetworkTrafficChart({
|
|||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
|||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
import { useI18n } from "../lib/i18n/provider"
|
import { useI18n } from "../lib/i18n/provider"
|
||||||
|
import { MetricsCacheNotice } from "./metrics-cache-notice"
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||||
@@ -132,6 +133,7 @@ export function NodeMetricsCharts() {
|
|||||||
}>({})
|
}>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<MetricsError | null>(null)
|
const [error, setError] = useState<MetricsError | null>(null)
|
||||||
|
const [staleAt, setStaleAt] = useState<number | null>(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const [visibleLines, setVisibleLines] = useState({
|
const [visibleLines, setVisibleLines] = useState({
|
||||||
@@ -144,98 +146,124 @@ export function NodeMetricsCharts() {
|
|||||||
const hasMemoryFree = data.some(d => d.memoryFree > 0)
|
const hasMemoryFree = data.some(d => d.memoryFree > 0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMetrics()
|
let active = true
|
||||||
}, [timeframe, language])
|
let hasData = false
|
||||||
|
let lastChecked: number | null = null
|
||||||
const fetchMetrics = async () => {
|
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setStaleAt(null)
|
||||||
|
|
||||||
try {
|
const fetchMetrics = async () => {
|
||||||
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
|
let retry = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
|
||||||
if (!result.data || !Array.isArray(result.data)) {
|
if (!result.data || !Array.isArray(result.data)) {
|
||||||
console.error("Invalid data format - data is not an array:", result)
|
console.error("Invalid data format - data is not an array:", result)
|
||||||
throw new Error(t("overview.invalidMetricsData"))
|
throw new Error(t("overview.invalidMetricsData"))
|
||||||
}
|
|
||||||
|
|
||||||
if (result.data.length === 0) {
|
|
||||||
console.warn("No data points received")
|
|
||||||
setData([])
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.data[0]?.loadavg) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const transformedData = result.data.map((item: any) => {
|
|
||||||
const date = new Date(item.time * 1000)
|
|
||||||
let timeLabel = ""
|
|
||||||
|
|
||||||
if (timeframe === "hour") {
|
|
||||||
timeLabel = date.toLocaleString(language, {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
} else if (timeframe === "day") {
|
|
||||||
timeLabel = date.toLocaleString(language, {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
} else if (timeframe === "week") {
|
|
||||||
timeLabel = date.toLocaleString(language, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
timeLabel = date.toLocaleString(language, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
setError(null)
|
||||||
time: timeLabel,
|
lastChecked = result.last_checked || Date.now() / 1000
|
||||||
timestamp: item.time,
|
hasData = result.data.length > 0
|
||||||
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
|
setStaleAt(result.cache_status === "stale" ? lastChecked : null)
|
||||||
load: item.loadavg
|
retry = result.cache_status === "stale"
|
||||||
? typeof item.loadavg === "number"
|
|
||||||
? Number(item.loadavg.toFixed(2))
|
|
||||||
: Array.isArray(item.loadavg) && item.loadavg.length > 0
|
|
||||||
? Number(item.loadavg[0].toFixed(2))
|
|
||||||
: 0
|
|
||||||
: 0,
|
|
||||||
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
|
||||||
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
|
||||||
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
|
||||||
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setData(transformedData)
|
if (result.data.length === 0) {
|
||||||
setPeriodStats(result.period_stats || {})
|
console.warn("No data points received")
|
||||||
} catch (err: any) {
|
setData([])
|
||||||
console.error("Error fetching node metrics:", err)
|
setLoading(false)
|
||||||
// fetchApi attaches the parsed JSON body to err.body. The metrics
|
return
|
||||||
// endpoint enriches 503 responses with `details` (Proxmox-side
|
}
|
||||||
// diagnostic) and `suggestion` (how to fix). Pull them through so
|
|
||||||
// the user sees actionable text instead of a bare "503".
|
const transformedData = result.data.map((item: any) => {
|
||||||
const body = err?.body
|
const date = new Date(item.time * 1000)
|
||||||
setError({
|
let timeLabel = ""
|
||||||
headline: body?.error || err?.message || t("overview.metricsLoadError"),
|
|
||||||
details: body?.details,
|
if (timeframe === "hour") {
|
||||||
suggestion: body?.suggestion,
|
timeLabel = date.toLocaleString(language, {
|
||||||
})
|
hour: "2-digit",
|
||||||
} finally {
|
minute: "2-digit",
|
||||||
setLoading(false)
|
hour12: false,
|
||||||
|
})
|
||||||
|
} else if (timeframe === "day") {
|
||||||
|
timeLabel = date.toLocaleString(language, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
} else if (timeframe === "week") {
|
||||||
|
timeLabel = date.toLocaleString(language, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
timeLabel = date.toLocaleString(language, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: timeLabel,
|
||||||
|
timestamp: item.time,
|
||||||
|
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
|
||||||
|
load: item.loadavg
|
||||||
|
? typeof item.loadavg === "number"
|
||||||
|
? Number(item.loadavg.toFixed(2))
|
||||||
|
: Array.isArray(item.loadavg) && item.loadavg.length > 0
|
||||||
|
? Number(item.loadavg[0].toFixed(2))
|
||||||
|
: 0
|
||||||
|
: 0,
|
||||||
|
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||||
|
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||||
|
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||||
|
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setData(transformedData)
|
||||||
|
setPeriodStats(result.period_stats || {})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!active) return
|
||||||
|
retry = true
|
||||||
|
console.error("Error fetching node metrics:", err)
|
||||||
|
if (hasData) {
|
||||||
|
setStaleAt(lastChecked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// fetchApi attaches the parsed JSON body to err.body. The metrics
|
||||||
|
// endpoint enriches 503 responses with `details` (Proxmox-side
|
||||||
|
// diagnostic) and `suggestion` (how to fix). Pull them through so
|
||||||
|
// the user sees actionable text instead of a bare "503".
|
||||||
|
const body = err?.body
|
||||||
|
setError({
|
||||||
|
headline: body?.code === "metrics_timeout"
|
||||||
|
? t("overview.metricsTimeout")
|
||||||
|
: body?.error || err?.message || t("overview.metricsLoadError"),
|
||||||
|
details: body?.code === "metrics_timeout" ? undefined : body?.details,
|
||||||
|
suggestion: body?.suggestion,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false)
|
||||||
|
if (retry) retryTimer = setTimeout(fetchMetrics, 60000)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
void fetchMetrics()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
}
|
||||||
|
}, [timeframe, language])
|
||||||
|
|
||||||
const tickInterval = Math.ceil(data.length / 8)
|
const tickInterval = Math.ceil(data.length / 8)
|
||||||
|
|
||||||
@@ -358,6 +386,7 @@ export function NodeMetricsCharts() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<MetricsCacheNotice lastChecked={staleAt} />
|
||||||
{/* Timeframe Selector */}
|
{/* Timeframe Selector */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useT } from "../lib/i18n/provider"
|
|||||||
import {
|
import {
|
||||||
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
||||||
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
||||||
Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook,
|
Trash2, ChevronRight, TestTube2, Mail, Webhook,
|
||||||
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
|
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
|
||||||
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
|
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
|
||||||
Moon, Newspaper
|
Moon, Newspaper
|
||||||
@@ -77,6 +77,7 @@ interface NotificationConfig {
|
|||||||
ai_api_keys: Record<string, string> // Per-provider API keys
|
ai_api_keys: Record<string, string> // Per-provider API keys
|
||||||
ai_models: Record<string, string> // Per-provider selected models
|
ai_models: Record<string, string> // Per-provider selected models
|
||||||
ai_model: string // Current active model (for the selected provider)
|
ai_model: string // Current active model (for the selected provider)
|
||||||
|
notification_language: string
|
||||||
ai_language: string
|
ai_language: string
|
||||||
ai_ollama_url: string
|
ai_ollama_url: string
|
||||||
ai_openai_base_url: string
|
ai_openai_base_url: string
|
||||||
@@ -150,7 +151,7 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "other"].map(key => ({ key }))
|
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
|
||||||
|
|
||||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
||||||
|
|
||||||
@@ -199,6 +200,24 @@ const AI_PROVIDERS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const NOTIFICATION_LANGUAGES = [
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "de", label: "Deutsch" },
|
||||||
|
{ value: "es", label: "Español" },
|
||||||
|
{ value: "fr", label: "Français" },
|
||||||
|
{ value: "it", label: "Italiano" },
|
||||||
|
{ value: "pt", label: "Português" },
|
||||||
|
{ value: "sk", label: "Slovenčina" },
|
||||||
|
{ value: "sv", label: "Svenska" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalizeNotificationLanguage(notificationLanguage?: string, legacyAiLanguage?: string): string {
|
||||||
|
const runtimeLanguages = new Set(NOTIFICATION_LANGUAGES.map(language => language.value))
|
||||||
|
if (notificationLanguage && runtimeLanguages.has(notificationLanguage)) return notificationLanguage
|
||||||
|
if (legacyAiLanguage && runtimeLanguages.has(legacyAiLanguage)) return legacyAiLanguage
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
const AI_LANGUAGES = [
|
const AI_LANGUAGES = [
|
||||||
{ value: "en", label: "English" },
|
{ value: "en", label: "English" },
|
||||||
{ value: "sk", label: "Slovenčina" },
|
{ value: "sk", label: "Slovenčina" },
|
||||||
@@ -254,7 +273,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
|||||||
event_categories: {
|
event_categories: {
|
||||||
vm_ct: true, backup: true, resources: true, storage: true,
|
vm_ct: true, backup: true, resources: true, storage: true,
|
||||||
network: true, security: true, cluster: true, services: true,
|
network: true, security: true, cluster: true, services: true,
|
||||||
health: true, updates: true, other: true,
|
health: true, updates: true, hardware: true, system: true, other: true,
|
||||||
},
|
},
|
||||||
event_toggles: {},
|
event_toggles: {},
|
||||||
event_types_by_group: {},
|
event_types_by_group: {},
|
||||||
@@ -284,6 +303,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
|||||||
openrouter: "",
|
openrouter: "",
|
||||||
},
|
},
|
||||||
ai_model: "",
|
ai_model: "",
|
||||||
|
notification_language: "en",
|
||||||
ai_language: "en",
|
ai_language: "en",
|
||||||
ai_ollama_url: "http://localhost:11434",
|
ai_ollama_url: "http://localhost:11434",
|
||||||
ai_openai_base_url: "",
|
ai_openai_base_url: "",
|
||||||
@@ -323,7 +343,6 @@ export function NotificationSettings() {
|
|||||||
const [testing, setTesting] = useState<string | null>(null)
|
const [testing, setTesting] = useState<string | null>(null)
|
||||||
const [testResult, setTestResult] = useState<{ channel: string; success: boolean; message: string } | null>(null)
|
const [testResult, setTestResult] = useState<{ channel: string; success: boolean; message: string } | null>(null)
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
||||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
|
||||||
// Cleartext secrets cached only while the eye toggle is "on" for
|
// Cleartext secrets cached only while the eye toggle is "on" for
|
||||||
// that field. Settings GET returns "************" for everything in
|
// that field. Settings GET returns "************" for everything in
|
||||||
@@ -401,6 +420,10 @@ export function NotificationSettings() {
|
|||||||
ai_prompt_mode: data.config.ai_prompt_mode || "default",
|
ai_prompt_mode: data.config.ai_prompt_mode || "default",
|
||||||
ai_custom_prompt: data.config.ai_custom_prompt || "",
|
ai_custom_prompt: data.config.ai_custom_prompt || "",
|
||||||
ai_allow_suggestions: data.config.ai_allow_suggestions || "false",
|
ai_allow_suggestions: data.config.ai_allow_suggestions || "false",
|
||||||
|
notification_language: normalizeNotificationLanguage(
|
||||||
|
data.config.notification_language,
|
||||||
|
data.config.ai_language,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
// If ai_model exists but ai_models doesn't have it, save it
|
// If ai_model exists but ai_models doesn't have it, save it
|
||||||
if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) {
|
if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) {
|
||||||
@@ -485,13 +508,6 @@ export function NotificationSettings() {
|
|||||||
if (showHistory) loadHistory()
|
if (showHistory) loadHistory()
|
||||||
}, [showHistory, loadHistory])
|
}, [showHistory, loadHistory])
|
||||||
|
|
||||||
// Auto-expand AI section when AI is enabled
|
|
||||||
useEffect(() => {
|
|
||||||
if (config.ai_enabled) {
|
|
||||||
setShowAdvanced(true)
|
|
||||||
}
|
|
||||||
}, [config.ai_enabled])
|
|
||||||
|
|
||||||
const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => {
|
const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => {
|
||||||
setConfig(prev => {
|
setConfig(prev => {
|
||||||
const next = updater(prev)
|
const next = updater(prev)
|
||||||
@@ -834,6 +850,7 @@ export function NotificationSettings() {
|
|||||||
ai_enabled: String(cfg.ai_enabled),
|
ai_enabled: String(cfg.ai_enabled),
|
||||||
ai_provider: cfg.ai_provider,
|
ai_provider: cfg.ai_provider,
|
||||||
ai_model: cfg.ai_model,
|
ai_model: cfg.ai_model,
|
||||||
|
notification_language: normalizeNotificationLanguage(cfg.notification_language, cfg.ai_language),
|
||||||
ai_language: cfg.ai_language,
|
ai_language: cfg.ai_language,
|
||||||
ai_ollama_url: cfg.ai_ollama_url,
|
ai_ollama_url: cfg.ai_ollama_url,
|
||||||
ai_openai_base_url: cfg.ai_openai_base_url,
|
ai_openai_base_url: cfg.ai_openai_base_url,
|
||||||
@@ -894,13 +911,13 @@ export function NotificationSettings() {
|
|||||||
return flat
|
return flat
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const saveConfig = async (nextConfig: NotificationConfig): Promise<boolean> => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
try {
|
try {
|
||||||
// If notifications are being disabled, clean up PVE webhook first
|
// If notifications are being disabled, clean up PVE webhook first
|
||||||
const wasEnabled = originalConfig.enabled
|
const wasEnabled = originalConfig.enabled
|
||||||
const isNowDisabled = !config.enabled
|
const isNowDisabled = !nextConfig.enabled
|
||||||
|
|
||||||
if (wasEnabled && isNowDisabled) {
|
if (wasEnabled && isNowDisabled) {
|
||||||
try {
|
try {
|
||||||
@@ -910,26 +927,33 @@ export function NotificationSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = flattenConfig(config)
|
const payload = flattenConfig(nextConfig)
|
||||||
await fetchApi("/api/notifications/settings", {
|
await fetchApi("/api/notifications/settings", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
setOriginalConfig(config)
|
setConfig(nextConfig)
|
||||||
|
setOriginalConfig(nextConfig)
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
setEditMode(false)
|
setEditMode(false)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 3000)
|
setTimeout(() => setSaved(false), 3000)
|
||||||
loadStatus()
|
loadStatus()
|
||||||
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save notification settings:", err)
|
console.error("Failed to save notification settings:", err)
|
||||||
const msg = err instanceof Error ? err.message : t("settings.notifications.errors.saveFailed")
|
const msg = err instanceof Error ? err.message : t("settings.notifications.errors.saveFailed")
|
||||||
setSaveError(msg)
|
setSaveError(msg)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await saveConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setConfig(originalConfig)
|
setConfig(originalConfig)
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
@@ -2176,74 +2200,70 @@ export function NotificationSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Advanced: AI Enhancement ── */}
|
{/* ── Runtime notification language (independent of AI) ── */}
|
||||||
<div>
|
<div className="space-y-2 pb-3 border-b border-border/50">
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Globe className="h-4 w-4 text-green-400" />
|
||||||
className="flex items-center gap-2 text-sm text-foreground hover:bg-muted/60 rounded-md px-2 py-1.5 -mx-2 transition-colors"
|
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ui.notificationLanguage")}</Label>
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
||||||
>
|
|
||||||
{showAdvanced ? (
|
|
||||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<Sparkles className="h-4 w-4 text-purple-400" />
|
|
||||||
<span className="font-medium">{t("settings.notifications.ai.title")}</span>
|
|
||||||
{config.ai_enabled ? (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-purple-500/40 text-purple-400 ml-1">
|
|
||||||
{t("status.active")}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-border text-muted-foreground ml-1">
|
|
||||||
{t("settings.notifications.ui.optional")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{showAdvanced && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{editMode ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{t("actions.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="h-6 px-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !hasChanges}
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
|
||||||
{t("actions.save")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
|
|
||||||
onClick={() => setEditMode(true)}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-3 w-3" />
|
|
||||||
{t("actions.edit")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Select
|
||||||
|
value={normalizeNotificationLanguage(config.notification_language, config.ai_language)}
|
||||||
|
onValueChange={value => updateConfig(previous => ({ ...previous, notification_language: value }))}
|
||||||
|
disabled={!editMode}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder={t("settings.notifications.ui.selectNotificationLanguage")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{NOTIFICATION_LANGUAGES.map(language => (
|
||||||
|
<SelectItem key={language.value} value={language.value}>{language.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.notifications.ui.notificationLanguageHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showAdvanced && (
|
{/* ── AI Enhancement ── */}
|
||||||
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between py-1">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
|
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">{t("settings.notifications.ai.enhancedMessages")}</span>
|
<span className="text-sm font-medium">{t("settings.notifications.ai.title")}</span>
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.ai.enhancedMessagesDescription")}</p>
|
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.ai.enhancedMessagesDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{editMode ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
|
className="h-7 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="h-7 px-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !hasChanges}
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||||
|
{t("actions.save")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="h-7 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
|
||||||
|
onClick={() => setEditMode(true)}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3 w-3" />
|
||||||
|
{t("actions.edit")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||||
config.ai_enabled ? "bg-purple-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
|
config.ai_enabled ? "bg-purple-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
|
||||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
@@ -2255,10 +2275,12 @@ export function NotificationSettings() {
|
|||||||
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||||
config.ai_enabled ? "translate-x-[18px]" : "translate-x-0"
|
config.ai_enabled ? "translate-x-[18px]" : "translate-x-0"
|
||||||
}`} />
|
}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{config.ai_enabled && (
|
{config.ai_enabled && (
|
||||||
|
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||||
<>
|
<>
|
||||||
{/* Provider + Info button */}
|
{/* Provider + Info button */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -2611,10 +2633,12 @@ export function NotificationSettings() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
updateConfig(p => ({ ...p, ai_custom_prompt: customPromptDraft }))
|
const nextConfig = { ...config, ai_custom_prompt: customPromptDraft }
|
||||||
setEditingCustomPrompt(false)
|
if (await saveConfig(nextConfig)) {
|
||||||
handleSave()
|
setEditingCustomPrompt(false)
|
||||||
|
setCustomPromptDraft("")
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="h-7 px-2 text-xs flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
|
className="h-7 px-2 text-xs flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
|
||||||
>
|
>
|
||||||
@@ -2664,8 +2688,8 @@ export function NotificationSettings() {
|
|||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (file) {
|
if (file) {
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
updateConfig(p => ({ ...p, ai_custom_prompt: text }))
|
const nextConfig = { ...config, ai_custom_prompt: text }
|
||||||
handleSave()
|
await saveConfig(nextConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
@@ -2733,7 +2757,6 @@ export function NotificationSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Console output of a native OCI container: what the image's main process
|
||||||
|
// writes to stdout/stderr, kept on the host by liblxc. The first request
|
||||||
|
// brings the last N lines; while following, each poll sends back the byte
|
||||||
|
// offset and inode it holds and receives only what was appended. Nothing is
|
||||||
|
// cached — the backend reads the file on every request.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { Card, CardContent } from "./ui/card"
|
||||||
|
import { Badge } from "./ui/badge"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { Input } from "./ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
|
import { AlertCircle, Download, FileText, Loader2, Pause, Play, RefreshCw } from "lucide-react"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
|
interface ConsoleLogResponse {
|
||||||
|
ok: boolean
|
||||||
|
enabled: boolean
|
||||||
|
lines: string[]
|
||||||
|
offset: number
|
||||||
|
inode: number | null
|
||||||
|
reset?: boolean
|
||||||
|
head_truncated?: boolean
|
||||||
|
start_marks?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINE_OPTIONS = [100, 500, 1000] as const
|
||||||
|
const POLL_MS = 2000
|
||||||
|
|
||||||
|
// Written into the log by the container's pre-start hook at every start.
|
||||||
|
const START_MARK_RE = /^=== ProxMenux: container started (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ===$/
|
||||||
|
const lastStart = (lines: string[]) => {
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) if (START_MARK_RE.test(lines[i])) return i
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineTone = (line: string) => {
|
||||||
|
if (/\b(error|fatal|panic|critical|exception)\b/i.test(line)) return "text-red-400"
|
||||||
|
if (/\bwarn(ing)?\b/i.test(line)) return "text-amber-400"
|
||||||
|
return "text-foreground/90"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: boolean }) {
|
||||||
|
const t = useT()
|
||||||
|
const [lineCount, setLineCount] = useState<number>(500)
|
||||||
|
const [lines, setLines] = useState<string[]>([])
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [follow, setFollow] = useState(true)
|
||||||
|
const [filter, setFilter] = useState("")
|
||||||
|
const [sinceStart, setSinceStart] = useState(true)
|
||||||
|
const position = useRef<{ offset: number; inode: number | null } | null>(null)
|
||||||
|
const scroller = useRef<HTMLDivElement>(null)
|
||||||
|
const inFlight = useRef(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetchApi<ConsoleLogResponse>(
|
||||||
|
`/api/lxc/${vmid}/console-log?lines=${lineCount}${sinceStart ? "&since_start=1" : ""}`,
|
||||||
|
)
|
||||||
|
setEnabled(r.enabled)
|
||||||
|
setLines(r.lines || [])
|
||||||
|
position.current = { offset: r.offset, inode: r.inode }
|
||||||
|
if (r.error) setError(r.error)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [vmid, lineCount, sinceStart])
|
||||||
|
|
||||||
|
const poll = useCallback(async () => {
|
||||||
|
const pos = position.current
|
||||||
|
if (!pos || inFlight.current) return
|
||||||
|
inFlight.current = true
|
||||||
|
try {
|
||||||
|
const inode = pos.inode === null ? "" : `&inode=${pos.inode}`
|
||||||
|
const r = await fetchApi<ConsoleLogResponse>(
|
||||||
|
`/api/lxc/${vmid}/console-log?lines=${lineCount}&offset=${pos.offset}${inode}`,
|
||||||
|
)
|
||||||
|
position.current = { offset: r.offset, inode: r.inode }
|
||||||
|
if (r.reset) {
|
||||||
|
setLines(r.lines || [])
|
||||||
|
} else if (r.lines?.length) {
|
||||||
|
setLines((prev) => {
|
||||||
|
let next = prev.concat(r.lines)
|
||||||
|
// A restart while following opens a new session.
|
||||||
|
const mark = sinceStart ? lastStart(next) : -1
|
||||||
|
if (mark > 0) next = next.slice(mark)
|
||||||
|
return next.length > lineCount ? next.slice(next.length - lineCount) : next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A failed poll leaves the view as it is; the next one retries.
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false
|
||||||
|
}
|
||||||
|
}, [vmid, lineCount, sinceStart])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!follow || !running || !enabled || loading) return
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") poll()
|
||||||
|
}, POLL_MS)
|
||||||
|
poll()
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [follow, running, enabled, loading, poll])
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const needle = filter.trim().toLowerCase()
|
||||||
|
return needle ? lines.filter((l) => START_MARK_RE.test(l) || l.toLowerCase().includes(needle)) : lines
|
||||||
|
}, [lines, filter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (follow && scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
|
||||||
|
}, [shown, follow])
|
||||||
|
|
||||||
|
// Scrolling up to read stops following; the Follow button resumes it.
|
||||||
|
const onScroll = () => {
|
||||||
|
const el = scroller.current
|
||||||
|
if (!el || !follow) return
|
||||||
|
if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const download = () => {
|
||||||
|
const blob = new Blob([lines.join("\n") + "\n"], { type: "text/plain;charset=utf-8" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = `ct-${vmid}-console.log`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col min-h-0">
|
||||||
|
<Card className="border border-border bg-card/50 flex flex-col flex-1 min-h-0">
|
||||||
|
<CardContent className="p-4 flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-md bg-sky-500/10">
|
||||||
|
<FileText className="h-4 w-4 text-sky-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.consoleLog.title")}</h3>
|
||||||
|
{enabled && shown.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs h-5 ml-1">{shown.length}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{enabled && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={sinceStart ? "start" : "all"} onValueChange={(v) => setSinceStart(v === "start")}>
|
||||||
|
<SelectTrigger className="h-7 w-auto text-xs gap-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="start" className="text-xs">{t("vmLxc.consoleLog.sinceStart")}</SelectItem>
|
||||||
|
<SelectItem value="all" className="text-xs">{t("vmLxc.consoleLog.allHistory")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={String(lineCount)} onValueChange={(v) => setLineCount(Number(v))}>
|
||||||
|
<SelectTrigger className="h-7 w-auto text-xs gap-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LINE_OPTIONS.map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)} className="text-xs">
|
||||||
|
{t("vmLxc.consoleLog.lastLines", { count: n })}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{running && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs gap-1"
|
||||||
|
onClick={() => setFollow((f) => !f)}
|
||||||
|
aria-pressed={follow}
|
||||||
|
>
|
||||||
|
{follow ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
|
||||||
|
<span>{follow ? t("vmLxc.consoleLog.pause") : t("vmLxc.consoleLog.follow")}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={load} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||||
|
<span>{t("vmLxc.consoleLog.refresh")}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs gap-1"
|
||||||
|
onClick={download}
|
||||||
|
disabled={lines.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
<span>{t("vmLxc.consoleLog.download")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mb-3 shrink-0">
|
||||||
|
{t("vmLxc.consoleLog.description")}
|
||||||
|
{enabled && !running && <> {t("vmLxc.consoleLog.stopped")}</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<Input
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder={t("vmLxc.consoleLog.filter")}
|
||||||
|
className="h-8 text-xs mb-3 shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && lines.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
<span className="text-sm">{t("vmLxc.consoleLog.loading")}</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm shrink-0">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-500 mb-1">{t("vmLxc.consoleLog.readFailed")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground break-all">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !enabled ? (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm shrink-0">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium text-amber-500">{t("vmLxc.consoleLog.notAvailableTitle")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{t("vmLxc.consoleLog.notAvailableHint")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : lines.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-sm text-muted-foreground min-h-0">
|
||||||
|
{t("vmLxc.consoleLog.empty")}
|
||||||
|
<div className="text-xs mt-1">{t("vmLxc.consoleLog.emptyHint")}</div>
|
||||||
|
</div>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground min-h-0">
|
||||||
|
{t("vmLxc.consoleLog.noMatches")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={scroller}
|
||||||
|
onScroll={onScroll}
|
||||||
|
className="rounded-md border border-border bg-background/50 flex-1 overflow-y-auto min-h-0"
|
||||||
|
>
|
||||||
|
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
|
||||||
|
{shown.map((line, idx) => {
|
||||||
|
const mark = START_MARK_RE.exec(line)
|
||||||
|
if (mark) {
|
||||||
|
const when = new Date(`${mark[1]}T${mark[2]}`)
|
||||||
|
return (
|
||||||
|
<div key={idx} className="my-1.5 flex items-center gap-2 text-sky-400/90 select-none">
|
||||||
|
<span className="h-px flex-1 bg-sky-500/30" />
|
||||||
|
<span>{t("vmLxc.consoleLog.startedAt", {
|
||||||
|
date: Number.isNaN(when.getTime()) ? `${mark[1]} ${mark[2]}` : when.toLocaleString(),
|
||||||
|
})}</span>
|
||||||
|
<span className="h-px flex-1 bg-sky-500/30" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div key={idx} className={lineTone(line)}>{line || " "}</div>
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,11 +12,12 @@ import { VirtualMachines } from "./virtual-machines"
|
|||||||
import { AppsDashboard } from "./apps-dashboard"
|
import { AppsDashboard } from "./apps-dashboard"
|
||||||
import Hardware from "./hardware"
|
import Hardware from "./hardware"
|
||||||
import { SystemLogs } from "./system-logs"
|
import { SystemLogs } from "./system-logs"
|
||||||
|
import { AuditReport } from "./audit-report"
|
||||||
import { Settings } from "./settings"
|
import { Settings } from "./settings"
|
||||||
import { Security } from "./security"
|
import { Security } from "./security"
|
||||||
import { Profile } from "./profile"
|
import { Profile } from "./profile"
|
||||||
import { About } from "./about"
|
import { About } from "./about"
|
||||||
import { HostBackup } from "./host-backup"
|
import { BackupTab } from "./backup-tab"
|
||||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||||
import { HealthStatusModal } from "./health-status-modal"
|
import { HealthStatusModal } from "./health-status-modal"
|
||||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
Terminal,
|
Terminal,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
ClipboardCheck,
|
||||||
Info,
|
Info,
|
||||||
DatabaseBackup,
|
DatabaseBackup,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -237,8 +239,7 @@ export function ProxmoxDashboard() {
|
|||||||
fetchHealthInfoCount()
|
fetchHealthInfoCount()
|
||||||
fetchUpdateStatus()
|
fetchUpdateStatus()
|
||||||
|
|
||||||
// En overview: cada 30 segundos para actualización frecuente del estado de salud
|
// Overview polls health every 30 s; the other tabs every 60 s.
|
||||||
// En otras tabs: cada 60 segundos para reducir carga
|
|
||||||
let interval: ReturnType<typeof setInterval> | null = null
|
let interval: ReturnType<typeof setInterval> | null = null
|
||||||
let healthInterval: ReturnType<typeof setInterval> | null = null
|
let healthInterval: ReturnType<typeof setInterval> | null = null
|
||||||
if (activeTab === "overview") {
|
if (activeTab === "overview") {
|
||||||
@@ -399,6 +400,7 @@ export function ProxmoxDashboard() {
|
|||||||
case "terminal": return t("navigation.terminal")
|
case "terminal": return t("navigation.terminal")
|
||||||
case "logs": return t("navigation.systemLogs")
|
case "logs": return t("navigation.systemLogs")
|
||||||
case "security": return t("navigation.security")
|
case "security": return t("navigation.security")
|
||||||
|
case "audit": return t("navigation.audit")
|
||||||
case "settings": return t("navigation.settings")
|
case "settings": return t("navigation.settings")
|
||||||
case "about": return t("navigation.about")
|
case "about": return t("navigation.about")
|
||||||
case "profile": return t("navigation.profile")
|
case "profile": return t("navigation.profile")
|
||||||
@@ -618,6 +620,7 @@ export function ProxmoxDashboard() {
|
|||||||
const ADMIN_ITEMS = [
|
const ADMIN_ITEMS = [
|
||||||
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
|
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
|
||||||
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
|
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
|
||||||
|
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
|
||||||
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
||||||
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
||||||
]
|
]
|
||||||
@@ -774,6 +777,7 @@ export function ProxmoxDashboard() {
|
|||||||
<React.Fragment key="admin">
|
<React.Fragment key="admin">
|
||||||
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
||||||
{btn("security", ShieldCheck, t("navigation.security"))}
|
{btn("security", ShieldCheck, t("navigation.security"))}
|
||||||
|
{btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||||
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
||||||
{btn("about", Info, t("navigation.about"))}
|
{btn("about", Info, t("navigation.about"))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -840,8 +844,12 @@ export function ProxmoxDashboard() {
|
|||||||
<SystemLogs key={`logs-${componentKey}`} />
|
<SystemLogs key={`logs-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
|
||||||
|
<AuditReport key={`audit-${componentKey}`} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||||
<HostBackup key={`backup-${componentKey}`} />
|
<BackupTab key={`backup-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="terminal" className="mt-0">
|
<TabsContent value="terminal" className="mt-0">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages } from "lucide-react"
|
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages, Package, ScrollText, ArrowUpCircle } from "lucide-react"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
import { APP_VERSION } from "../lib/version"
|
import { APP_VERSION } from "../lib/version"
|
||||||
@@ -305,34 +305,49 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
|||||||
// that haven't been curated by hand.
|
// that haven't been curated by hand.
|
||||||
const CURRENT_VERSION_FEATURES = [
|
const CURRENT_VERSION_FEATURES = [
|
||||||
{
|
{
|
||||||
icon: <Sparkles className="h-5 w-5" />,
|
icon: <Package className="h-5 w-5" />,
|
||||||
key: "releaseNotes.currentFeatures.aiCustomEndpoint",
|
key: "releaseNotes.currentFeatures.ociManager",
|
||||||
text: "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
text: "OCI manager Apps (beta) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Wrench className="h-5 w-5" />,
|
icon: <ScrollText className="h-5 w-5" />,
|
||||||
key: "releaseNotes.currentFeatures.secureGatewayArch",
|
key: "releaseNotes.currentFeatures.ociInMonitor",
|
||||||
text: "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
text: "OCI applications in the Monitor — the App tab recognises them, the Updates tab updates and modifies them as the menu does, and a Logs tab follows the console output of the application live.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Bell className="h-5 w-5" />,
|
icon: <Cpu className="h-5 w-5" />,
|
||||||
key: "releaseNotes.currentFeatures.atomicNotifications",
|
key: "releaseNotes.currentFeatures.ociAcceleration",
|
||||||
text: "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
text: "Hardware acceleration for OCI applications — the installer offers only the profiles the host can run: NVIDIA, Intel and AMD GPUs and, for Frigate, an Intel NPU or a Coral. The NVIDIA Container Toolkit installs with the driver.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||||
key: "releaseNotes.currentFeatures.borgSshPort",
|
key: "releaseNotes.currentFeatures.ociRecovery",
|
||||||
text: "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
text: "An OCI application travels with its backup — restored on this host or on another one, it is registered and managed again, and in a cluster it starts on whichever node it moves to.",
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Shield className="h-5 w-5" />,
|
|
||||||
key: "releaseNotes.currentFeatures.githubToken",
|
|
||||||
text: "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <RefreshCw className="h-5 w-5" />,
|
icon: <RefreshCw className="h-5 w-5" />,
|
||||||
key: "releaseNotes.currentFeatures.replicationContext",
|
key: "releaseNotes.currentFeatures.ociWatchdog",
|
||||||
text: "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.).",
|
text: "Watchdog for OCI applications — an application that stops on its own is started again and a notification says so. It is not restarted when the user stops it, or while a backup or a migration is in progress.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Server className="h-5 w-5" />,
|
||||||
|
key: "releaseNotes.currentFeatures.shareCoveX",
|
||||||
|
text: "ShareCoveX joins the catalog — a file server with a web panel that shares host directories or container volumes over SMB, NFS and Time Machine.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Calendar className="h-5 w-5" />,
|
||||||
|
key: "releaseNotes.currentFeatures.guestBackupJobs",
|
||||||
|
text: "Backup jobs of VMs and LXCs — the Backup tab lists the scheduled jobs of Proxmox and creates, edits, enables and runs them; they are the same ones as Datacenter → Backup (contributed by @MattiaC46).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Languages className="h-5 w-5" />,
|
||||||
|
key: "releaseNotes.currentFeatures.notificationLanguage",
|
||||||
|
text: "Notifications in your language — titles, bodies and daily digests are written in the language chosen for notifications (contributed by @Vaso73).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Shield className="h-5 w-5" />,
|
||||||
|
key: "releaseNotes.currentFeatures.auditReviewDate",
|
||||||
|
text: "Audit & Report — an accepted finding can carry a review date that brings it back without withdrawing the decision, and the comparison says whether a finding got worse, got better or held (with @Vaso73).",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -287,8 +287,7 @@ const initMessage = {
|
|||||||
// Snapshot the open-state at call time. After the dynamic xterm
|
// Snapshot the open-state at call time. After the dynamic xterm
|
||||||
// imports resolve, bail out if the modal has since been closed —
|
// imports resolve, bail out if the modal has since been closed —
|
||||||
// otherwise we attach a Terminal to a stale ref and open a WS that
|
// otherwise we attach a Terminal to a stale ref and open a WS that
|
||||||
// nobody reads. Audit Tier 6 — useEffect con `import("xterm")` sin
|
// nobody reads.
|
||||||
// cancelación.
|
|
||||||
const wasOpenAtCall = isOpenRef.current
|
const wasOpenAtCall = isOpenRef.current
|
||||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||||
import("xterm").then((mod) => mod.Terminal),
|
import("xterm").then((mod) => mod.Terminal),
|
||||||
|
|||||||
@@ -162,6 +162,19 @@ export function SecureGatewaySetup() {
|
|||||||
loadInitialData()
|
loadInitialData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// The gateway was updated from the Updates tab of its container: read the
|
||||||
|
// update state and the status again so this card agrees with it.
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent).detail || {}
|
||||||
|
if (detail.appId !== "secure-gateway" || detail.source === "card") return
|
||||||
|
void loadUpdateInfo(true)
|
||||||
|
void loadStatus()
|
||||||
|
}
|
||||||
|
window.addEventListener("proxmenuxManagedAppUpdated", refresh)
|
||||||
|
return () => window.removeEventListener("proxmenuxManagedAppUpdated", refresh)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadInitialData = async () => {
|
const loadInitialData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
@@ -288,6 +301,8 @@ export function SecureGatewaySetup() {
|
|||||||
// Status may briefly show "stopped" if tailscale was restarted —
|
// Status may briefly show "stopped" if tailscale was restarted —
|
||||||
// refresh that too so the action buttons render the right state.
|
// refresh that too so the action buttons render the right state.
|
||||||
await loadStatus()
|
await loadStatus()
|
||||||
|
// The Updates tab of the gateway container reads it again.
|
||||||
|
window.dispatchEvent(new CustomEvent("proxmenuxManagedAppUpdated", { detail: { appId: "secure-gateway", source: "card" } }))
|
||||||
} else {
|
} else {
|
||||||
setUpdateError(res?.message || sg("errors.updateFailed"))
|
setUpdateError(res?.message || sg("errors.updateFailed"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useI18n } from "../lib/i18n/provider"
|
|||||||
interface ApiTokenEntry {
|
interface ApiTokenEntry {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
scope?: "read_only" | "full_admin"
|
||||||
token_prefix: string
|
token_prefix: string
|
||||||
created_at: string
|
created_at: string
|
||||||
expires_at: string
|
expires_at: string
|
||||||
@@ -81,6 +82,12 @@ export function Security() {
|
|||||||
if (normalized.includes("invalid 2fa code")) {
|
if (normalized.includes("invalid 2fa code")) {
|
||||||
return st("errors.invalid2faCode")
|
return st("errors.invalid2faCode")
|
||||||
}
|
}
|
||||||
|
if (normalized.includes("2fa code required")) {
|
||||||
|
return st("errors.enter2faOrBackup")
|
||||||
|
}
|
||||||
|
if (normalized.includes("current password is incorrect")) {
|
||||||
|
return st("errors.invalidPassword")
|
||||||
|
}
|
||||||
if (normalized.includes("invalid password")) {
|
if (normalized.includes("invalid password")) {
|
||||||
return st("errors.invalidPassword")
|
return st("errors.invalidPassword")
|
||||||
}
|
}
|
||||||
@@ -103,6 +110,7 @@ export function Security() {
|
|||||||
const [currentPassword, setCurrentPassword] = useState("")
|
const [currentPassword, setCurrentPassword] = useState("")
|
||||||
const [newPassword, setNewPassword] = useState("")
|
const [newPassword, setNewPassword] = useState("")
|
||||||
const [confirmNewPassword, setConfirmNewPassword] = useState("")
|
const [confirmNewPassword, setConfirmNewPassword] = useState("")
|
||||||
|
const [changePasswordTotpCode, setChangePasswordTotpCode] = useState("")
|
||||||
|
|
||||||
const [show2FASetup, setShow2FASetup] = useState(false)
|
const [show2FASetup, setShow2FASetup] = useState(false)
|
||||||
const [show2FADisable, setShow2FADisable] = useState(false)
|
const [show2FADisable, setShow2FADisable] = useState(false)
|
||||||
@@ -123,6 +131,10 @@ export function Security() {
|
|||||||
const [loadingTokens, setLoadingTokens] = useState(false)
|
const [loadingTokens, setLoadingTokens] = useState(false)
|
||||||
const [revokingTokenId, setRevokingTokenId] = useState<string | null>(null)
|
const [revokingTokenId, setRevokingTokenId] = useState<string | null>(null)
|
||||||
const [tokenName, setTokenName] = useState("")
|
const [tokenName, setTokenName] = useState("")
|
||||||
|
// API tokens default to read-only (the safe choice for dashboards that
|
||||||
|
// only read metrics). full_admin is opt-in and carries a warning: it
|
||||||
|
// can do everything the logged-in user can (host power, updates, terminal).
|
||||||
|
const [tokenScope, setTokenScope] = useState<"read_only" | "full_admin">("read_only")
|
||||||
|
|
||||||
// Proxmox Firewall state
|
// Proxmox Firewall state
|
||||||
const [firewallLoading, setFirewallLoading] = useState(true)
|
const [firewallLoading, setFirewallLoading] = useState(true)
|
||||||
@@ -370,18 +382,19 @@ export function Security() {
|
|||||||
setSuccess("")
|
setSuccess("")
|
||||||
setShowLynisUninstallConfirm(false)
|
setShowLynisUninstallConfirm(false)
|
||||||
try {
|
try {
|
||||||
const data = await fetchApi("/api/security/lynis/uninstall", {
|
const data = await fetchApi<{ success: boolean; partial?: boolean; outcome?: string }>("/api/security/lynis/uninstall", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
if (data.success) {
|
if (data.success === true) {
|
||||||
setSuccess(st("messages.lynisUninstalled"))
|
setSuccess(st("messages.lynisUninstalled"))
|
||||||
loadSecurityTools()
|
loadSecurityTools()
|
||||||
setLynisReport(null)
|
setLynisReport(null)
|
||||||
} else {
|
} else {
|
||||||
setError(data.message || st("errors.lynisUninstallFailed"))
|
setError(data.outcome === "no_files" ? st("errors.lynisUninstallNoFiles") : data.partial ? st("errors.lynisRemovalPartial") : st("errors.lynisRemovalUnconfirmed"))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : st("errors.lynisUninstallFailed"))
|
const body = err instanceof Error && "body" in err ? err.body as { partial?: boolean } | undefined : undefined
|
||||||
|
setError(body?.partial ? st("errors.lynisRemovalPartial") : st("errors.lynisRemovalUnconfirmed"))
|
||||||
} finally {
|
} finally {
|
||||||
setUninstallingLynis(false)
|
setUninstallingLynis(false)
|
||||||
}
|
}
|
||||||
@@ -976,6 +989,11 @@ export function Security() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totpEnabled && !changePasswordTotpCode.trim()) {
|
||||||
|
setError(st("errors.enter2faOrBackup"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pwError = validatePasswordStrength(newPassword, t)
|
const pwError = validatePasswordStrength(newPassword, t)
|
||||||
if (pwError) {
|
if (pwError) {
|
||||||
setError(pwError)
|
setError(pwError)
|
||||||
@@ -992,8 +1010,9 @@ export function Security() {
|
|||||||
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
|
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
current_password: currentPassword,
|
old_password: currentPassword,
|
||||||
new_password: newPassword,
|
new_password: newPassword,
|
||||||
|
...(totpEnabled ? { totp_code: changePasswordTotpCode.trim() } : {}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1012,6 +1031,7 @@ export function Security() {
|
|||||||
setCurrentPassword("")
|
setCurrentPassword("")
|
||||||
setNewPassword("")
|
setNewPassword("")
|
||||||
setConfirmNewPassword("")
|
setConfirmNewPassword("")
|
||||||
|
setChangePasswordTotpCode("")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : st("errors.changePasswordFailed"))
|
setError(err instanceof Error ? err.message : st("errors.changePasswordFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1138,6 +1158,7 @@ export function Security() {
|
|||||||
password: tokenPassword,
|
password: tokenPassword,
|
||||||
totp_token: totpEnabled ? tokenTotpCode : undefined,
|
totp_token: totpEnabled ? tokenTotpCode : undefined,
|
||||||
token_name: tokenName || st("apiTokens.defaultName"),
|
token_name: tokenName || st("apiTokens.defaultName"),
|
||||||
|
scope: tokenScope,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1156,6 +1177,7 @@ export function Security() {
|
|||||||
setTokenPassword("")
|
setTokenPassword("")
|
||||||
setTokenTotpCode("")
|
setTokenTotpCode("")
|
||||||
setTokenName("")
|
setTokenName("")
|
||||||
|
setTokenScope("read_only")
|
||||||
loadApiTokens()
|
loadApiTokens()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : st("errors.generateTokenRetry"))
|
setError(err instanceof Error ? err.message : st("errors.generateTokenRetry"))
|
||||||
@@ -1332,10 +1354,12 @@ export function Security() {
|
|||||||
.top-bar-title { font-weight: 600; }
|
.top-bar-title { font-weight: 600; }
|
||||||
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
|
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
|
||||||
.top-bar button {
|
.top-bar button {
|
||||||
background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
|
background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px;
|
||||||
font-size: 14px; font-weight: 600; cursor: pointer;
|
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 button:hover { background: #0891b2; }
|
||||||
|
.top-bar .btn-group { display: flex; gap: 8px; }
|
||||||
|
.top-bar button svg { width: 18px; height: 18px; display: block; }
|
||||||
.hide-mobile { }
|
.hide-mobile { }
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.top-bar { padding: 12px 24px; }
|
.top-bar { padding: 12px 24px; }
|
||||||
@@ -1390,6 +1414,9 @@ export function Security() {
|
|||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||||
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||||
|
@media screen and (max-width: 639px) {
|
||||||
|
.grid-3, .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
|
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
|
||||||
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
|
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||||
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
|
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||||
@@ -1458,7 +1485,10 @@ function pmxPrint(){
|
|||||||
<strong>${st("lynis.report.brandTitle")}</strong>
|
<strong>${st("lynis.report.brandTitle")}</strong>
|
||||||
<span id="pmx-print-hint" class="hide-mobile" style="font-size:11px;opacity:0.7;">${st("lynis.report.reviewHint")}</span>
|
<span id="pmx-print-hint" class="hide-mobile" style="font-size:11px;opacity:0.7;">${st("lynis.report.reviewHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="pmxPrint()">${st("lynis.printSavePdf")}</button>
|
<div class="btn-group">
|
||||||
|
<button onclick="pmxPrint()" 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="pmxPrint()" 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>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -2012,6 +2042,22 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{totpEnabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="change-password-totp">{st("twoFactor.codeOrBackup")}</Label>
|
||||||
|
<Input
|
||||||
|
id="change-password-totp"
|
||||||
|
type="text"
|
||||||
|
inputMode="text"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder={st("twoFactor.codeOrBackupPlaceholder")}
|
||||||
|
value={changePasswordTotpCode}
|
||||||
|
onChange={(e) => setChangePasswordTotpCode(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePassword}
|
||||||
@@ -2021,7 +2067,13 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
{loading ? st("auth.changing") : st("auth.changePassword")}
|
{loading ? st("auth.changing") : st("auth.changePassword")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowChangePassword(false)}
|
onClick={() => {
|
||||||
|
setShowChangePassword(false)
|
||||||
|
setCurrentPassword("")
|
||||||
|
setNewPassword("")
|
||||||
|
setConfirmNewPassword("")
|
||||||
|
setChangePasswordTotpCode("")
|
||||||
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -2442,6 +2494,44 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{st("apiTokens.scope.label")}</Label>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTokenScope("read_only")}
|
||||||
|
disabled={generatingToken}
|
||||||
|
className={`rounded-md border px-3 py-2 text-left transition-colors ${
|
||||||
|
tokenScope === "read_only"
|
||||||
|
? "border-blue-500 bg-blue-500/10"
|
||||||
|
: "border-border hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-medium">{st("apiTokens.scope.readOnly")}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{st("apiTokens.scope.readOnlyHint")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTokenScope("full_admin")}
|
||||||
|
disabled={generatingToken}
|
||||||
|
className={`rounded-md border px-3 py-2 text-left transition-colors ${
|
||||||
|
tokenScope === "full_admin"
|
||||||
|
? "border-amber-500 bg-amber-500/10"
|
||||||
|
: "border-border hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-medium">{st("apiTokens.scope.fullAdmin")}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{st("apiTokens.scope.fullAdminHint")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tokenScope === "full_admin" && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-2.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<TriangleAlert className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{st("apiTokens.scope.fullAdminWarning")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="token-password">{st("auth.password")}</Label>
|
<Label htmlFor="token-password">{st("auth.password")}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -2614,6 +2704,15 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<p className="text-sm font-medium truncate">{token.name}</p>
|
<p className="text-sm font-medium truncate">{token.name}</p>
|
||||||
|
{token.scope === "read_only" ? (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-500/15 text-blue-500 border border-blue-500/30 whitespace-nowrap">
|
||||||
|
{st("apiTokens.scope.readOnly")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500/15 text-amber-500 border border-amber-500/30 whitespace-nowrap">
|
||||||
|
{st("apiTokens.scope.fullAdmin")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isInvalid && (
|
{isInvalid && (
|
||||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/15 text-red-500 border border-red-500/30 whitespace-nowrap">
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/15 text-red-500 border border-red-500/30 whitespace-nowrap">
|
||||||
{st("apiTokens.invalidRegenerate")}
|
{st("apiTokens.invalidRegenerate")}
|
||||||
@@ -3174,7 +3273,7 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
<span className="text-xs text-foreground font-mono font-medium">{rule.dport || "*"}</span>
|
<span className="text-xs text-foreground font-mono font-medium">{rule.dport || "*"}</span>
|
||||||
<span className={`text-[10px] px-1 py-0 rounded flex-shrink-0 ${
|
<span className={`text-[10px] px-1 py-0 rounded flex-shrink-0 ${
|
||||||
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400"
|
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400"
|
||||||
}`}>{rule.source_file}</span>
|
}`}>{rule.source_file === "cluster" ? st("firewall.cluster") : st("firewall.host")}</span>
|
||||||
</div>
|
</div>
|
||||||
{comment && (
|
{comment && (
|
||||||
<p className="text-[10px] text-muted-foreground truncate mt-0.5">{comment}</p>
|
<p className="text-[10px] text-muted-foreground truncate mt-0.5">{comment}</p>
|
||||||
@@ -3192,7 +3291,7 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
<span className={`hidden sm:block text-[10px] px-1.5 py-0.5 rounded text-center ${
|
<span className={`hidden sm:block text-[10px] px-1.5 py-0.5 rounded text-center ${
|
||||||
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400"
|
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400"
|
||||||
}`}>
|
}`}>
|
||||||
{rule.source_file}
|
{rule.source_file === "cluster" ? st("firewall.cluster") : st("firewall.host")}
|
||||||
</span>
|
</span>
|
||||||
{/* Expand/Delete */}
|
{/* Expand/Delete */}
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
@@ -4279,14 +4378,25 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (confirm(st("confirm.deleteAuditReport"))) {
|
if (confirm(st("confirm.deleteAuditReport"))) {
|
||||||
fetchApi("/api/security/lynis/report", { method: "DELETE" })
|
setError("")
|
||||||
.then(() => {
|
setSuccess("")
|
||||||
setLynisReport(null)
|
fetchApi<{ success: boolean; outcome?: string; partial?: boolean }>("/api/security/lynis/report", { method: "DELETE" })
|
||||||
setLynisShowReport(false)
|
.then((data) => {
|
||||||
setSuccess(st("messages.reportDeleted"))
|
if (data.success === true) {
|
||||||
loadSecurityTools()
|
setLynisReport(null)
|
||||||
|
setLynisShowReport(false)
|
||||||
|
setSuccess(st("messages.reportDeleted"))
|
||||||
|
loadSecurityTools()
|
||||||
|
} else if (data.outcome === "no_files") {
|
||||||
|
setError(st("errors.deleteReportNoFiles"))
|
||||||
|
} else {
|
||||||
|
setError(data.partial ? st("errors.lynisRemovalPartial") : st("errors.lynisRemovalUnconfirmed"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const body = err instanceof Error && "body" in err ? err.body as { partial?: boolean } | undefined : undefined
|
||||||
|
setError(body?.partial ? st("errors.lynisRemovalPartial") : st("errors.lynisRemovalUnconfirmed"))
|
||||||
})
|
})
|
||||||
.catch(() => setError(st("errors.deleteReportFailed")))
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-7 px-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 ml-2 sm:ml-0"
|
className="h-7 px-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 ml-2 sm:ml-0"
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react"
|
import { Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { NotificationSettings } from "./notification-settings"
|
import { NotificationSettings } from "./notification-settings"
|
||||||
import { HealthThresholds } from "./health-thresholds"
|
import { HealthThresholds } from "./health-thresholds"
|
||||||
import { LxcUpdateDetection } from "./lxc-update-detection"
|
import { LxcUpdateDetection } from "./lxc-update-detection"
|
||||||
|
import { DiskExclusions } from "./disk-exclusions"
|
||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
import { Switch } from "./ui/switch"
|
import { Switch } from "./ui/switch"
|
||||||
@@ -1862,6 +1863,8 @@ export function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<DiskExclusions />
|
||||||
|
|
||||||
{/* Health Monitor Thresholds — placed above Notifications because the
|
{/* Health Monitor Thresholds — placed above Notifications because the
|
||||||
values configured here drive what triggers the notifications below. */}
|
values configured here drive what triggers the notifications below. */}
|
||||||
<HealthThresholds />
|
<HealthThresholds />
|
||||||
@@ -2034,7 +2037,7 @@ export function Settings() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wrench className="h-5 w-5 text-orange-500" />
|
<Sparkles className="h-5 w-5 text-orange-500" />
|
||||||
<CardTitle>{t("settings.optimizations.title")}</CardTitle>
|
<CardTitle>{t("settings.optimizations.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>{t("settings.optimizations.description")}</CardDescription>
|
<CardDescription>{t("settings.optimizations.description")}</CardDescription>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power } from "lucide-react"
|
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, Moon, EyeOff } from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
@@ -70,6 +70,8 @@ interface DiskInfo {
|
|||||||
// badge AND to suppress the (stale) temperature value, so the
|
// badge AND to suppress the (stale) temperature value, so the
|
||||||
// operator understands the graph is frozen on purpose — issue #232.
|
// operator understands the graph is frozen on purpose — issue #232.
|
||||||
standby?: boolean
|
standby?: boolean
|
||||||
|
idle?: boolean
|
||||||
|
excluded?: boolean
|
||||||
health: string
|
health: string
|
||||||
power_on_hours?: number
|
power_on_hours?: number
|
||||||
smart_status?: string
|
smart_status?: string
|
||||||
@@ -114,7 +116,6 @@ interface DiskObservation {
|
|||||||
occurrence_count: number
|
occurrence_count: number
|
||||||
raw_message: string
|
raw_message: string
|
||||||
severity: string
|
severity: string
|
||||||
dismissed: boolean
|
|
||||||
device_name: string
|
device_name: string
|
||||||
serial: string
|
serial: string
|
||||||
model: string
|
model: string
|
||||||
@@ -434,7 +435,21 @@ export function StorageOverview() {
|
|||||||
// spun-down drive. Centralised here because the same pattern shows up
|
// spun-down drive. Centralised here because the same pattern shows up
|
||||||
// in 4 different disk-list views (system / data / pool / other) and we
|
// in 4 different disk-list views (system / data / pool / other) and we
|
||||||
// want them all to behave identically — issue #232 fix.
|
// want them all to behave identically — issue #232 fix.
|
||||||
const renderDiskTempOrStandby = (disk: DiskInfo) => {
|
// Why a disk shows no live temperature, when it doesn't: excluded by the
|
||||||
|
// user, parked, or idle and deliberately not read. Shared by every view
|
||||||
|
// that paints a disk's temperature, so they cannot disagree.
|
||||||
|
const renderNoReadingBadge = (disk: DiskInfo) => {
|
||||||
|
if (disk.excluded) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="bg-muted text-muted-foreground border-border gap-1"
|
||||||
|
title={t("storage.diskExcludedTitle")}
|
||||||
|
>
|
||||||
|
<EyeOff className="h-3 w-3" />
|
||||||
|
{t("storage.diskExcluded")}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (disk.standby) {
|
if (disk.standby) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -446,6 +461,23 @@ export function StorageOverview() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (disk.idle) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="bg-muted text-muted-foreground border-border gap-1"
|
||||||
|
title={t("storage.idleTitle")}
|
||||||
|
>
|
||||||
|
<Moon className="h-3 w-3" />
|
||||||
|
{t("storage.idle")}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDiskTempOrStandby = (disk: DiskInfo) => {
|
||||||
|
const noReading = renderNoReadingBadge(disk)
|
||||||
|
if (noReading) return noReading
|
||||||
if (disk.temperature > 0) {
|
if (disk.temperature > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -533,14 +565,8 @@ export function StorageOverview() {
|
|||||||
{/* Header line 2: size + temperature/standby. */}
|
{/* Header line 2: size + temperature/standby. */}
|
||||||
<div className="flex items-center justify-between gap-3 mt-1">
|
<div className="flex items-center justify-between gap-3 mt-1">
|
||||||
<span className="text-sm text-muted-foreground">{disk.size_formatted}</span>
|
<span className="text-sm text-muted-foreground">{disk.size_formatted}</span>
|
||||||
{disk.standby ? (
|
{renderNoReadingBadge(disk) ? (
|
||||||
<Badge
|
renderNoReadingBadge(disk)
|
||||||
className="bg-blue-500/10 text-blue-300 border-blue-500/30 gap-1"
|
|
||||||
title={t("storage.standbyTitle")}
|
|
||||||
>
|
|
||||||
<Power className="h-3 w-3" />
|
|
||||||
{t("storage.standby")}
|
|
||||||
</Badge>
|
|
||||||
) : disk.temperature > 0 ? (
|
) : disk.temperature > 0 ? (
|
||||||
<span
|
<span
|
||||||
className={`text-base font-semibold ${getTempColor(
|
className={`text-base font-semibold ${getTempColor(
|
||||||
@@ -938,37 +964,25 @@ export function StorageOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDiskHealthBreakdown = () => {
|
const getDiskHealthBreakdown = () => {
|
||||||
if (!storageData || !storageData.disks) {
|
if (!storageData || !Array.isArray(storageData.disks)) {
|
||||||
return { normal: 0, warning: 0, critical: 0 }
|
return { normal: 0, warning: 0, critical: 0, unavailable: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
let normal = 0
|
let normal = 0
|
||||||
let warning = 0
|
let warning = 0
|
||||||
let critical = 0
|
let critical = 0
|
||||||
|
let unavailable = 0
|
||||||
|
|
||||||
storageData.disks.forEach((disk) => {
|
storageData.disks.forEach((disk) => {
|
||||||
if (disk.temperature === 0) {
|
// Match disk rows: policy-suppressed cached readings are not live temperatures.
|
||||||
// No temperature reading available — count as normal so a
|
if (disk.standby || disk.idle || disk.excluded || typeof disk.temperature !== "number" ||
|
||||||
// missing sensor doesn't inflate the warning count.
|
!Number.isFinite(disk.temperature) || disk.temperature <= 0) {
|
||||||
normal++
|
unavailable++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse the exact threshold lookup that the per-disk badge
|
// Only usable readings enter the existing configurable threshold lookup.
|
||||||
// (`getTempColor`) uses, so the green / amber / red colour in
|
// Keep the resolver and defaults shared with the per-disk temperature badge.
|
||||||
// the disk card and the "X normal, Y warning, Z critical" tally
|
|
||||||
// at the top of the page always agree.
|
|
||||||
//
|
|
||||||
// Previously this breakdown carried its own hardcoded ladder
|
|
||||||
// (HDD ≤45 normal, ≤55 warning, …) which was far stricter than
|
|
||||||
// the configurable defaults from `useDiskTempThresholds`
|
|
||||||
// (HDD warn 60, hot 65). A disk at 48 °C therefore showed a
|
|
||||||
// green badge but was counted as "warning" in the summary —
|
|
||||||
// exactly the case the user reported. Driving both from the
|
|
||||||
// same source (`dtThresholds`) means the user-tunable values
|
|
||||||
// under Settings → Health Monitor Thresholds → Disk temperature
|
|
||||||
// now apply to the breakdown as well, and the displayed colour
|
|
||||||
// is always the source of truth.
|
|
||||||
const diskType = getDiskType(disk.name, disk.rotation_rate)
|
const diskType = getDiskType(disk.name, disk.rotation_rate)
|
||||||
const cls: keyof DiskTempMap =
|
const cls: keyof DiskTempMap =
|
||||||
diskType === "NVMe" ? "NVMe" : diskType === "SSD" ? "SSD" : "HDD"
|
diskType === "NVMe" ? "NVMe" : diskType === "SSD" ? "SSD" : "HDD"
|
||||||
@@ -978,11 +992,11 @@ export function StorageOverview() {
|
|||||||
else normal++
|
else normal++
|
||||||
})
|
})
|
||||||
|
|
||||||
return { normal, warning, critical }
|
return { normal, warning, critical, unavailable }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDiskTypesBreakdown = () => {
|
const getDiskTypesBreakdown = () => {
|
||||||
if (!storageData || !storageData.disks) {
|
if (!storageData || !Array.isArray(storageData.disks)) {
|
||||||
return { nvme: 0, ssd: 0, hdd: 0, usb: 0 }
|
return { nvme: 0, ssd: 0, hdd: 0, usb: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,7 +1121,7 @@ export function StorageOverview() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!storageData || storageData.error) {
|
if (!storageData || storageData.error || !Array.isArray(storageData.disks)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-red-500">
|
<div className="text-red-500">
|
||||||
@@ -1285,12 +1299,16 @@ export function StorageOverview() {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const total = Math.max(1, storageData.disk_count || 0)
|
const total = Math.max(1, storageData.disk_count || 0)
|
||||||
const seg = 100 / total
|
const seg = 100 / total
|
||||||
const allHealthy = diskHealthBreakdown.warning === 0 && diskHealthBreakdown.critical === 0
|
const allTemperaturesNormal = storageData.disks.length > 0 && diskHealthBreakdown.unavailable === 0 && diskHealthBreakdown.warning === 0 && diskHealthBreakdown.critical === 0
|
||||||
const healthBadge = allHealthy
|
const healthBadge = storageData.disks.length === 0
|
||||||
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">✓ {t("storage.allHealthy")}</Badge>
|
? <Badge variant="outline" className="bg-muted text-muted-foreground border-border">{t("storage.temperatureNoDisks")}</Badge>
|
||||||
: diskHealthBreakdown.critical > 0
|
: allTemperaturesNormal
|
||||||
? <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">{t("storage.criticalCount", { count: diskHealthBreakdown.critical })}</Badge>
|
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{t("storage.temperatureNormal")}</Badge>
|
||||||
: <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">{t("storage.warningCount", { count: diskHealthBreakdown.warning })}</Badge>
|
: diskHealthBreakdown.critical > 0
|
||||||
|
? <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">{t("storage.temperatureCriticalCount", { count: diskHealthBreakdown.critical })}</Badge>
|
||||||
|
: diskHealthBreakdown.warning > 0
|
||||||
|
? <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">{t("storage.temperatureHighCount", { count: diskHealthBreakdown.warning })}</Badge>
|
||||||
|
: <Badge variant="outline" className="bg-muted text-muted-foreground border-border">{t(diskHealthBreakdown.normal > 0 ? "storage.temperatureIncomplete" : "storage.temperatureUnavailable")}</Badge>
|
||||||
const seg_purple = '#a855f7'
|
const seg_purple = '#a855f7'
|
||||||
const seg_cyan = '#06b6d4'
|
const seg_cyan = '#06b6d4'
|
||||||
const seg_blue = '#3b82f6'
|
const seg_blue = '#3b82f6'
|
||||||
@@ -1312,7 +1330,15 @@ export function StorageOverview() {
|
|||||||
<span className="text-3xl font-bold leading-none">{storageData.disk_count}</span>
|
<span className="text-3xl font-bold leading-none">{storageData.disk_count}</span>
|
||||||
<span className="text-base font-medium ml-1 text-muted-foreground">{diskCountLabel(storageData.disk_count)}</span>
|
<span className="text-base font-medium ml-1 text-muted-foreground">{diskCountLabel(storageData.disk_count)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex flex-col items-start gap-1 [&>div]:max-w-full [&>div]:whitespace-normal [&>div]:break-words">
|
||||||
{healthBadge}
|
{healthBadge}
|
||||||
|
{diskHealthBreakdown.critical > 0 && diskHealthBreakdown.warning > 0 && (
|
||||||
|
<p className="text-xs text-yellow-500">{t("storage.temperatureHighCount", { count: diskHealthBreakdown.warning })}</p>
|
||||||
|
)}
|
||||||
|
{diskHealthBreakdown.unavailable > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t("storage.temperatureUnavailableCount", { count: diskHealthBreakdown.unavailable })}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
|
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
|
||||||
{segments.map((s, i) => (
|
{segments.map((s, i) => (
|
||||||
@@ -2065,7 +2091,7 @@ export function StorageOverview() {
|
|||||||
<h4 className="font-semibold mb-3 flex items-center gap-2">
|
<h4 className="font-semibold mb-3 flex items-center gap-2">
|
||||||
{t("storage.wearLifetime")}
|
{t("storage.wearLifetime")}
|
||||||
{smartJsonData?.has_data && !wi && (
|
{smartJsonData?.has_data && !wi && (
|
||||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">{t("storage.realTest")}</Badge>
|
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">{t("storage.savedSmartData")}</Badge>
|
||||||
)}
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex gap-5 items-start">
|
<div className="flex gap-5 items-start">
|
||||||
@@ -2777,7 +2803,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
// Build recommendations
|
// Build recommendations
|
||||||
const recommendations: string[] = []
|
const recommendations: string[] = []
|
||||||
if (isHealthy) {
|
if (isHealthy) {
|
||||||
recommendations.push(`<div class="rec-item rec-ok"><div class="rec-icon">✓</div><div><strong>${t("storage.smartReport.recommendations.healthyTitle")}</strong><p>${t("storage.smartReport.recommendations.healthyText")}</p></div></div>`)
|
recommendations.push(`<div class="rec-item rec-ok"><div class="rec-icon">✓</div><div><strong>${t("storage.smartReport.recommendations.passedTitle")}</strong><p>${t("storage.smartReport.recommendations.passedText")}</p></div></div>`)
|
||||||
} else {
|
} else {
|
||||||
recommendations.push(`<div class="rec-item rec-critical"><div class="rec-icon">✗</div><div><strong>${t("storage.smartReport.recommendations.criticalTitle")}</strong><p>${t("storage.smartReport.recommendations.criticalText")}</p></div></div>`)
|
recommendations.push(`<div class="rec-item rec-critical"><div class="rec-icon">✗</div><div><strong>${t("storage.smartReport.recommendations.criticalTitle")}</strong><p>${t("storage.smartReport.recommendations.criticalText")}</p></div></div>`)
|
||||||
}
|
}
|
||||||
@@ -2860,7 +2886,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
const severityLabel = severityText(obs.severity)
|
const severityLabel = severityText(obs.severity)
|
||||||
const firstDate = obs.first_occurrence ? new Date(obs.first_occurrence).toLocaleString() : na
|
const firstDate = obs.first_occurrence ? new Date(obs.first_occurrence).toLocaleString() : na
|
||||||
const lastDate = obs.last_occurrence ? new Date(obs.last_occurrence).toLocaleString() : na
|
const lastDate = obs.last_occurrence ? new Date(obs.last_occurrence).toLocaleString() : na
|
||||||
const dismissedBadge = obs.dismissed ? `<span style="background:#16a34a20;color:#16a34a;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:4px;">${t("storage.smartReport.dismissed")}</span>` : ''
|
|
||||||
const errorTypeLabel = observationTypeText(type)
|
const errorTypeLabel = observationTypeText(type)
|
||||||
|
|
||||||
obsItemsHtml += `
|
obsItemsHtml += `
|
||||||
@@ -2870,7 +2895,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
<span style="background:${severityBadgeColor}20;color:${severityBadgeColor};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${severityLabel}</span>
|
<span style="background:${severityBadgeColor}20;color:${severityBadgeColor};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${severityLabel}</span>
|
||||||
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">ID: #${obs.id}</span>
|
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">ID: #${obs.id}</span>
|
||||||
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">${t("storage.smartReport.occurrences")}: <strong>${obs.occurrence_count}</strong></span>
|
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">${t("storage.smartReport.occurrences")}: <strong>${obs.occurrence_count}</strong></span>
|
||||||
${dismissedBadge}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:10px;">
|
<div style="margin-bottom:10px;">
|
||||||
@@ -3068,8 +3092,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
}
|
}
|
||||||
@media screen and (max-width: 640px) {
|
@media screen and (max-width: 640px) {
|
||||||
body { padding: 16px; padding-top: 64px; }
|
body { padding: 16px; padding-top: 64px; }
|
||||||
.grid-4 { grid-template-columns: 1fr 1fr; }
|
|
||||||
.grid-3 { grid-template-columns: 1fr 1fr; }
|
|
||||||
.rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
.rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
||||||
.rpt-header-right { text-align: left; }
|
.rpt-header-right { text-align: left; }
|
||||||
.exec-box { flex-wrap: wrap; }
|
.exec-box { flex-wrap: wrap; }
|
||||||
@@ -3173,6 +3195,9 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||||
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||||
|
@media screen and (max-width: 640px) {
|
||||||
|
.grid-3, .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
|
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
|
||||||
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
|
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||||
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
|
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||||
@@ -3284,7 +3309,7 @@ function pmxPrint(){
|
|||||||
<h3>${t("storage.smartReport.healthAssessment")}</h3>
|
<h3>${t("storage.smartReport.healthAssessment")}</h3>
|
||||||
<p>
|
<p>
|
||||||
${isHealthy
|
${isHealthy
|
||||||
? t("storage.smartReport.healthyAssessment", { uptime: powerOnFormatted, temperature: disk.temperature > 0 ? disk.temperature + '°C' : na, sectors: (disk.reallocated_sectors ?? 0) === 0 ? t("storage.smartReport.noBadSectors") : t("storage.smartReport.reallocatedSectors", { count: disk.reallocated_sectors ?? 0 }) })
|
? t("storage.smartReport.passedAssessment", { uptime: powerOnFormatted, temperature: disk.temperature > 0 ? disk.temperature + '°C' : na, sectors: (disk.reallocated_sectors ?? 0) === 0 ? t("storage.smartReport.noReallocatedSectorsReported") : t("storage.smartReport.reallocatedSectors", { count: disk.reallocated_sectors ?? 0 }) })
|
||||||
: t("storage.smartReport.failedAssessment")
|
: t("storage.smartReport.failedAssessment")
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
@@ -3294,11 +3319,11 @@ function pmxPrint(){
|
|||||||
<!-- Simple Explanation for Non-Technical Users -->
|
<!-- Simple Explanation for Non-Technical Users -->
|
||||||
<div style="background:${isHealthy ? '#dcfce7' : (hasCritical ? '#fee2e2' : '#fef3c7')};border:1px solid ${isHealthy ? '#86efac' : (hasCritical ? '#fca5a5' : '#fcd34d')};border-radius:8px;padding:16px;margin-top:12px;">
|
<div style="background:${isHealthy ? '#dcfce7' : (hasCritical ? '#fee2e2' : '#fef3c7')};border:1px solid ${isHealthy ? '#86efac' : (hasCritical ? '#fca5a5' : '#fcd34d')};border-radius:8px;padding:16px;margin-top:12px;">
|
||||||
<div style="font-weight:700;font-size:14px;color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};margin-bottom:8px;">
|
<div style="font-weight:700;font-size:14px;color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};margin-bottom:8px;">
|
||||||
${isHealthy ? t("storage.smartReport.healthyMeaningTitle") : (hasCritical ? t("storage.smartReport.attentionTitle") : t("storage.smartReport.monitorTitle"))}
|
${isHealthy ? t("storage.smartReport.passedMeaningTitle") : (hasCritical ? t("storage.smartReport.attentionTitle") : t("storage.smartReport.monitorTitle"))}
|
||||||
</div>
|
</div>
|
||||||
<p style="color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};font-size:12px;margin:0 0 8px 0;">
|
<p style="color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};font-size:12px;margin:0 0 8px 0;">
|
||||||
${isHealthy
|
${isHealthy
|
||||||
? t("storage.smartReport.healthyMeaning")
|
? t("storage.smartReport.passedMeaning")
|
||||||
: (hasCritical
|
: (hasCritical
|
||||||
? t("storage.smartReport.criticalMeaning")
|
? t("storage.smartReport.criticalMeaning")
|
||||||
: t("storage.smartReport.warningMeaning")
|
: t("storage.smartReport.warningMeaning")
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState, useEffect, useMemo } from "react"
|
import { useState, useEffect, useMemo } from "react"
|
||||||
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
|
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
|
||||||
import { useT } from "@/lib/i18n/provider"
|
import { useI18n } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface Backup {
|
interface Backup {
|
||||||
volid: string
|
volid: string
|
||||||
@@ -89,7 +89,7 @@ interface CombinedLogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SystemLogs() {
|
export function SystemLogs() {
|
||||||
const t = useT()
|
const { language, t } = useI18n()
|
||||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||||
const [backups, setBackups] = useState<Backup[]>([])
|
const [backups, setBackups] = useState<Backup[]>([])
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
@@ -607,7 +607,7 @@ export function SystemLogs() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
|
{(logsCounts?.total ?? 0).toLocaleString(language)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.selectedRange")}</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.selectedRange")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -619,7 +619,7 @@ export function SystemLogs() {
|
|||||||
<XCircle className="h-4 w-4 text-red-500" />
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString(language)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.requiresAttention")}</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.requiresAttention")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -630,7 +630,7 @@ export function SystemLogs() {
|
|||||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString(language)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.monitorClosely")}</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.monitorClosely")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -641,7 +641,7 @@ export function SystemLogs() {
|
|||||||
<Database className="h-4 w-4 text-blue-500" />
|
<Database className="h-4 w-4 text-blue-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-blue-500">{backupStats.total.toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-blue-500">{backupStats.total.toLocaleString(language)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">{formatBytes(backupStats.totalSize)}</p>
|
<p className="text-xs text-muted-foreground mt-2">{formatBytes(backupStats.totalSize)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||||
import { formatStorage } from "../lib/utils"
|
import { formatStorage } from "../lib/utils"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { getCountFormKey, useI18n, useT } from "../lib/i18n/provider"
|
||||||
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
||||||
|
|
||||||
interface TempDataPoint {
|
interface TempDataPoint {
|
||||||
@@ -173,6 +173,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
|||||||
|
|
||||||
export function SystemOverview() {
|
export function SystemOverview() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
|
const { language } = useI18n()
|
||||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||||
const [vmData, setVmData] = useState<VMData[]>([])
|
const [vmData, setVmData] = useState<VMData[]>([])
|
||||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||||
@@ -511,7 +512,7 @@ export function SystemOverview() {
|
|||||||
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
|
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
{t("overview.runningCount", { count: vmStats.running })}
|
{t(getCountFormKey(language, "overview.runningCount", vmStats.running), { count: vmStats.running })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-1 flex-wrap">
|
<div className="mt-3 flex gap-1 flex-wrap">
|
||||||
@@ -864,7 +865,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
|
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
|
||||||
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
|
<span className="text-foreground">{systemData.proxmox_version || t("app.notAvailable")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">{t("overview.kernel")}</span>
|
<span className="text-muted-foreground">{t("overview.kernel")}</span>
|
||||||
@@ -905,18 +906,18 @@ export function SystemOverview() {
|
|||||||
|
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">{t("overview.cpuThreads")}</span>
|
<span className="text-sm text-muted-foreground">{t("overview.cpuThreads")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
|
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || t("app.notAvailable")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
|
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
|
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || t("app.notAvailable")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">{t("overview.networkInterfaces")}</span>
|
<span className="text-sm text-muted-foreground">{t("overview.networkInterfaces")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">
|
<span className="text-lg font-semibold text-foreground">
|
||||||
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || t("app.notAvailable")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyButton = (key: string, e?: React.MouseEvent | React.TouchEvent) => {
|
const handleKeyButton = (key: string, e?: React.MouseEvent | React.TouchEvent) => {
|
||||||
// Prevenir comportamientos por defecto del navegador
|
// Stop the browser default for this key
|
||||||
if (e) {
|
if (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user