mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 20:32:15 +00:00
Compare commits
685
Commits
v1.2.6.2-beta
...
v1.2.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f14ad4d7b1 | ||
|
|
1285132326 | ||
|
|
2b1ae0be72 | ||
|
|
28d9ff1c9e | ||
|
|
a98f7d859b | ||
|
|
860fac9271 | ||
|
|
e904f9d37f | ||
|
|
6dc2c1f13c | ||
|
|
a48bccab38 | ||
|
|
96a388d1e7 | ||
|
|
314178e0ac | ||
|
|
cca3516e65 | ||
|
|
05cf4a40c3 | ||
|
|
e5b5a99c52 | ||
|
|
f6d16fd054 | ||
|
|
01584b974a | ||
|
|
106454d47b | ||
|
|
80456679f1 | ||
|
|
575e759718 | ||
|
|
617d85e427 | ||
|
|
ede91f30b8 | ||
|
|
e9b8fd28db | ||
|
|
9b712982e1 | ||
|
|
85e5c5f29b | ||
|
|
7b1736fe3e | ||
|
|
a4148c6616 | ||
|
|
acbe53ad4a | ||
|
|
542ff12890 | ||
|
|
55e830904d | ||
|
|
a640993b94 | ||
|
|
b6500c147e | ||
|
|
92c6a000b5 | ||
|
|
f6f811f5ea | ||
|
|
0d063d519c | ||
|
|
fc7c3181bc | ||
|
|
a1f90ddaf2 | ||
|
|
be6ef0069b | ||
|
|
ae809142f6 | ||
|
|
3e1729a262 | ||
|
|
88b942a543 | ||
|
|
ba6f691f90 | ||
|
|
5e6ed0a19b | ||
|
|
d1c7fa0158 | ||
|
|
5596cc9149 | ||
|
|
c316e901cd | ||
|
|
1e0c6e70bf | ||
|
|
0313e58bd8 | ||
|
|
f2ef3bebbd | ||
|
|
d403f25fbb | ||
|
|
ff11a6d462 | ||
|
|
e82d95f1cf | ||
|
|
4e00fcd746 | ||
|
|
c5458f34d9 | ||
|
|
85650ca0e8 | ||
|
|
25953b03bd | ||
|
|
e99b5f32ee | ||
|
|
975a3b99cf | ||
|
|
a4fb46f861 | ||
|
|
dede474ff4 | ||
|
|
2a02154f17 | ||
|
|
ecd0b9c54b | ||
|
|
1ff10e7a7f | ||
|
|
5c90f40ef6 | ||
|
|
ee5729aba2 | ||
|
|
a42dd9d28c | ||
|
|
2014105343 | ||
|
|
744c0165e2 | ||
|
|
723f53a1f1 | ||
|
|
fd74b3cacc | ||
|
|
11326badb2 | ||
|
|
44ab9d506d | ||
|
|
af29bc9274 | ||
|
|
9f977ac0be | ||
|
|
761385a107 | ||
|
|
354966180c | ||
|
|
fab4aee37a | ||
|
|
170293c67a | ||
|
|
9340def0e7 | ||
|
|
b4bd674a80 | ||
|
|
6b62aa2608 | ||
|
|
541eb6379e | ||
|
|
5c6afd1fcb | ||
|
|
23ac5ba228 | ||
|
|
e255b4cc64 | ||
|
|
e909e1ce67 | ||
|
|
8269d9aaaf | ||
|
|
aa8a28294d | ||
|
|
3b983a0a78 | ||
|
|
f2854ebece | ||
|
|
8eeafa1f18 | ||
|
|
6c2e268c4b | ||
|
|
12f424911d | ||
|
|
1ff63fa955 | ||
|
|
c24d022f40 | ||
|
|
6bb9a1ebb0 | ||
|
|
e649a92f30 | ||
|
|
bab512281c | ||
|
|
3686d613e6 | ||
|
|
896817affa | ||
|
|
b2067e18a5 | ||
|
|
d152fc42d3 | ||
|
|
69518f701f | ||
|
|
281dfdf638 | ||
|
|
17fe62062e | ||
|
|
8b1cbbbb0d | ||
|
|
fc41b04e87 | ||
|
|
e233d1856b | ||
|
|
257c2728fb | ||
|
|
2f48689087 | ||
|
|
779dd7fb26 | ||
|
|
225438209e | ||
|
|
adda62d1ed | ||
|
|
6556d560fa | ||
|
|
5ece466d1e | ||
|
|
b879adf54c | ||
|
|
c15834ba63 | ||
|
|
39891e5177 | ||
|
|
e032d869ff | ||
|
|
1f0c72b90b | ||
|
|
03770129a3 | ||
|
|
6422d6ecd5 | ||
|
|
d17c017ccf | ||
|
|
6699a4afbb | ||
|
|
b3319268a7 | ||
|
|
00ebb0b2f9 | ||
|
|
7aacd08a96 | ||
|
|
3298b55cc6 | ||
|
|
396b44eacb | ||
|
|
d9bd8c47de | ||
|
|
dbcc4de6e1 | ||
|
|
61168b9abd | ||
|
|
ff81c4269c | ||
|
|
c04fd65b4f | ||
|
|
203496f14f | ||
|
|
b0ffcd2049 | ||
|
|
c0046cb24f | ||
|
|
4ffbaa26d3 | ||
|
|
a1ee7fa744 | ||
|
|
0d346c6bef | ||
|
|
53e9f08fe7 | ||
|
|
9864fae62a | ||
|
|
1e0595aac2 | ||
|
|
c471bccdab | ||
|
|
b75f2789b3 | ||
|
|
a5371b05dc | ||
|
|
6b41ff4247 | ||
|
|
d687a79ad8 | ||
|
|
09f9ee18fc | ||
|
|
06d06041c7 | ||
|
|
0d7c4554bc | ||
|
|
08d2b22c14 | ||
|
|
92cde1bac3 | ||
|
|
ae37003df8 | ||
|
|
40f967d87e | ||
|
|
62b8b4e2ae | ||
|
|
e9b310743a | ||
|
|
ab1b6ce7a1 | ||
|
|
a191096ee4 | ||
|
|
c00207bd20 | ||
|
|
bb8f5b5c06 | ||
|
|
153743b80d | ||
|
|
6c05688196 | ||
|
|
37a6c02111 | ||
|
|
092a62e2dd | ||
|
|
3b12e68e8c | ||
|
|
37ad54232a | ||
|
|
d5f4691be7 | ||
|
|
06fbea8735 | ||
|
|
56844d7f04 | ||
|
|
96b62c59cc | ||
|
|
5ccc1451ce | ||
|
|
90f080744a | ||
|
|
c7a134b00a | ||
|
|
441c34514d | ||
|
|
7213fb2553 | ||
|
|
43041846eb | ||
|
|
3970bafbe0 | ||
|
|
a0379fee0b | ||
|
|
0e01b9f652 | ||
|
|
34fcc64550 | ||
|
|
f8ebe93730 | ||
|
|
aed77ecefe | ||
|
|
1dc9e92f74 | ||
|
|
f9e36f0b0a | ||
|
|
c991b8280a | ||
|
|
9ffd154a2a | ||
|
|
133929fda6 | ||
|
|
65f47ec646 | ||
|
|
0a7a598210 | ||
|
|
d8741a8c5d | ||
|
|
5f1aa4cde7 | ||
|
|
58e16c686e | ||
|
|
29b4913047 | ||
|
|
ad56c02b21 | ||
|
|
5d87106e69 | ||
|
|
77a6d1c397 | ||
|
|
1384a5e2cc | ||
|
|
6066442cc6 | ||
|
|
d7cc914c35 | ||
|
|
361f26bf70 | ||
|
|
cb585632c6 | ||
|
|
311952cdf5 | ||
|
|
423156214a | ||
|
|
818ab1308a | ||
|
|
95bf21b6de | ||
|
|
e138f4c800 | ||
|
|
1385ab3a7c | ||
|
|
91fe0b400a | ||
|
|
7bf1e1e34d | ||
|
|
ec934c8c2d | ||
|
|
3e3e0916f2 | ||
|
|
83ad947b2c | ||
|
|
1511680a95 | ||
|
|
3e9c9eb25d | ||
|
|
ff6f023238 | ||
|
|
7e00904dce | ||
|
|
70d4101243 | ||
|
|
e44db4897b | ||
|
|
8775fd30c5 | ||
|
|
cfb90a094c | ||
|
|
4332fec8a3 | ||
|
|
440b6be6ef | ||
|
|
bc51adcd8f | ||
|
|
3071095109 | ||
|
|
0a06a81871 | ||
|
|
6c269bf7a5 | ||
|
|
e63ec2ad7b | ||
|
|
29778aa709 | ||
|
|
b1dc624e7d | ||
|
|
e52d194b9a | ||
|
|
f6cff70298 | ||
|
|
ab8c4a56ea | ||
|
|
b5a2a3f52b | ||
|
|
c6f919b085 | ||
|
|
b720b4a8de | ||
|
|
a2b96bf6a4 | ||
|
|
bdb4544ede | ||
|
|
62ddd7599b | ||
|
|
7e32000302 | ||
|
|
241d7a18bf | ||
|
|
0000086a93 | ||
|
|
06968696e3 | ||
|
|
3c814b24b4 | ||
|
|
0149327367 | ||
|
|
947b5291e9 | ||
|
|
cc7e626785 | ||
|
|
c1fb72ed0e | ||
|
|
456f7e64f6 | ||
|
|
700a4de2ee | ||
|
|
d06cf0f6f6 | ||
|
|
27a2c35eff | ||
|
|
1f0c78608d | ||
|
|
023c512df3 | ||
|
|
f648521a73 | ||
|
|
b80a255f93 | ||
|
|
fae36bc09d | ||
|
|
53191b9e7a | ||
|
|
2d48b87ac1 | ||
|
|
c3cb726cbf | ||
|
|
aedd3085a9 | ||
|
|
2b3f844eef | ||
|
|
0eb8c5db20 | ||
|
|
f66eea2eb7 | ||
|
|
5ac7d976ab | ||
|
|
7ef834411d | ||
|
|
e87a1e8f51 | ||
|
|
f6d43e7f5f | ||
|
|
cfe0f31e14 | ||
|
|
bde0cf8da6 | ||
|
|
9e03f59034 | ||
|
|
2868f7b8ac | ||
|
|
d8efdb440b | ||
|
|
98998fcbd9 | ||
|
|
b85e3a0f49 | ||
|
|
944df3f93a | ||
|
|
b18ed8c3d6 | ||
|
|
95b7950ca3 | ||
|
|
d07b970f8b | ||
|
|
9247e6d887 | ||
|
|
20e355c09f | ||
|
|
c09f27d489 | ||
|
|
3be6dfbfc8 | ||
|
|
f18238aad7 | ||
|
|
8cd262b295 | ||
|
|
4213878884 | ||
|
|
8cdb519ccb | ||
|
|
5ee0506212 | ||
|
|
e5bf63b31f | ||
|
|
7a6a6f93dc | ||
|
|
d3da2da48c | ||
|
|
85bea617d6 | ||
|
|
f73154a2c7 | ||
|
|
2efd775559 | ||
|
|
64095689ac | ||
|
|
d4c4dce832 | ||
|
|
163c964314 | ||
|
|
746c5a61df | ||
|
|
85bf3b8584 | ||
|
|
ab2dd5dc02 | ||
|
|
3756a6de9a | ||
|
|
36a8e84491 | ||
|
|
94a8e44ca5 | ||
|
|
3ee8f8cd58 | ||
|
|
dd62e5ce7d | ||
|
|
ee919dad24 | ||
|
|
d67eb39a65 | ||
|
|
b6bce3cae4 | ||
|
|
fc5884cdc1 | ||
|
|
45fdceaaa4 | ||
|
|
3e9ac6750b | ||
|
|
68eb53c2d9 | ||
|
|
f6d3ad4337 | ||
|
|
2d2789a4b7 | ||
|
|
efaeed3706 | ||
|
|
f8cabb4700 | ||
|
|
f363ef4395 | ||
|
|
e8b8f95c5b | ||
|
|
1cbcdd69aa | ||
|
|
8757bbeb4f | ||
|
|
ccdc21504a | ||
|
|
9861a85ccd | ||
|
|
2aa6e0ba44 | ||
|
|
e3c9162982 | ||
|
|
f2bb006728 | ||
|
|
bef2f837c2 | ||
|
|
d4e98d16b1 | ||
|
|
41ef07ac55 | ||
|
|
32b71bdd3d | ||
|
|
7efb63c478 | ||
|
|
d8ae19c76e | ||
|
|
750421a12f | ||
|
|
2c8ebcaa9f | ||
|
|
7d17220294 | ||
|
|
a32a7577cc | ||
|
|
84200fadb4 | ||
|
|
7e458bae2e | ||
|
|
1bc38884ce | ||
|
|
12ab5ae10a | ||
|
|
152c3ddfe0 | ||
|
|
e38c085e5f | ||
|
|
4bce49eb46 | ||
|
|
ba9ae51615 | ||
|
|
4dc19ed4d8 | ||
|
|
26cfc50555 | ||
|
|
e938a25da3 | ||
|
|
ac42bea70f | ||
|
|
e2fa8adcae | ||
|
|
99adc343e8 | ||
|
|
6496318452 | ||
|
|
1e97f732ac | ||
|
|
8a774d05c7 | ||
|
|
592354b0e0 | ||
|
|
6157f34ced | ||
|
|
74c0321956 | ||
|
|
98ae1f3626 | ||
|
|
14513cc898 | ||
|
|
b70e968f73 | ||
|
|
d286b650d7 | ||
|
|
784ce13c14 | ||
|
|
1c42203166 | ||
|
|
2390257515 | ||
|
|
3e1ae3c4ee | ||
|
|
178540a94f | ||
|
|
9f6296501a | ||
|
|
a35700f54f | ||
|
|
068485d339 | ||
|
|
a9241d7a6c | ||
|
|
5210fc415a | ||
|
|
2b1ae1620d | ||
|
|
5369c3acce | ||
|
|
f07482f201 | ||
|
|
45756cb45f | ||
|
|
11b1c895f4 | ||
|
|
ea6f6dbf6e | ||
|
|
de9c29a9e9 | ||
|
|
f36b5e22a8 | ||
|
|
d15876d940 | ||
|
|
427c3dc2b9 | ||
|
|
dace78214a | ||
|
|
7aec721eb5 | ||
|
|
22098042ba | ||
|
|
c393a56b92 | ||
|
|
503302cea3 | ||
|
|
20acf0852a | ||
|
|
1f75147d51 | ||
|
|
e978bac91a | ||
|
|
61fae8f6f7 | ||
|
|
6d82cf4f15 | ||
|
|
bbe811e873 | ||
|
|
e89f2596b2 | ||
|
|
b7b412b45d | ||
|
|
111593bc38 | ||
|
|
ce1a3de719 | ||
|
|
85f0123c5d | ||
|
|
08ae134860 | ||
|
|
69b2dc31d0 | ||
|
|
a42d402e3a | ||
|
|
c83559643d | ||
|
|
40c0701ab1 | ||
|
|
227780aba7 | ||
|
|
628dbe3aa3 | ||
|
|
72b596ff4b | ||
|
|
c32a9880d9 | ||
|
|
f13ce25071 | ||
|
|
a230e8b067 | ||
|
|
63ae1ba68f | ||
|
|
7e97bcb25b | ||
|
|
29260a0e41 | ||
|
|
8d9c0083cd | ||
|
|
ac391cdb35 | ||
|
|
f336eb65f0 | ||
|
|
f7220826a6 | ||
|
|
1a20638097 | ||
|
|
9a94045c33 | ||
|
|
3c962fef11 | ||
|
|
b75d373f31 | ||
|
|
5661891d83 | ||
|
|
c9caea0b7a | ||
|
|
40fdaf0145 | ||
|
|
9ba5a797c4 | ||
|
|
4ca430db3a | ||
|
|
9558bd3609 | ||
|
|
3a90f218ac | ||
|
|
5197d84328 | ||
|
|
6a9a025199 | ||
|
|
aba6da22db | ||
|
|
766cfa0779 | ||
|
|
8772b286da | ||
|
|
b035373efb | ||
|
|
8bbb475614 | ||
|
|
2898ecbfdb | ||
|
|
5063c7a006 | ||
|
|
def0987711 | ||
|
|
b938e14fc5 | ||
|
|
248ac5dec6 | ||
|
|
52632abbd6 | ||
|
|
ada0f1109f | ||
|
|
87720f14f1 | ||
|
|
e4c517f258 | ||
|
|
816efa533e | ||
|
|
511bfe52ea | ||
|
|
31254b1c9c | ||
|
|
8840490da0 | ||
|
|
af1a648689 | ||
|
|
c2ef5a54a6 | ||
|
|
4ffc883c32 | ||
|
|
ee6b3bb232 | ||
|
|
113659c0e3 | ||
|
|
9e6978cf10 | ||
|
|
c9cdac58aa | ||
|
|
d487f33700 | ||
|
|
6fc9f9003b | ||
|
|
7d2d430320 | ||
|
|
690ace1e57 | ||
|
|
d25747646e | ||
|
|
413ac5e80e | ||
|
|
9bc83a13ef | ||
|
|
fdbd420961 | ||
|
|
59ab7784a7 | ||
|
|
c2ad35ca73 | ||
|
|
3000c1cd66 | ||
|
|
1b0d6a086e | ||
|
|
db0e609259 | ||
|
|
d0f8df39db | ||
|
|
653dee192c | ||
|
|
ea3b579089 | ||
|
|
4c8b5b1188 | ||
|
|
728baf3714 | ||
|
|
4e572b8c21 | ||
|
|
ebce0b8511 | ||
|
|
2cbeacefb2 | ||
|
|
b55eca753d | ||
|
|
3ad7407e07 | ||
|
|
f07dbfe10f | ||
|
|
84eb6d2167 | ||
|
|
36b7bf1bf9 | ||
|
|
7f21cae196 | ||
|
|
8220edf3b6 | ||
|
|
ab730977ab | ||
|
|
2500fb0cfc | ||
|
|
d6f814ba52 | ||
|
|
307c7cbb83 | ||
|
|
abf05493b9 | ||
|
|
c7480fc4e7 | ||
|
|
6890d770bf | ||
|
|
1e9d97f1aa | ||
|
|
f5d2074a9a | ||
|
|
bec077dd9e | ||
|
|
cf2c0637a7 | ||
|
|
00350655bf | ||
|
|
abbb3afc0c | ||
|
|
1ca028481d | ||
|
|
1056cc50c2 | ||
|
|
6677743a82 | ||
|
|
89fdfd0952 | ||
|
|
983ed38e3b | ||
|
|
54d9283eba | ||
|
|
346c9988a9 | ||
|
|
cd817524ff | ||
|
|
8b0ca0d06b | ||
|
|
84faf0bce4 | ||
|
|
3254ca6d05 | ||
|
|
3e806afe8c | ||
|
|
017b73fffd | ||
|
|
18236e06bc | ||
|
|
664ef981e5 | ||
|
|
51ed84f9b1 | ||
|
|
f3add354e6 | ||
|
|
638552e5f8 | ||
|
|
3489dc0530 | ||
|
|
ceb783bcf2 | ||
|
|
51ce1184da | ||
|
|
c9479c6ac1 | ||
|
|
f2c5ab1636 | ||
|
|
1a8efb96a5 | ||
|
|
7e04500e0c | ||
|
|
e0e2f10471 | ||
|
|
2167934289 | ||
|
|
3023ac33ec | ||
|
|
03c8dc9eb6 | ||
|
|
39bb4f8616 | ||
|
|
f3d97724c5 | ||
|
|
00b00b9db7 | ||
|
|
7a9878e458 | ||
|
|
6efe322c44 | ||
|
|
63f1e631b9 | ||
|
|
e83e66caaf | ||
|
|
ea5831d1bf | ||
|
|
3708e9cf79 | ||
|
|
61041bcbfd | ||
|
|
118f7c1cf6 | ||
|
|
3414a77aa4 | ||
|
|
d7f2f13cd9 | ||
|
|
631b1cf253 | ||
|
|
c8e5421d75 | ||
|
|
aba805d4d9 | ||
|
|
21ae8e94a4 | ||
|
|
cc12c625a6 | ||
|
|
87403a2e48 | ||
|
|
9089f79342 | ||
|
|
3198209493 | ||
|
|
c496f111b6 | ||
|
|
fbafb92f49 | ||
|
|
03f2a195a6 | ||
|
|
f17f87c94d | ||
|
|
4e4b19213b | ||
|
|
e587e27144 | ||
|
|
bff791581c | ||
|
|
de7fc4bd49 | ||
|
|
80af670794 | ||
|
|
1ac1f58afa | ||
|
|
370b45a2d8 | ||
|
|
5f3729568e | ||
|
|
c3c29c8692 | ||
|
|
9ebc959530 | ||
|
|
35d61beaa1 | ||
|
|
6a7565c858 | ||
|
|
7b2d345e83 | ||
|
|
b026332032 | ||
|
|
7215c551d4 | ||
|
|
bb817ad1f9 | ||
|
|
dad116c8b5 | ||
|
|
34c549b738 | ||
|
|
2d022a873d | ||
|
|
ee3d8db577 | ||
|
|
8169aeadce | ||
|
|
9ac9556940 | ||
|
|
8902a2574f | ||
|
|
ded2af3a57 | ||
|
|
4ea1bf4a80 | ||
|
|
1bf8dd00c9 | ||
|
|
7305713c80 | ||
|
|
a02057fd52 | ||
|
|
65d55b0f58 | ||
|
|
0d2d639795 | ||
|
|
bc913ff5eb | ||
|
|
113c31ab09 | ||
|
|
8094d69fb4 | ||
|
|
f3a3332327 | ||
|
|
5734947e17 | ||
|
|
ae22df1f9d | ||
|
|
e82216eb5b | ||
|
|
346d9b15f6 | ||
|
|
b9e5b03d6b | ||
|
|
3fc99a35b6 | ||
|
|
071d6081f8 | ||
|
|
af10486436 | ||
|
|
db1ab725d6 | ||
|
|
85b2d2114a | ||
|
|
0cd2efdd4e | ||
|
|
f40910125c | ||
|
|
98da85bf00 | ||
|
|
ddb0b5a400 | ||
|
|
f641bab0e0 | ||
|
|
cbd4aff7a4 | ||
|
|
8efccc3d03 | ||
|
|
5b789d2433 | ||
|
|
bfe7794d98 | ||
|
|
ff51c2239e | ||
|
|
f7239889ca | ||
|
|
7a3f713670 | ||
|
|
fd8dd11643 | ||
|
|
dd18ba6cd6 | ||
|
|
8cf19ad79b | ||
|
|
7899ffb43d | ||
|
|
3cff8caa84 | ||
|
|
b23eaf1713 | ||
|
|
b2294a6c59 | ||
|
|
3df04b8428 | ||
|
|
8a8deed134 | ||
|
|
cb3ff72699 | ||
|
|
7c64f7427a | ||
|
|
f1c15a1cbe | ||
|
|
a26876cff9 | ||
|
|
0fd051a498 | ||
|
|
4eccbd9269 | ||
|
|
c5752fe582 | ||
|
|
c8f7a4fe3e | ||
|
|
7198c19da8 | ||
|
|
acc426e723 | ||
|
|
662793d7c2 | ||
|
|
e704210685 | ||
|
|
33c8ff5e7e | ||
|
|
dc26a872fd | ||
|
|
af41036c18 | ||
|
|
ef6d024a4a | ||
|
|
15e819385f | ||
|
|
50b4dc33fd | ||
|
|
26bc278b34 | ||
|
|
7ba90791ea | ||
|
|
c28b22fb59 | ||
|
|
7640762462 | ||
|
|
891c68ba42 | ||
|
|
7aa9840414 | ||
|
|
09e4b81d65 | ||
|
|
80c1cf4475 | ||
|
|
26d30481b5 | ||
|
|
4d32422fef | ||
|
|
43a655a57a | ||
|
|
93900b872e | ||
|
|
ac4be7031f | ||
|
|
102ac40226 | ||
|
|
7f90154410 | ||
|
|
013def197a | ||
|
|
baaff2725b | ||
|
|
8e7588f372 | ||
|
|
8507097a9a | ||
|
|
ed5f0e8393 | ||
|
|
49efa18f97 | ||
|
|
a4537be773 | ||
|
|
8f62859971 | ||
|
|
fb1517807c | ||
|
|
685b37bc43 | ||
|
|
5867d467f3 | ||
|
|
dd2a9f611d | ||
|
|
30e6e64d4f | ||
|
|
f9373f8777 | ||
|
|
cad825ae7e | ||
|
|
5b4366fa82 | ||
|
|
1df63be887 | ||
|
|
b599d1cf26 | ||
|
|
1919e62646 | ||
|
|
38d445a379 | ||
|
|
150494bce1 | ||
|
|
2424545532 | ||
|
|
e9c5a12702 | ||
|
|
3dcf6cd661 | ||
|
|
d91599d354 | ||
|
|
97bf9e24e5 | ||
|
|
9a1642b53c | ||
|
|
c90e365d1f | ||
|
|
9ae3a54e8b | ||
|
|
cc66939095 | ||
|
|
56da2ac880 | ||
|
|
2630e4f016 | ||
|
|
966e9799f2 | ||
|
|
ae3b93a3c1 | ||
|
|
459ea9c6db | ||
|
|
636983b697 | ||
|
|
986301d152 | ||
|
|
5b41cbfbe8 | ||
|
|
17a72753bb | ||
|
|
333d03a51e | ||
|
|
f59323455b | ||
|
|
ac30502b67 |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"repository": "MacRimi/ProxMenux",
|
||||
"updatedAt": "2026-09-02T08:48:44.825Z",
|
||||
"updatedAt": "2026-10-26T04:36:49.492Z",
|
||||
"backfilledAt": "2026-07-22T18:09:20.834Z",
|
||||
"totals": {
|
||||
"downloads": 0,
|
||||
"clones": 22221
|
||||
"clones": 59792
|
||||
},
|
||||
"assets": {},
|
||||
"traffic": {
|
||||
@@ -133,6 +133,218 @@
|
||||
"2026-09-01": {
|
||||
"count": 552,
|
||||
"uniques": 396
|
||||
},
|
||||
"2026-09-02": {
|
||||
"count": 1065,
|
||||
"uniques": 726
|
||||
},
|
||||
"2026-09-03": {
|
||||
"count": 1489,
|
||||
"uniques": 770
|
||||
},
|
||||
"2026-09-04": {
|
||||
"count": 938,
|
||||
"uniques": 642
|
||||
},
|
||||
"2026-09-05": {
|
||||
"count": 722,
|
||||
"uniques": 513
|
||||
},
|
||||
"2026-09-06": {
|
||||
"count": 884,
|
||||
"uniques": 445
|
||||
},
|
||||
"2026-09-07": {
|
||||
"count": 655,
|
||||
"uniques": 485
|
||||
},
|
||||
"2026-09-08": {
|
||||
"count": 597,
|
||||
"uniques": 440
|
||||
},
|
||||
"2026-09-09": {
|
||||
"count": 615,
|
||||
"uniques": 431
|
||||
},
|
||||
"2026-09-10": {
|
||||
"count": 542,
|
||||
"uniques": 383
|
||||
},
|
||||
"2026-09-11": {
|
||||
"count": 646,
|
||||
"uniques": 342
|
||||
},
|
||||
"2026-09-12": {
|
||||
"count": 451,
|
||||
"uniques": 336
|
||||
},
|
||||
"2026-09-13": {
|
||||
"count": 482,
|
||||
"uniques": 362
|
||||
},
|
||||
"2026-09-14": {
|
||||
"count": 610,
|
||||
"uniques": 349
|
||||
},
|
||||
"2026-09-15": {
|
||||
"count": 452,
|
||||
"uniques": 343
|
||||
},
|
||||
"2026-09-16": {
|
||||
"count": 651,
|
||||
"uniques": 335
|
||||
},
|
||||
"2026-09-17": {
|
||||
"count": 881,
|
||||
"uniques": 316
|
||||
},
|
||||
"2026-09-18": {
|
||||
"count": 414,
|
||||
"uniques": 295
|
||||
},
|
||||
"2026-09-19": {
|
||||
"count": 701,
|
||||
"uniques": 300
|
||||
},
|
||||
"2026-09-20": {
|
||||
"count": 405,
|
||||
"uniques": 271
|
||||
},
|
||||
"2026-09-21": {
|
||||
"count": 477,
|
||||
"uniques": 303
|
||||
},
|
||||
"2026-09-22": {
|
||||
"count": 587,
|
||||
"uniques": 347
|
||||
},
|
||||
"2026-09-23": {
|
||||
"count": 436,
|
||||
"uniques": 293
|
||||
},
|
||||
"2026-09-24": {
|
||||
"count": 377,
|
||||
"uniques": 276
|
||||
},
|
||||
"2026-09-25": {
|
||||
"count": 526,
|
||||
"uniques": 350
|
||||
},
|
||||
"2026-09-26": {
|
||||
"count": 723,
|
||||
"uniques": 397
|
||||
},
|
||||
"2026-09-27": {
|
||||
"count": 994,
|
||||
"uniques": 620
|
||||
},
|
||||
"2026-09-28": {
|
||||
"count": 1548,
|
||||
"uniques": 1091
|
||||
},
|
||||
"2026-09-29": {
|
||||
"count": 1043,
|
||||
"uniques": 692
|
||||
},
|
||||
"2026-09-30": {
|
||||
"count": 768,
|
||||
"uniques": 504
|
||||
},
|
||||
"2026-10-01": {
|
||||
"count": 661,
|
||||
"uniques": 443
|
||||
},
|
||||
"2026-10-02": {
|
||||
"count": 596,
|
||||
"uniques": 425
|
||||
},
|
||||
"2026-10-03": {
|
||||
"count": 486,
|
||||
"uniques": 372
|
||||
},
|
||||
"2026-10-04": {
|
||||
"count": 1074,
|
||||
"uniques": 333
|
||||
},
|
||||
"2026-10-05": {
|
||||
"count": 1183,
|
||||
"uniques": 366
|
||||
},
|
||||
"2026-10-06": {
|
||||
"count": 648,
|
||||
"uniques": 367
|
||||
},
|
||||
"2026-10-07": {
|
||||
"count": 527,
|
||||
"uniques": 381
|
||||
},
|
||||
"2026-10-08": {
|
||||
"count": 540,
|
||||
"uniques": 352
|
||||
},
|
||||
"2026-10-09": {
|
||||
"count": 548,
|
||||
"uniques": 363
|
||||
},
|
||||
"2026-10-10": {
|
||||
"count": 443,
|
||||
"uniques": 305
|
||||
},
|
||||
"2026-10-11": {
|
||||
"count": 511,
|
||||
"uniques": 305
|
||||
},
|
||||
"2026-10-12": {
|
||||
"count": 520,
|
||||
"uniques": 362
|
||||
},
|
||||
"2026-10-13": {
|
||||
"count": 564,
|
||||
"uniques": 354
|
||||
},
|
||||
"2026-10-14": {
|
||||
"count": 540,
|
||||
"uniques": 372
|
||||
},
|
||||
"2026-10-15": {
|
||||
"count": 1253,
|
||||
"uniques": 429
|
||||
},
|
||||
"2026-10-16": {
|
||||
"count": 751,
|
||||
"uniques": 342
|
||||
},
|
||||
"2026-10-17": {
|
||||
"count": 821,
|
||||
"uniques": 380
|
||||
},
|
||||
"2026-10-18": {
|
||||
"count": 727,
|
||||
"uniques": 348
|
||||
},
|
||||
"2026-10-19": {
|
||||
"count": 712,
|
||||
"uniques": 368
|
||||
},
|
||||
"2026-10-20": {
|
||||
"count": 908,
|
||||
"uniques": 464
|
||||
},
|
||||
"2026-10-21": {
|
||||
"count": 623,
|
||||
"uniques": 406
|
||||
},
|
||||
"2026-10-22": {
|
||||
"count": 755,
|
||||
"uniques": 419
|
||||
},
|
||||
"2026-10-23": {
|
||||
"count": 601,
|
||||
"uniques": 337
|
||||
},
|
||||
"2026-10-24": {
|
||||
"count": 900,
|
||||
"uniques": 316
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3369,7 +3581,385 @@
|
||||
"stars": 2926,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 22221
|
||||
"clones": 23286
|
||||
},
|
||||
{
|
||||
"date": "2026-09-03",
|
||||
"stars": 2927,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 24775
|
||||
},
|
||||
{
|
||||
"date": "2026-09-04",
|
||||
"stars": 2930,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 25713
|
||||
},
|
||||
{
|
||||
"date": "2026-09-05",
|
||||
"stars": 2944,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 26435
|
||||
},
|
||||
{
|
||||
"date": "2026-09-06",
|
||||
"stars": 2954,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 27319
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"stars": 2959,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 27974
|
||||
},
|
||||
{
|
||||
"date": "2026-09-08",
|
||||
"stars": 2959,
|
||||
"forks": 153,
|
||||
"downloads": 0,
|
||||
"clones": 28571
|
||||
},
|
||||
{
|
||||
"date": "2026-09-09",
|
||||
"stars": 2960,
|
||||
"forks": 153,
|
||||
"downloads": 0,
|
||||
"clones": 29186
|
||||
},
|
||||
{
|
||||
"date": "2026-09-10",
|
||||
"stars": 2961,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 29728
|
||||
},
|
||||
{
|
||||
"date": "2026-09-11",
|
||||
"stars": 2965,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 30374
|
||||
},
|
||||
{
|
||||
"date": "2026-09-12",
|
||||
"stars": 2967,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 30825
|
||||
},
|
||||
{
|
||||
"date": "2026-09-13",
|
||||
"stars": 2971,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 31307
|
||||
},
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"stars": 2972,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 31917
|
||||
},
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"stars": 2974,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 32369
|
||||
},
|
||||
{
|
||||
"date": "2026-09-16",
|
||||
"stars": 2974,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 33020
|
||||
},
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"stars": 2975,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 33901
|
||||
},
|
||||
{
|
||||
"date": "2026-09-18",
|
||||
"stars": 2978,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 34315
|
||||
},
|
||||
{
|
||||
"date": "2026-09-19",
|
||||
"stars": 2981,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 35016
|
||||
},
|
||||
{
|
||||
"date": "2026-09-20",
|
||||
"stars": 2981,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 35421
|
||||
},
|
||||
{
|
||||
"date": "2026-09-21",
|
||||
"stars": 2984,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 35898
|
||||
},
|
||||
{
|
||||
"date": "2026-09-22",
|
||||
"stars": 2986,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 36485
|
||||
},
|
||||
{
|
||||
"date": "2026-09-23",
|
||||
"stars": 2989,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 36921
|
||||
},
|
||||
{
|
||||
"date": "2026-09-24",
|
||||
"stars": 2991,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 37298
|
||||
},
|
||||
{
|
||||
"date": "2026-09-25",
|
||||
"stars": 2993,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 37824
|
||||
},
|
||||
{
|
||||
"date": "2026-09-26",
|
||||
"stars": 2996,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 38547
|
||||
},
|
||||
{
|
||||
"date": "2026-09-27",
|
||||
"stars": 3007,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 39541
|
||||
},
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"stars": 3041,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 41089
|
||||
},
|
||||
{
|
||||
"date": "2026-09-29",
|
||||
"stars": 3052,
|
||||
"forks": 157,
|
||||
"downloads": 0,
|
||||
"clones": 42132
|
||||
},
|
||||
{
|
||||
"date": "2026-09-30",
|
||||
"stars": 3062,
|
||||
"forks": 157,
|
||||
"downloads": 0,
|
||||
"clones": 42900
|
||||
},
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"stars": 3067,
|
||||
"forks": 158,
|
||||
"downloads": 0,
|
||||
"clones": 43561
|
||||
},
|
||||
{
|
||||
"date": "2026-10-02",
|
||||
"stars": 3072,
|
||||
"forks": 159,
|
||||
"downloads": 0,
|
||||
"clones": 44157
|
||||
},
|
||||
{
|
||||
"date": "2026-10-03",
|
||||
"stars": 3077,
|
||||
"forks": 159,
|
||||
"downloads": 0,
|
||||
"clones": 44643
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"stars": 3082,
|
||||
"forks": 160,
|
||||
"downloads": 0,
|
||||
"clones": 45717
|
||||
},
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"stars": 3087,
|
||||
"forks": 160,
|
||||
"downloads": 0,
|
||||
"clones": 46900
|
||||
},
|
||||
{
|
||||
"date": "2026-10-06",
|
||||
"stars": 3090,
|
||||
"forks": 161,
|
||||
"downloads": 0,
|
||||
"clones": 47548
|
||||
},
|
||||
{
|
||||
"date": "2026-10-07",
|
||||
"stars": 3103,
|
||||
"forks": 163,
|
||||
"downloads": 0,
|
||||
"clones": 48075
|
||||
},
|
||||
{
|
||||
"date": "2026-10-08",
|
||||
"stars": 3112,
|
||||
"forks": 163,
|
||||
"downloads": 0,
|
||||
"clones": 48615
|
||||
},
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"stars": 3123,
|
||||
"forks": 165,
|
||||
"downloads": 0,
|
||||
"clones": 49163
|
||||
},
|
||||
{
|
||||
"date": "2026-10-10",
|
||||
"stars": 3128,
|
||||
"forks": 164,
|
||||
"downloads": 0,
|
||||
"clones": 49606
|
||||
},
|
||||
{
|
||||
"date": "2026-10-11",
|
||||
"stars": 3134,
|
||||
"forks": 165,
|
||||
"downloads": 0,
|
||||
"clones": 50117
|
||||
},
|
||||
{
|
||||
"date": "2026-10-12",
|
||||
"stars": 3138,
|
||||
"forks": 168,
|
||||
"downloads": 0,
|
||||
"clones": 50637
|
||||
},
|
||||
{
|
||||
"date": "2026-10-13",
|
||||
"stars": 3141,
|
||||
"forks": 169,
|
||||
"downloads": 0,
|
||||
"clones": 51201
|
||||
},
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"stars": 3148,
|
||||
"forks": 170,
|
||||
"downloads": 0,
|
||||
"clones": 51741
|
||||
},
|
||||
{
|
||||
"date": "2026-10-15",
|
||||
"stars": 3160,
|
||||
"forks": 171,
|
||||
"downloads": 0,
|
||||
"clones": 52994
|
||||
},
|
||||
{
|
||||
"date": "2026-10-16",
|
||||
"stars": 3166,
|
||||
"forks": 173,
|
||||
"downloads": 0,
|
||||
"clones": 53745
|
||||
},
|
||||
{
|
||||
"date": "2026-10-17",
|
||||
"stars": 3170,
|
||||
"forks": 173,
|
||||
"downloads": 0,
|
||||
"clones": 54566
|
||||
},
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"stars": 3174,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 55293
|
||||
},
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"stars": 3180,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 56005
|
||||
},
|
||||
{
|
||||
"date": "2026-10-20",
|
||||
"stars": 3191,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 56913
|
||||
},
|
||||
{
|
||||
"date": "2026-10-21",
|
||||
"stars": 3205,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 57536
|
||||
},
|
||||
{
|
||||
"date": "2026-10-22",
|
||||
"stars": 3214,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 58291
|
||||
},
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"stars": 3220,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 58892
|
||||
},
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"stars": 3226,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 59792
|
||||
},
|
||||
{
|
||||
"date": "2026-10-25",
|
||||
"stars": 3229,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 59792
|
||||
},
|
||||
{
|
||||
"date": "2026-10-26",
|
||||
"stars": 3234,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 59792
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
|
||||
# provider, then restore the exact source spelling afterwards. Keep longer
|
||||
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
|
||||
PROTECTED_TECHNICAL_TERMS = (
|
||||
# Product names, kept whole. Left to a translator, "Proxmox Backup Server"
|
||||
# became "Proxmox Sicherungsserver" and "Serveur de sauvegarde Proxmox",
|
||||
# and when a provider rightly handed it back unchanged the string was
|
||||
# retried and then dropped as untranslated.
|
||||
"Proxmox Backup Server",
|
||||
"Proxmox Datacenter Manager",
|
||||
"Proxmox Mail Gateway",
|
||||
"Proxmox VE",
|
||||
"ProxMenux Monitor",
|
||||
"ProxMenux Optimizations",
|
||||
"Secure Gateway",
|
||||
"Tailscale",
|
||||
"google/gasket-driver",
|
||||
"feranick/gasket-driver",
|
||||
"libedgetpu1-std",
|
||||
@@ -102,6 +114,9 @@ PROTECTED_TECHNICAL_TERMS = (
|
||||
# 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",
|
||||
# The file an application is installed from. Its name is the one of the
|
||||
# file on disk; translated, it names something the menu does not offer.
|
||||
"oci-compose",
|
||||
"OCI",
|
||||
"beta",
|
||||
# Debian release names. "Bookworm" is a bird and a reader of books, and
|
||||
|
||||
@@ -73,6 +73,24 @@ TECHNICAL_TERMS = (
|
||||
"DKMS",
|
||||
"SSH",
|
||||
"API",
|
||||
"Flask",
|
||||
"SQLite",
|
||||
"Prometheus",
|
||||
"Grafana",
|
||||
"Uptime Kuma",
|
||||
"PWA",
|
||||
"Fail2Ban",
|
||||
"Tailscale",
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"Next.js",
|
||||
"Coral",
|
||||
"rclone",
|
||||
"journald",
|
||||
"gasket",
|
||||
"apex",
|
||||
"Alpine",
|
||||
"OpenVINO",
|
||||
)
|
||||
|
||||
PROTECTED_PATTERNS = (
|
||||
@@ -88,9 +106,13 @@ PROTECTED_PATTERNS = (
|
||||
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
|
||||
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
|
||||
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
|
||||
# Identifiers and encoded delimiters remain literal even outside <code>.
|
||||
re.compile(r"(?<![A-Za-z0-9_])_?[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+(?![A-Za-z0-9_])"),
|
||||
re.compile(r"\b[A-Za-z0-9]*(?:[a-z][A-Z])[A-Za-z0-9]*\b"),
|
||||
re.compile(r"&(?:[A-Za-z]+|#[0-9]+|#x[0-9A-Fa-f]+);"),
|
||||
)
|
||||
LITERAL_TAG_RE = re.compile(
|
||||
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
@@ -109,16 +131,23 @@ ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
||||
BARE_SENTINELS = False
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||
NON_TRANSLATABLE_KEYS = {
|
||||
"accent",
|
||||
"categoryslug",
|
||||
"command",
|
||||
"code",
|
||||
"href",
|
||||
"icon",
|
||||
"iconbg",
|
||||
"id",
|
||||
"menucmd",
|
||||
"path",
|
||||
"route",
|
||||
"slug",
|
||||
"src",
|
||||
"url",
|
||||
"color",
|
||||
"tone",
|
||||
"variant",
|
||||
}
|
||||
SOURCE_STATE_VERSION = 1
|
||||
|
||||
@@ -186,12 +215,34 @@ def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
|
||||
if not source.strip() or not re.search(r"[A-Za-z]", source):
|
||||
return True
|
||||
last = str(path[-1]).lower() if path else ""
|
||||
# These fields describe the surrounding snippet; they are prose, even
|
||||
# though their names end in "Code".
|
||||
if last in {"aftercode", "beforecode"}:
|
||||
return False
|
||||
# This UI breadcrumb is prose, rather than a filesystem path.
|
||||
if last == "uipath" and "<strong>" in source:
|
||||
return False
|
||||
if last == "flow" and source in {"loader", "dedicated"}:
|
||||
return True
|
||||
# These exact values drive rendering/navigation; similarly named visible
|
||||
# labels remain translatable.
|
||||
if last == "branch" and source in {"main", "develop", "feature/*"}:
|
||||
return True
|
||||
if last == "kind" and source in {"internal", "external"}:
|
||||
return True
|
||||
if last == "key" and source in {"linux", "nas", "windows"}:
|
||||
return True
|
||||
if last in NON_TRANSLATABLE_KEYS or any(
|
||||
last.endswith(suffix)
|
||||
for suffix in ("url", "href", "path", "command", "code", "icon")
|
||||
):
|
||||
return True
|
||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
|
||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/\S+", source):
|
||||
return True
|
||||
# Standalone literal blocks contain examples, not prose. Separators between
|
||||
# several blocks do not make their command/configuration contents translatable.
|
||||
without_literals = LITERAL_TAG_RE.sub("", source)
|
||||
if without_literals != source and not re.search(r"[A-Za-z]", ANY_TAG_RE.sub("", without_literals)):
|
||||
return True
|
||||
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
|
||||
return True
|
||||
@@ -202,6 +253,15 @@ def leaf_token(path: tuple[str | int, ...]) -> str:
|
||||
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def literal_values_match(source: Any, target: Any) -> bool:
|
||||
"""Literal commands and rendering metadata must follow the source too."""
|
||||
return all(
|
||||
not should_copy(leaf.source, leaf.path)
|
||||
or get_at_path(target, leaf.path) == leaf.source
|
||||
for leaf in iter_leaves(source)
|
||||
)
|
||||
|
||||
|
||||
def source_fingerprints(source: Any) -> dict[str, str]:
|
||||
return {
|
||||
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
|
||||
@@ -240,7 +300,7 @@ def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
original = match.group(1)
|
||||
if original.lower() in {"code", "kbd", "pre"}:
|
||||
if original.lower() in {"code", "codenw", "codexs", "kbd", "pre"}:
|
||||
return match.group(0)
|
||||
key = original.lower()
|
||||
internal = names.get(key)
|
||||
@@ -329,11 +389,14 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
||||
|
||||
|
||||
def restore_text(text: str, mapping: dict[str, str]) -> str:
|
||||
missing = [token for token in mapping if token not in text]
|
||||
# A local model may append a digit to a bare sentinel. A substring match
|
||||
# would silently restore the prefix and leak the extra digit into prose.
|
||||
patterns = {token: re.compile(re.escape(token) + r"(?![0-9])") for token in mapping}
|
||||
missing = [token for token, pattern in patterns.items() if not pattern.search(text)]
|
||||
if missing:
|
||||
raise ValueError(f"translation provider changed protected token {missing[0]}")
|
||||
for token, original in mapping.items():
|
||||
text = text.replace(token, original)
|
||||
text = patterns[token].sub(lambda match: original, text)
|
||||
return text
|
||||
|
||||
|
||||
@@ -347,6 +410,11 @@ def validate_contract(source: str, target: str) -> None:
|
||||
raise ValueError("literal code changed")
|
||||
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||
raise ValueError("placeholder contract changed")
|
||||
# Bare numeric protection markers once leaked a trailing zero just
|
||||
# outside an otherwise intact code span. Literal/ICU checks cannot see it.
|
||||
residue = re.compile(r'(?:</(?:code|codeNw|codeXs|pre|kbd)>|<strong>)(0+)(?=\s|[;:/<().,-]|$)')
|
||||
if len(residue.findall(target)) > len(residue.findall(source)):
|
||||
raise ValueError("unexpected numeric residue next to literal markup")
|
||||
|
||||
|
||||
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||
@@ -822,7 +890,10 @@ def main() -> int:
|
||||
leaves = pending_leaves(source, target, queued_tokens)
|
||||
total_strings += len(iter_leaves(source))
|
||||
missing_strings += len(leaves)
|
||||
schema_changed = not schema_matches(source, target)
|
||||
schema_changed = (
|
||||
not schema_matches(source, target)
|
||||
or not literal_values_match(source, target)
|
||||
)
|
||||
if leaves or schema_changed:
|
||||
pending.append(
|
||||
(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check active terminal translations offline, without generating any text.
|
||||
|
||||
Validates formatting fields and literal commands/identifiers. Missing values
|
||||
can retain English fallback; --require-complete makes them errors. This does
|
||||
not establish the accuracy or fluency of the translated prose.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from build_translation_cache import (
|
||||
DEFAULT_LANGUAGES, STRUCTURAL_LITERAL_RE, extract_catalog_texts,
|
||||
extract_python_texts, extract_translate_texts,
|
||||
)
|
||||
from build_web_docs_i18n import read_json, validate_contract
|
||||
|
||||
|
||||
def active_sources(root: Path) -> list[str]:
|
||||
# Match build-translation-cache.yml, including launchers outside scripts/.
|
||||
extras = [root / name for name in ('menu', 'install_proxmenux.sh', 'install_proxmenux_beta.sh')]
|
||||
texts = extract_translate_texts(root / 'scripts', extras)
|
||||
texts += extract_translate_texts(root / 'oci/remote')
|
||||
texts += extract_python_texts([root / 'scripts', root / 'oci/src', root / 'oci/remote'])
|
||||
texts += extract_catalog_texts([root / 'oci/catalog'])
|
||||
return sorted({text for text in texts if '$' not in text and '`' not in text})
|
||||
|
||||
|
||||
def validate_message(source: str, target: str, *, title_separator: bool = False) -> None:
|
||||
validate_contract(source, target)
|
||||
for match in STRUCTURAL_LITERAL_RE.finditer(source):
|
||||
literal = match.group()
|
||||
# A page title may replace its decorative separator with a dash.
|
||||
# In command examples, a pipe remains a shell operator.
|
||||
if title_separator and literal == '|':
|
||||
continue
|
||||
# DNS hostnames are case-insensitive. Paths, field names and command
|
||||
# names are not, so do not case-fold those to hide a broken example.
|
||||
is_hostname = re.fullmatch(r'[A-Za-z0-9.-]+\.(?:com|org|net|io)', literal)
|
||||
if literal not in target and not (is_hostname and literal.casefold() in target.casefold()):
|
||||
raise ValueError(f'literal command or identifier changed: {literal}')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root', type=Path, default=Path('.'))
|
||||
parser.add_argument('--languages', default=','.join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument('--require-complete', action='store_true')
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
texts = active_sources(root)
|
||||
errors = 0
|
||||
for language in args.languages.split(','):
|
||||
if language not in DEFAULT_LANGUAGES:
|
||||
parser.error(f'Unsupported locale: {language}')
|
||||
catalog = read_json(root / 'lang' / f'{language}.json') or {}
|
||||
missing = 0
|
||||
for source in texts:
|
||||
target = catalog.get(source)
|
||||
if not isinstance(target, str) or not target.strip():
|
||||
missing += 1
|
||||
continue
|
||||
try:
|
||||
validate_message(source, target)
|
||||
except ValueError as error:
|
||||
print(f'[{language}] {source!r}: {error}')
|
||||
errors += 1
|
||||
print(f'[{language}] active={len(texts)} missing={missing}')
|
||||
if args.require_complete and missing:
|
||||
errors += 1
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check documentation translation structure and literal contracts offline.
|
||||
|
||||
Missing locales/leaves are allowed by default because the website falls back
|
||||
to English. Use --require-complete when validating a catalog completion PR.
|
||||
This checks data integrity, not translation fluency or linguistic accuracy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from build_web_docs_i18n import (
|
||||
DEFAULT_LANGUAGES,
|
||||
get_at_path,
|
||||
iter_leaves,
|
||||
literal_values_match,
|
||||
read_json,
|
||||
schema_matches,
|
||||
source_files,
|
||||
)
|
||||
from check_terminal_catalogs import validate_message
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
|
||||
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument("--require-complete", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = args.messages_dir.resolve()
|
||||
files = source_files(root / "en", ".")
|
||||
errors = 0
|
||||
for language in args.languages.split(","):
|
||||
language = language.strip()
|
||||
if language not in DEFAULT_LANGUAGES:
|
||||
parser.error(f"Unsupported locale: {language}")
|
||||
missing = checked = present_files = 0
|
||||
for file in files:
|
||||
relative = file.relative_to(root / "en")
|
||||
source = read_json(file)
|
||||
target = read_json(root / language / relative)
|
||||
if target is not None:
|
||||
present_files += 1
|
||||
if args.require_complete and not schema_matches(source, target):
|
||||
print(f"[{language}] {relative}: source/target schema differs")
|
||||
errors += 1
|
||||
for leaf in iter_leaves(source):
|
||||
localized = get_at_path(target, leaf.path)
|
||||
if not isinstance(localized, str) or (leaf.source.strip() and not localized.strip()):
|
||||
missing += 1
|
||||
continue
|
||||
try:
|
||||
validate_message(
|
||||
leaf.source, localized,
|
||||
title_separator=bool(
|
||||
leaf.path and leaf.path[0] == "meta"
|
||||
and leaf.path[-1] in ("title", "ogTitle", "twitterTitle")
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"[{language}] {relative}:{'.'.join(map(str, leaf.path))}: {exc}")
|
||||
errors += 1
|
||||
checked += 1
|
||||
if args.require_complete and not literal_values_match(source, target):
|
||||
print(f"[{language}] {relative}: literal commands or rendering metadata differ")
|
||||
errors += 1
|
||||
print(f"[{language}] files={present_files}/{len(files)} strings={checked} missing={missing}")
|
||||
if args.require_complete and missing:
|
||||
errors += 1
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -19,7 +19,7 @@ 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"
|
||||
DISCUSSION = f"{REPO}/discussions/440"
|
||||
MAINTAINERS = {"macrimi"}
|
||||
LAB = "laboratory-validated"
|
||||
|
||||
@@ -102,7 +102,7 @@ def render(verification, index):
|
||||
"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}).",
|
||||
f"Questions and ideas about OCI testing go in [discussion #440]({DISCUSSION}).",
|
||||
"",
|
||||
"## Tested by the community",
|
||||
"",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only the independently qualified, host-independent tests/ fixtures.
|
||||
"""Run only the independently qualified, host-independent 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.
|
||||
@@ -10,8 +10,19 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"tests/test_lxc_update_jobs.py",
|
||||
"tests/test_post_install_i18n.py",
|
||||
"tests/test_remaining_cli_i18n.py",
|
||||
"AppImage/scripts/tests/test_pbs_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links.py",
|
||||
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
|
||||
"AppImage/scripts/tests/test_local_app_icons.py",
|
||||
"AppImage/scripts/tests/test_docker_app_discovery.py",
|
||||
"AppImage/scripts/tests/test_native_app_discovery.py",
|
||||
"tests/test_audit_presentation.py",
|
||||
"tests/test_audit_safety_wording.py",
|
||||
"tests/test_audit_policy.py",
|
||||
@@ -20,8 +31,22 @@ PYTHON_FILES = (
|
||||
"tests/storage/test_nvme_status_message.py",
|
||||
"tests/test_fastfetch_config_generation.py",
|
||||
"tests/test_notification_active_payloads.py",
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/test_lxc_update_jobs.cjs",
|
||||
"tests/test_api_import_boundaries.cjs",
|
||||
"tests/test_api_errors.cjs",
|
||||
"tests/test_optimization_labels.cjs",
|
||||
"tests/test_optimization_source.cjs",
|
||||
"tests/lxc_updates/test_app_editor_errors.cjs",
|
||||
"tests/test_oci_update_cancellation.cjs",
|
||||
"tests/test_host_backup_messages.cjs",
|
||||
"tests/test_pbs_i18n.cjs",
|
||||
"tests/test_monitor_links_i18n.cjs",
|
||||
"tests/lxc_updates/test_local_app_icons_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_delegated_ui.cjs",
|
||||
"tests/test_audit_diagnostic_document.cjs",
|
||||
"tests/test_audit_policy.cjs",
|
||||
@@ -69,9 +94,13 @@ def main() -> int:
|
||||
if args.lane == "python":
|
||||
folder, filename = str(Path(path).parent), Path(path).name
|
||||
command = [sys.executable, "-I", "-B", "-c", PYTHON_RUN, folder, filename]
|
||||
with tempfile.TemporaryDirectory(prefix="qualified-offline-") as scratch:
|
||||
environment = os.environ.copy()
|
||||
environment["TMPDIR"] = scratch
|
||||
result = subprocess.run(command, cwd=root, env=environment, check=False)
|
||||
else:
|
||||
command = ["node", path]
|
||||
result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False)
|
||||
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
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"Apagar una máquina virtual de forma segura",
|
||||
"Mostrar estado de VM",
|
||||
"Mostrar configuración de VM",
|
||||
"Crear una instantánea de VM",
|
||||
"Crear un snapshot de VM",
|
||||
"Listar todos los contenedores LXC",
|
||||
"Iniciar un contenedor LXC",
|
||||
"Detener un contenedor LXC",
|
||||
@@ -126,7 +126,7 @@
|
||||
"Dateieigentümer ändern",
|
||||
"Tar.gz-Archiv extrahieren",
|
||||
"Tar.gz-Archiv erstellen",
|
||||
"Festplattennutzung anzeigen",
|
||||
"Laufwerksnutzung anzeigen",
|
||||
"Verzeichnisgrößen anzeigen",
|
||||
"Speichernutzung anzeigen",
|
||||
"Laufende Prozesse anzeigen",
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Run only the Bashrc functions, with all root files in a private fixture."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
FLOWS = ("auto_post_install.sh", "customizable_post_install.sh")
|
||||
BEGIN = b"# BEGIN PMX_CORE_BASHRC\n"
|
||||
END = b"# END PMX_CORE_BASHRC\n"
|
||||
# The unmarked block shipped in v1.1.3 (fc974995), without its leading blank line.
|
||||
LEGACY = rb'''# ProxMenux customizations
|
||||
export HISTTIMEFORMAT="%d/%m/%y %T "
|
||||
export PS1="\[\e[31m\][\[\e[m\]\[\e[38;5;172m\]\u\[\e[m\]@\[\e[38;5;153m\]\h\[\e[m\] \[\e[38;5;214m\]\W\[\e[m\]\[\e[31m\]]\[\e[m\]\\$ "
|
||||
alias l='ls -CF'
|
||||
alias la='ls -A'
|
||||
alias ll='ls -alF'
|
||||
alias ls='ls --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias fgrep='fgrep --color=auto'
|
||||
alias egrep='egrep --color=auto'
|
||||
source /etc/profile.d/bash_completion.sh
|
||||
'''
|
||||
|
||||
|
||||
def extract(source, name):
|
||||
match = re.search(rf"(?ms)^{name}\(\) \{{\n.*?^\}}$", source)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing function: {name}")
|
||||
return match.group()
|
||||
|
||||
|
||||
class BashrcMigrationTests(unittest.TestCase):
|
||||
def run_flow(self, flow, original, times=1, injection="", style=""):
|
||||
with tempfile.TemporaryDirectory(prefix="bashrc-migration-") as temporary:
|
||||
work = Path(temporary)
|
||||
bashrc = work / ".bashrc"
|
||||
bashrc.write_bytes(original)
|
||||
bashrc.chmod(0o640)
|
||||
before = bashrc.stat()
|
||||
(work / "bin").mkdir()
|
||||
# The embedded Python must use the interpreter running this test.
|
||||
(work / "bin/python3").symlink_to(sys.executable)
|
||||
source = (ROOT / "scripts/post_install" / flow).read_text()
|
||||
functions = "\n".join(extract(source, name) for name in
|
||||
("_migrate_proxmenux_bashrc", "customize_bashrc"))
|
||||
# Relocate only the fixture copy; never source the administrative script.
|
||||
functions = functions.replace("/root/", str(work) + "/")
|
||||
prelude = f'source {shlex.quote(str(ROOT / "scripts/global/pmx_journal.sh"))}\n'
|
||||
prelude += r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_info() { :; }
|
||||
msg_info2() { :; }
|
||||
msg_warn() { :; }
|
||||
msg_error() { printf '%s\n' "$*" >&2; }
|
||||
msg_ok() { printf 'success\n' >> "$FIXTURE_ROOT/messages"; }
|
||||
register_tool() { printf '%s|%s|%s\n' "$@" >> "$FIXTURE_ROOT/registry"; }
|
||||
'''
|
||||
calls = "\n".join(
|
||||
f'customize_bashrc; rc=$?; printf "%s\\n" "$rc" >> "$FIXTURE_ROOT/status"; '
|
||||
f'cp "$FIXTURE_ROOT/.bashrc" "$FIXTURE_ROOT/pass-{i}"'
|
||||
for i in range(times))
|
||||
env = os.environ.copy()
|
||||
env.update(HOME=str(work), FIXTURE_ROOT=str(work), TMPDIR=str(work),
|
||||
PMX_JOURNAL_ROOT=str(work / "journal"), PMX_BASHRC_PATH_STYLE=style,
|
||||
PATH=str(work / "bin") + os.pathsep + env.get("PATH", ""))
|
||||
result = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c",
|
||||
prelude + functions + "\n" + injection + "\n" + calls],
|
||||
cwd=work, env=env, stdin=subprocess.DEVNULL, capture_output=True,
|
||||
text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
after = bashrc.stat()
|
||||
def read(name):
|
||||
path = work / name
|
||||
return path.read_bytes() if path.exists() else b""
|
||||
return {
|
||||
"passes": [read(f"pass-{i}") for i in range(times)],
|
||||
"status": read("status").splitlines(), "registry": read("registry"),
|
||||
"messages": read("messages"), "backup": read(".bashrc.bak"),
|
||||
"profile": read(".bash_profile"), "stderr": result.stderr,
|
||||
"detected_style": read("style"),
|
||||
"before": (stat.S_IMODE(before.st_mode), before.st_uid, before.st_gid),
|
||||
"after": (stat.S_IMODE(after.st_mode), after.st_uid, after.st_gid),
|
||||
"inode_before": before.st_ino, "inode_after": after.st_ino,
|
||||
"leftovers": [p.name for p in work.iterdir() if "proxmenux" in p.name],
|
||||
"journal": [json.loads(p.read_text()) for p in
|
||||
sorted((work / "journal/spool").glob("*.json"))],
|
||||
}
|
||||
|
||||
def test_inspect_detects_style_without_rewriting_the_original(self):
|
||||
for flow in FLOWS:
|
||||
for block in (LEGACY, LEGACY.replace(rb"\W", rb"\w")):
|
||||
with self.subTest(flow=flow, full=rb"\w" in block):
|
||||
injection = r'''customize_bashrc() {
|
||||
_migrate_proxmenux_bashrc "$FIXTURE_ROOT/.bashrc" inspect > "$FIXTURE_ROOT/style"
|
||||
}'''
|
||||
run = self.run_flow(flow, block, injection=injection)
|
||||
self.assertEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [block])
|
||||
self.assertEqual(run["detected_style"], b"full\n" if rb"\w" in block else b"short\n")
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["backup"], b"")
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["journal"], [])
|
||||
|
||||
def test_historical_blocks_migrate_once_with_prompt_backup_and_journal(self):
|
||||
for flow in FLOWS:
|
||||
for full in (False, True):
|
||||
legacy = LEGACY.replace(rb"\W", rb"\w") if full else LEGACY
|
||||
blocks = (legacy, b"${marker_begin}\n" + legacy + b"${marker_end}\n",
|
||||
BEGIN + legacy + END, legacy + BEGIN + legacy + END)
|
||||
for block in blocks:
|
||||
with self.subTest(flow=flow, full=full, block=block[:40]):
|
||||
original = b"# user prefix\n" + block + b"# user suffix\n"
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
first, second = run["passes"]
|
||||
self.assertEqual(first, second)
|
||||
self.assertTrue(first.startswith(b"# user prefix\n# user suffix\n"))
|
||||
self.assertEqual(first.count(BEGIN), 1)
|
||||
self.assertEqual(first.count(END), 1)
|
||||
self.assertEqual(first.count(b"alias ll="), 1)
|
||||
self.assertNotIn(b"${marker_", first)
|
||||
self.assertIn(rb"\w" if full else rb"\W", first)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(len(run["profile"].splitlines()), 1)
|
||||
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 2)
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
entries = [e for e in run["journal"] if e["target"].endswith("/.bashrc")]
|
||||
self.assertEqual(len(entries), 1)
|
||||
entry = entries[0]
|
||||
self.assertEqual(entry["operation"], "write_file")
|
||||
self.assertEqual(entry["exactness"], "exact")
|
||||
self.assertEqual(entry["capture"], "present")
|
||||
self.assertNotEqual(entry["before"], entry["after"])
|
||||
self.assertTrue(all("proxmenux." not in e["target"] for e in run["journal"]))
|
||||
syntax = subprocess.run(["bash", "-n"], input=first, capture_output=True)
|
||||
self.assertEqual(syntax.returncode, 0, syntax.stderr)
|
||||
|
||||
def test_incomplete_and_oversized_ranges_are_preserved_on_rerun(self):
|
||||
fragments = (LEGACY.rsplit(b"source", 1)[0], END,
|
||||
b"${marker_begin}\n# user\n", b"${marker_end}\n",
|
||||
BEGIN + b"# user\n" * 33 + END,
|
||||
b"${marker_begin}\n" + b"# user\n" * 33 + b"${marker_end}\n",
|
||||
BEGIN + b"# user\n${marker_begin}\n# inner\n${marker_end}\n# suffix\n")
|
||||
for flow in FLOWS:
|
||||
for fragment in fragments:
|
||||
with self.subTest(flow=flow, fragment=fragment[:40]):
|
||||
run = self.run_flow(flow, fragment, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
# Complete inner ranges can migrate, but their outer prefix cannot.
|
||||
preserved = BEGIN + b"# user\n# suffix\n" if b"# inner" in fragment else fragment
|
||||
self.assertTrue(run["passes"][0].startswith(preserved))
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
|
||||
def test_nested_ranges_with_outer_end_are_preserved_on_repeated_runs(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
for flow in FLOWS:
|
||||
for outer_begin, outer_end in markers:
|
||||
for inner_begin, inner_end in markers:
|
||||
with self.subTest(flow=flow, outer=outer_begin, inner=inner_begin):
|
||||
original = (outer_begin + b"# OUTER_USER_PREFIX\n"
|
||||
b"user_function() { :; }\n" + inner_begin
|
||||
+ b"# old inner block\n" + inner_end
|
||||
+ b"# OUTER_USER_SUFFIX\n" + outer_end)
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
for content in run["passes"]:
|
||||
self.assertTrue(content.startswith(original), content)
|
||||
self.assertEqual(content.count(BEGIN), original.count(BEGIN) + 1)
|
||||
self.assertEqual(content.count(END), original.count(END) + 1)
|
||||
self.assertEqual(content.count(b"# ProxMenux core customizations\n"), 1)
|
||||
self.assertEqual(run["passes"], [run["passes"][0]] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
|
||||
def test_crossed_marker_families_preserve_every_byte_on_repeated_runs(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
for flow in FLOWS:
|
||||
canonical = self.run_flow(flow, b"")["passes"][0]
|
||||
for outer, inner in (markers, markers[::-1]):
|
||||
for newline in (b"\n", b"\r\n"):
|
||||
with self.subTest(flow=flow, outer=outer[0], newline=newline):
|
||||
original = (outer[0] + b"user_prefix() { :; } # \xff\n"
|
||||
+ inner[0] + outer[1]
|
||||
+ b"user_suffix() { :; }\n" + inner[1])
|
||||
original = original.replace(b"\n", newline)
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
self.assertEqual(run["passes"], [original + canonical] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 3)
|
||||
|
||||
def test_marker_ambiguity_matrix_preserves_spans_without_capturing_separate_blocks(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
prefix = b"user_prefix() { :; }\n"
|
||||
suffix = b"user_suffix() { :; }\n"
|
||||
for flow in FLOWS:
|
||||
canonical = self.run_flow(flow, b"")["passes"][0]
|
||||
for (begin, end), (foreign_begin, foreign_end) in (markers, markers[::-1]):
|
||||
# Each malformed complete span is ambiguous, even if the foreign
|
||||
# token has no partner. Never expose its user bytes on a rerun.
|
||||
ambiguous = {
|
||||
"unmatched-inner-begin": begin + prefix + foreign_begin + suffix + end,
|
||||
"unmatched-foreign-end": begin + prefix + foreign_end + suffix + end,
|
||||
"inner-begin-extra-end": begin + prefix + foreign_begin + end + suffix + end,
|
||||
"nested-with-stray-end": (begin + prefix + foreign_begin + end
|
||||
+ foreign_end + suffix + end),
|
||||
"overlap-chain": (begin + prefix + foreign_begin + end + begin
|
||||
+ foreign_end + suffix + end),
|
||||
"ambiguous-legacy-signature": (begin + prefix + foreign_end
|
||||
+ LEGACY + suffix + end),
|
||||
"oversized-ambiguous-span": (begin + prefix + foreign_end
|
||||
+ b"# user\n" * 33 + suffix + end),
|
||||
}
|
||||
cases = [(name, fragment, fragment) for name, fragment in ambiguous.items()]
|
||||
# An unclosed old BEGIN is not an enclosing range. Its prefix
|
||||
# must survive, but independent complete blocks must still migrate.
|
||||
cases.extend([
|
||||
("orphan-before-same-family", begin + prefix + begin + b"old\n" + end + suffix,
|
||||
begin + prefix + suffix),
|
||||
("orphan-before-other-family", begin + prefix + foreign_begin + b"old\n"
|
||||
+ foreign_end + suffix, begin + prefix + suffix),
|
||||
("missing-outer-end", begin + prefix + foreign_begin + b"old\n"
|
||||
+ foreign_end + suffix + foreign_end, begin + prefix + suffix + foreign_end),
|
||||
("separated-valid-blocks", foreign_end + prefix + begin + b"old\n" + end
|
||||
+ suffix + foreign_begin + b"old\n" + foreign_end + foreign_begin,
|
||||
foreign_end + prefix + suffix + foreign_begin),
|
||||
("limit-32", begin + b"# old\n" * 31 + end + suffix, suffix),
|
||||
("over-limit-33", begin + b"# user\n" * 32 + end + suffix,
|
||||
begin + b"# user\n" * 32 + end + suffix),
|
||||
])
|
||||
for name, original, preserved in cases:
|
||||
with self.subTest(flow=flow, family=begin, case=name):
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
self.assertEqual(run["passes"], [preserved + canonical] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
|
||||
def test_path_override_and_empty_install_remain_idempotent(self):
|
||||
for flow in FLOWS:
|
||||
for style in ("short", "full"):
|
||||
with self.subTest(flow=flow, style=style):
|
||||
run = self.run_flow(flow, b"", times=2, style=style)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
self.assertIn(rb"\w" if style == "full" else rb"\W", run["passes"][0])
|
||||
|
||||
def test_preparation_and_commit_failures_leave_bashrc_untouched(self):
|
||||
failures = {
|
||||
"inspect": 'python3() { [[ "$3" == inspect ]] && return 71; command python3 "$@"; }',
|
||||
"migrate": 'python3() { [[ "$3" == migrate ]] && return 71; command python3 "$@"; }',
|
||||
"replace": '''python3() {
|
||||
command python3 -c 'import os, sys
|
||||
sys.argv = sys.argv[1:]
|
||||
def fail(*args):
|
||||
raise OSError("injected replace failure")
|
||||
os.replace = fail
|
||||
exec(sys.stdin.read())' "$@"
|
||||
}''',
|
||||
"backup": 'cp() { [[ "${@: -1}" == *.bak ]] && return 71; command cp "$@"; }',
|
||||
"mktemp": 'mktemp() { return 71; }',
|
||||
"copy": 'cp() { if [[ "$1" == -p ]]; then printf partial > "$3"; return 71; fi; command cp "$@"; }',
|
||||
"profile": 'pmx_append_file() { return 71; }',
|
||||
"commit": 'mv() { [[ "${@: -1}" == "$FIXTURE_ROOT/.bashrc" ]] && return 71; command mv "$@"; }',
|
||||
}
|
||||
original = b"# user\n" + LEGACY
|
||||
for flow in FLOWS:
|
||||
for failure, injection in failures.items():
|
||||
with self.subTest(flow=flow, failure=failure):
|
||||
run = self.run_flow(flow, original, injection=injection)
|
||||
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["messages"], b"")
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
self.assertFalse(any(e["target"].endswith("/.bashrc") for e in run["journal"]))
|
||||
with self.subTest(flow=flow, failure="invalid-style"):
|
||||
run = self.run_flow(flow, original, style="invalid")
|
||||
self.assertNotEqual(run["status"], [b"0"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
|
||||
def test_migration_preserves_unrelated_non_utf8_bytes_and_line_endings(self):
|
||||
prefix = b"# user bytes: \xff\r\n"
|
||||
suffix = b"user_function() { :; }\r\n"
|
||||
original = prefix + BEGIN + b"old block\n" + END + suffix
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
self.assertTrue(run["passes"][0].startswith(prefix + suffix))
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
|
||||
def test_failed_block_write_preserves_original_without_success(self):
|
||||
original = b"# user content\n" + BEGIN + b"old managed block\n" + END
|
||||
# Fail the real stdin writer after a partial canonical-block write.
|
||||
# The override is confined to this shell fixture, not production.
|
||||
injection = r'''
|
||||
cat() {
|
||||
if [[ $# == 0 ]]; then
|
||||
local first
|
||||
IFS= read -r first
|
||||
if [[ "$first" == '# BEGIN PMX_CORE_BASHRC' ]]; then
|
||||
printf '# partial block'
|
||||
return 71
|
||||
fi
|
||||
printf '%s\n' "$first"
|
||||
fi
|
||||
command cat "$@"
|
||||
}
|
||||
'''
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, injection=injection)
|
||||
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["messages"], b"")
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
|
||||
def test_orphan_begin_cannot_consume_user_content_on_rerun(self):
|
||||
original = BEGIN + b"user_function() { :; }\n# user suffix\n"
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
for content in run["passes"]:
|
||||
self.assertTrue(content.startswith(original), content)
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -60,6 +60,124 @@ class WebDocsI18nTests(unittest.TestCase):
|
||||
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
||||
self.assertEqual(MODULE.pending_leaves(source, target), [])
|
||||
|
||||
def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self):
|
||||
for key, value in (("tone", "amber"), ("variant", "source"),
|
||||
("accent", "border-blue-300 bg-blue-50"),
|
||||
("iconBg", "bg-blue-100 text-blue-700"),
|
||||
("color", "green")):
|
||||
with self.subTest(key=key):
|
||||
self.assertTrue(MODULE.should_copy(value, ("steps", 0, key)))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value})
|
||||
self.assertFalse(MODULE.should_copy("Translation", ("type",)))
|
||||
self.assertFalse(MODULE.should_copy("English", ("lang",)))
|
||||
for value in ("loader", "dedicated"):
|
||||
self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow")))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
|
||||
def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self):
|
||||
for source in ("<code>apt update</code>",
|
||||
"<code>net0</code> / <code>net1</code>.",
|
||||
"/api/task-log/<upid>", "/api/vms/<vmid>/config"):
|
||||
with self.subTest(source=source):
|
||||
self.assertTrue(MODULE.should_copy(source, ("cell",)))
|
||||
self.assertFalse(MODULE.should_copy("Run <code>apt update</code> first.", ("body",)))
|
||||
self.assertFalse(MODULE.should_copy("After running the command, check its output.", ("afterCode",)))
|
||||
self.assertFalse(MODULE.should_copy("Before running the command, read this.", ("beforeCode",)))
|
||||
self.assertTrue(MODULE.should_copy("curl http://localhost:8008/api/health", ("curlCode",)))
|
||||
|
||||
def test_inline_identifiers_and_encoded_delimiters_round_trip(self):
|
||||
source = "Set RS_SKIP_PATHS, call _rs_apply_hot and keep localStorage at <host>."
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "<", ">"):
|
||||
self.assertIn(literal, mapping.values())
|
||||
self.assertNotIn(literal, protected)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_custom_code_spans_keep_commands_literal(self):
|
||||
for tag in ("codeNw", "codeXs"):
|
||||
source = f"Run <{tag}>pct config 100</{tag}>."
|
||||
with self.subTest(tag=tag):
|
||||
protected, mapping = MODULE.protect_rich_tags(source)
|
||||
self.assertEqual(protected, source)
|
||||
self.assertEqual(mapping, {})
|
||||
with self.assertRaisesRegex(ValueError, "literal code changed"):
|
||||
MODULE.validate_contract(source, source.replace("config", "configuration"))
|
||||
with self.assertRaisesRegex(ValueError, "numeric residue"):
|
||||
MODULE.validate_contract(source, source.replace(f"</{tag}>", f"</{tag}>0"))
|
||||
self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100</{tag}>", ("example",)))
|
||||
|
||||
def test_navigation_identifiers_are_distinct_from_visible_labels(self):
|
||||
values = {
|
||||
"categorySlug": ["system", "network"],
|
||||
"menuCmd": ["menu"],
|
||||
"branch": ["main", "develop", "feature/*"],
|
||||
"kind": ["internal", "external"],
|
||||
"key": ["nas", "linux", "windows"],
|
||||
}
|
||||
for key, items in values.items():
|
||||
for value in items:
|
||||
with self.subTest(key=key, value=value):
|
||||
self.assertTrue(MODULE.should_copy(value, ("cards", 0, key)))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
self.assertFalse(MODULE.should_copy("Translation", ("kind",)))
|
||||
self.assertFalse(MODULE.should_copy("Storage", ("branch",)))
|
||||
|
||||
def test_ui_breadcrumb_is_prose_while_paths_remain_literal(self):
|
||||
source = "In the UI: <strong>Datacenter → Backup → Retention</strong>:"
|
||||
self.assertFalse(MODULE.should_copy(source, ("retention", "uiPath")))
|
||||
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("uiPath",)))
|
||||
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("configPath",)))
|
||||
|
||||
def test_bare_sentinel_with_added_digit_is_rejected(self):
|
||||
with mock.patch.object(MODULE, "BARE_SENTINELS", True):
|
||||
protected, mapping = MODULE.protect_text("ProxMenux collects metrics.")
|
||||
token = next(iter(mapping))
|
||||
with self.assertRaisesRegex(ValueError, "changed protected token"):
|
||||
MODULE.restore_text(protected.replace(token, token + "0"), mapping)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), "ProxMenux collects metrics.")
|
||||
|
||||
def test_numeric_residue_outside_intact_literal_is_rejected(self):
|
||||
examples = [
|
||||
('Run <code>pct config</code> first.', 'Spustite <code>pct config</code>0 najprv.'),
|
||||
('<strong><code>Restart=on-failure</code></strong> restarts the service.',
|
||||
'<strong>0<code>Restart=on-failure</code></strong> reštartuje službu.'),
|
||||
]
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, 'numeric residue'):
|
||||
MODULE.validate_contract(source, target)
|
||||
MODULE.validate_contract('The result is <code>echo</code>0.',
|
||||
'Výsledok je <code>echo</code>0.')
|
||||
|
||||
def test_product_and_kernel_module_names_are_protected(self):
|
||||
source = "Flask uses SQLite and journald; rclone works with Coral, gasket, apex and Alpine."
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
for name in ("Flask", "SQLite", "journald", "rclone", "Coral", "gasket", "apex", "Alpine"):
|
||||
self.assertIn(name, mapping.values())
|
||||
self.assertNotIn(name, protected)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_literal_only_changes_sync_without_retranslating_human_prose(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "messages/en/common.json"
|
||||
target = root / "messages/de/common.json"
|
||||
source.parent.mkdir(parents=True)
|
||||
target.parent.mkdir(parents=True)
|
||||
source.write_text(json.dumps({"title": "Updates", "code": "tool --version=2", "tone": "blue"}))
|
||||
target.write_text(json.dumps({"title": "Aktualisierungen", "code": "tool --version=1", "tone": "blau"}))
|
||||
provider = mock.Mock(side_effect=AssertionError("Existing prose must not be retranslated"))
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(json.loads(target.read_text()), {
|
||||
"title": "Aktualisierungen", "code": "tool --version=2", "tone": "blue",
|
||||
})
|
||||
provider.assert_not_called()
|
||||
before = target.stat().st_mtime_ns
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(target.stat().st_mtime_ns, before)
|
||||
provider.assert_not_called()
|
||||
|
||||
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
||||
source = {"title": "Updated installation guidance"}
|
||||
target = {"title": "Vorherige Installationsanleitung"}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""The Changes view files a change under the post-install section by the name
|
||||
its script records itself with. Both post-install scripts have to be known to
|
||||
it, or what one of them changes is shown as the work of an unnamed script."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
VIEW = (ROOT / "AppImage/components/audit-changes.tsx").read_text()
|
||||
SCRIPTS = ("scripts/post_install/auto_post_install.sh", "scripts/post_install/customizable_post_install.sh")
|
||||
|
||||
|
||||
class PostInstallSourcesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
declared = re.search(r"const POST_INSTALL_SOURCES = new Set\(\[([^\]]*)\]\)", VIEW)
|
||||
self.assertIsNotNone(declared)
|
||||
self.known = set(re.findall(r'"([^"]+)"', declared.group(1)))
|
||||
|
||||
def test_every_post_install_script_is_known_by_the_name_it_records(self):
|
||||
for script in SCRIPTS:
|
||||
with self.subTest(script=script):
|
||||
source = re.search(r'^SCRIPT_SOURCE="([^"]+)"', (ROOT / script).read_text(), re.M)
|
||||
self.assertIsNotNone(source)
|
||||
self.assertIn(source.group(1), self.known)
|
||||
|
||||
def test_no_name_is_listed_that_no_script_records(self):
|
||||
recorded = {re.search(r'^SCRIPT_SOURCE="([^"]+)"', (ROOT / script).read_text(), re.M).group(1)
|
||||
for script in SCRIPTS}
|
||||
self.assertEqual(self.known, recorded)
|
||||
|
||||
def test_figurine_and_fastfetch_have_a_name_in_the_view(self):
|
||||
for function in ("configure_figurine", "configure_fastfetch"):
|
||||
self.assertRegex(VIEW, rf'\b{function}: "')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Exercise the Cloud-Init menu without network access or guest creation."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SELECTOR = ROOT / "scripts/vm/select_linux_iso.sh"
|
||||
|
||||
|
||||
class CloudInitLauncherTests(unittest.TestCase):
|
||||
def run_menu(self, selection="2", body=None, curl_exit=0, dialog_exit=0,
|
||||
installer_exit=0, stdin="\n"):
|
||||
source = SELECTOR.read_text(encoding="utf-8")
|
||||
match = re.search(r"(?ms)^function select_linux_cloudinit\(\) \{.*?^\}", source)
|
||||
assert match is not None, "Cloud-Init menu function not found"
|
||||
with tempfile.TemporaryDirectory(prefix="cloudinit-launcher-") as temporary:
|
||||
work = Path(temporary)
|
||||
(work / "menus").mkdir()
|
||||
(work / "menus/create_vm_menu.sh").write_text(
|
||||
'printf "menu-returned\\n" >> "$ACTION_LOG"\n', encoding="utf-8")
|
||||
environment = os.environ.copy()
|
||||
environment.update({
|
||||
"LOCAL_SCRIPTS": str(work), "SELECTION": selection,
|
||||
"DIALOG_EXIT": str(dialog_exit), "CURL_EXIT": str(curl_exit),
|
||||
"INSTALLER_EXIT": str(installer_exit),
|
||||
"CURL_BODY": body if body is not None else
|
||||
'printf "helper-executed\\n" >> "$ACTION_LOG"\nexit "$INSTALLER_EXIT"\n',
|
||||
"ACTION_LOG": str(work / "actions"),
|
||||
"CURL_ARGS": str(work / "curl-args"),
|
||||
"DIALOG_LOG": str(work / "dialog-args"),
|
||||
})
|
||||
prelude = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_success() { printf 'continue-prompt\n' >> "$ACTION_LOG"; }
|
||||
whiptail() { printf 'website-prompt\n' >> "$ACTION_LOG"; }
|
||||
dialog() {
|
||||
printf '%s\n' "$@" >> "$DIALOG_LOG"
|
||||
if [[ " $* " == *" --menu "* ]]; then
|
||||
printf '%s' "$SELECTION" >&2
|
||||
return "$DIALOG_EXIT"
|
||||
fi
|
||||
}
|
||||
curl() {
|
||||
printf '%s\n' "$@" > "$CURL_ARGS"
|
||||
printf '%s' "$CURL_BODY"
|
||||
return "$CURL_EXIT"
|
||||
}
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c",
|
||||
prelude + match.group() + "\nselect_linux_cloudinit\n"],
|
||||
input=stdin, capture_output=True, text=True, env=environment,
|
||||
timeout=5)
|
||||
def contents(name):
|
||||
path = work / name
|
||||
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
return result, contents("curl-args"), contents("dialog-args"), contents("actions")
|
||||
|
||||
def test_debian_uses_the_maintained_generic_installer(self):
|
||||
result, curl_args, _, actions = self.run_menu()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(curl_args.splitlines()[-1],
|
||||
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/debian-vm.sh")
|
||||
self.assertIn("helper-executed", actions)
|
||||
|
||||
|
||||
def test_ubuntu_uses_one_generic_entry_without_obsolete_release_labels(self):
|
||||
result, curl_args, menu, actions = self.run_menu(selection="3")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(curl_args.splitlines()[-1],
|
||||
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu-vm.sh")
|
||||
self.assertIn("helper-executed", actions)
|
||||
self.assertEqual(menu.count("(Cloud-Init automated)"), 3)
|
||||
for obsolete in ("Debian 13", "Debian 12", "Ubuntu 22.04", "Ubuntu 24.04",
|
||||
"Ubuntu 24.10", "Ubuntu 25.04"):
|
||||
self.assertNotIn(obsolete, menu)
|
||||
|
||||
|
||||
def test_failed_or_empty_download_never_runs_a_script(self):
|
||||
for body, exit_code in (("404: Not Found", 22),
|
||||
('printf "helper-executed\\n" >> "$ACTION_LOG"', 28),
|
||||
("", 0), (" \n\t", 0)):
|
||||
with self.subTest(body=body, exit_code=exit_code):
|
||||
result, _, messages, actions = self.run_menu(body=body, curl_exit=exit_code)
|
||||
self.assertNotIn("helper-executed", actions)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Error: Failed to download the script.", messages)
|
||||
self.assertNotIn("menu-returned", actions)
|
||||
|
||||
|
||||
def test_installer_failure_returns_its_status_without_success_navigation(self):
|
||||
result, _, _, actions = self.run_menu(installer_exit=17)
|
||||
self.assertEqual(result.returncode, 17, result.stderr)
|
||||
self.assertIn("helper-executed", actions)
|
||||
self.assertNotIn("continue-prompt", actions)
|
||||
self.assertNotIn("website-prompt", actions)
|
||||
self.assertNotIn("menu-returned", actions)
|
||||
|
||||
|
||||
def test_arch_and_successful_menu_navigation_are_preserved(self):
|
||||
result, curl_args, _, actions = self.run_menu(selection="1")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(curl_args.splitlines()[-1],
|
||||
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/archlinux-vm.sh")
|
||||
self.assertEqual(actions.splitlines(),
|
||||
["helper-executed", "continue-prompt", "website-prompt", "menu-returned"])
|
||||
arguments = curl_args.splitlines()
|
||||
self.assertIn("-fsSL", arguments)
|
||||
self.assertEqual(arguments[arguments.index("--connect-timeout") + 1], "5")
|
||||
self.assertEqual(arguments[arguments.index("--max-time") + 1], "60")
|
||||
|
||||
def test_return_and_cancel_do_not_download_or_execute(self):
|
||||
for selection, status, expected in (("4", 0, 1), ("2", 1, 0), ("2", 255, 0)):
|
||||
with self.subTest(selection=selection, status=status):
|
||||
result, curl_args, _, actions = self.run_menu(
|
||||
selection=selection, dialog_exit=status)
|
||||
self.assertEqual(result.returncode, expected, result.stderr)
|
||||
self.assertEqual(curl_args, "")
|
||||
self.assertEqual(actions, "")
|
||||
|
||||
def test_unknown_selection_is_rejected(self):
|
||||
result, curl_args, _, actions = self.run_menu(selection="999")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(curl_args, "")
|
||||
self.assertEqual(actions, "")
|
||||
|
||||
def test_installer_keeps_interactive_standard_input(self):
|
||||
body = 'IFS= read -r answer; printf "input:%s\\n" "$answer" >> "$ACTION_LOG"\n'
|
||||
result, _, _, actions = self.run_menu(body=body, stdin="answer with spaces\n\n")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("input:answer with spaces\n", actions)
|
||||
self.assertIn("menu-returned", actions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -80,133 +80,74 @@ class CommandDescriptionsTests(unittest.TestCase):
|
||||
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
|
||||
FIXTURE["locales"]["sv"][index])
|
||||
|
||||
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
|
||||
def assert_completed_catalogs_are_stable(self, *, future_messages=None):
|
||||
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))
|
||||
locales = ["en", *languages.split(",")]
|
||||
shipped = {ROOT / f"AppImage/messages/{lang}/common.json": catalog(lang) for lang in locales}
|
||||
original_bytes = {path: path.read_bytes() for path in shipped}
|
||||
|
||||
def strings(node, path=()):
|
||||
# Independent traversal: every existing nonempty string, including
|
||||
# explicit English and obsolete human keys, must survive completion.
|
||||
if isinstance(node, str) and node:
|
||||
yield path, node
|
||||
elif isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
yield from strings(value, (*path, key))
|
||||
elif isinstance(node, list):
|
||||
for index, value in enumerate(node):
|
||||
yield from strings(value, (*path, index))
|
||||
|
||||
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")}
|
||||
root = Path(directory).resolve()
|
||||
for path in shipped:
|
||||
destination = root / path.parent.name / "common.json"
|
||||
destination.parent.mkdir()
|
||||
shutil.copyfile(path, destination)
|
||||
if future_messages is not None:
|
||||
source = json.loads((root / "en/common.json").read_text())
|
||||
source["futureFeatureFixture"] = future_messages
|
||||
(root / "en/common.json").write_text(json.dumps(source))
|
||||
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
||||
output = io.StringIO()
|
||||
# Establish steady state with the real generator and a deterministic
|
||||
# offline provider. Translation happens only in disposable catalogs;
|
||||
# adding an EN-only feature never needs a hand-maintained seed list.
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=lambda text, lang, *_: f"TEST {lang} {text}") as seed, \
|
||||
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as initial_writer, \
|
||||
redirect_stdout(output), redirect_stderr(output):
|
||||
self.assertEqual(MODULE.main(), 0, output.getvalue())
|
||||
self.assertNotRegex(output.getvalue(), r"(?m)^Completed with [0-9]+ translation failures")
|
||||
self.assertTrue(all(call.args[0].is_relative_to(root) for call in initial_writer.call_args_list))
|
||||
for path, original in shipped.items():
|
||||
completed = json.loads((root / path.parent.name / "common.json").read_text())
|
||||
for keys, value in strings(original):
|
||||
actual = completed
|
||||
for key in keys:
|
||||
actual = actual[key]
|
||||
self.assertEqual(actual, value, f"{path.parent.name}: {keys}")
|
||||
# The recovered arrays remain exact, including frozen English.
|
||||
if path.parent.name != "en":
|
||||
self.assertEqual(completed["terminal"]["commandDescriptions"],
|
||||
FIXTURE["locales"].get(path.parent.name,
|
||||
original["terminal"]["commandDescriptions"]))
|
||||
if future_messages is not None and path.parent.name != "en":
|
||||
self.assertEqual(completed["futureFeatureFixture"], {
|
||||
"title": f"TEST {path.parent.name} Future feature {{count}}",
|
||||
"items": [f"TEST {path.parent.name} First item", {"label": f"TEST {path.parent.name} Second item"}],
|
||||
"enabled": False, "count": 2, "empty": [],
|
||||
})
|
||||
if future_messages is not None:
|
||||
self.assertGreaterEqual(seed.call_count, 3 * len(languages.split(",")))
|
||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||
output = io.StringIO()
|
||||
# Keep the original strict assertion: once completed, any provider
|
||||
# call or catalog write is a regression. No production locale edits.
|
||||
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, \
|
||||
@@ -218,6 +159,17 @@ class CommandDescriptionsTests(unittest.TestCase):
|
||||
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())
|
||||
self.assertEqual(original_bytes, {path: path.read_bytes() for path in shipped})
|
||||
|
||||
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
|
||||
self.assert_completed_catalogs_are_stable()
|
||||
|
||||
def test_new_english_feature_preserves_curated_values_and_reruns_without_writes(self):
|
||||
self.assert_completed_catalogs_are_stable(future_messages={
|
||||
"title": "Future feature {count}",
|
||||
"items": ["First item", {"label": "Second item"}],
|
||||
"enabled": False, "count": 2, "empty": [],
|
||||
})
|
||||
|
||||
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
|
||||
node = require_node()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Keep the canonical conduct policy and its English website source in sync."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DocumentationMarkdownTests(unittest.TestCase):
|
||||
def test_english_conduct_body_matches_canonical_policy(self):
|
||||
source = json.loads((ROOT / 'web/messages/en/docs/about/code-of-conduct.json').read_text(encoding='utf-8'))
|
||||
self.assertEqual(source['contentMarkdown'], (ROOT / 'CODE_OF_CONDUCT.md').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Keep machine generation explicit without losing automatic validation."""
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DocumentationReviewWorkflowTests(unittest.TestCase):
|
||||
def workflow(self, name):
|
||||
return yaml.load(
|
||||
(ROOT / '.github/workflows' / name).read_text(encoding='utf-8'),
|
||||
Loader=yaml.BaseLoader,
|
||||
)
|
||||
|
||||
def test_generation_requires_explicit_dispatch(self):
|
||||
triggers = self.workflow('build-web-docs-i18n.yml')['on']
|
||||
self.assertEqual(set(triggers), {'workflow_dispatch'})
|
||||
|
||||
def test_website_changes_keep_automatic_offline_validation(self):
|
||||
triggers = self.workflow('test-i18n.yml')['on']
|
||||
for event in ('pull_request', 'push'):
|
||||
with self.subTest(event=event):
|
||||
self.assertIn('web/**', triggers[event]['paths'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""The passthrough entries of a VM that belong to a GPU are the GPU, the audio
|
||||
at its slot and the devices of its IOMMU group. Another function at the same
|
||||
slot is a device of its own: on an AMD APU the USB controllers share the slot
|
||||
of the integrated graphics, and a VM that only has one of them does not use
|
||||
the GPU."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
HELPERS = ROOT / "scripts/global/pci_passthrough_helpers.sh"
|
||||
SCRIPTS = [ROOT / "scripts/gpu_tpu" / name
|
||||
for name in ("switch_gpu_mode.sh", "switch_gpu_mode_direct.sh", "add_gpu_vm.sh")]
|
||||
IGPU = "0000:05:00.0"
|
||||
# The functions of the slot of an AMD APU, and a discrete card.
|
||||
CLASSES = {"0000:05:00.0": "0x030000", "0000:05:00.1": "0x040300", "0000:05:00.3": "0x0c0330",
|
||||
"0000:05:00.4": "0x0c0330", "0000:05:00.6": "0x040300",
|
||||
"0000:01:00.0": "0x030000", "0000:01:00.1": "0x040300"}
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class GpuEntriesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.folder = Path(folder.name)
|
||||
for bdf, value in CLASSES.items():
|
||||
(self.folder / "sys" / bdf).mkdir(parents=True)
|
||||
(self.folder / "sys" / bdf / "class").write_text(value + "\n")
|
||||
|
||||
def entries(self, config, *group, gpu=IGPU):
|
||||
conf = self.folder / "vm.conf"
|
||||
conf.write_text(config)
|
||||
script = f'source "{HELPERS}"; _vm_gpu_hostpci_indexes "$1" "$2" "${{@:3}}"'
|
||||
done = subprocess.run(["bash", "-c", script, "bash", str(conf), gpu, *group], capture_output=True, text=True,
|
||||
env={"PMX_PCI_SYSFS": str(self.folder / "sys"), "PATH": "/usr/bin:/bin"}, check=False)
|
||||
self.assertEqual(done.stderr, "")
|
||||
return done.stdout.split()
|
||||
|
||||
def test_a_vm_with_only_a_usb_controller_of_that_slot_does_not_have_the_gpu(self):
|
||||
self.assertEqual(self.entries("hostpci0: 0000:01:00.0,pcie=1,x-vga=1\nhostpci1: 0000:01:00.1,pcie=1\n"
|
||||
"hostpci2: 0000:05:00.4,pcie=1\n"), [])
|
||||
|
||||
def test_the_gpu_goes_with_the_audio_of_its_slot_and_the_usb_stays(self):
|
||||
self.assertEqual(self.entries("hostpci0: 0000:05:00.0,pcie=1,x-vga=1\nhostpci1: 0000:05:00.1,pcie=1\n"
|
||||
"hostpci2: 0000:05:00.4,pcie=1\nhostpci3: 0000:05:00.6\n"), ["0", "1", "3"])
|
||||
|
||||
def test_a_device_of_the_same_iommu_group_goes_with_the_gpu(self):
|
||||
self.assertEqual(self.entries("hostpci0: 0000:05:00.0\nhostpci1: 0000:05:00.4\n", "0000:05:00.4"), ["0", "1"])
|
||||
|
||||
def test_the_whole_slot_carries_the_gpu(self):
|
||||
self.assertEqual(self.entries("hostpci0: 05:00,pcie=1\nhostpci1: 0000:01:00.0\n"), ["0"])
|
||||
|
||||
def test_several_devices_in_one_entry(self):
|
||||
self.assertEqual(self.entries("hostpci4: 0000:05:00.0;0000:05:00.1,pcie=1\n"), ["4"])
|
||||
|
||||
def test_addresses_are_read_with_or_without_domain_and_in_any_case(self):
|
||||
self.assertEqual(self.entries("hostpci0: 05:00.0,pcie=1\n", gpu="05:00.0"), ["0"])
|
||||
self.assertEqual(self.entries("hostpci0: 0000:0A:00.0\n", gpu="0000:0a:00.0"), ["0"])
|
||||
|
||||
def test_another_gpu_and_a_slot_that_only_looks_alike_are_left(self):
|
||||
# 00:02 inside 0000:02:00.0 is not the same device.
|
||||
self.assertEqual(self.entries("hostpci0: 0000:02:00.0\n", gpu="0000:00:02.0"), [])
|
||||
self.assertEqual(self.entries("hostpci0: 0000:01:00.0\nhostpci1: 0000:05:00.0\n", gpu="0000:01:00.0"), ["0"])
|
||||
|
||||
def test_snapshots_and_resource_mappings_are_not_read(self):
|
||||
self.assertEqual(self.entries("hostpci0: 0000:01:00.0\n[snap1]\nhostpci0: 0000:05:00.0\n"), [])
|
||||
self.assertEqual(self.entries("hostpci0: mapping=gpu1,pcie=1\nhostpci1: 0000:05:00.1\n"), [])
|
||||
|
||||
def test_the_gpu_scripts_decide_through_it(self):
|
||||
for script in SCRIPTS:
|
||||
with self.subTest(script=script.name):
|
||||
source = script.read_text()
|
||||
self.assertIn("_vm_gpu_hostpci_indexes", source)
|
||||
# No entry is deleted, or taken for the GPU, by its slot alone.
|
||||
self.assertNotIn("sed -E -i \"/^hostpci", source)
|
||||
self.assertNotRegex(source, r'grep -qE "hostpci\[0-9\]\+:\.\*\$\{pci_slot\}"')
|
||||
self.assertNotRegex(source, r"\$\{slot\}\(\\\\\.\[0-7\]\)\?")
|
||||
|
||||
def test_the_device_check_does_not_match_a_neighbour(self):
|
||||
script = (f'qm() {{ printf "hostpci0: 0000:05:00.4,pcie=1\\n"; }}; source "{HELPERS}"; '
|
||||
'_pci_slot_assigned_to_vm 0000:05:00.0 100 && echo gpu; '
|
||||
'_pci_slot_assigned_to_vm 0000:05:00.4 100 && echo usb; true')
|
||||
done = subprocess.run(["bash", "-c", script], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(done.stdout.split(), ["usb"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""The shared install functions are sourced by both post-install flows. An
|
||||
update that runs functions of the two sources the file twice in one shell,
|
||||
which has to be as quiet as the first time."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SHARED = ROOT / "scripts/global/utils-install-functions.sh"
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class SourcedTwiceTests(unittest.TestCase):
|
||||
def source(self, times):
|
||||
script = "; ".join([f'source "{SHARED}"'] * times) + '; echo "${PMX_NIC_LINK_DIR}|${#PMX_DKMS_COMPONENTS[@]}"'
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, check=False)
|
||||
|
||||
def test_a_second_source_prints_nothing(self):
|
||||
once, twice = self.source(1), self.source(2)
|
||||
self.assertEqual(twice.stderr, once.stderr)
|
||||
self.assertNotIn("readonly variable", twice.stderr)
|
||||
self.assertEqual(twice.returncode, 0)
|
||||
|
||||
def test_the_values_are_the_same_and_stay_read_only(self):
|
||||
self.assertEqual(self.source(2).stdout.strip(), "/etc/systemd/network|2")
|
||||
attempt = subprocess.run(["bash", "-c", f'source "{SHARED}"; PMX_NIC_LINK_DIR=/tmp'],
|
||||
capture_output=True, text=True, check=False)
|
||||
self.assertIn("readonly variable", attempt.stderr)
|
||||
|
||||
def test_no_other_shared_file_declares_a_read_only_variable_unguarded(self):
|
||||
for path in sorted((ROOT / "scripts/global").glob("*.sh")) + [ROOT / "scripts/utils.sh"]:
|
||||
for line in path.read_text(errors="replace").splitlines():
|
||||
self.assertFalse(line.startswith(("readonly ", "declare -r ")), f"{path.name}: {line[:60]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Every shipped terminal language must be selectable during installation."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
ROOT = SCRIPTS.parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
from build_translation_cache import DEFAULT_LANGUAGES
|
||||
|
||||
|
||||
class InstallerLanguageTests(unittest.TestCase):
|
||||
def test_both_installers_offer_every_shipped_language(self):
|
||||
expected = {'en', *DEFAULT_LANGUAGES}
|
||||
for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
with self.subTest(installer=name):
|
||||
source = (ROOT / name).read_text(encoding='utf-8')
|
||||
function = re.search(r'^select_language\(\) \{(.*?)^\}', source, re.M | re.S)
|
||||
self.assertIsNotNone(function, 'Missing installer language selector')
|
||||
offered = re.findall(r'^\s*"([a-z]{2})"\s+"[^"]+"', function.group(1), re.M)
|
||||
self.assertEqual(set(offered), expected)
|
||||
self.assertEqual(len(offered), len(expected), 'Duplicate language option')
|
||||
# English is the source text in the scripts, not a JSON catalog.
|
||||
for language in set(offered) - {'en'}:
|
||||
self.assertTrue((ROOT / 'lang' / f'{language}.json').is_file(), language)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Log2RAM syncs only the archived journal files, so the active file it found
|
||||
on disk when it was installed stays there and is restored at every boot.
|
||||
journald then continues an old sequence and deletes its newest archives first.
|
||||
The post-install makes the sync remove that file from the disk copy."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SHARED = ROOT / "scripts/global/utils-install-functions.sh"
|
||||
FLOWS = {"auto_post_install.sh": "install_log2ram_auto", "customizable_post_install.sh": "configure_log2ram"}
|
||||
|
||||
# The sync of Log2RAM in its journald-aware mode, as its project ships it.
|
||||
JOURNALD_AWARE = '''#!/usr/bin/env bash
|
||||
sync_to_disk() {
|
||||
is_safe
|
||||
|
||||
local optional_params=()
|
||||
|
||||
if journald_logrotate; then
|
||||
optional_params+=("--include=journal/*/*@*.journal")
|
||||
optional_params+=("--exclude=journal/*/*")
|
||||
fi
|
||||
|
||||
rsync -aXv --no-acls --delete-after "${optional_params[@]}" "${RAM_LOG}"/ "${HDD_LOG}"/
|
||||
}
|
||||
'''
|
||||
NOT_AWARE = '''#!/usr/bin/env bash
|
||||
sync_to_disk() {
|
||||
rsync -aXv --delete-after "${RAM_LOG}"/ "${HDD_LOG}"/
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def gnu_sed():
|
||||
return shutil.which("bash") and subprocess.run(["sed", "--version"], capture_output=True).returncode == 0
|
||||
|
||||
|
||||
@unittest.skipUnless(gnu_sed(), "bash and GNU sed are needed")
|
||||
class Log2ramJournalSyncTests(unittest.TestCase):
|
||||
def correct(self, folder, content, times=1):
|
||||
script = Path(folder) / "log2ram"
|
||||
script.write_text(content)
|
||||
script.chmod(0o755)
|
||||
calls = "; ".join(['pmx_log2ram_drop_stale_journal "$1"; echo "rc=$?"'] * times)
|
||||
# As the change journal does, the new content is read whole before it is written.
|
||||
write = 'pmx_write_file() { local content; content="$(cat)"; printf "%s\\n" "$content" > "$1"; }'
|
||||
run = subprocess.run(["bash", "-c", f'source "{SHARED}"; {write}; {calls}',
|
||||
"bash", str(script)], capture_output=True, text=True, check=False)
|
||||
return script, run.stdout.split()
|
||||
|
||||
def test_the_sync_removes_what_it_does_not_copy(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script, results = self.correct(folder, JOURNALD_AWARE)
|
||||
lines = script.read_text().splitlines()
|
||||
position = lines.index(' optional_params+=("--exclude=journal/*/*")')
|
||||
self.assertEqual(lines[position + 1], ' optional_params+=("--delete-excluded")')
|
||||
self.assertEqual(lines[position + 2], " fi")
|
||||
self.assertEqual(results, ["rc=0"])
|
||||
self.assertEqual(subprocess.run(["bash", "-n", str(script)]).returncode, 0)
|
||||
self.assertTrue(script.stat().st_mode & stat.S_IXUSR)
|
||||
self.assertEqual(Path(str(script) + ".proxmenux.bak").read_text(), JOURNALD_AWARE)
|
||||
|
||||
def test_it_is_applied_once(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script, results = self.correct(folder, JOURNALD_AWARE, times=2)
|
||||
self.assertEqual(results, ["rc=0", "rc=1"])
|
||||
self.assertEqual(script.read_text().count("--delete-excluded"), 1)
|
||||
|
||||
def test_a_sync_that_copies_every_file_is_left_as_it_is(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script, results = self.correct(folder, NOT_AWARE)
|
||||
self.assertEqual(results, ["rc=1"])
|
||||
self.assertEqual(script.read_text(), NOT_AWARE)
|
||||
self.assertFalse(Path(str(script) + ".proxmenux.bak").exists())
|
||||
|
||||
def test_both_flows_apply_it_to_new_and_existing_installations(self):
|
||||
for name, function in FLOWS.items():
|
||||
with self.subTest(flow=name):
|
||||
text = (ROOT / "scripts/post_install" / name).read_text()
|
||||
self.assertEqual(text.count("pmx_log2ram_drop_stale_journal"), 2)
|
||||
# An existing installation is told that it needs a reboot.
|
||||
self.assertIn('if pmx_log2ram_drop_stale_journal "$log2ram_bin"; then\n NECESSARY_REBOOT=1', text)
|
||||
# The version is what offers the update to existing installations.
|
||||
version = re.search(rf'{function}\(\) {{\n local FUNC_VERSION="([0-9.]+)"', text)
|
||||
self.assertIsNotNone(version)
|
||||
self.assertGreaterEqual(tuple(map(int, version.group(1).split("."))), (1, 7))
|
||||
|
||||
def test_rotated_logs_are_compressed_at_once_on_a_tmpfs_var_log(self):
|
||||
# A rotated log kept uncompressed filled the 128M Log2RAM of a PBS.
|
||||
for name, function in FLOWS.items():
|
||||
with self.subTest(flow=name):
|
||||
text = (ROOT / "scripts/post_install" / name).read_text()
|
||||
self.assertNotIn("delaycompress", text)
|
||||
version = re.search(rf'{function}\(\) {{\n local FUNC_VERSION="([0-9.]+)"', text)
|
||||
self.assertGreaterEqual(tuple(map(int, version.group(1).split("."))), (1, 8))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -110,7 +110,7 @@ class LynisThresholdContracts(unittest.TestCase):
|
||||
_, 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_WARNING=80, MEMORY_CRITICAL=90, 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),
|
||||
|
||||
@@ -4,9 +4,13 @@ lives: the scripts it launches are the ones the menus launch."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
@@ -42,11 +46,13 @@ class IndexTests(unittest.TestCase):
|
||||
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):
|
||||
def test_every_script_a_pve_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"):
|
||||
# PBS replaces the PVE main menu and does not use its search.
|
||||
# The exclusive platform dispatch is executed in the test below.
|
||||
if menu.name in ("main_menu.sh", "lxc_menu.sh", "pbs_menu.sh"):
|
||||
continue
|
||||
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
|
||||
launched -= {"menus/main_menu.sh"}
|
||||
@@ -54,6 +60,27 @@ class IndexTests(unittest.TestCase):
|
||||
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
|
||||
self.assertEqual(sorted(launched - indexed), [])
|
||||
|
||||
def test_pbs_replaces_the_searchable_pve_menu(self):
|
||||
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||
dispatch = menu[menu.rindex('source "$LOCAL_SCRIPTS/global/platform-functions.sh"'):]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
scripts = Path(directory)
|
||||
(scripts / "global").mkdir()
|
||||
(scripts / "menus").mkdir()
|
||||
helper = scripts / "global/platform-functions.sh"
|
||||
helper.write_text(
|
||||
"source " + shlex.quote(str(SCRIPTS / "global/platform-functions.sh")) + "\n"
|
||||
'pmx_platform_field() { printf "%s" "$TEST_PROXMENUX_PRODUCT"; }\n')
|
||||
(scripts / "menus/pbs_menu.sh").write_text('printf "PBS menu\\n"\n')
|
||||
harness = 'show_menu() { printf "PVE search menu\\n"; }\n' + dispatch
|
||||
for product, expected in (("pve", "PVE search menu"), ("pbs", "PBS menu")):
|
||||
with self.subTest(product=product):
|
||||
result = subprocess.run(["bash", "-e", "-c", harness], text=True, capture_output=True,
|
||||
env={**os.environ, "LOCAL_SCRIPTS": str(scripts),
|
||||
"TEST_PROXMENUX_PRODUCT": product})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), expected)
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The notification settings show the delivery history the Monitor records,
|
||||
the daily digest runs included, and never offer to erase it: Audit & Report
|
||||
reads the same history to tell whether each channel delivers."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PAGE = ROOT / 'AppImage/components/notification-settings.tsx'
|
||||
KEYS = ('title', 'description', 'empty', 'loadFailed', 'sent', 'notSent',
|
||||
'severityInfo', 'severityWarning', 'severityCritical')
|
||||
|
||||
|
||||
class NotificationHistoryPanelTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.page = PAGE.read_text(encoding='utf-8')
|
||||
|
||||
def test_the_panel_opens_and_lists_the_history(self):
|
||||
self.assertIn('setShowHistory(open => !open)', self.page)
|
||||
self.assertIn('history.map(entry =>', self.page)
|
||||
self.assertIn('t("settings.notifications.history.title")', self.page)
|
||||
|
||||
def test_the_history_is_read_without_a_success_flag(self):
|
||||
loader = re.search(r'const loadHistory = useCallback\(async \(\) => \{(.*?)\n \}, \[\]\)', self.page, re.S)
|
||||
self.assertIsNotNone(loader)
|
||||
self.assertIn('Array.isArray(data.history)', loader.group(1))
|
||||
self.assertNotIn('data.success', loader.group(1))
|
||||
|
||||
def test_a_digest_run_and_a_failed_delivery_show_their_message(self):
|
||||
self.assertIn('entry.event_type === "digest" ? entry.message', self.page)
|
||||
self.assertIn('!sent ? entry.error_message', self.page)
|
||||
|
||||
def test_the_page_never_erases_the_history(self):
|
||||
self.assertNotIn('handleClearHistory', self.page)
|
||||
self.assertIsNone(re.search(r'notifications/history["\'`][^)]*method:\s*["\']DELETE', self.page))
|
||||
|
||||
def test_the_texts_exist_in_english_and_spanish(self):
|
||||
for language in ('en', 'es'):
|
||||
catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8'))
|
||||
history = catalog['settings']['notifications']['history']
|
||||
for key in KEYS:
|
||||
with self.subTest(language=language, key=key):
|
||||
self.assertTrue(history.get(key))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Human diagnostics translate; IDs, JSON, error types and forbidden effects do not."""
|
||||
from contextlib import ExitStack, nullcontext, redirect_stderr, redirect_stdout
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
import urllib.error
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_oci_complete_ui_sk as fixtures
|
||||
from proxmenux_oci import casaos, cli, converter, i18n
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
from proxmenux_oci.github_source import GitHubSource, SourceError
|
||||
import oci_image_cache
|
||||
import oci_dhcp_lease
|
||||
import oci_rocm_check
|
||||
import oci_ui
|
||||
import oci_watchdog
|
||||
import configure_jellyfin_encoding
|
||||
|
||||
|
||||
class TechnicalMessages(unittest.TestCase):
|
||||
setUp = fixtures.CompleteUI.setUp
|
||||
patch = fixtures.CompleteUI.patch
|
||||
|
||||
def remote_language(self):
|
||||
self.patch(oci_ui, '_language', 'sk')
|
||||
self.patch(oci_ui, '_cache', self.sk)
|
||||
|
||||
def test_real_readme_errors_translate_only_at_the_display_boundary(self):
|
||||
for text, expected in [('# App', 'README neobsahuje'),
|
||||
('## docker-compose\nplain text', 'Sekcia docker-compose neobsahuje blok kódu'),
|
||||
('## docker-compose\n```yaml\nservices: {}', 'Blok docker-compose nie je ukončený'),
|
||||
('## docker-compose\n```yaml\nnot-compose\n```', 'Nájdený blok nevyzerá')]:
|
||||
with self.subTest(text=text), self.assertRaises(converter.ConversionError) as caught:
|
||||
converter.extract_compose(text)
|
||||
raw = str(caught.exception)
|
||||
self.assertIn(expected, i18n.diagnostic(caught.exception))
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
|
||||
def test_schema_error_keeps_field_details_and_original_exception(self):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
self.catalog.validate({})
|
||||
raw = str(caught.exception)
|
||||
self.assertTrue(raw.startswith('La plantilla generada no cumple el esquema:'))
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Vygenerovaná šablóna nezodpovedá schéme:' + raw.split(':', 1)[1])
|
||||
|
||||
def test_missing_schema_validator_is_distinct_from_invalid_schema_without_effects(self):
|
||||
template = {}
|
||||
read = self.patch(Path, 'read_text', side_effect=AssertionError('schema must not be read without a validator'))
|
||||
with patch.dict(sys.modules, {'jsonschema': None}):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
self.catalog.validate(template)
|
||||
raw = 'python3-jsonschema is required to generate templates'
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Na generovanie šablón je potrebný python3-jsonschema')
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
self.assertIsNone(self.catalog.validate(template, required=False))
|
||||
read.assert_not_called()
|
||||
self.assertEqual(template, {})
|
||||
for effect in self.effects:
|
||||
effect.assert_not_called()
|
||||
|
||||
def test_real_casaos_error_preserves_the_protocol_and_localizes_the_message(self):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
casaos.parse_casaos_compose('[]')
|
||||
self.assertEqual(str(caught.exception), 'The CasaOS document is not a Compose mapping')
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Dokument Compose musí byť mapa kľúčov a hodnôt.')
|
||||
|
||||
def test_github_rate_limit_keeps_url_and_token_variable_without_a_network_call(self):
|
||||
url = 'https://fiction.invalid/catalogue?ref=example'
|
||||
error = urllib.error.HTTPError(url, 403, 'Forbidden', {'X-RateLimit-Remaining': '0'}, None)
|
||||
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen', side_effect=error)
|
||||
with self.assertRaises(SourceError) as caught:
|
||||
GitHubSource(token='fictional-token')._request(url)
|
||||
request.assert_called_once()
|
||||
text = i18n.diagnostic(caught.exception)
|
||||
self.assertIn('GitHub vrátil HTTP 403', text)
|
||||
self.assertIn(url, text)
|
||||
self.assertIn('GITHUB_TOKEN', text)
|
||||
self.assertNotIn('fictional-token', text)
|
||||
|
||||
def test_github_timeout_retains_external_reason_and_three_attempts(self):
|
||||
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen',
|
||||
side_effect=urllib.error.URLError('fictional external transport reason'))
|
||||
self.patch(__import__('time'), 'sleep')
|
||||
with self.assertRaises(SourceError) as caught:
|
||||
GitHubSource()._request('https://fiction.invalid/template')
|
||||
self.assertEqual(request.call_count, 3)
|
||||
self.assertIn('ani po 3 pokusoch: fictional external transport reason', i18n.diagnostic(caught.exception))
|
||||
|
||||
def test_unknown_diagnostics_and_machine_codes_pass_through_unchanged(self):
|
||||
for value in ['service:db:healthcheck-format', 'external tool: failure at 0x1234',
|
||||
'Check failed', 'Invalid cache', 'Ambiguous application', 'private-operation-id']:
|
||||
self.assertEqual(i18n.diagnostic(value), value)
|
||||
|
||||
def test_cli_help_preserves_flags_actions_defaults_and_numeric_types(self):
|
||||
argv = ['manage', '104', '--action', 'recreate', '--unattended', '--min-image-age-days', '7']
|
||||
slovak = cli.build_parser()
|
||||
self.assertIn('Vypísať aplikácie', slovak.format_help())
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
english = cli.build_parser()
|
||||
self.assertIn('List applications', english.format_help())
|
||||
self.assertEqual(vars(english.parse_args(argv)), vars(slovak.parse_args(argv)))
|
||||
|
||||
def test_cli_summary_preserves_image_reference_ports_mount_paths_and_status_code(self):
|
||||
template = self.catalog.compose('tandoor')
|
||||
saved = copy.deepcopy(template)
|
||||
text = cli._template_summary_text(template)
|
||||
self.assertIn('Obraz: ' + template['container_contract']['image']['reference'], text)
|
||||
self.assertIn('Dátové zväzky:', text)
|
||||
self.assertIn('Stav: ' + template['status'], text)
|
||||
for mount in template['container_contract']['volumes']:
|
||||
self.assertIn(mount['container_path'], text)
|
||||
self.assertEqual(template, saved)
|
||||
|
||||
def test_install_cli_no_stays_successful_cancellation_without_dispatch(self):
|
||||
template = self.catalog.compose('jellyfin')
|
||||
saved = copy.deepcopy(template)
|
||||
self.patch(cli.Catalog, 'compose', return_value=template)
|
||||
self.patch(cli, 'build_deployment', return_value={'fiction': True})
|
||||
self.patch(cli, '_deployment_summary_text', return_value='Fiktívny plán')
|
||||
answer = self.patch(__import__('builtins'), 'input', return_value='n')
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): self.assertEqual(cli.main(['install', 'jellyfin']), 0)
|
||||
self.assertIn('Nainštalovať? [y/N]:', answer.call_args.args[0])
|
||||
self.assertEqual(output.getvalue().count('Inštalácia bola zrušená.'), 1)
|
||||
self.assertEqual(template, saved)
|
||||
|
||||
def test_blocked_install_remains_error_one_and_never_asks_to_install(self):
|
||||
template = self.catalog.compose('jellyfin')
|
||||
template['compatibility'].update(automatic_install_candidate=False, untranslated_blockers=['fiction-blocker-code'])
|
||||
self.patch(cli.Catalog, 'compose', return_value=template)
|
||||
answer = self.patch(__import__('builtins'), 'input', side_effect=AssertionError('blocked installation asked a question'))
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(io.StringIO()), redirect_stderr(output):
|
||||
self.assertEqual(cli.main(['install', 'jellyfin']), 1)
|
||||
self.assertIn('Inštalácia je zablokovaná: fiction-blocker-code', output.getvalue())
|
||||
answer.assert_not_called()
|
||||
|
||||
def test_watchdog_manual_stop_stays_stopped_without_start_or_notice(self):
|
||||
self.remote_language()
|
||||
identity = '11111111-1111-4111-8111-111111111111'
|
||||
self.patch(oci_watchdog, 'watched', return_value={777: ('Fiction', identity)})
|
||||
self.patch(oci_watchdog.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_watchdog, 'same_installation', return_value=True)
|
||||
self.patch(oci_watchdog, 'is_running', return_value=False)
|
||||
self.patch(oci_watchdog, 'is_busy', return_value=False)
|
||||
self.patch(oci_watchdog, 'read_tasks', return_value=[('vzshutdown', '777', 75)])
|
||||
start = self.patch(oci_watchdog, 'start', side_effect=AssertionError('manual stop was undone'))
|
||||
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
|
||||
state = oci_watchdog.new_state(identity); state['seen'] = 50
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): oci_watchdog.look({777: state}, 100)
|
||||
self.assertTrue(state['settled'])
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
start.assert_not_called(); notice.assert_not_called()
|
||||
|
||||
def test_watchdog_localization_does_not_change_retry_state_or_notification_payload(self):
|
||||
identity = '11111111-1111-4111-8111-111111111111'
|
||||
self.patch(oci_watchdog, 'watched', return_value={777: ('Fiction', identity)})
|
||||
self.patch(oci_watchdog.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_watchdog, 'same_installation', return_value=True)
|
||||
self.patch(oci_watchdog, 'is_running', return_value=False)
|
||||
self.patch(oci_watchdog, 'is_busy', return_value=False)
|
||||
self.patch(oci_watchdog, 'read_tasks', return_value=[])
|
||||
start = self.patch(oci_watchdog, 'start', return_value=True)
|
||||
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
|
||||
outcomes = []
|
||||
for locale in ('en', 'sk'):
|
||||
with patch.object(oci_ui, '_language', locale), patch.object(oci_ui, '_cache', self.sk):
|
||||
state = oci_watchdog.new_state(identity); state['seen'] = 50
|
||||
text = io.StringIO()
|
||||
with redirect_stdout(text): oci_watchdog.look({777: state}, 100)
|
||||
outcomes.append((copy.deepcopy(state), notice.call_args, start.call_args))
|
||||
self.assertIn('CT 777 (Fiction)', text.getvalue())
|
||||
if locale == 'sk': self.assertIn('spúšťa sa znova (pokus 1)', text.getvalue())
|
||||
self.assertEqual(outcomes[0], outcomes[1])
|
||||
|
||||
def test_image_cache_json_contract_and_invalid_command_have_no_mutations(self):
|
||||
self.remote_language()
|
||||
self.patch(oci_image_cache.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_image_cache, 'archives_of', return_value={})
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): self.assertEqual(oci_image_cache.main(['list', '777']), 0)
|
||||
self.assertEqual(json.loads(output.getvalue()), {'archives': [], 'freed': 0})
|
||||
error = io.StringIO()
|
||||
with redirect_stderr(error): self.assertEqual(oci_image_cache.main(['invalid']), 2)
|
||||
self.assertIn('Použitie: oci_image_cache.py [prune | list VMID... | remove VMID...]', error.getvalue())
|
||||
|
||||
def test_rocm_timeout_localizes_but_external_gpu_errors_are_preserved(self):
|
||||
self.remote_language()
|
||||
self.patch(sys, 'argv', ['oci_rocm_check.py', '777'])
|
||||
for raw, expected in [('the GPU did not answer in time', 'GPU neodpovedalo včas'),
|
||||
('external provider exception 0x123', 'external provider exception 0x123')]:
|
||||
with self.subTest(raw=raw), patch.object(oci_rocm_check, 'check', side_effect=RuntimeError(raw)):
|
||||
output = io.StringIO()
|
||||
with redirect_stderr(output): self.assertEqual(oci_rocm_check.main(), 1)
|
||||
self.assertEqual(output.getvalue().strip(), expected)
|
||||
|
||||
def test_jellyfin_completion_distinguishes_changed_from_already_applied(self):
|
||||
self.remote_language()
|
||||
self.patch(sys, 'argv', ['configure_jellyfin_encoding.py', '/fiction-root', '{}'])
|
||||
for changed, expected in [(True, 'Nastavenia Jellyfin boli aktualizované:'),
|
||||
(False, 'Nastavenia Jellyfin už boli použité:')]:
|
||||
with self.subTest(changed=changed), patch.object(configure_jellyfin_encoding, 'update_encoding',
|
||||
return_value=(Path('/fiction-root/config/encoding.xml'), changed)):
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): configure_jellyfin_encoding.main()
|
||||
self.assertEqual(output.getvalue().strip(), expected + ' /config/encoding.xml')
|
||||
|
||||
def test_dhcp_completion_keeps_interface_and_ip_version_without_repairing_a_guest(self):
|
||||
self.remote_language()
|
||||
self.patch(oci_dhcp_lease, 'LOCKS', MagicMock())
|
||||
self.patch(oci_dhcp_lease.fcntl, 'flock')
|
||||
repair = self.patch(oci_dhcp_lease, 'repair', return_value=[('eth0', 4)])
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): oci_dhcp_lease.check(777, 0)
|
||||
repair.assert_called_once_with(777, 0)
|
||||
self.assertEqual(output.getvalue().strip(), 'CT 777: klient DHCP rozhrania eth0 (IPv4) bol znova spustený')
|
||||
|
||||
|
||||
class StandalonePublisher(unittest.TestCase):
|
||||
def test_mount_error_keeps_host_language_after_entering_the_container_root(self):
|
||||
spec = importlib.util.spec_from_file_location('fiction_namespace_publisher',
|
||||
fixtures.ROOT / 'oci/remote/rclone_mount_publish.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
inside = False
|
||||
def chroot(path):
|
||||
nonlocal inside
|
||||
inside = True
|
||||
def read(path, **kwargs):
|
||||
if inside:
|
||||
raise FileNotFoundError('Host language catalog is outside the container root')
|
||||
return ('{"language":"sk"}' if path.name == 'config.json' else
|
||||
'{"clone source mount tree":"naklonovať strom zdrojového pripojenia"}')
|
||||
libc = Mock(); libc.setns.return_value = 0; libc.syscall.return_value = -1
|
||||
with ExitStack() as guards:
|
||||
for target, name, options in [
|
||||
(sys, 'argv', {'new': ['publisher.py', '777', '/fiction-source', '/fiction-target', 'ro']}),
|
||||
(module.platform, 'machine', {'return_value': 'x86_64'}),
|
||||
(Path, 'read_text', {'autospec': True, 'side_effect': read}),
|
||||
(module.ctypes, 'CDLL', {'return_value': libc}),
|
||||
(module.ctypes, 'get_errno', {'return_value': 13}),
|
||||
(module.os, 'open', {'return_value': 42}),
|
||||
(module.os, 'close', {}), (module.os, 'fchdir', {}),
|
||||
(module.os, 'chroot', {'side_effect': chroot}), (module.os, 'chdir', {})]:
|
||||
guards.enter_context(patch.object(target, name, **options))
|
||||
with self.assertRaises(OSError) as caught:
|
||||
module.main()
|
||||
self.assertTrue(inside)
|
||||
self.assertEqual(caught.exception.errno, 13)
|
||||
self.assertIn('naklonovať strom zdrojového pripojenia', str(caught.exception))
|
||||
self.assertEqual(libc.syscall.call_args.args[0], module.SYSCALLS['x86_64'][0])
|
||||
|
||||
def test_copied_helper_has_localized_errors_without_engine_import_or_mount_syscalls(self):
|
||||
with tempfile.TemporaryDirectory(prefix='oci-publisher-language-') as directory:
|
||||
root = Path(directory); (root / 'lang').mkdir()
|
||||
(root / 'config.json').write_text('{"language":"sk"}')
|
||||
(root / 'lang/sk.json').write_text(json.dumps({'Usage:': 'Použitie:', 'Unsupported host architecture:': 'Nepodporovaná architektúra hostiteľa:'}))
|
||||
source = fixtures.ROOT / 'oci/remote/rclone_mount_publish.py'
|
||||
copied = root / 'publisher.py'; copied.write_bytes(source.read_bytes())
|
||||
spec = importlib.util.spec_from_file_location('fiction_standalone_publisher', copied)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {'oci_ui': None}), patch.dict(__import__('os').environ, {'PMX_BASE_DIR': str(root)}):
|
||||
spec.loader.exec_module(module)
|
||||
with patch.object(module.ctypes, 'CDLL', side_effect=AssertionError('mount syscall path entered')) as syscall:
|
||||
with patch.object(sys, 'argv', ['publisher.py']), redirect_stderr(io.StringIO()) as output:
|
||||
self.assertEqual(module.main(), 2)
|
||||
self.assertIn('Použitie: publisher.py PID SOURCE TARGET rw|ro', output.getvalue())
|
||||
with patch.object(sys, 'argv', ['publisher.py', '1', '/fiction-source', '/fiction-target', 'ro']), \
|
||||
patch.object(module.platform, 'machine', return_value='fiction-architecture'), redirect_stderr(io.StringIO()) as output:
|
||||
self.assertEqual(module.main(), 2)
|
||||
self.assertIn('Nepodporovaná architektúra hostiteľa: fiction-architecture', output.getvalue())
|
||||
syscall.assert_not_called()
|
||||
(root / 'config.json').write_text('invalid json')
|
||||
module._messages.cache_clear()
|
||||
self.assertEqual(module.translate('Usage:'), 'Usage:')
|
||||
(root / 'config.json').write_text('{"language":"en"}')
|
||||
module._messages.cache_clear()
|
||||
self.assertEqual(module.translate('Usage:'), 'Usage:')
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Real catalogue/wizard rendering, with host and installation effects forbidden."""
|
||||
import copy
|
||||
from contextlib import ExitStack, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(os.environ.get('OCI_I18N_TEST_ROOT', Path(__file__).resolve().parents[3]))
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote'), str(ROOT / 'oci/tests')]
|
||||
from proxmenux_oci import cli, i18n, installer, stack
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
from proxmenux_oci.converter import ConversionError
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
from test_advanced_flow_order import RecordingUI, addresses, storages
|
||||
|
||||
|
||||
class CompleteUI(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.scope = ExitStack()
|
||||
self.addCleanup(self.scope.close)
|
||||
self.sk = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
self.catalog = Catalog(ROOT / 'oci')
|
||||
self.patch(i18n, '_language', 'sk')
|
||||
self.patch(i18n, '_cache', self.sk)
|
||||
self.patch(installer.host, 'storages', side_effect=storages)
|
||||
self.patch(installer.host, 'bridges', return_value=[{'iface': 'vmbr0', 'cidr': '192.0.2.10/24'}])
|
||||
self.patch(installer.host, 'timezone', return_value='Europe/Bratislava')
|
||||
self.patch(installer.host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': False})
|
||||
self.patch(installer.access, 'ask_addresses', side_effect=addresses)
|
||||
self.patch(installer.access, 'ask_ipv4', return_value=('dhcp', None))
|
||||
self.effects = []
|
||||
for module, name in [(cli, '_install'), (cli, '_rclone_mount'), (cli, 'run_remote_install'),
|
||||
(installer, 'run_remote_install'), (cli.images, 'offer_removal'),
|
||||
(subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
|
||||
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink')]:
|
||||
self.effects.append(self.patch(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
|
||||
self.addCleanup(lambda: [effect.assert_not_called() for effect in self.effects])
|
||||
|
||||
def patch(self, obj, name, *args, **kwargs):
|
||||
return self.scope.enter_context(patch.object(obj, name, *args, **kwargs))
|
||||
|
||||
def test_search_no_match_uses_application_wording_and_never_installs(self):
|
||||
ui = Mock()
|
||||
ui.ask.return_value = 'fiction-no-such-app'
|
||||
cli._search(self.catalog, ui, [])
|
||||
self.assertEqual(ui.ask.call_args.args[0], 'Názov alebo časť popisu aplikácie')
|
||||
self.assertIn('Žiadna zhoda aplikácií', ui.message.call_args.args[0])
|
||||
ui.choose.assert_not_called()
|
||||
|
||||
def test_immich_detail_translates_service_roles_without_changing_template(self):
|
||||
template = self.catalog.compose('immich')
|
||||
before = copy.deepcopy(template)
|
||||
text = cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template)
|
||||
self.assertIn('Strojové učenie', text)
|
||||
self.assertIn('Aplikácia', text)
|
||||
self.assertIn('Oficiálny obraz', text)
|
||||
self.assertNotIn('Machine learning', text)
|
||||
self.assertEqual(template, before)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
self.assertIn('Machine learning', cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template))
|
||||
|
||||
def test_missing_catalogue_entry_preserves_raw_error_and_localizes_cli(self):
|
||||
for language, expected in [('sk', "Aplikácia 'fiction-missing' nie je v indexe"),
|
||||
('en', "The application 'fiction-missing' is not in the index")]:
|
||||
with self.subTest(language=language), patch.object(i18n, '_language', language):
|
||||
with self.assertRaisesRegex(ConversionError, "The application 'fiction-missing' is not in the index"):
|
||||
self.catalog.find_item('fiction-missing')
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(io.StringIO()), patch.object(sys, 'stderr', output), patch.object(cli, 'PROJECT_ROOT', ROOT / 'oci'):
|
||||
self.assertEqual(cli.main(['show', 'fiction-missing']), 1)
|
||||
self.assertEqual(output.getvalue().strip(), 'ERROR: ' + expected)
|
||||
|
||||
def test_missing_template_uses_existing_translation_and_keeps_identifier(self):
|
||||
self.patch(self.catalog, 'find_item', return_value={'id': 'fiction-missing'})
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
self.catalog.compose('fiction-missing')
|
||||
self.assertEqual(str(caught.exception), "No template is available for 'fiction-missing'")
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), "Pre aplikáciu 'fiction-missing' nie je dostupná šablóna")
|
||||
|
||||
def test_detail_cancel_and_escape_do_not_enter_install_or_rclone(self):
|
||||
for app in ('jellyfin', 'rclone', 'immich', 'suite-arr'):
|
||||
for escape in (False, True):
|
||||
with self.subTest(app=app, escape=escape):
|
||||
ui = Mock()
|
||||
ui.confirm.return_value = False
|
||||
ui.detail_menu.return_value = None
|
||||
if escape:
|
||||
ui.detail_menu.side_effect = UserCancelled()
|
||||
try:
|
||||
cli._app_detail(self.catalog, ui, self.catalog.find_item(app))
|
||||
except UserCancelled:
|
||||
self.assertTrue(escape)
|
||||
|
||||
def test_special_wizard_plans_are_identical_in_english_and_slovak(self):
|
||||
for app in ('immich', 'nextcloud-stack', 'paperless-ngx', 'tandoor'):
|
||||
for mode in (installer.DEFAULT_MODE, installer.ADVANCED_MODE):
|
||||
with self.subTest(app=app, mode=mode):
|
||||
template = self.catalog.compose(app)
|
||||
before = copy.deepcopy(template)
|
||||
plan_sk = installer.build_deployment(template, RecordingUI(), mode)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
plan_en = installer.build_deployment(template, RecordingUI(), mode)
|
||||
self.assertEqual(plan_sk, plan_en)
|
||||
self.assertEqual(template, before)
|
||||
text = cli._deployment_summary_text(template, plan_sk)
|
||||
self.assertNotIn('Consume/export:', text)
|
||||
if app == 'paperless-ngx':
|
||||
self.assertIn('Priečinky consume a export', text)
|
||||
|
||||
def test_romm_optional_credentials_are_localized_but_variable_names_stay(self):
|
||||
ui = RecordingUI()
|
||||
ui.confirm = lambda text, default=False, **kwargs: (ui.asked.append(text) or text.startswith('Nastaviť IGDB'))
|
||||
ui.password = lambda text, **kwargs: (ui.asked.append(text) or 'fiction-secret')
|
||||
ui.ask = lambda text, default=None, **kwargs: (ui.asked.append(text) or (default if default is not None else 'fiction-value'))
|
||||
plan = installer.build_deployment(self.catalog.compose('romm'), ui, installer.ADVANCED_MODE)
|
||||
self.assertTrue(any('Tajný kľúč klienta IGDB' == text for text in ui.asked))
|
||||
self.assertFalse(any('IGDB Client Secret' == text for text in ui.asked))
|
||||
server = next(service for service in plan['services'] if service['name'] == 'romm')
|
||||
self.assertTrue(any(row['name'] == 'IGDB_CLIENT_SECRET' for row in server['deployment']['environment']))
|
||||
|
||||
def test_tandoor_warning_keeps_negative_requirement_and_first_run_account(self):
|
||||
template = self.catalog.compose('tandoor')
|
||||
warning = i18n.translate(template['proxmox']['security_profile']['warning'])
|
||||
self.assertIn('nevyžaduje privilegovaný', warning)
|
||||
credentials = template['first_run']['credentials']
|
||||
self.assertTrue(any('Vygenerovaný účet správcu Tandoor' == i18n.translate(row['label']) for row in credentials))
|
||||
|
||||
def test_documented_commands_paths_and_literals_remain_copyable(self):
|
||||
fragments = {
|
||||
'rsnapshot starts with the default configuration, which backs up /data into /.snapshots. Edit /config/rsnapshot.conf inside the container to set your own backup points, snapshot root and retention intervals.': ['/data', '/.snapshots', '/config/rsnapshot.conf'],
|
||||
'Each /request command needs a backend: add a [[backends]] block in the same file with the url and api_key of your Sonarr, Radarr or Seerr instance, then restart the container.': ['/request', '[[backends]]', 'url', 'api_key'],
|
||||
'Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>': ['public: false', 'pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>'],
|
||||
'Certificate provider: empty for Let\'s Encrypt, zerossl for ZeroSSL': ["Let's Encrypt", 'zerossl', 'ZeroSSL'],
|
||||
'Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.': ['/data/media/movies', '/data/media/series', '/data/media/music'],
|
||||
}
|
||||
for source, tokens in fragments.items():
|
||||
with self.subTest(source=source):
|
||||
for token in tokens:
|
||||
self.assertIn(token, i18n.translate(source))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Exercise the native custom-image and registry UI with the real EN/SK lookup."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / 'oci/src'))
|
||||
from proxmenux_oci import custom, i18n, registry_accounts as accounts, registry_auth as auth
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
|
||||
COMPOSE = 'services:\n app:\n image: ghcr.io/owner/app:latest\n'
|
||||
|
||||
|
||||
class UI:
|
||||
def __init__(self, choices=(), answers=(), confirms=()):
|
||||
self.choices = iter(choices)
|
||||
self.answers = iter(answers)
|
||||
self.confirms = iter(confirms)
|
||||
self.seen = []
|
||||
|
||||
def choose(self, text, options, default=None, **kwargs):
|
||||
self.seen.append((text, options, kwargs))
|
||||
return next(self.choices)
|
||||
|
||||
def ask(self, text, default=None, **kwargs):
|
||||
self.seen.append(text)
|
||||
return next(self.answers)
|
||||
|
||||
def password(self, text):
|
||||
self.seen.append(text)
|
||||
raise UserCancelled(text)
|
||||
|
||||
def confirm(self, text, default=False, **kwargs):
|
||||
self.seen.append((text, default))
|
||||
return next(self.confirms)
|
||||
|
||||
def review(self, text, title=None, **kwargs):
|
||||
self.seen.append((text, title, kwargs))
|
||||
return False
|
||||
|
||||
def message(self, text, title=None):
|
||||
self.seen.append((text, title))
|
||||
|
||||
info = message
|
||||
|
||||
|
||||
class CustomRegistryLocalizationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
(self.base / 'lang').mkdir()
|
||||
(self.base / 'config.json').write_text('{"language":"sk"}')
|
||||
(self.base / 'lang/sk.json').write_bytes((ROOT / 'lang/sk.json').read_bytes())
|
||||
for name, value in [('BASE_DIR', self.base), ('_language', None), ('_cache', None)]:
|
||||
item = patch.object(i18n, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
# Any unmocked subprocess, network fetch or installation is a failure.
|
||||
self.run = self.guard(custom.subprocess, 'run')
|
||||
self.network = self.guard(custom.urllib.request, 'urlopen')
|
||||
self.install = self.guard(custom, 'install_template')
|
||||
|
||||
def guard(self, module, name):
|
||||
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
mock = item.start()
|
||||
self.addCleanup(item.stop)
|
||||
return mock
|
||||
|
||||
def no_effects(self):
|
||||
self.run.assert_not_called()
|
||||
self.network.assert_not_called()
|
||||
self.install.assert_not_called()
|
||||
|
||||
def manager(self):
|
||||
return SimpleNamespace(path=Path('/fixture/auth.json'),
|
||||
accounts=MagicMock(return_value=[{'registry': 'ghcr.io', 'account': 'owner',
|
||||
'method': 'Používateľské meno / token', 'status': 'Saved'}]),
|
||||
login=MagicMock(), logout=MagicMock())
|
||||
|
||||
def menu(self, ui, manager):
|
||||
with patch.object(accounts, 'require_root'):
|
||||
accounts.registry_accounts_menu(ui, auth=manager)
|
||||
|
||||
def test_dynamic_blockers_are_extracted_and_rendered_in_slovak(self):
|
||||
expected = {
|
||||
'multi-service-compose': 'definuje viac než jeden obraz',
|
||||
'native-multi-lxc-orchestrator-not-yet-implemented': 'definuje viac než jeden obraz',
|
||||
'top-level-configs': 'používa nastavenia Compose configs, ktoré tu nemajú ekvivalent',
|
||||
'devices-format': 'požadované zariadenia sú zadané v nepodporovanom formáte',
|
||||
'healthcheck-format': 'kontrola stavu je zadaná v nepodporovanom formáte',
|
||||
'stop-grace-period-format': 'časový limit zastavenia je zadaný v nepodporovanom formáte',
|
||||
'shm-size-format': 'veľkosť zdieľanej pamäte je zadaná v nepodporovanom formáte',
|
||||
'ulimits-format-or-resource': 'požadované limity systémových prostriedkov nemožno uplatniť',
|
||||
'mem-limit-format-or-below-proxmox-minimum': 'požadovaný limit pamäte nemožno uplatniť',
|
||||
'mem-limit-deploy-consistency-review': 'požaduje dva rozdielne limity pamäte',
|
||||
}
|
||||
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
extracted = extractor['extract_python_texts']([ROOT / 'oci/src'])
|
||||
self.assertTrue(set(custom.BLOCKER_TEXTS.values()) <= set(extracted))
|
||||
for code, message in expected.items():
|
||||
with self.subTest(code=code):
|
||||
self.assertEqual(custom.blocker_text('service:app:' + code), message)
|
||||
self.assertEqual(custom.blocker_text('unrecognized:raw-code'), 'unrecognized:raw-code')
|
||||
self.no_effects()
|
||||
|
||||
def test_cancelled_source_menu_is_translated_without_reading_an_image(self):
|
||||
ui = UI([None])
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
custom.explore(ui)
|
||||
self.assertEqual(str(caught.exception), 'Výber obrazu bol zrušený')
|
||||
self.assertIn('Obraz, ktorý nie je v katalógu', repr(ui.seen))
|
||||
self.assertIn('Len odkaz na obraz bez súboru Compose', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_invalid_compose_inputs_reach_localized_error_before_registry_or_install(self):
|
||||
cases = [
|
||||
('services: {}', 'Súbor Compose neobsahuje žiadnu službu'),
|
||||
('services: [', 'Súbor Compose nie je platný YAML:'),
|
||||
(COMPOSE + ' environment: 42\n', 'Položka environment'),
|
||||
(COMPOSE + ' volumes: {}\n', 'Položka volumes'),
|
||||
(COMPOSE + ' ports: invalid\n', 'Položka ports'),
|
||||
(COMPOSE + ' ports: [invalid]\n', 'Neplatný port'),
|
||||
(COMPOSE + ' volumes: [relative:relative]\n', 'Neplatná cesta dátového úložiska'),
|
||||
(COMPOSE + 'x-casaos:\n main: missing\n', 'Položka x-casaos.main'),
|
||||
]
|
||||
for text, expected in cases:
|
||||
with self.subTest(text=text), patch.object(custom, 'read_definition', return_value=(text, 'fixture')), \
|
||||
self.assertRaises(custom.ConversionError) as caught:
|
||||
custom.explore(UI())
|
||||
self.assertIn(expected, str(caught.exception))
|
||||
self.no_effects()
|
||||
|
||||
def test_converter_raw_contract_and_english_ui_are_preserved(self):
|
||||
bad = COMPOSE + ' ports: [invalid]\n'
|
||||
with self.assertRaises(custom.ConversionError) as raw:
|
||||
custom.template_from_compose(bad)
|
||||
self.assertEqual(str(raw.exception), "Puerto no valido: 'invalid'")
|
||||
with patch.object(i18n, '_language', 'en'), \
|
||||
patch.object(custom, 'read_definition', return_value=(bad, 'fixture')), \
|
||||
self.assertRaises(custom.ConversionError) as ui_error:
|
||||
custom.explore(UI())
|
||||
self.assertEqual(str(ui_error.exception), str(raw.exception))
|
||||
self.no_effects()
|
||||
|
||||
def test_conversion_message_retains_literal_diagnostic_data_and_unknown_fallback(self):
|
||||
detail = " {'target': '<path>{literal}\\á'}"
|
||||
message = custom.conversion_message(custom.ConversionError('Ruta de volumen no valida:' + detail))
|
||||
self.assertEqual(message, 'Neplatná cesta dátového úložiska v súbore Compose:' + detail)
|
||||
unknown = 'unknown <diagnostic> {literal}'
|
||||
self.assertEqual(custom.conversion_message(custom.ConversionError(unknown)), unknown)
|
||||
self.no_effects()
|
||||
|
||||
def test_all_legacy_converter_diagnostics_have_slovak_ui_and_unchanged_english(self):
|
||||
messages = [
|
||||
'environment must be a list or a mapping', 'volumes must be a list', 'ports must be a list',
|
||||
'The CasaOS Compose file has no services', 'The CasaOS document is not a Compose mapping',
|
||||
'The Compose file has no x-casaos metadata', 'x-casaos.main no identifica un servicio valido',
|
||||
'The main CasaOS service declares no image', 'Ruta de volumen no valida: /data',
|
||||
'Puerto no valido: invalid', 'Cannot normalise the CasaOS identifier: ???',
|
||||
'Docker Compose CasaOS no valido: fixture',
|
||||
]
|
||||
for text in messages:
|
||||
with self.subTest(text=text):
|
||||
self.assertNotEqual(custom.conversion_message(custom.ConversionError(text)), text)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
self.assertEqual(custom.conversion_message(custom.ConversionError(text)), text)
|
||||
self.no_effects()
|
||||
|
||||
def test_custom_review_cancel_keeps_installation_disabled(self):
|
||||
ui = UI()
|
||||
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'vloženého súboru Compose')), \
|
||||
patch.object(custom, 'registry_report', return_value=(True, 'Dostupný obraz')):
|
||||
custom.explore(ui)
|
||||
self.assertIn('Nainštalovať tento obraz?', repr(ui.seen))
|
||||
self.assertIn('Obraz:', repr(ui.seen))
|
||||
self.assertIn('ghcr.io/owner/app:latest', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_unavailable_image_explains_registry_accounts_without_authentication_or_install(self):
|
||||
ui = UI()
|
||||
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'fixture')), \
|
||||
patch.object(custom, 'registry_report', return_value=(False, 'Nedostupný obraz')), \
|
||||
patch.object(custom, '_registry_refused', return_value=False), \
|
||||
patch.object(accounts, 'offer_sign_in') as offer:
|
||||
custom.explore(ui)
|
||||
self.assertIn('prihláste cez Účty registrov', repr(ui.seen))
|
||||
offer.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_registry_metadata_dialog_uses_image_and_digest_and_retains_values(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['account-0', 'inspect', None], ['ghcr.io/owner/app:latest'])
|
||||
metadata = dict(architecture='amd64', version='<v>{literal}', size=1024, digest='sha256:' + 'a'*64)
|
||||
with patch.object(accounts, 'inspect_registry_image', return_value=metadata) as inspect:
|
||||
self.menu(ui, manager)
|
||||
inspect.assert_called_once_with('ghcr.io/owner/app:latest')
|
||||
rendered = repr(ui.seen)
|
||||
for value in ('Prístup k obrazu bol potvrdený.', 'Digest: sha256:', '<v>{literal}', 'Účty registrov'):
|
||||
self.assertIn(value, rendered)
|
||||
self.assertNotIn('obráz', rendered)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_credential_help_retains_permissions_and_no_encryption_claim(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['storage', None])
|
||||
self.menu(ui, manager)
|
||||
rendered = repr(ui.seen)
|
||||
for value in ('root', '700', '600', 'base64', 'nie sú šifrované', 'každý uzol klastra', '/fixture/auth.json'):
|
||||
self.assertIn(value, rendered)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_cancelled_password_and_declined_logout_do_not_change_accounts(self):
|
||||
for ui in (UI(['add', None], ['ghcr.io', 'owner']), UI(['account-0', 'logout', None], confirms=[False])):
|
||||
manager = self.manager()
|
||||
self.menu(ui, manager)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_mismatched_registry_is_rejected_before_download(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['account-0', 'download', None], ['quay.io/owner/app:latest'])
|
||||
with patch.object(accounts, 'download_registry_image') as download:
|
||||
self.menu(ui, manager)
|
||||
download.assert_not_called()
|
||||
self.assertIn('Zadajte obraz z tohto registra.', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_declined_private_image_signin_does_not_even_construct_auth_manager(self):
|
||||
ui = UI(confirms=[False])
|
||||
with patch.object(accounts, 'RegistryAuth') as manager, patch.object(accounts, 'require_root') as root:
|
||||
self.assertFalse(accounts.offer_sign_in(ui, 'ghcr.io/owner/app:latest'))
|
||||
manager.assert_not_called()
|
||||
root.assert_not_called()
|
||||
self.assertIn('Prihlásiť sa teraz?', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_registry_error_classes_hide_untrusted_remote_data_in_both_languages(self):
|
||||
cases = [('status code: 429', 'obmedzuje počet požiadaviek'),
|
||||
('requested access to the resource is denied', 'odmietol prístup'),
|
||||
('manifest unknown', 'Obraz alebo tag'),
|
||||
('unsupported manifest', 'metadáta obrazu'),
|
||||
('docker-credential-helper', 'správu prihlasovacích údajov'),
|
||||
('unknown failure', 'Obraz sa nepodarilo načítať')]
|
||||
for language in ('sk', 'en'):
|
||||
for error, expected in cases:
|
||||
with self.subTest(language=language, error=error), patch.object(i18n, '_language', language):
|
||||
message = auth.registry_error('registry.test:5000', error + ' REMOTE_PRIVATE_VALUE')
|
||||
self.assertNotIn('REMOTE_PRIVATE_VALUE', message)
|
||||
if language == 'sk':
|
||||
self.assertIn(expected, message)
|
||||
if 'credential' not in error:
|
||||
self.assertIn('registry.test:5000', message)
|
||||
self.no_effects()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Cancel a real editor through Menu/CLI/Monitor contracts, never apply it."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, management as menu, recreation
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
|
||||
|
||||
class ModifyCancellationTests(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.record.update({
|
||||
'installation_id': 'fixture-oci-edit-cancel',
|
||||
'observed': {'config_sha256': 'fixture-config-hash'},
|
||||
'template': {'id': 'fixture-app', 'catalog_ui': {'title': 'Fixture'},
|
||||
'container_contract': {'image': {'reference': 'example/fixture:latest'}}},
|
||||
'deployment': {'vmid': 110, 'hostname': 'fixture',
|
||||
'resources': {'cores': 2, 'memory_mb': 512},
|
||||
'rootfs': {'storage': 'fixture-storage', 'size_gb': 8},
|
||||
'mounts': [], 'devices': [], 'environment': [
|
||||
{'name': 'PASSWORD', 'value': 'private-fixture-value', 'sensitive': True}],
|
||||
'network': {'bridge': 'vmbr-fixture', 'ipv4': 'dhcp'}}})
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.ui.back_enabled = False
|
||||
self.path_write = self.guard(Path, 'write_text')
|
||||
self.path_bytes = self.guard(Path, 'write_bytes')
|
||||
|
||||
def script(self, choices, answers=(), confirms=(), reviews=()):
|
||||
self.ui.choose.side_effect = choices
|
||||
self.ui.ask.side_effect = answers
|
||||
self.ui.confirm.side_effect = confirms
|
||||
self.ui.review.side_effect = reviews
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
self.path_write.assert_not_called()
|
||||
self.path_bytes.assert_not_called()
|
||||
self.assertFalse(self.ui.back_enabled)
|
||||
|
||||
def test_main_cancel_is_distinct_falsy_cancellation(self):
|
||||
self.script([None])
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row, action='modify'), menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED)
|
||||
self.no_effects()
|
||||
|
||||
def test_main_discard_without_edits_returns_cancel_code(self):
|
||||
self.script(['discard'])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.ui.confirm.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_cancel_returns_code_six_without_extra_console_output(self):
|
||||
self.script([None])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_discard_after_edits_never_calls_backup_or_lifecycle(self):
|
||||
self.script(['cores', 'discard'], ['3'], [True])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
|
||||
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
|
||||
self.no_effects()
|
||||
|
||||
def test_main_cancel_after_edits_requires_discard_confirmation(self):
|
||||
self.script(['cores', None], ['3'], [True])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
|
||||
self.no_effects()
|
||||
|
||||
def test_declining_discard_preserves_draft_until_later_main_cancel(self):
|
||||
self.script(['cores', 'discard', None], ['3'], [False, True])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
last_options = dict(self.ui.choose.call_args.args[1])
|
||||
self.assertTrue(last_options['cores'].rstrip().endswith('3'))
|
||||
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
|
||||
self.assertEqual(self.ui.confirm.call_count, 2)
|
||||
self.no_effects()
|
||||
|
||||
def test_section_cancel_returns_to_editor_before_main_cancel(self):
|
||||
self.script(['cores', None], [UserCancelled()])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertEqual(self.ui.choose.call_count, 2)
|
||||
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
|
||||
self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_zero_cores_rejected_then_section_and_main_cancel_are_safe(self):
|
||||
self.script(['cores', None], ['0', UserCancelled()])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertIn('Cores: minimum 1', self.ui.message.call_args.args[0])
|
||||
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
|
||||
self.no_effects()
|
||||
|
||||
def test_review_no_returns_to_editor_with_draft_then_discard_cancels(self):
|
||||
self.script(['cores', 'apply', 'discard'], ['3'], [True], [False])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
|
||||
self.assertNotIn('private-fixture-value', self.ui.review.call_args.args[0])
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_list_after_editor_cancel(self):
|
||||
self.script(['110', 'modify', None, None])
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 4)
|
||||
self.no_effects()
|
||||
|
||||
def test_legacy_editor_callers_still_receive_none_on_cancel(self):
|
||||
for choice in (None, 'discard'):
|
||||
with self.subTest(choice=choice):
|
||||
self.script([choice])
|
||||
self.assertIsNone(recreation.edit_recreation(self.record, self.ui))
|
||||
self.no_effects()
|
||||
|
||||
def test_unchanged_review_is_not_mislabeled_as_user_cancellation(self):
|
||||
self.script(['apply'])
|
||||
self.assertIsNone(recreation.edit_recreation(self.record, self.ui, cancelled_result=menu.CANCELLED))
|
||||
self.ui.message.assert_called_once_with('Nothing was changed.', 'Modify OCI')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_editor_error_is_not_converted_to_cancellation(self):
|
||||
self.script(['cores'], [RuntimeError('fixture editor failure')])
|
||||
with self.assertRaisesRegex(RuntimeError, 'fixture editor failure'):
|
||||
menu.direct_management(ROOT/'oci', 110, 'modify')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_validation_failure_remains_failure_one(self):
|
||||
self.script(['cores'], [cli.InstallError('fixture validation failure')])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 1)
|
||||
self.no_effects()
|
||||
|
||||
def test_unattended_modify_is_rejected_before_opening_the_editor(self):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify', unattended=True), 1)
|
||||
self.ui.choose.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_accepted_proposal_keeps_original_backend_and_private_payload(self):
|
||||
self.script(['cores', 'apply'], ['3'], reviews=[True])
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
def lifecycle(command, title):
|
||||
self.assertEqual(command[:3], [sys.executable, str(ROOT/'oci/remote/oci_update_current.py'), '110'])
|
||||
proposal_file = Path(command[command.index('--proposal')+1])
|
||||
self.assertEqual(proposal_file.stat().st_mode & 0o777, 0o600)
|
||||
proposal = json.loads(proposal_file.read_text())
|
||||
self.assertEqual(proposal['operation'], 'recreate')
|
||||
self.assertEqual(proposal['candidate']['deployment']['resources']['cores'], 3)
|
||||
self.assertEqual(proposal['base_config_sha256'], 'fixture-config-hash')
|
||||
self.assertNotIn('private-fixture-value', ' '.join(command))
|
||||
return True
|
||||
self.lifecycle.side_effect = lifecycle
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 0)
|
||||
self.backup.assert_called_once()
|
||||
self.lifecycle.assert_called_once()
|
||||
self.assertEqual(self.record, self.original)
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_failed_backup_after_review_remains_failure_without_lifecycle(self):
|
||||
self.script(['cores', 'apply'], ['3'], reviews=[True])
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.write.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""OCI manager Apps opens only on Proxmox VE 9.1 or later, the first release
|
||||
that runs OCI images as LXC containers."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LAUNCHER = ROOT / 'scripts/oci/oci_manager_apps.sh'
|
||||
|
||||
|
||||
class OciPveVersionGateTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
text = LAUNCHER.read_text(encoding='utf-8')
|
||||
cls.function = re.search(r'^pve_supports_oci\(\) \{\n.*?^\}\n', text, re.M | re.S).group(0)
|
||||
cls.text = text
|
||||
|
||||
def supported(self, version):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
fake = Path(folder, 'pveversion')
|
||||
# Without Proxmox the command is missing: a stand-in that fails hides the host's own.
|
||||
fake.write_text('#!/bin/sh\nexit 127\n' if version is None else
|
||||
f'#!/bin/sh\necho "pve-manager/{version}/b9984c6d90a4bd80 (running kernel: 6.17)"\n')
|
||||
fake.chmod(0o755)
|
||||
result = subprocess.run(['bash', '-c', f'PATH={folder}:/usr/bin:/bin\n{self.function}pve_supports_oci'],
|
||||
capture_output=True, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
def test_versions_from_9_1_open_it(self):
|
||||
for version in ('9.1.0', '9.1.4', '9.2.2', '10.0.1'):
|
||||
with self.subTest(version=version):
|
||||
self.assertTrue(self.supported(version))
|
||||
|
||||
def test_older_versions_and_hosts_without_proxmox_do_not(self):
|
||||
for version in ('8.4.1', '9.0.11', None):
|
||||
with self.subTest(version=version):
|
||||
self.assertFalse(self.supported(version))
|
||||
|
||||
def test_it_is_checked_before_anything_is_installed(self):
|
||||
self.assertLess(self.text.index('if ! pve_supports_oci; then'), self.text.index('ensure_oci_dependencies || exit 1'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType, SimpleNamespace
|
||||
@@ -52,7 +53,8 @@ class InventoryMessages(unittest.TestCase):
|
||||
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}
|
||||
'translate': translate, 're': re, 'subprocess': subprocess,
|
||||
'_selected_configuration': remover.guest_config}
|
||||
summary = extracted(MENU, '_removal_summary', scope)
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
@@ -114,7 +116,7 @@ class RecoveryMessages(unittest.TestCase):
|
||||
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,
|
||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
|
||||
'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}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Pending recovery through real Menu/CLI/Dialog paths; all host effects forbidden."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
import oci_native_modify as native_modify
|
||||
import oci_stack_transaction as stack_transaction
|
||||
import oci_ui
|
||||
|
||||
ACTUAL_RECOVER = transaction.recover
|
||||
|
||||
|
||||
class PendingRecoveryFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.record.update(pending_transaction='/fiction-only/transaction.json')
|
||||
self.row['pending'] = True
|
||||
self.ui.choose.return_value = 'recover'
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.writes = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
|
||||
self.processes = [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
|
||||
self.recovery = self.guard(transaction, 'recover')
|
||||
self.native_recovery = self.guard(native_modify, 'recover')
|
||||
self.stack_recovery = self.guard(stack_transaction, 'execute')
|
||||
|
||||
def stack(self):
|
||||
self.row['stack'] = True
|
||||
self.record['pending_stack_transaction'] = '/fiction-only/stack.json'
|
||||
self.original = copy.deepcopy(self.record)
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
for mock in [*self.writes, *self.processes, self.recovery, self.native_recovery, self.stack_recovery]:
|
||||
mock.assert_not_called()
|
||||
|
||||
|
||||
class PendingRecoveryTests(PendingRecoveryFixture):
|
||||
def test_single_no_is_distinct_falsy_cancellation(self):
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED)
|
||||
self.no_effects()
|
||||
|
||||
def test_single_menu_cancel_is_distinct_cancellation(self):
|
||||
self.ui.choose.return_value = None
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
|
||||
self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_single_no_and_cancel_return_six_without_duplicate_output(self):
|
||||
for choice in ('recover', None):
|
||||
with self.subTest(choice=choice), patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
self.ui.choose.return_value = choice
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_stack_no_returns_six_before_reading_the_journal(self):
|
||||
self.stack()
|
||||
with patch.object(cli, 'Catalog'), patch.object(Path, 'read_text', side_effect=AssertionError('Journal read after No')) as read:
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
|
||||
read.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_no_and_escape_cancel_single_and_stack(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for stacked in (False, True):
|
||||
if stacked:
|
||||
self.stack()
|
||||
for code in (1, 255):
|
||||
with self.subTest(stack=stacked, code=code):
|
||||
answers = ([] if stacked else [subprocess.CompletedProcess('dialog', 0, stderr='2')])
|
||||
answers.append(subprocess.CompletedProcess('dialog', code, stderr=''))
|
||||
with patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', side_effect=answers):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_cancel_and_escape_from_single_operation_menu(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for code in (1, 255):
|
||||
with self.subTest(code=code), patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_its_list_after_pending_single_or_stack_no(self):
|
||||
for stacked in (False, True):
|
||||
if stacked:
|
||||
self.stack()
|
||||
self.ui.choose.reset_mock()
|
||||
self.ui.choose.side_effect = ['110', None] if stacked else ['110', 'recover', None]
|
||||
with self.subTest(stack=stacked), patch.object(menu, '_clean_orphans'), \
|
||||
patch.object(menu, 'offer_recovery'), patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 2 if stacked else 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_confirmed_single_recovery_and_status_preserve_exact_commands_and_results(self):
|
||||
self.ui.review.return_value = True
|
||||
for action in ('recover', 'status'):
|
||||
for success, code in ((True, 0), (False, 1)):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = success
|
||||
self.ui.choose.return_value = action
|
||||
with self.subTest(action=action, result=success):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
|
||||
self.lifecycle.assert_called_once_with(
|
||||
[sys.executable, str(ROOT/'oci/remote/oci_instance_transaction.py'), action, '110'],
|
||||
'Recover OCI' if action == 'recover' else 'OCI management')
|
||||
self.write.assert_not_called(); self.backup.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_confirmed_stack_recovery_preserves_command_and_failure_is_not_cancel(self):
|
||||
self.stack()
|
||||
self.ui.review.return_value = True
|
||||
for success, code in ((True, 0), (False, 1)):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = success
|
||||
with self.subTest(result=success), patch.object(Path, 'read_text', return_value=json.dumps({'plan': {'members': [{'vmid': 110}, {'vmid': 111}]}})):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
|
||||
self.lifecycle.assert_called_once_with(
|
||||
[sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--recover', '--acknowledge-external-data'],
|
||||
'Recover OCI stack')
|
||||
self.write.assert_not_called(); self.backup.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_identity_failure_stays_failure_and_does_not_offer_recovery(self):
|
||||
with patch.object(menu, 'check_selected', return_value={**self.row, 'reason': 'different-installation'}):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 1)
|
||||
self.ui.choose.assert_not_called(); self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_unattended_does_not_turn_pending_menu_into_an_automatic_restore(self):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update', unattended=True), 1)
|
||||
self.no_effects()
|
||||
|
||||
def test_registration_cli_retains_its_separate_entry_point(self):
|
||||
with patch.object(cli, 'Catalog'), patch.object(menu, 'direct_recovery', return_value=0) as register, \
|
||||
patch.object(menu, 'direct_management', side_effect=AssertionError('Registration routed to rollback')):
|
||||
self.assertEqual(cli.main(['recover']), 0)
|
||||
register.assert_called_once_with(ROOT/'oci')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RecoverySlovakMeaning(PendingRecoveryFixture):
|
||||
def test_actual_recovery_dialogs_preserve_host_data_and_bounded_recovery_meaning(self):
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row)
|
||||
text = self.ui.review.call_args.args[0]
|
||||
self.assertIn('Zdieľané hostiteľské adresáre sa nevrátia späť', text)
|
||||
self.assertIn('po úspešnom vyčistení', text)
|
||||
self.assertEqual(self.ui.choose.call_args.args[0], 'Prerušená operácia')
|
||||
self.assertEqual(dict(self.ui.choose.call_args.args[1])['status'], 'Zobraziť stav')
|
||||
self.stack()
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row)
|
||||
self.assertEqual(self.ui.review.call_args.args[1], 'Obnoviť OCI aplikáciu s viacerými kontajnermi')
|
||||
self.assertIn('môžu zlyhať', self.ui.review.call_args.args[0])
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Obnoviť alebo dokončiť operáciu?')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RecoveryGuardMessages(PendingRecoveryFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for name, value in (('_language', 'sk'), ('_cache', cache)):
|
||||
p = patch.object(oci_ui, name, value)
|
||||
p.start(); self.addCleanup(p.stop)
|
||||
self.record.update(installation_id='fiction-installation', transaction_id='fiction-tx')
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.journal = Path(self.record['pending_transaction'])
|
||||
self.state = {'id': 'fiction-tx', 'vmid': 110, 'phase': 'backup-ready',
|
||||
'record': {'installation_id': 'fiction-installation'}, 'before_config': 'before',
|
||||
'backup': None, 'backup_sha256': 'saved-sha'}
|
||||
|
||||
def rejected(self, text, *, node='fiction-host', changed_backup=False):
|
||||
calls = []
|
||||
def readonly(*args):
|
||||
calls.append(args)
|
||||
if args[0] == 'pvesh':
|
||||
return json.dumps([{'type': 'lxc', 'vmid': 110, 'node': node}]).encode()
|
||||
if args == ('pct', 'config', '110'):
|
||||
return b'changed'
|
||||
raise AssertionError('Unexpected recovery effect: ' + repr(args))
|
||||
with patch.object(Path, 'read_text', return_value=json.dumps(self.state)), \
|
||||
patch.object(Path, 'exists', return_value=True), patch.object(Path, 'is_file', return_value=True), \
|
||||
patch.object(transaction.socket, 'gethostname', return_value='fiction-host'), \
|
||||
patch.object(transaction.host_mounts, 'verify_sources'), patch.object(transaction.gpu_devices, 'verify'), \
|
||||
patch.object(transaction, 'owned', return_value=b'fiction config'), \
|
||||
patch.object(transaction, 'filehash', return_value='changed-sha' if changed_backup else 'saved-sha'), \
|
||||
patch.object(transaction, 'run', side_effect=readonly), redirect_stdout(io.StringIO()):
|
||||
# Only reads before the rejecting guard are supplied. Any next
|
||||
# command or attempt to persist state fails this fixture.
|
||||
with self.assertRaisesRegex(ValueError, text):
|
||||
ACTUAL_RECOVER(ROOT/'oci', self.journal)
|
||||
self.no_effects()
|
||||
return calls
|
||||
|
||||
def test_foreign_transaction_is_rejected_before_probing_the_container(self):
|
||||
self.state['record']['installation_id'] = 'different-installation'
|
||||
self.assertEqual(self.rejected('Záznam nepatrí k tejto operácii'), [])
|
||||
|
||||
def test_missing_verified_backup_cannot_touch_a_changed_container(self):
|
||||
self.assertEqual(len(self.rejected('Chýba overená záloha; zmenený kontajner zostáva nedotknutý')), 2)
|
||||
|
||||
def test_changed_backup_is_rejected_before_decompression_or_restore(self):
|
||||
self.state['backup'] = '/fiction-only/backup.zst'
|
||||
self.assertEqual(len(self.rejected('Záloha bola zmenená; obnova zablokovaná', changed_backup=True)), 2)
|
||||
|
||||
def test_guest_on_another_node_is_never_overwritten(self):
|
||||
self.assertEqual(len(self.rejected('kontajner je na inom uzle; nie je prepísaný', node='another-node')), 1)
|
||||
|
||||
def test_incomplete_stack_recovery_state_fails_before_any_adapter_call(self):
|
||||
adapter = SimpleNamespace(validate=self.recovery, stop=self.native_recovery,
|
||||
restore=self.stack_recovery)
|
||||
state = {'phase': 'recovery-failed', 'plan': {'start_order': [110, 111]},
|
||||
'running': {'110': True}, 'stop_intent': True, 'replacement_intent': False}
|
||||
before = copy.deepcopy(state)
|
||||
with self.assertRaisesRegex(ValueError, 'Záznam operácie obsahuje neúplný stav obnovy'):
|
||||
stack_transaction.recover_state(self.journal, state, adapter)
|
||||
self.assertEqual(state, before)
|
||||
self.no_effects()
|
||||
|
||||
def test_critical_safety_and_untouched_result_have_the_correct_meaning(self):
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
self.assertEqual(cache['A host mount is not part of the journal; recovery blocked'],
|
||||
'Pripojenie adresára hostiteľa nie je uvedené v zázname operácie; obnova je zablokovaná')
|
||||
self.assertEqual(cache['Recovery completed. The container had not been modified yet.'],
|
||||
'Obnova dokončená. Kontajner ešte nebol zmenený.')
|
||||
self.assertEqual(cache['A backup was modified'], 'Záloha bola zmenená')
|
||||
self.assertEqual(cache['Recovering the previous installation'], 'Obnovovanie predchádzajúcej inštalácie')
|
||||
self.assertEqual(cache['Recover the previous installation'], 'Obnoviť predchádzajúcu inštaláciu')
|
||||
self.no_effects()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Restored-app registration through real Menu/CLI/Dialog; no host effects."""
|
||||
from contextlib import nullcontext, redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import string
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
|
||||
import oci_restore_recovery as restored
|
||||
import oci_ui
|
||||
|
||||
|
||||
class RegistrationFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.found = [{'vmid':110, 'hostname':'fiction-primary'}]
|
||||
self.original_found = copy.deepcopy(self.found)
|
||||
self.ui.confirm.return_value = False
|
||||
item = patch.object(menu, 'restored_applications', side_effect=lambda project:self.found)
|
||||
self.pending = item.start();self.addCleanup(item.stop)
|
||||
self.firewall = self.guard(menu, '_restored_firewall_rules')
|
||||
self.backend = [self.guard(restored, name) for name in ('recover','register','run')]
|
||||
self.files = [self.guard(Path, name) for name in ('write_text','write_bytes','unlink')]
|
||||
self.processes = [self.guard(subprocess, name) for name in ('run','Popen','check_output')]
|
||||
|
||||
def members(self):
|
||||
self.found.append({'vmid':111,'hostname':'fiction-dependent'})
|
||||
self.original_found = copy.deepcopy(self.found)
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
self.firewall.assert_not_called()
|
||||
self.safe_backend()
|
||||
|
||||
def safe_backend(self):
|
||||
for item in [*self.backend,*self.files,*self.processes]:item.assert_not_called()
|
||||
self.assertEqual(self.found,self.original_found)
|
||||
|
||||
def accepted(self, answers, completed=True):
|
||||
self.ui.confirm.side_effect=answers
|
||||
self.firewall.side_effect=None
|
||||
self.firewall.return_value=[{'vmid':row['vmid'],'port':61208 if row['vmid']==110 else 19999,
|
||||
'source':'192.0.2.0/24'} for row in self.found]
|
||||
self.lifecycle.side_effect=None;self.lifecycle.return_value=completed
|
||||
self.pending.side_effect=lambda project:[] if completed and self.lifecycle.called else self.found
|
||||
|
||||
|
||||
class RegistrationCancellationTests(RegistrationFixture):
|
||||
def test_offer_no_is_explicit_falsy_cancellation(self):
|
||||
self.assertIs(menu.offer_recovery(ROOT/'oci',self.ui),menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED);self.no_effects()
|
||||
|
||||
def test_direct_single_no_finishes_before_a_third_pending_read(self):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
self.assertEqual(self.pending.call_count,2)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_single_no_returns_six_without_output(self):
|
||||
with patch.object(cli,'Catalog'),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(cli.main(['recover']),6)
|
||||
self.assertEqual(output.getvalue(),'');self.no_effects()
|
||||
|
||||
def test_real_cli_multi_container_no_is_not_a_guest_action(self):
|
||||
self.members()
|
||||
with patch.object(cli,'Catalog'):
|
||||
self.assertEqual(cli.main(['recover']),6)
|
||||
text=self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('CT 110',text);self.assertIn('CT 111',text)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_initial_no_and_escape_for_single_and_multi(self):
|
||||
dialog=native_ui.DialogUI()
|
||||
for multi in (False,True):
|
||||
if multi:self.members()
|
||||
for code in (1,255):
|
||||
with self.subTest(multi=multi,code=code),patch.object(native_ui,'interactive_ui',return_value=dialog), \
|
||||
patch.object(dialog,'_run',return_value=subprocess.CompletedProcess('dialog',code,stderr='')):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_continues_to_list_after_registration_no(self):
|
||||
self.ui.choose.return_value=None
|
||||
with patch.object(menu,'_clean_orphans'),patch.object(menu,'_records_unread',return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci',self.ui)
|
||||
self.ui.choose.assert_called_once();self.no_effects()
|
||||
|
||||
def test_optional_firewall_and_start_no_registers_without_flags(self):
|
||||
self.members();self.accepted([True,False,False,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0],
|
||||
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
|
||||
self.assertEqual(self.ui.confirm.call_count,4);self.safe_backend()
|
||||
|
||||
def test_single_optional_no_preserves_registration_without_start(self):
|
||||
self.accepted([True,False,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0],
|
||||
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
|
||||
self.assertEqual(self.ui.confirm.call_count,3);self.safe_backend()
|
||||
|
||||
def test_only_one_confirmed_firewall_is_forwarded_and_no_start(self):
|
||||
self.members();self.accepted([True,False,True,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0][-2:],['--host-firewall','111'])
|
||||
self.assertNotIn('--start',self.lifecycle.call_args.args[0]);self.safe_backend()
|
||||
|
||||
def test_explicit_start_yes_retains_flag_but_never_starts_any_guest_here(self):
|
||||
self.accepted([True,False,True])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0][-1],'--start');self.safe_backend()
|
||||
|
||||
def test_failed_registration_stays_failure_with_pending_records(self):
|
||||
self.accepted([True,False,False],completed=False)
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
|
||||
self.safe_backend()
|
||||
|
||||
def test_empty_list_is_success_without_confirmation_or_backend(self):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.ui.confirm.assert_not_called();self.ui.message.assert_called_once();self.no_effects()
|
||||
|
||||
def test_root_identity_failure_never_probes_restored_apps(self):
|
||||
with patch.object(menu.os,'geteuid',return_value=1000):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
|
||||
self.pending.assert_not_called();self.ui.confirm.assert_not_called();self.no_effects()
|
||||
|
||||
def test_empty_offer_does_not_cancel_the_native_menu(self):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertIsNone(menu.offer_recovery(ROOT/'oci',self.ui))
|
||||
self.ui.confirm.assert_not_called();self.no_effects()
|
||||
|
||||
def test_empty_registration_state_is_rendered_in_slovak_without_recovery(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
with patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.ui.message.call_args.args[0],
|
||||
'Žiadna OCI aplikácia obnovená zo zálohy nečaká na opätovnú registráciu.')
|
||||
self.no_effects()
|
||||
|
||||
def test_initial_single_and_multi_dialog_describes_registration_precheck(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for multi in (False,True):
|
||||
if multi:self.members()
|
||||
with self.subTest(multi=multi),patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
text=self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('Pred opätovnou registráciou',text)
|
||||
self.assertNotIn('Obnova najprv',text)
|
||||
self.assertIn('aplikácie sa potom nespúšťajú automaticky',text)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_backend_blocker_stays_error_and_uses_registration_wording(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
plan={'title':'Fictional restored application','restored':[{'vmid':110}],
|
||||
'blockers':['Fictional missing resource'],'notes':[]}
|
||||
with patch.object(menu.sys,'argv',['fixture','recover','--root','/fiction-only']), \
|
||||
patch.object(restored.instances,'locked',return_value=nullcontext()), \
|
||||
patch.object(restored,'prepare',return_value=[plan]), \
|
||||
patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
|
||||
patch.object(restored,'msg_error') as error,patch.object(restored,'msg_info2'):
|
||||
self.assertEqual(restored.main(),1)
|
||||
error.assert_called_once_with('Túto aplikáciu zatiaľ nemožno znova zaregistrovať:')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_host_resource_check_reports_missing_data_without_creating_anything(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
plan={'restored':[{'vmid':110,'config':'mp0: /fiction-only/data,mp=/data\n',
|
||||
'copy':{'record':{'deployment':{},'observed':{}}}}], 'blockers':[],'notes':[]}
|
||||
with patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
|
||||
patch.object(restored.os.path,'isdir',return_value=False), \
|
||||
patch.object(restored,'firewall_rule',return_value=None):
|
||||
restored.check_host_resources(plan)
|
||||
self.assertEqual(len(plan['blockers']),1)
|
||||
self.assertIn('/fiction-only/data',plan['blockers'][0])
|
||||
self.assertIn('registráciu zopakujte',plan['blockers'][0])
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RegistrationContractTests(unittest.TestCase):
|
||||
def test_real_monitor_shell_recover_keeps_global_cli_contract_and_exit_six(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
base=Path(folder);(base/'scripts/oci').mkdir(parents=True)
|
||||
(base/'utils.sh').write_text('load_language() { :; }\ninitialize_cache() { :; }\n')
|
||||
(base/'scripts/oci/oci_manager_apps.sh').write_text('printf "%s\\n" "$@"\nexit 6\n')
|
||||
script=(ROOT/'scripts/oci/manage_instance.sh').read_text()
|
||||
script=script.replace('/usr/local/share/proxmenux',folder)
|
||||
import os
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,
|
||||
env={**os.environ,'ACTION':'recover','VMID':'not-a-vmid'})
|
||||
self.assertEqual(result.returncode,6);self.assertEqual(result.stdout,'recover\n')
|
||||
self.assertEqual(result.stderr,'')
|
||||
|
||||
def test_curated_slovak_registration_words_and_format_fields(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
expected={
|
||||
'Recover restored OCI applications':'Registrácia',
|
||||
'No restored OCI application is waiting to be recovered.':'registráciu',
|
||||
'Recover them now?':'zaregistrovať',
|
||||
'This application cannot be recovered yet:':'zaregistrovať',
|
||||
'The application could not be recovered:':'zaregistrovať',
|
||||
'Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.':'vyberte Nie',
|
||||
'it is locked or its disk could not be mounted':'uzamknutý',
|
||||
'The local storage does not accept snippets':'local',
|
||||
'it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.':'NVIDIA Container Toolkit',
|
||||
'the NVIDIA hook of the container is not available:':'spúšťací skript',
|
||||
'its Rclone mount is published again on this host at':'v ceste',
|
||||
'Choose another bridge for the container in Proxmox (Network) and run the recovery again.':'registráciu',
|
||||
'Mount or create it with its data and run the recovery again.':'registráciu',
|
||||
'Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.':'registráciu',
|
||||
}
|
||||
fields=lambda text:sorted(f for _,f,_,_ in string.Formatter().parse(text) if f)
|
||||
for key,word in expected.items():
|
||||
with self.subTest(key=key):
|
||||
self.assertIn(word,catalog[key]);self.assertEqual(fields(key),fields(catalog[key]))
|
||||
|
||||
def test_registration_web_text_is_distinct_from_previous_installation_recovery(self):
|
||||
for locale in ('en','sk'):
|
||||
data=json.loads((ROOT/'AppImage/messages'/locale/'common.json').read_text())['vmLxc']
|
||||
with self.subTest(locale=locale):
|
||||
self.assertEqual(data['ociUpdates']['registerRestored'],
|
||||
'Znova zaregistrovať' if locale=='sk' else 'Register again')
|
||||
self.assertEqual(data['ociUpdates']['recover'],'Obnoviť' if locale=='sk' else 'Recover')
|
||||
self.assertIn('zaregistrovať' if locale=='sk' else 'Register again',data['updates']['ociRestoredBody'])
|
||||
self.assertIn('Zaregistrovať' if locale=='sk' else 'Register',data['ociUpdates']['terminalTitleRecover'])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -56,7 +56,8 @@ class RemovalWordings(unittest.TestCase):
|
||||
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}
|
||||
'translate': translated or (lambda value: value), 're': re,
|
||||
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
|
||||
@@ -87,7 +88,8 @@ class RemovalWordings(unittest.TestCase):
|
||||
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}
|
||||
'translate': lambda value: value, 're': re,
|
||||
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
|
||||
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
|
||||
self.assertIn(WHOLE, preview)
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Actual removal UI/guards in SK and EN; real host effects are forbidden."""
|
||||
from contextlib import nullcontext
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import i18n, management
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
import oci_instances as instances
|
||||
import oci_remove as removal
|
||||
import oci_ui
|
||||
|
||||
|
||||
class RemovalSlovakTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
cache = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in (('_language', 'sk'), ('_cache', cache)):
|
||||
p = patch.object(module, name, value)
|
||||
p.start(); self.addCleanup(p.stop)
|
||||
self.records = {}
|
||||
self.make_records(2)
|
||||
self.read = self.start(instances, 'read', side_effect=lambda root, vmid: self.records[vmid])
|
||||
self.start(management, '_selected_configuration', side_effect=self.config)
|
||||
self.bridge = self.start(removal, 'bridge_in_use', return_value=False)
|
||||
self.ui = Mock()
|
||||
self.ui.review.return_value = False
|
||||
self.forbidden = []
|
||||
for module, name in ((subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
|
||||
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink'),
|
||||
(removal.shutil, 'rmtree'), (instances, 'write'), (instances, 'command'),
|
||||
(management, '_run_lifecycle'), (removal, 'run'),
|
||||
(removal, 'remove_host_state'), (removal.oci_console, 'remove_log'),
|
||||
(removal, 'release_bridge'), (removal, 'remove_owned_host_firewall'),
|
||||
(removal, 'release_shared_host_files'), (removal.work_backup, 'forget'),
|
||||
(removal.image_cache, 'prune')):
|
||||
self.forbidden.append(self.start(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
|
||||
|
||||
def start(self, module, name, **kwargs):
|
||||
p = patch.object(module, name, **kwargs)
|
||||
result = p.start(); self.addCleanup(p.stop)
|
||||
return result
|
||||
|
||||
def make_records(self, count):
|
||||
self.records.clear()
|
||||
for offset in range(count):
|
||||
vmid = 999990 + offset
|
||||
self.records[vmid] = {'vmid': vmid, 'installation_id': 'fixture-' + str(vmid),
|
||||
'template': {'catalog_ui': {'title': 'Fixture App'}},
|
||||
'deployment': {'mounts': [{'type': 'host-bind', 'source': '/fixture/saved',
|
||||
'container_path': '/saved'}]}}
|
||||
if offset:
|
||||
self.records[vmid]['stack_member'] = {'primary_vmid': 999990}
|
||||
if count > 1:
|
||||
self.records[999990]['stack'] = {'members': [{'vmid': vmid} for vmid in self.records],
|
||||
'deployment': {'network': {'private_bridge': 'vmbr999', 'private_subnet': '10.77.99.0/24'}}}
|
||||
self.records[999990]['deployment']['host_firewall'] = {'port': 8080}
|
||||
self.original = copy.deepcopy(self.records)
|
||||
|
||||
def config(self, vmid):
|
||||
return (f'hostname: fixture-{vmid}\nrootfs: fixture:vm-{vmid}-disk-0,size=8G\n'
|
||||
f'mp0: fixture:vm-{vmid}-disk-1,mp=/data,size=16G\n'
|
||||
'mp1: /fixture/shared,mp=/shared\n').encode()
|
||||
|
||||
def tearDown(self):
|
||||
self.assertEqual(self.records, self.original)
|
||||
for effect in self.forbidden:
|
||||
effect.assert_not_called()
|
||||
|
||||
def test_single_summary_separates_deleted_disks_and_retained_host_paths(self):
|
||||
self.make_records(1)
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Kontajnery určené na odstránenie:', text)
|
||||
self.assertIn('Údaje kontajnera určené na vymazanie:', text)
|
||||
self.assertIn('/data (16G)', text)
|
||||
kept = text.split('Cesty hostiteľa nájdené')[1]
|
||||
self.assertIn('/fixture/shared', kept); self.assertIn('/fixture/saved', kept)
|
||||
self.assertNotIn('/fixture/shared', text.split('Cesty hostiteľa nájdené')[0])
|
||||
self.assertNotIn('súkromnej siete:', text)
|
||||
|
||||
def test_primary_summary_counts_are_grammatical_for_two_three_and_five(self):
|
||||
for count in (2, 3, 5):
|
||||
with self.subTest(count=count):
|
||||
self.make_records(count)
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn(f'Počet kontajnerov aplikácie Fixture App: {count}.', text)
|
||||
self.assertIn('Všetky sú určené na odstránenie.', text)
|
||||
for vmid in self.records:
|
||||
self.assertIn(f'CT {vmid}', text)
|
||||
|
||||
def test_member_summary_warns_that_entire_application_is_selected(self):
|
||||
text = management._removal_summary(ROOT / 'oci', 999991)
|
||||
self.assertIn('CT 999991 patrí do aplikácie Fixture App (počet kontajnerov: 2).', text)
|
||||
self.assertIn('odstránenie celej aplikácie', text)
|
||||
self.assertNotIn('nádob', text)
|
||||
|
||||
def test_free_bridge_is_only_targeted_and_firewall_removal_is_conditional(self):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Súkromná sieť určená na uvoľnenie, ak ju nepoužíva iná VM alebo iný CT: vmbr999', text)
|
||||
self.assertIn('Môže sa odstrániť aj zodpovedajúce spravované pravidlo firewallu hostiteľa.', text)
|
||||
|
||||
def test_shared_bridge_is_kept_and_not_reported_as_released(self):
|
||||
self.bridge.return_value = True
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Súkromná sieť zachovaná', text)
|
||||
self.assertNotIn('určená na uvoľnenie', text)
|
||||
|
||||
def test_missing_config_still_discloses_saved_host_paths_without_deleting(self):
|
||||
with patch.object(management, '_selected_configuration', side_effect=OSError('fixture missing')):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('/fixture/saved', text)
|
||||
self.assertIn('rootfs -', text)
|
||||
|
||||
def test_no_from_real_remove_review_never_calls_lifecycle(self):
|
||||
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999991))
|
||||
review = self.ui.review.call_args
|
||||
self.assertFalse(review.kwargs['default'])
|
||||
self.assertEqual(review.kwargs['question'],
|
||||
'Odstrániť aplikáciu? Jej kontajnerové disky sa vymažú a obnoviť ich možno iba zo zálohy.')
|
||||
|
||||
def test_cancelled_real_remove_review_never_calls_lifecycle(self):
|
||||
self.ui.review.side_effect = UserCancelled('fixture cancelled')
|
||||
with self.assertRaises(UserCancelled):
|
||||
management._remove(ROOT / 'oci', self.ui, 999990)
|
||||
|
||||
def test_preparation_error_does_not_show_confirmation_or_call_lifecycle(self):
|
||||
with patch.object(removal, 'members_of', side_effect=ValueError('fixture record invalid')):
|
||||
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999990))
|
||||
self.ui.review.assert_not_called()
|
||||
self.assertIn('Odstránenie nebolo možné pripraviť:', self.ui.message.call_args.args[0])
|
||||
|
||||
def test_pending_operation_error_precedes_configuration_probe_and_all_mutations(self):
|
||||
self.make_records(1)
|
||||
self.records[999990]['pending_transaction'] = {'fixture': True}
|
||||
self.original = copy.deepcopy(self.records)
|
||||
with patch.object(removal, 'guest_config') as config:
|
||||
with self.assertRaisesRegex(ValueError, 'Operácia tejto inštalácie sa ešte nedokončila'):
|
||||
removal.remove(Path('/fixture/oci'), 999990)
|
||||
config.assert_not_called()
|
||||
|
||||
def test_container_on_other_node_errors_before_host_cleanup(self):
|
||||
with patch.object(removal, 'guest_config', return_value=None), \
|
||||
patch.object(removal, 'guest_node', return_value='fixture-other-node'):
|
||||
with self.assertRaisesRegex(ValueError, 'Kontajner beží na inom uzle klastra'):
|
||||
removal.remove(Path('/fixture/oci'), 999990)
|
||||
|
||||
def run_main(self, result=False, busy=False):
|
||||
with patch.object(sys, 'argv', ['oci_remove.py', '999990']), \
|
||||
patch.object(removal.os, 'geteuid', return_value=0), \
|
||||
patch.object(instances, 'locked', side_effect=BlockingIOError if busy else None,
|
||||
return_value=nullcontext()), \
|
||||
patch.object(removal, 'remove', return_value=result) as mocked_remove, \
|
||||
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_error') as error:
|
||||
code = removal.main()
|
||||
return code, mocked_remove, ok, error
|
||||
|
||||
def test_busy_registry_error_does_not_enter_removal(self):
|
||||
code, remove, ok, error = self.run_main(busy=True)
|
||||
self.assertEqual(code, 1); remove.assert_not_called(); ok.assert_not_called()
|
||||
self.assertEqual(error.call_args.args[0], 'Register inštancií používa iná operácia OCI')
|
||||
|
||||
def test_partial_completion_is_a_warning_to_review_not_unqualified_success(self):
|
||||
code, remove, ok, error = self.run_main(result=True)
|
||||
self.assertEqual(code, 0); error.assert_not_called()
|
||||
self.assertEqual(ok.call_args.args[0],
|
||||
'Príkaz na odstránenie sa dokončil; skontrolujte všetky upozornenia vyššie.')
|
||||
self.assertNotEqual(ok.call_args.args[0], i18n.translate('The application was removed'))
|
||||
|
||||
def test_clean_completion_keeps_existing_success_contract(self):
|
||||
code, remove, ok, error = self.run_main()
|
||||
self.assertEqual(code, 0); error.assert_not_called()
|
||||
self.assertEqual(ok.call_args.args[0], 'Aplikácia bola odstránená')
|
||||
|
||||
def test_english_primary_and_member_summaries_keep_identical_sentences(self):
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
primary = management._removal_summary(ROOT / 'oci', 999990)
|
||||
member = management._removal_summary(ROOT / 'oci', 999991)
|
||||
self.assertTrue(primary.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
|
||||
self.assertTrue(member.startswith('CT 999991 is one of the 2 containers of Fixture App. It cannot be removed on its own,'))
|
||||
|
||||
def test_missing_translation_falls_back_to_the_same_english_summary(self):
|
||||
with patch.object(i18n, '_cache', {}):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertTrue(text.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
|
||||
|
||||
def test_yes_only_builds_the_existing_command_when_lifecycle_is_inert(self):
|
||||
self.ui.review.return_value = True
|
||||
with patch.object(management, '_run_lifecycle', return_value=True) as inert:
|
||||
self.assertTrue(management._remove(ROOT / 'oci', self.ui, 999990))
|
||||
self.assertEqual(inert.call_args.args[0],
|
||||
[sys.executable, str(ROOT / 'oci/remote/oci_remove.py'), '999990'])
|
||||
|
||||
def test_single_and_stack_menu_routes_use_the_translated_label_and_real_no_review(self):
|
||||
for count in (1, 2):
|
||||
with self.subTest(count=count):
|
||||
self.make_records(count)
|
||||
self.ui.reset_mock()
|
||||
self.ui.choose.return_value = 'remove'
|
||||
row = {'vmid': 999990, 'stack': count > 1, 'pending': False,
|
||||
'status': 'installed', 'reason': 'matched'}
|
||||
with patch.object(management, 'carry_records', return_value={}), \
|
||||
patch.object(management, 'check_selected', return_value=row):
|
||||
self.assertFalse(management.manage_instance(ROOT / 'oci', self.ui, row))
|
||||
options = self.ui.choose.call_args.args[1]
|
||||
self.assertIn(('remove', 'Odstrániť: zmazať aplikáciu a jej kontajnery'), options)
|
||||
self.ui.review.assert_called_once()
|
||||
self.assertFalse(self.ui.review.call_args.kwargs['default'])
|
||||
|
||||
def test_changed_identity_warning_does_not_claim_the_other_container_was_deleted(self):
|
||||
self.make_records(1)
|
||||
# Exercise the real backend branch; record cleanup is inert as well.
|
||||
with patch.object(removal, 'guest_config', return_value=b'fixture'), \
|
||||
patch.object(instances, 'identity', return_value='fixture-other-identity'), \
|
||||
patch.object(removal, 'remove_owned_host_firewall', return_value=True), \
|
||||
patch.object(removal, 'release_shared_host_files'), \
|
||||
patch.object(removal.work_backup, 'forget'), \
|
||||
patch.object(removal.image_cache, 'prune', return_value=[]), \
|
||||
patch.object(Path, 'exists', return_value=False), \
|
||||
patch.object(Path, 'is_dir', return_value=False), \
|
||||
patch.object(removal, 'msg_warn') as warning, \
|
||||
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_info'):
|
||||
self.assertTrue(removal.remove(Path('/fixture/oci'), 999990))
|
||||
self.assertEqual(warning.call_args.args[0],
|
||||
'VMID teraz patrí inému kontajneru; ten zostane nedotknutý: CT 999990')
|
||||
self.assertNotIn('Odstránený kontajner:', str(ok.mock_calls))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
@@ -42,12 +42,56 @@ class SelectionSetupWording(TestCase):
|
||||
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: {},
|
||||
carry_records=lambda project, **options: {}, _records_unread=lambda project: False,
|
||||
translate=lambda s: s)
|
||||
fn(Path('/fixture'), ui)
|
||||
ui.message.assert_called_once_with(EMPTY, 'OCI management')
|
||||
ui.choose.assert_not_called()
|
||||
|
||||
def _menu(self, inventories, selections, unread=True):
|
||||
rows = iter(inventories)
|
||||
ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections))
|
||||
refresh = Mock(return_value={})
|
||||
manage = Mock(return_value=True)
|
||||
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
|
||||
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows),
|
||||
_clean_orphans=lambda project: None,
|
||||
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
|
||||
carry_records=refresh, _records_unread=lambda project: unread,
|
||||
manage_instance=manage, menu_size=lambda: 10,
|
||||
STATUS_LABELS={}, translate=lambda s: s)
|
||||
fn(Path('/fixture'), ui)
|
||||
return ui, refresh, manage
|
||||
|
||||
def test_the_list_comes_back_after_an_operation_without_refreshing_every_copy(self):
|
||||
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
|
||||
ui, refresh, manage = self._menu([[row], [row]], ['101', None])
|
||||
self.assertEqual(ui.choose.call_count, 2)
|
||||
manage.assert_called_once()
|
||||
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True,
|
||||
heading='Manage installed OCI applications')
|
||||
|
||||
def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self):
|
||||
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
|
||||
_, refresh, _ = self._menu([[row]], [None], unread=False)
|
||||
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False,
|
||||
heading='Manage installed OCI applications')
|
||||
|
||||
def test_removing_the_last_application_returns_without_a_message(self):
|
||||
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
|
||||
ui, _, manage = self._menu([[row], []], ['101'])
|
||||
manage.assert_called_once()
|
||||
ui.message.assert_not_called()
|
||||
|
||||
def test_the_watchdog_refreshes_the_copy_of_the_containers_it_changed(self):
|
||||
refresh = Mock(return_value={})
|
||||
watchdog = SimpleNamespace(set_watchdog=Mock(side_effect=[[101, 102]]))
|
||||
fn = extracted('set_watchdog', sys=SimpleNamespace(path=[]), carry_records=refresh,
|
||||
instances=SimpleNamespace(ROOT=Path('/fixture')))
|
||||
with patch.dict('sys.modules', {'oci_watchdog': watchdog}):
|
||||
self.assertTrue(fn(Path('/fixture'), [102], True))
|
||||
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, vmids=[101, 102])
|
||||
|
||||
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
|
||||
class Entry:
|
||||
name = '101'
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Exercise the multi-container editor in Slovak without applying a change."""
|
||||
from contextlib import redirect_stderr
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import host, i18n, installer, management, stack_recreation as editor
|
||||
import oci_immich_recognition as recognition
|
||||
import oci_instances as instances
|
||||
import oci_stack_modify as modify
|
||||
import oci_ui
|
||||
|
||||
CONFIG = {
|
||||
'rootfs': 'fixture:vm-999991-disk-0,size=8G',
|
||||
'mp0': 'fixture:vm-999991-disk-1,mp=/opt/recipes/mediafiles,size=8G',
|
||||
'mp1': 'fixture:vm-999991-disk-2,mp=/extra,size=4G',
|
||||
'mp2': '/fixture/shared,mp=/shared',
|
||||
'dev0': 'path=/dev/ttyACM7,mode=0660',
|
||||
}
|
||||
|
||||
|
||||
class StackEditorSlovakTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.translations = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in (('_language', 'sk'), ('_cache', self.translations)):
|
||||
item = patch.object(module, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
self.member = {'vmid': 999991, 'stack_member': {'name': 'application'},
|
||||
'deployment': {'replay_profile': {'adapter': 'install_tandoor_stack.sh',
|
||||
'role': 'application'}}}
|
||||
self.primary = {'vmid': 999990, 'stack': {'members': [self.member,
|
||||
{'vmid': 999992, 'deployment': {'replay_profile': {'role': 'database'}}}]}}
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
self.ui = Mock()
|
||||
self.ui.choose.return_value = None
|
||||
self.ui.confirm.return_value = False
|
||||
self.ui.review.return_value = False
|
||||
self.ui.checklist.side_effect = lambda text, options, defaults: list(defaults)
|
||||
config = patch.object(editor, '_config', return_value=CONFIG)
|
||||
config.start()
|
||||
self.addCleanup(config.stop)
|
||||
self.forbidden = []
|
||||
for target, name in ((subprocess, 'run'), (subprocess, 'Popen'),
|
||||
(subprocess, 'check_output'), (Path, 'write_text'),
|
||||
(Path, 'write_bytes'), (instances, 'write'),
|
||||
(instances, 'command'), (modify, 'apply'),
|
||||
(recognition, 'apply'), (recognition, 'configure'),
|
||||
(management, '_run_lifecycle')):
|
||||
item = patch.object(target, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
self.forbidden.append(item.start())
|
||||
self.addCleanup(item.stop)
|
||||
self.lifecycle = Mock(side_effect=AssertionError('Editor called lifecycle'))
|
||||
self.forbidden.append(self.lifecycle)
|
||||
|
||||
def tearDown(self):
|
||||
self.assertEqual(self.primary, self.original)
|
||||
for callback in self.forbidden:
|
||||
callback.assert_not_called()
|
||||
|
||||
def shown(self):
|
||||
return '\n'.join(str(call) for call in self.ui.mock_calls)
|
||||
|
||||
def immich(self, acceleration='cpu'):
|
||||
self.member['deployment']['replay_profile'] = {
|
||||
'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}
|
||||
self.member['deployment']['machine_learning'] = {'acceleration': acceleration}
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
return self.member
|
||||
|
||||
def changes(self, **values):
|
||||
return {'add_mounts': [], 'remove_mounts': [], 'add_devices': [],
|
||||
'remove_devices': [], **values}
|
||||
|
||||
def test_editor_only_offers_extra_paths_and_leaves_database_out(self):
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
shown = self.shown()
|
||||
self.assertIn('odznačené sa odstránia po potvrdení', shown)
|
||||
self.assertIn('odznačené sa odpoja po potvrdení', shown)
|
||||
options = self.ui.checklist.call_args_list[0].args[1]
|
||||
self.assertEqual([path for path, label in options], ['/extra', '/shared'])
|
||||
self.assertNotIn('zásobník', shown)
|
||||
|
||||
def test_declining_volume_deletion_keeps_it_and_only_detaches_host_path_in_draft(self):
|
||||
self.ui.checklist.side_effect = [[], ['/dev/ttyACM7']]
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
warning = self.ui.confirm.call_args_list[0].args[0]
|
||||
self.assertIn('trvalé úložisko aj všetky údaje v ňom', warning)
|
||||
self.assertIn('/extra', warning)
|
||||
summary = self.ui.review.call_args.args[0]
|
||||
self.assertIn('- /shared', summary)
|
||||
self.assertNotIn('- /extra', summary)
|
||||
self.assertIn('Spustený kontajner sa reštartuje', summary)
|
||||
self.assertIn('zastavený zostane zastavený', summary)
|
||||
self.assertIn('Databáza ani ostatné kontajnery sa nemenia', summary)
|
||||
|
||||
def test_declined_review_describes_confirmed_volume_deletion_without_performing_it(self):
|
||||
self.ui.checklist.side_effect = [[], []]
|
||||
self.ui.confirm.side_effect = [True, False, False]
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('- /extra', self.ui.review.call_args.args[0])
|
||||
self.assertIn('vymažú aj údaje', self.ui.review.call_args.args[0])
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Použiť tieto zmeny?')
|
||||
|
||||
def test_member_selection_cancels_before_any_configuration_read(self):
|
||||
other = copy.deepcopy(self.member)
|
||||
other['vmid'] = 999993
|
||||
self.primary['stack']['members'].append(other)
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
with patch.object(editor, '_config') as read:
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
read.assert_not_called()
|
||||
self.assertEqual(self.ui.choose.call_args.args[0], 'Kontajner, ktorý chcete upraviť')
|
||||
|
||||
def test_missing_member_error_uses_application_language(self):
|
||||
self.primary['stack']['members'] = []
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('Pre túto aplikáciu nie je uložený zoznam kontajnerov.', self.shown())
|
||||
self.assertIn('Správa OCI aplikácie s viacerými kontajnermi', self.shown())
|
||||
|
||||
def test_immich_action_menu_identifies_recognition_instead_of_video_transcoding(self):
|
||||
self.immich()
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('Vybrať CPU alebo GPU pre rozpoznávanie', self.shown())
|
||||
self.assertIn('Upraviť pripojené cesty a zariadenia', self.shown())
|
||||
|
||||
def test_cpu_only_host_explains_why_no_gpu_choice_is_offered(self):
|
||||
member = self.immich()
|
||||
with patch.object(host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': []}):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
self.assertIn('nenašla použiteľná grafická karta', self.shown())
|
||||
self.assertIn('Rozpoznávanie zostáva na CPU.', self.shown())
|
||||
self.ui.review.assert_not_called()
|
||||
|
||||
def test_gpu_review_explains_whole_application_update_and_kept_data_before_no(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'openvino'
|
||||
found = {'intel': ['/dev/dri/renderD128'], 'amd': [], 'nvidia': []}
|
||||
with patch.object(host, 'gpus', return_value=found):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
choices = self.ui.choose.call_args.args[1]
|
||||
self.assertEqual(choices, [('cpu', 'CPU'), ('openvino', 'Intel GPU')])
|
||||
text = self.ui.review.call_args.args[0]
|
||||
for fragment in ('CPU → Intel GPU', 'pamäť modelov, knižnica a databáza sa zachovajú',
|
||||
'Celá aplikácia sa zastaví a aktualizuje', 'ProxMenux sa pokúsi obnoviť',
|
||||
'ponuky správy OCI'):
|
||||
self.assertIn(fragment, text)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'],
|
||||
'Použiť zvolený CPU/GPU pre rozpoznávanie?')
|
||||
|
||||
def test_unchanged_recognition_does_not_open_confirmation_or_apply(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'cpu'
|
||||
with patch.object(editor, 'recognition_choices', return_value=[('cpu', 'CPU', {}),
|
||||
('cuda', 'NVIDIA GPU', {})]):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
self.ui.review.assert_not_called()
|
||||
self.assertIn('Nič sa nezmenilo.', self.shown())
|
||||
|
||||
def test_experimental_rocm_can_be_declined_before_final_review(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'rocm'
|
||||
choices = [('cpu', 'CPU', {}), ('rocm', 'AMD GPU', {'experimental': True})]
|
||||
with patch.object(editor, 'recognition_choices', return_value=choices), \
|
||||
patch.object(host, 'amd_gpu_name', return_value='Fixture AMD'):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
text = self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('ROCm túto grafickú kartu oficiálne nepodporuje.', text)
|
||||
self.assertIn('GPU prestať reagovať', text)
|
||||
self.assertIn('bez preinštalovania', text)
|
||||
self.assertIn('Použiť napriek tomu GPU s ROCm?', text)
|
||||
self.assertFalse(self.ui.confirm.call_args.args[1])
|
||||
self.ui.review.assert_not_called()
|
||||
|
||||
def test_backend_invalid_volume_size_preserves_path_and_fails_before_mutation(self):
|
||||
mount = {'type': 'managed-volume', 'container_path': '/new-data',
|
||||
'size_gb': 0, 'source': 'fixture'}
|
||||
with patch.object(modify, 'entries', return_value={}):
|
||||
with self.assertRaisesRegex(ValueError, 'Neplatná veľkosť trvalého úložiska: /new-data'):
|
||||
modify.validate(999991, self.changes(add_mounts=[mount]))
|
||||
|
||||
def test_backend_unsupported_device_explains_separate_profile_before_mutation(self):
|
||||
device = {'kind': 'character-device', 'host_path': '/dev/sda'}
|
||||
with patch.object(modify, 'entries', return_value={}):
|
||||
with self.assertRaisesRegex(ValueError, 'NVIDIA a celé stromy zariadení vyžadujú iný profil: /dev/sda'):
|
||||
modify.validate(999991, self.changes(add_devices=[device]))
|
||||
|
||||
def test_root_error_is_localized_without_starting_the_operation(self):
|
||||
with patch.object(modify.os, 'geteuid', return_value=1000), \
|
||||
patch.object(sys, 'argv', ['fixture', '999991', '--changes', '/not-read.json']), \
|
||||
redirect_stderr(io.StringIO()) as output:
|
||||
with self.assertRaises(SystemExit) as result:
|
||||
modify.main()
|
||||
self.assertEqual(result.exception.code, 2)
|
||||
self.assertIn('Vyžadujú sa oprávnenia používateľa root', output.getvalue())
|
||||
self.assertNotIn('required', output.getvalue())
|
||||
|
||||
def test_invalid_recognition_profile_is_localized_without_any_gpu_probe(self):
|
||||
with self.assertRaisesRegex(ValueError, 'Profil GPU pre Immich nebol overený'):
|
||||
recognition.validate('unknown', None, None)
|
||||
|
||||
def test_recognition_errors_distinguish_unchanged_from_incomplete_operation(self):
|
||||
incomplete = RuntimeError('fixture diagnostic')
|
||||
incomplete.recognition_kept = False
|
||||
for error, expected in ((ValueError('fixture diagnostic'), 'Spôsob rozpoznávania v Immichu sa nezmenil:'),
|
||||
(incomplete, 'Zmenu CPU/GPU pre rozpoznávanie v Immichu sa nepodarilo dokončiť:')):
|
||||
with self.subTest(error=type(error).__name__), \
|
||||
patch.object(recognition.os, 'geteuid', return_value=0), \
|
||||
patch.object(sys, 'argv', ['fixture', '999990', '--acceleration', 'cpu']), \
|
||||
patch.object(recognition, 'change', side_effect=error), \
|
||||
patch.object(recognition, 'msg_error') as message:
|
||||
self.assertEqual(recognition.main(), 1)
|
||||
self.assertEqual(message.call_args.args[0], expected + ' fixture diagnostic')
|
||||
|
||||
def test_english_and_missing_translation_fallback_preserve_source_text(self):
|
||||
changes = self.changes(remove_mounts=['/extra'])
|
||||
for language, cache in (('en', self.translations), ('sk', {})):
|
||||
with self.subTest(language=language, empty_cache=not cache), \
|
||||
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
|
||||
text = editor.summary(self.member, changes)
|
||||
self.assertIn('A running container is restarted', text)
|
||||
self.assertIn('- /extra', text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Coordinated update/recreate contracts; no host lifecycle is permitted."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
|
||||
import oci_instances as instances
|
||||
import oci_stack_plan as stack_plan
|
||||
import oci_stack_transaction as transaction
|
||||
import oci_ui
|
||||
|
||||
|
||||
class StackFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.row['stack'] = True
|
||||
self.record.update(installation_id='fiction-main', stack={
|
||||
'id': 'fiction-main', 'members': [
|
||||
{'vmid': vmid, 'installation_id': identity, 'status': 'installed',
|
||||
'stack_member': {'stack_id': 'fiction-main', 'primary_vmid': 110}}
|
||||
for vmid, identity in [(110, 'fiction-main'), (111, 'fiction-dependent')]],
|
||||
'deployment': {'services': [{'vmid': 111}, {'vmid': 110}]}})
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.extra_effects = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
|
||||
self.extra_effects += [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
|
||||
self.extra_effects += [self.guard(transaction, 'execute'), self.guard(transaction, 'save'),
|
||||
self.guard(menu.images, 'offer_removal')]
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
for effect in self.extra_effects:
|
||||
effect.assert_not_called()
|
||||
|
||||
|
||||
class StackCancellationTests(StackFixture):
|
||||
def test_update_and_recreate_no_are_falsy_distinct_cancellation(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action):
|
||||
result = menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
self.assertIs(result, menu.CANCELLED)
|
||||
self.assertFalse(result)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_cancel_returns_six_for_both_actions(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action), patch.object(cli, 'Catalog'):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', action]), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_no_and_escape_return_six_before_any_effect(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for action in ('update', 'recreate'):
|
||||
for code in (1, 255):
|
||||
with self.subTest(action=action, code=code), \
|
||||
patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_confirmation_defaults_and_member_count_are_preserved(self):
|
||||
for action, default in [('update', True), ('recreate', False)]:
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
args, kwargs = self.ui.review.call_args
|
||||
self.assertIn('2 containers', args[0])
|
||||
self.assertIn('main CT: 110', args[0])
|
||||
self.assertEqual(kwargs['default'], default)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_the_application_list_after_no(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action):
|
||||
self.ui.choose.reset_mock()
|
||||
self.ui.choose.side_effect = ['110', action, None]
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_batch_cancel_never_checks_the_next_application(self):
|
||||
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 112, 'title': 'Second'}]
|
||||
self.ui.checklist.return_value = ['all']
|
||||
self.ui.review.side_effect = [True, False]
|
||||
with patch.object(updates, 'UpdateCheck') as checker, \
|
||||
patch.object(menu, 'saved_inventory', return_value=[self.row, {**self.row, 'vmid': 112}]), \
|
||||
patch.object(menu, 'check_selected', side_effect=lambda project, row: row) as selected:
|
||||
checker.return_value.status.return_value = {'available': available}
|
||||
self.assertFalse(updates.select_updates(ROOT/'oci', self.ui, available))
|
||||
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
|
||||
self.no_effects()
|
||||
|
||||
def test_rejected_backup_remains_failure_one_without_dispatch(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
for action in ('update', 'recreate'):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_positive_confirmation_preserves_success_failure_and_exact_commands(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
for action in ('update', 'recreate'):
|
||||
for result in (False, True):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.return_value = result
|
||||
with patch.object(menu.images, 'offer_removal') as offer:
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action,
|
||||
lifecycle_args=('--keep-backup',)), 0 if result else 1)
|
||||
expected = [sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--keep-backup']
|
||||
if action == 'recreate':
|
||||
expected += ['--operation', 'recreate']
|
||||
self.assertEqual(self.lifecycle.call_args.args[0], expected+['--acknowledge-external-data'])
|
||||
self.assertEqual(offer.call_count, int(result))
|
||||
self.assertEqual(self.record, self.original)
|
||||
self.write.assert_not_called()
|
||||
self.command.assert_not_called()
|
||||
|
||||
def test_acknowledgement_is_not_duplicated(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = False
|
||||
menu.direct_management(ROOT/'oci', 110, 'recreate', lifecycle_args=('--acknowledge-external-data',))
|
||||
self.assertEqual(self.lifecycle.call_args.args[0].count('--acknowledge-external-data'), 1)
|
||||
|
||||
|
||||
class StackSlovakTests(StackFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.catalog = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in [('_language', 'sk'), ('_cache', self.catalog)]:
|
||||
item = patch.object(module, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
def test_questions_and_titles_name_the_whole_application(self):
|
||||
for action, question in [('update', 'Aktualizovať celú aplikáciu?'),
|
||||
('recreate', 'Znovu vytvoriť všetky kontajnery aplikácie?')]:
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], question)
|
||||
self.assertIn('aplikáciu s viacerými kontajnermi', self.ui.review.call_args.args[1])
|
||||
self.no_effects()
|
||||
|
||||
def test_recreation_dialog_explains_saved_images_and_restoring_backups(self):
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action='recreate')
|
||||
text = self.ui.review.call_args.args[0]
|
||||
self.assertIn('uložených identifikátorov obrazov', text)
|
||||
self.assertIn('2', text)
|
||||
self.assertIn('110', text)
|
||||
self.assertIn('zálohy sa obnovia', text)
|
||||
self.assertNotIn('Stack', text)
|
||||
self.no_effects()
|
||||
|
||||
def test_shared_missing_primary_error_in_plan_and_publish_before_any_effect(self):
|
||||
primary = copy.deepcopy(self.record)
|
||||
primary['vmid'] = 112
|
||||
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
|
||||
stack_plan.build(primary, {}, {}, 'update')
|
||||
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
|
||||
instances.publish_stack(Path('/fiction-only'), 110, {}, {}, [])
|
||||
self.no_effects()
|
||||
|
||||
def test_missing_member_error_keeps_volume_verification_requirement(self):
|
||||
records = {row['vmid']: row for row in self.record['stack']['members']}
|
||||
with self.assertRaisesRegex(ValueError, 'znova ich vytvorte až po overení ich dátových zväzkov'):
|
||||
stack_plan.build(self.record, records, {110: 'fiction-main'}, 'update')
|
||||
self.no_effects()
|
||||
|
||||
def test_pending_and_conflicting_member_errors_keep_the_identifier(self):
|
||||
records = {row['vmid']: copy.deepcopy(row) for row in self.record['stack']['members']}
|
||||
inventory = {key: row['installation_id'] for key, row in records.items()}
|
||||
records[111]['status'] = 'pending'
|
||||
with self.assertRaisesRegex(ValueError, '^Nedokončená operácia existuje pre CT 111$'):
|
||||
stack_plan.build(self.record, records, inventory, 'update')
|
||||
records[111]['status'] = 'installed'
|
||||
inventory[111] = 'different-installation'
|
||||
with self.assertRaisesRegex(ValueError, '^Iná inštancia už používa VMID 111$'):
|
||||
stack_plan.build(self.record, records, inventory, 'update')
|
||||
self.no_effects()
|
||||
|
||||
def test_plan_failure_is_read_only_and_distinguishes_invalid_identity(self):
|
||||
primary = copy.deepcopy(self.record)
|
||||
primary['stack']['id'] = 'different-stack'
|
||||
with self.assertRaisesRegex(ValueError, '^Nekonzistentná identita aplikácie s viacerými kontajnermi$'):
|
||||
stack_plan.build(primary, {}, {}, 'recreate')
|
||||
self.no_effects()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Slovak storage questions through real builders and UI; installation forbidden."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[3]
|
||||
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
|
||||
from proxmenux_oci import cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self, answers=None):
|
||||
self.answers=answers or {}
|
||||
self.asked=[]
|
||||
self.messages=[]
|
||||
def _answer(self,text,default):
|
||||
self.asked.append(text)
|
||||
return self.answers.get(text,default)
|
||||
def ask(self,text,default=None,**kwargs):return self._answer(text,default if default is not None else '')
|
||||
def password(self,text,**kwargs):return self._answer(text,'fictional-value-for-tests-000000')
|
||||
def choose(self,text,options,default=None,**kwargs):return self._answer(text,default)
|
||||
def confirm(self,text,default=False,**kwargs):return self._answer(text,default)
|
||||
def checklist(self,text,options,defaults=None,**kwargs):return self._answer(text,defaults or [])
|
||||
def review(self,*args,**kwargs):return False
|
||||
def info(self,text):pass
|
||||
def message(self,text,*args):self.messages.append(text)
|
||||
|
||||
|
||||
class StorageFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.catalog=Catalog(ROOT/'oci')
|
||||
self.sk=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
self.forbidden=[]
|
||||
self.patch(i18n,'_language','sk')
|
||||
self.patch(i18n,'_cache',self.sk)
|
||||
self.patch(installer.host,'storages',side_effect=lambda content: [
|
||||
{'storage':'local-lvm','type':'lvmthin','avail':100*2**30},
|
||||
{'storage':'fiction-storage','type':'dir','avail':200*2**30}] if content=='rootdir' else [
|
||||
{'storage':'local','type':'dir','avail':100*2**30}])
|
||||
self.patch(installer.host,'bridges',return_value=[{'iface':'vmbr0','cidr':'192.0.2.10/24'}])
|
||||
self.patch(installer.host,'timezone',return_value='Europe/Bratislava')
|
||||
self.patch(installer.host,'gpus',return_value={'intel':[],'amd':[],'nvidia':False})
|
||||
self.patch(installer.access,'ask_addresses',side_effect=lambda ui,bridge,names,*a:({name:'dhcp' for name in names},None))
|
||||
self.patch(installer.access,'ask_ipv4',return_value=('dhcp',None))
|
||||
for module,name in [(installer,'run_remote_install'),(cli,'run_remote_install'),
|
||||
(cli.images,'offer_removal'),(subprocess,'run'),(subprocess,'Popen'),(subprocess,'check_output'),
|
||||
(Path,'write_text'),(Path,'write_bytes'),(Path,'unlink')]:
|
||||
self.forbidden.append(self.patch(module,name,side_effect=AssertionError('Forbidden effect: '+name)))
|
||||
self.addCleanup(self.no_effects)
|
||||
def patch(self,module,name,*args,**kwargs):
|
||||
item=patch.object(module,name,*args,**kwargs);value=item.start();self.addCleanup(item.stop);return value
|
||||
def no_effects(self):
|
||||
for effect in self.forbidden:effect.assert_not_called()
|
||||
def recorder(self,answers=None):
|
||||
return Recorder({i18n.translate(key):value for key,value in (answers or {}).items()})
|
||||
def build(self,app,answers=None,mode=installer.ADVANCED_MODE):
|
||||
ui=self.recorder(answers)
|
||||
plan=installer.build_deployment(self.catalog.compose(app),ui,mode)
|
||||
return ui,plan
|
||||
|
||||
|
||||
class StorageWordingTests(StorageFixture):
|
||||
def test_tandoor_files_question_and_minimum_error_keep_the_limit(self):
|
||||
ui,plan=self.build('tandoor',{'Files volume size in GB':'9'})
|
||||
self.assertIn('Veľkosť dátového zväzku pre súbory v GB',ui.asked)
|
||||
self.assertEqual(plan['media']['size_gb'],9)
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok súborov Tandoor musí mať najmenej 2 GB'):
|
||||
self.build('tandoor',{'Files volume size in GB':'1'})
|
||||
|
||||
def test_paperless_storage_documents_and_consume_export_names(self):
|
||||
ui,plan=self.build('paperless-ngx',{'Documents volume size in GB':'30',
|
||||
'Where to store the consume and export folders':'managed-volume','Size of each consume/export volume in GB':'3'})
|
||||
for text in ['Úložisko pre údaje a dokumenty aplikácie Paperless',
|
||||
'Kam uložiť priečinky consume a export',
|
||||
'Veľkosť dátového zväzku pre dokumenty v GB',
|
||||
'Veľkosť každého dátového zväzku pre consume a export v GB']:
|
||||
self.assertIn(text,ui.asked)
|
||||
self.assertEqual((plan['media_size_gb'],plan['transfer']['size_gb'],plan['transfer']['backup']),(30,3,True))
|
||||
|
||||
def test_paperless_volume_limit_and_transfer_limit_errors(self):
|
||||
with self.assertRaisesRegex(installer.InstallError,'Trvalé dátové zväzky aplikácie Paperless musia mať najmenej 8 GB'):
|
||||
self.build('paperless-ngx',{'Documents volume size in GB':'7'})
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátové zväzky pre consume a export musia mať najmenej 1 GB'):
|
||||
self.build('paperless-ngx',{'Where to store the consume and export folders':'managed-volume',
|
||||
'Size of each consume/export volume in GB':'0'})
|
||||
|
||||
def test_postgresql_question_and_error_are_shared_by_immich_and_nextcloud(self):
|
||||
for app in ('immich','nextcloud-stack'):
|
||||
with self.subTest(app=app):
|
||||
ui,plan=self.build(app,{'PostgreSQL volume size in GB':'12'})
|
||||
self.assertIn('Veľkosť dátového zväzku PostgreSQL v GB',ui.asked)
|
||||
self.assertEqual(plan['database_size_gb'],12)
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok PostgreSQL musí mať najmenej 8 GB'):
|
||||
self.build(app,{'PostgreSQL volume size in GB':'7'})
|
||||
|
||||
def test_host_directory_remains_outside_the_container_backup(self):
|
||||
ui,plan=self.build('nextcloud-stack',{'Where to store the Nextcloud files, configuration and data':'host-bind',
|
||||
'Shared host directory':'/fiction-only/existing-data'})
|
||||
self.assertIn('Zdieľaný adresár hostiteľa',ui.asked)
|
||||
self.assertEqual((plan['application']['mode'],plan['application']['host_path'],plan['application']['backup']),
|
||||
('host-bind','/fiction-only/existing-data',False))
|
||||
|
||||
def test_default_and_advanced_builders_preserve_language_independent_plans(self):
|
||||
for app in ('jellyfin','immich','nextcloud-stack','paperless-ngx','tandoor'):
|
||||
for mode in (installer.DEFAULT_MODE,installer.ADVANCED_MODE):
|
||||
with self.subTest(app=app,mode=mode):
|
||||
_ui,slovak=self.build(app,mode=mode)
|
||||
with patch.object(i18n,'_language','en'):
|
||||
_ui,english=self.build(app,mode=mode)
|
||||
self.assertEqual(slovak,english)
|
||||
|
||||
def test_multi_path_cancel_and_invalid_selection_preserve_all_members(self):
|
||||
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
|
||||
{'name':'database','deployment':{'mounts':[]}}]
|
||||
original=copy.deepcopy(services)
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':[]})
|
||||
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
|
||||
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertIn('Pridať ďalšie cesty do aplikácie s viacerými kontajnermi',ui.asked)
|
||||
self.assertEqual(services,original)
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main'],
|
||||
'Data location':None})
|
||||
with self.assertRaises(native_ui.UserCancelled):custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertEqual(services,original)
|
||||
|
||||
def test_shared_selection_error_in_device_flow_also_preserves_members(self):
|
||||
services=[{'name':'main','main':True,'deployment':{}}];original=copy.deepcopy(services)
|
||||
ui=self.recorder()
|
||||
with patch.object(extra_devices,'ask_extra_devices',return_value=[{'kind':'usb','host_path':'/fiction-only/device'}]), \
|
||||
patch.object(ui,'checklist',return_value=[]):
|
||||
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
|
||||
extra_devices.ask_stack_extra_devices(ui,services)
|
||||
self.assertEqual(services,original)
|
||||
|
||||
def test_selected_path_is_added_only_to_selected_member_with_backup(self):
|
||||
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
|
||||
{'name':'database','deployment':{'mounts':[]}}]
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main']})
|
||||
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertEqual(services[1]['deployment']['mounts'],[])
|
||||
mount=services[0]['deployment']['mounts'][0]
|
||||
self.assertEqual((mount['source'],mount['container_path'],mount['size_gb'],mount['backup']),
|
||||
('fiction-storage','/media-extra',8,True))
|
||||
self.assertIn('Pridať ďalšiu cestu do aplikácie s viacerými kontajnermi',ui.asked)
|
||||
|
||||
def test_rclone_shared_directory_question_is_localized_without_creating_it(self):
|
||||
ui=self.recorder({'Shared directory for copy/sync operations':'/fiction-only/rclone'})
|
||||
with patch.object(ui,'confirm',return_value=True):
|
||||
plan=installer.build_deployment(self.catalog.compose('rclone'),ui,installer.ADVANCED_MODE)
|
||||
self.assertIn('Zdieľaný adresár na kopírovanie a synchronizáciu',ui.asked)
|
||||
self.assertTrue(any(m.get('source')=='/fiction-only/rclone' and not m['backup'] for m in plan['mounts']))
|
||||
|
||||
|
||||
class InputAndCancellationTests(StorageFixture):
|
||||
def test_dialog_required_value_and_password_use_slovak_then_retry(self):
|
||||
dialog=native_ui.DialogUI()
|
||||
empty=subprocess.CompletedProcess('dialog',0,stderr='')
|
||||
value=subprocess.CompletedProcess('dialog',0,stderr='fiction-value')
|
||||
with patch.object(dialog,'_run',side_effect=[empty,value]),patch.object(dialog,'message') as message:
|
||||
self.assertEqual(dialog.ask('fiction-field'),'fiction-value')
|
||||
message.assert_called_once_with('Táto hodnota je povinná.')
|
||||
with patch.object(dialog,'_run',side_effect=[empty,value,value]),patch.object(dialog,'message') as message:
|
||||
self.assertEqual(dialog.password('fiction-field'),'fiction-value')
|
||||
message.assert_called_once_with('Táto hodnota je povinná.')
|
||||
|
||||
def test_terminal_required_value_and_password_are_also_localized(self):
|
||||
terminal=native_ui.TerminalUI()
|
||||
with patch('builtins.input',side_effect=['','fiction-value']),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(terminal.ask('fiction-field'),'fiction-value')
|
||||
self.assertIn('Táto hodnota je povinná.',output.getvalue())
|
||||
with patch.object(native_ui.getpass,'getpass',side_effect=['','fiction-value','fiction-value']),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(terminal.password('fiction-field'),'fiction-value')
|
||||
self.assertIn('Táto hodnota je povinná.',output.getvalue())
|
||||
|
||||
def test_real_install_flow_storage_cancel_and_final_no_never_dispatch(self):
|
||||
template=self.catalog.compose('tandoor');original=copy.deepcopy(template)
|
||||
dialog=native_ui.DialogUI()
|
||||
for code in (1,255):
|
||||
replies=[subprocess.CompletedProcess('dialog',0,stderr=value) for value in ('','fiction-stack','2','2048')]
|
||||
replies.append(subprocess.CompletedProcess('dialog',code,stderr=''))
|
||||
with self.subTest(code=code),patch.object(dialog,'_run',side_effect=replies) as run:
|
||||
with self.assertRaises(native_ui.UserCancelled):cli.install_template(dialog,template,'tandoor',installer.ADVANCED_MODE)
|
||||
self.assertEqual(run.call_count,5)
|
||||
self.assertEqual(template,original)
|
||||
self.assertIsNone(cli.install_template(self.recorder(),template,'tandoor',installer.ADVANCED_MODE))
|
||||
self.assertEqual(template,original)
|
||||
|
||||
def test_numeric_retry_keeps_previous_answers_and_already_correct_slovak(self):
|
||||
ui=Recorder();wizard=native_ui.BacktrackUI(ui)
|
||||
wizard.answers=[('ask','previous'),('ask','abc')];wizard.cursor=2
|
||||
self.assertTrue(wizard.retry_last(ValueError("invalid literal for int() with base 10: 'abc'")))
|
||||
self.assertEqual(ui.messages,["Hodnota musí byť číslo: 'abc'\n\nZadajte hodnotu znova."])
|
||||
self.assertEqual((wizard.answers,wizard.cursor),([('ask','previous')],0));wizard.close()
|
||||
|
||||
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
|
||||
ui=Mock();ui.choose.side_effect=['custom',None]
|
||||
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
|
||||
patch.object(updates,'UpdateCheck') as checker:
|
||||
check=checker.return_value.__enter__.return_value
|
||||
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
|
||||
self.assertEqual(cli.interactive(self.catalog),0)
|
||||
ui.message.assert_called_once_with('Operáciu sa nepodarilo dokončiť:\n\nfiction-detail')
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Real native OCI menu/CLI paths with all external effects replaced."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
|
||||
import oci_instances as instances
|
||||
import oci_instance_reconcile as reconcile
|
||||
|
||||
|
||||
class CancellationFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.record = {'status': 'installed', 'vmid': 110, 'deployment': {}, 'template': {}}
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.row = dict(vmid=110, reason='matched', stack=False, pending=False, status='installed',
|
||||
title='Fixture application', hostname='fixture', image='fixture:latest')
|
||||
self.ui = Mock()
|
||||
self.ui.unattended = False
|
||||
self.ui.review.return_value = False
|
||||
for module, name, options in [
|
||||
(i18n, '_language', dict(new='en')), (i18n, '_cache', dict(new={})),
|
||||
(menu.os, 'geteuid', dict(return_value=0)), (menu.shutil, 'which', dict(return_value='/fixture/pct')),
|
||||
(native_ui, 'interactive_ui', dict(return_value=self.ui)),
|
||||
(menu, 'recover_automatically', dict(return_value=None)),
|
||||
(menu, 'carry_records', dict(return_value={})),
|
||||
(menu, 'check_selected', dict(side_effect=lambda project, row: row)),
|
||||
(menu, 'saved_inventory', dict(return_value=[self.row])),
|
||||
(menu, '_selected_configuration', dict(return_value=b'fixture configuration')),
|
||||
(instances, 'read', dict(return_value=self.record)),
|
||||
(reconcile, 'propose', dict(return_value=None)),
|
||||
]:
|
||||
item = patch.object(module, name, **options)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
self.lifecycle = self.guard(menu, '_run_lifecycle')
|
||||
self.backup = self.guard(menu, '_work_backup_ready')
|
||||
self.write = self.guard(instances, 'write')
|
||||
self.command = self.guard(instances, 'command')
|
||||
self.commit = self.guard(reconcile, 'commit')
|
||||
|
||||
def guard(self, module, name):
|
||||
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
mock = item.start()
|
||||
self.addCleanup(item.stop)
|
||||
return mock
|
||||
|
||||
def no_effects(self):
|
||||
for mock in [self.lifecycle, self.backup, self.write, self.command, self.commit]:
|
||||
mock.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
|
||||
class UpdateCancellationTests(CancellationFixture):
|
||||
def test_no_is_a_distinct_falsy_result_before_backup_or_lifecycle(self):
|
||||
result = menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
|
||||
self.assertIs(result, menu.CANCELLED)
|
||||
self.assertFalse(result)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_no_returns_cancel_code_instead_of_failure_or_success(self):
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
result = cli.main(['manage', '110', '--action', 'update'])
|
||||
self.assertEqual(result, 6)
|
||||
self.assertEqual(output.getvalue(), '') # The terminal renders the localized outcome once.
|
||||
self.no_effects()
|
||||
|
||||
def test_no_in_stack_confirmation_keeps_every_member_unchanged(self):
|
||||
self.row['stack'] = True
|
||||
self.record['stack'] = {'members': [{'vmid': 110}, {'vmid': 111}]}
|
||||
self.original = copy.deepcopy(self.record)
|
||||
with redirect_stdout(io.StringIO()):
|
||||
result = menu.direct_management(ROOT / 'oci', 110, 'update')
|
||||
self.assertEqual(result, 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_its_list_after_no(self):
|
||||
self.ui.choose.side_effect = ['110', 'update', None]
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT / 'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_batch_no_does_not_start_the_next_application(self):
|
||||
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 111, 'title': 'Second'}]
|
||||
rows = [self.row, {**self.row, 'vmid': 111}]
|
||||
self.ui.checklist.return_value = ['all']
|
||||
self.ui.review.side_effect = [True, False] # Batch review, first application's confirmation.
|
||||
with patch.object(updates, 'UpdateCheck') as checker, patch.object(menu, 'saved_inventory', return_value=rows), \
|
||||
patch.object(menu, 'check_selected', wraps=lambda project, row: row) as selected:
|
||||
checker.return_value.status.return_value = {'available': available}
|
||||
self.assertFalse(updates.select_updates(ROOT / 'oci', self.ui, available))
|
||||
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
|
||||
self.no_effects()
|
||||
|
||||
def test_accepted_update_failure_keeps_failure_code_one(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_accepted_update_preserves_success_and_existing_lifecycle_command(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = True
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 0)
|
||||
self.lifecycle.assert_called_once()
|
||||
command = self.lifecycle.call_args.args[0]
|
||||
self.assertEqual(command[:3], [sys.executable, str(ROOT / 'oci/remote/oci_update_current.py'), '110'])
|
||||
self.assertIn('--acknowledge-external-data', command)
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_actual_lifecycle_failure_remains_failure_after_positive_confirmation(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
|
||||
self.lifecycle.assert_called_once()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_unattended_positive_default_still_reaches_existing_update(self):
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = True
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 0)
|
||||
self.lifecycle.assert_called_once()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_existing_unattended_review_age_busy_and_missing_codes_are_preserved(self):
|
||||
with patch.object(reconcile, 'propose', return_value={'details': ['external change']}), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
|
||||
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
|
||||
with patch.object(menu, 'saved_inventory', return_value=[]):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 2)
|
||||
with patch.object(menu, 'recover_automatically', side_effect=BlockingIOError()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_slovak_dialog_and_recovery_choice_have_the_approved_meaning(self):
|
||||
cache = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
|
||||
menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
|
||||
self.assertEqual(i18n.translate('Recover the previous installation'), 'Obnoviť predchádzajúcu inštaláciu')
|
||||
text = self.ui.review.call_args.args[0]
|
||||
for part in ['novší obraz aplikácie', 'overí sa jej integrita', 'pokúsi obnoviť', 'Pripojené adresáre hostiteľa']:
|
||||
self.assertIn(part, text)
|
||||
self.assertNotIn('kanál obrazov', text)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Spustiť aktualizáciu?')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -3,6 +3,7 @@ import ast
|
||||
from contextlib import nullcontext
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
@@ -23,9 +24,12 @@ OLD_GUARD = 'The current template changes the image or identity; an explicit mig
|
||||
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.')
|
||||
PREVIEW = ('If a newer application image is available, the container is stopped and backed up, and the backup '
|
||||
'integrity is verified. The container is then updated using the new image.\n\n'
|
||||
'If the update fails after the container was changed, ProxMenux attempts to restore the previous '
|
||||
'installation from the backup. If the operation remains incomplete, select the application in OCI '
|
||||
'management and choose "Recover the previous installation".\n\n'
|
||||
'Host directories mounted in the container are not restored by the container backup.')
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
@@ -129,7 +133,9 @@ class UpdateWording(unittest.TestCase):
|
||||
reconcile = ModuleType('oci_instance_reconcile')
|
||||
reconcile.propose = lambda *args: None
|
||||
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
|
||||
'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
|
||||
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
||||
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'carry_records': lambda project, **options: {},
|
||||
'_work_backup_ready': lambda project, ui, vmid: True,
|
||||
|
||||
@@ -17,13 +17,15 @@ 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.'
|
||||
REGISTRY_ADVICE = ('Check the image reference and registry access. If the registry is unavailable, '
|
||||
'try again later. For a private image, sign in from Registry accounts in the OCI 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.',
|
||||
REGISTRY_ADVICE,
|
||||
BEFORE, AFTER,
|
||||
)
|
||||
|
||||
@@ -44,7 +46,8 @@ def function(path, name, **dependencies):
|
||||
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)
|
||||
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024,
|
||||
MAX_OCI_COMPOSE_BYTES=2*1024*1024, OCI_COMPOSE='oci-compose')
|
||||
scope.update(dependencies)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||
str(path), 'exec'), scope)
|
||||
@@ -116,16 +119,16 @@ class OciWordingTests(TestCase):
|
||||
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):
|
||||
def test_failed_registry_inspection_points_to_registry_accounts(self):
|
||||
ui = SimpleNamespace(message=Mock())
|
||||
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
|
||||
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'))
|
||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
|
||||
_registry_refused=lambda _: False)
|
||||
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.')
|
||||
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n' + REGISTRY_ADVICE)
|
||||
self.assertNotIn('not supported yet', ui.message.call_args.args[0])
|
||||
|
||||
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
|
||||
# Pin the real translated management choice, not an English AST constant alone.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Wording contracts through the real metadata parser and exact-match formatter.
|
||||
|
||||
No post-install function or scanner is imported or executed. Node 22.6+ is
|
||||
required, as in test_command_descriptions.py; no npm dependencies are needed.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class OptimizationDescriptionWordingTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Admit only the pure parser and its actual dependencies, not the scanner.
|
||||
path = ROOT / "AppImage/scripts/post_install_versions.py"
|
||||
functions = {"_read_text", "_version_tuple", "parse_post_install_script"}
|
||||
constants = {"_FN_DEF_RE", "_VERSION_RE", "_DESC_RE", "_REGISTER_RE", "_HEREDOC_RE"}
|
||||
nodes: list[ast.stmt] = [node for node in ast.parse(path.read_text(encoding="utf-8")).body
|
||||
if (isinstance(node, ast.FunctionDef) and node.name in functions)
|
||||
or (isinstance(node, ast.Assign) and any(
|
||||
isinstance(target, ast.Name) and target.id in constants
|
||||
for target in node.targets))]
|
||||
if len(nodes) != len(functions) + len(constants):
|
||||
raise AssertionError("Metadata parser seam changed: review its dependencies")
|
||||
scope = {"re": re, "Path": Path}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), scope)
|
||||
cls.metadata = {
|
||||
source: scope["parse_post_install_script"](ROOT / "scripts/post_install" / filename)
|
||||
for source, filename in (("auto", "auto_post_install.sh"),
|
||||
("custom", "customizable_post_install.sh"))
|
||||
}
|
||||
cls.catalogs = {
|
||||
locale: json.loads((ROOT / f"AppImage/messages/{locale}/common.json").read_text(encoding="utf-8"))
|
||||
for locale in ("en", "it")
|
||||
}
|
||||
node = os.environ.get("NODE_BINARY") or shutil.which("node")
|
||||
if not node:
|
||||
raise AssertionError("Install Node.js 22.6+ or set NODE_BINARY")
|
||||
cls.node = node
|
||||
version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout
|
||||
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)\s*", version)
|
||||
if not match or tuple(map(int, match.groups())) < (22, 6, 0):
|
||||
raise AssertionError(f"Node.js 22.6+ required; detected {version.strip()}")
|
||||
# Replace only the JSON import; the formatter body executes unchanged.
|
||||
formatter = (ROOT / "AppImage/lib/optimization-descriptions.ts").read_text(encoding="utf-8")
|
||||
json_import = 'import enMessages from "../messages/en/common.json"'
|
||||
if formatter.count(json_import) != 1:
|
||||
raise AssertionError("Formatter import seam changed")
|
||||
fixture = formatter.replace(json_import, "const enMessages = " + json.dumps(cls.catalogs["en"]))
|
||||
fixture += '''
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
const [key, description, italian, catalogs] = JSON.parse(readFileSync(0, "utf8"));
|
||||
for (const locale of ["en", "it"]) {
|
||||
const calls: string[] = [];
|
||||
const t = (path: string) => {
|
||||
calls.push(path);
|
||||
return path.split(".").reduce((value, part) => value[part], catalogs[locale]);
|
||||
};
|
||||
assert.equal(optimizationDescription(description, t), locale === "en" ? description : italian);
|
||||
assert.deepEqual(calls, [`settings.optimizations.toolDescriptions.${key}`]);
|
||||
calls.length = 0;
|
||||
const unknown = description + " [new server wording]";
|
||||
assert.equal(optimizationDescription(unknown, t), unknown);
|
||||
assert.deepEqual(calls, []);
|
||||
}
|
||||
console.log("PASS actual exact lookup: EN/IT and unknown-description fallback");
|
||||
'''
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="optimization-wording-")
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
cls.fixture = Path(cls.directory.name) / "formatter.mts"
|
||||
cls.fixture.write_text(fixture, encoding="utf-8")
|
||||
|
||||
def assert_description(self, key, source, tool, function, english, italian):
|
||||
for locale, expected in (("en", english), ("it", italian)):
|
||||
with self.subTest(key=key, locale=locale):
|
||||
self.assertEqual(self.catalogs[locale]["settings"]["optimizations"]["toolDescriptions"][key], expected)
|
||||
metadata = self.metadata[source][tool]
|
||||
self.assertEqual(metadata["function"], function)
|
||||
self.assertEqual(metadata["description"], english)
|
||||
result = subprocess.run(
|
||||
[self.node, "--experimental-strip-types", str(self.fixture)],
|
||||
input=json.dumps([key, metadata["description"], italian, self.catalogs]),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("PASS actual exact lookup", result.stdout)
|
||||
|
||||
def test_log2ram_auto_fresh_install_detection_and_update_size(self):
|
||||
self.assert_description(
|
||||
"log2ramAuto", "auto", "log2ram", "install_log2ram_auto",
|
||||
"Fresh installs size Log2RAM from host RAM and ask for confirmation if storage is not detected as SSD. Updates preserve the configured size.",
|
||||
"Alla prima installazione, dimensiona Log2RAM in base alla RAM dell'host e chiede conferma se il dispositivo di archiviazione non viene rilevato come SSD. Gli aggiornamenti mantengono la dimensione configurata.")
|
||||
|
||||
def test_kexec_prepares_unit_and_alias_not_hotkey(self):
|
||||
self.assert_description(
|
||||
"kexec", "custom", "kexec", "enable_kexec",
|
||||
"Install kexec-tools and prepare a systemd unit plus the reboot-quick alias for fast reboots that skip BIOS/POST.",
|
||||
"Installa kexec-tools e prepara un'unità systemd e l'alias reboot-quick per riavvii rapidi che saltano BIOS/POST.")
|
||||
|
||||
def test_amd_only_claims_idle_parameter_and_kvm_module_options(self):
|
||||
self.assert_description(
|
||||
"amd", "custom", "amd_fixes", "apply_amd_fixes",
|
||||
"Apply idle=nomwait and KVM module options on AMD EPYC/Ryzen hosts.",
|
||||
"Applica idle=nomwait e opzioni del modulo KVM sugli host AMD EPYC/Ryzen.")
|
||||
|
||||
def test_guest_agent_only_lists_supported_environments_and_skip(self):
|
||||
self.assert_description(
|
||||
"guestAgent", "custom", "guest_agent", "install_guest_agent",
|
||||
"Detect QEMU/KVM, VMware or VirtualBox and install the matching guest agent; skip other environments.",
|
||||
"Rileva QEMU/KVM, VMware o VirtualBox e installa il guest agent corrispondente; salta gli altri ambienti.")
|
||||
|
||||
def test_backup_describes_global_defaults_without_speed_guarantee(self):
|
||||
self.assert_description(
|
||||
"backupLimits", "custom", "vzdump_speed", "optimize_vzdump",
|
||||
"Set global vzdump defaults to bwlimit: 0 and ionice: 5. This does not guarantee faster backups.",
|
||||
"Imposta i valori predefiniti globali di vzdump su bwlimit: 0 e ionice: 5. Questo non garantisce backup più veloci.")
|
||||
|
||||
def test_log2ram_custom_choices_and_update_preservation(self):
|
||||
self.assert_description(
|
||||
"log2ramCustom", "custom", "log2ram", "configure_log2ram",
|
||||
"Install Log2RAM with a chosen size, sync interval and optional autosync. Updates preserve the size and autosync choice.",
|
||||
"Installa Log2RAM con dimensione e intervallo di sincronizzazione a scelta e autosync facoltativo. Gli aggiornamenti mantengono la dimensione e la scelta sull'autosync.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,7 +20,11 @@ class OutcomePromises(TestCase):
|
||||
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)
|
||||
self.assertEqual(len(previews), 2) # Coordinated stack update/recreation previews.
|
||||
update = next(text for text in texts if text.startswith('If a newer application image is available'))
|
||||
self.assertIn('attempts to restore the previous installation', update)
|
||||
self.assertIn('If the operation remains incomplete', update)
|
||||
self.assertIn('choose "Recover the previous installation"', update)
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""PBS CLI language and no-effect boundaries using real functions, isolated files."""
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def function(path, name):
|
||||
text = (ROOT/path).read_text()
|
||||
start = text.index(name+'() {')
|
||||
return text[start:text.index('\n}\n', start)+3]
|
||||
|
||||
|
||||
def repository(components, suite='trixie'):
|
||||
return dict(Types=['deb'], URIs=['https://credential.invalid/private'], Suites=[suite],
|
||||
Components=components.split(), Enabled=True)
|
||||
|
||||
|
||||
class RepositoryCliTests(unittest.TestCase):
|
||||
def run_policy(self, case, language='sk', mode='plan', malformed=None, product='pbs'):
|
||||
spec = importlib.util.spec_from_file_location('policy_fixture', ROOT/'scripts/global/repository_policy.py')
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
state = dict(files=[dict(path='/etc/apt/sources.list.d/pbs.sources', repositories=[repository('pbs-enterprise')]),
|
||||
dict(path='/etc/apt/sources.list.d/debian.sources', repositories=[repository('main')])],
|
||||
errors=[], digest='fixture', **{'standard-repos':[{'handle':'no-subscription'}]})
|
||||
status='notfound'; calls=[]
|
||||
if case=='inventory': state['errors']=['PRIVATE RAW DIAGNOSTIC']
|
||||
if case=='status': status='unknown'
|
||||
if case=='suite': state['files'][0]['repositories'][0]['Suites']=['bookworm']
|
||||
if case=='active': status='active'; state['files']=state['files'][1:]
|
||||
if case=='debian': state['files']=state['files'][:1]
|
||||
if case=='mixed': state['files'][0]['repositories'][0]['Components'].append('main')
|
||||
if case=='handle': state['standard-repos']=[]
|
||||
if case=='preserve': state['files'][0]['repositories'][0]['Components']=['pbs-no-subscription']
|
||||
original=copy.deepcopy(state)
|
||||
def run(args):
|
||||
calls.append(args)
|
||||
if args[0]=='proxmox-backup-manager': return json.dumps(dict(status=status, serverid='PRIVATE SERVER ID'))
|
||||
if 'create' in args:
|
||||
state['files'][0]['repositories'][0]['Enabled']=False
|
||||
if case=='partial': state['errors']=['PRIVATE RAW DIAGNOSTIC']
|
||||
return ''
|
||||
if 'set' in args:
|
||||
state['files'].append(dict(path='/etc/apt/sources.list.d/public.sources',repositories=[repository('pbs-no-subscription')]))
|
||||
return ''
|
||||
return json.dumps(state)
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-policy-i18n-') as folder:
|
||||
module.BASE=Path(folder)
|
||||
(module.BASE/'lang').mkdir()
|
||||
(module.BASE/'config.json').write_text(json.dumps({'language':language}))
|
||||
catalog=(ROOT/'lang/sk.json').read_text() if malformed is None else malformed
|
||||
(module.BASE/'lang/sk.json').write_text(catalog)
|
||||
stdout,stderr=io.StringIO(),io.StringIO()
|
||||
suite='sid' if case=='unsupported' else 'trixie'
|
||||
with patch.object(module,'run',side_effect=run), patch.object(module.sys,'argv',['repository_policy.py',mode,suite,product]), contextlib.redirect_stdout(stdout),contextlib.redirect_stderr(stderr):
|
||||
result=module.main()
|
||||
return result,stdout.getvalue(),stderr.getvalue(),calls,state,original
|
||||
|
||||
def test_pbs_errors_are_localized_without_changing_decisions_or_exposing_raw_data(self):
|
||||
expectations={'inventory':'Zoznam repozitárov APT', 'status':'Stav predplatného',
|
||||
'suite':'iné vydanie', 'active':'aktívne predplatné', 'debian':'základný repozitár Debianu',
|
||||
'mixed':'spoločný záznam APT', 'handle':'no-subscription', 'unsupported':'Nepodporované vydanie',
|
||||
'partial':'stav repozitárov je neznámy'}
|
||||
for case,expected in expectations.items():
|
||||
with self.subTest(case=case):
|
||||
result,out,error,calls,state,original=self.run_policy(case,mode='apply' if case=='partial' else 'plan')
|
||||
self.assertEqual(result,1); self.assertEqual(out,''); self.assertIn(expected,error)
|
||||
self.assertNotIn('PRIVATE',error); self.assertNotIn('credential.invalid',error)
|
||||
writes=[c for c in calls if 'create' in c or 'set' in c]
|
||||
if case=='partial':
|
||||
self.assertEqual(len(writes),1)
|
||||
self.assertFalse(state['files'][0]['repositories'][0]['Enabled'])
|
||||
self.assertIn('Automatický návrat',error)
|
||||
else:
|
||||
self.assertEqual(writes,[]); self.assertEqual(state,original)
|
||||
if case=='unsupported': self.assertEqual(calls,[])
|
||||
|
||||
def test_english_error_and_stdout_tokens_remain_compatible(self):
|
||||
en=self.run_policy('unsupported',language='en')
|
||||
self.assertEqual(en[2],'Unsupported Proxmox suite; no repository changed.\n')
|
||||
pve=self.run_policy('unsupported',product='pve')
|
||||
self.assertEqual(pve[2],en[2])
|
||||
for mode,expected in [('plan','offer\n'),('apply','changed\n')]:
|
||||
result,out,error,*_=self.run_policy('offer',mode=mode)
|
||||
self.assertEqual((result,out,error),(0,expected,''))
|
||||
result,out,error,calls,*_=self.run_policy('preserve',mode='apply')
|
||||
self.assertEqual((result,out,error),(0,'preserve\n',''))
|
||||
self.assertFalse(any(c[0]=='proxmox-backup-manager' or 'set' in c or 'create' in c for c in calls))
|
||||
|
||||
def test_bad_or_missing_translation_falls_back_to_original_english(self):
|
||||
for catalog in ('{}','not json','[]',json.dumps({'Unsupported Proxmox suite; no repository changed.':42}),
|
||||
json.dumps({'Unsupported Proxmox suite; no repository changed.':'{unknown}'})):
|
||||
with self.subTest(catalog=catalog):
|
||||
result,out,error,calls,*_=self.run_policy('unsupported',malformed=catalog)
|
||||
self.assertEqual(result,1);self.assertEqual(out,'');self.assertEqual(calls,[])
|
||||
self.assertEqual(error,'Unsupported Proxmox suite; no repository changed.\n')
|
||||
|
||||
def test_translation_cannot_drop_actual_repository_details(self):
|
||||
spec=importlib.util.spec_from_file_location('policy_format_fixture',ROOT/'scripts/global/repository_policy.py')
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-policy-format-') as folder:
|
||||
module.BASE=Path(folder);(module.BASE/'lang').mkdir()
|
||||
(module.BASE/'config.json').write_text('{"language":"sk"}')
|
||||
error=module.PolicyError('Inspect {ui}; no repository changed.',ui='Administration > Repositories')
|
||||
for invalid in ('Skontrolujte repozitáre.','{ui.__class__}','{other}','{ui:.0}','{ui!r}'):
|
||||
(module.BASE/'lang/sk.json').write_text(json.dumps({error.template:invalid}))
|
||||
self.assertEqual(module.cli_error(error,'pbs'),str(error))
|
||||
for invalid in ('not json','[]','{"language":"../../private"}'):
|
||||
(module.BASE/'config.json').write_text(invalid)
|
||||
self.assertEqual(module.cli_error(error,'pbs'),str(error))
|
||||
|
||||
|
||||
UPDATE_HARNESS=r'''
|
||||
BASE_DIR="$FIXTURE"
|
||||
LANG_DIR="$REPO/lang"
|
||||
SCRIPT_TITLE='fixture'
|
||||
TAB='' BGN='' CL='' GN='' BL=''
|
||||
mkdir -p "$FIXTURE/log" "$FIXTURE/tmp" "$FIXTURE/boot" "$FIXTURE/run"
|
||||
: > "$FIXTURE/calls"
|
||||
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
|
||||
pmx_journal_context() { :; }
|
||||
pmx_is_pbs() { [[ "$CASE" != pve ]]; }
|
||||
pmx_platform_field() { [[ "$1" != supported ]] && printf '%s' "$SUITE" || printf true; }
|
||||
ensure_pbs_repositories() { record repository-plan; return 0; }
|
||||
dpkg-query() { [[ "$SUITE" == bookworm ]] && printf 3.4.0 || printf 4.0.1; }
|
||||
apt-cache() { record apt-cache "$@"; printf ' Candidate: 4.0.2\n'; }
|
||||
df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted\nfixture 10000 1000 %s 10%% /\n' "$([[ "$CASE" == disk ]] && echo 512000 || echo 4096000)"; }
|
||||
apt-get() { record apt-get "$@"; [[ "$CASE" != refresh ]]; }
|
||||
apt() {
|
||||
record apt "$@"
|
||||
if [[ "$*" == 'list --upgradable' ]]; then
|
||||
printf 'Listing...\n'
|
||||
[[ "$CASE" == current ]] || printf 'fixture/trixie 1.2 amd64 [upgradable from: 1.1]\n'
|
||||
return 0
|
||||
fi
|
||||
[[ "$CASE" != failure ]]
|
||||
}
|
||||
fixture_env() { "$@"; }
|
||||
APT_ENV='fixture_env'
|
||||
pmx_format_upgradable() { cat; }
|
||||
pmx_record_execution() { record journal "$1"; }
|
||||
pmx_dpkg_changes_since() { :; }
|
||||
msg_error() { printf '%s\n' "$*"; }
|
||||
msg_ok() { printf '%s\n' "$*"; }
|
||||
msg_info2() { printf '%s\n' "$*"; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
msg_title() { :; }
|
||||
clear() { :; }
|
||||
show_proxmenux_logo() { :; }
|
||||
whiptail() { printf '%s\n' "$*" >> "$FIXTURE/dialog"; [[ "$CASE" != cancel ]]; }
|
||||
uname() { printf old-kernel; }
|
||||
touch "$FIXTURE/boot/vmlinuz-new-kernel"
|
||||
'''
|
||||
|
||||
|
||||
class UpdateCliTests(unittest.TestCase):
|
||||
def run_update(self,case,language='sk',suite='trixie'):
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-update-i18n-') as folder:
|
||||
body=function('scripts/global/update-pbs.sh','update_pbs')
|
||||
for old,new in [('/var/log/',folder+'/log/'),('/tmp/proxmenux_screen_capture_',folder+'/tmp/proxmenux_screen_capture_'),('/var/run/',folder+'/run/'),('/boot/',folder+'/boot/')]: body=body.replace(old,new)
|
||||
script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+body+'\nupdate_pbs\n'
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite})
|
||||
files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()}
|
||||
return result,files
|
||||
|
||||
def test_pve_guard_cannot_reach_repository_or_apt_calls(self):
|
||||
result,files=self.run_update('pve')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertEqual(files['calls'],'')
|
||||
self.assertFalse(any(name.startswith('log/') for name in files))
|
||||
|
||||
def test_disk_and_upgrade_errors_keep_their_actual_parameters(self):
|
||||
result,files=self.run_update('disk')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertIn('Nedostatok miesta na disku. Dostupné:',result.stdout)
|
||||
self.assertIn('500MB',result.stdout)
|
||||
self.assertNotIn('apt',files['calls'])
|
||||
result,files=self.run_update('failure')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertIn('Aktualizácia systému zlyhala. Skontrolujte denník:',result.stdout)
|
||||
self.assertIn('/log/proxmox-update-',result.stdout)
|
||||
self.assertNotIn('autoremove',files['calls'])
|
||||
|
||||
def test_update_status_cancel_refresh_error_and_reboot_notice(self):
|
||||
for suite in ('bookworm','trixie'):
|
||||
for case in ('current','cancel','refresh','success'):
|
||||
with self.subTest(suite=suite,case=case):
|
||||
result,files=self.run_update(case,suite=suite)
|
||||
self.assertEqual(result.returncode,1 if case=='refresh' else 0,result.stderr)
|
||||
if case!='success': self.assertNotIn('full-upgrade',files['calls'])
|
||||
if case=='success': self.assertIn('reštartujte server',result.stdout)
|
||||
if case in ('current','cancel'): self.assertIn('Aktuálna verzia PBS',files['dialog'])
|
||||
|
||||
|
||||
class DialogTests(unittest.TestCase):
|
||||
def test_automated_confirmation_and_banner_cancel_do_not_run_actions(self):
|
||||
cases=[('scripts/menus/menu_post_install.sh','confirm_automated_script',
|
||||
['repozitár no-subscription','upozornenie na predplatné','pamäť','jadro','sieť']),
|
||||
('scripts/post_install/auto_post_install.sh','remove_subscription_banner',
|
||||
['Proxmox Backup Server – odstránenie upozornenia na predplatné'])]
|
||||
for path,name,expected in cases:
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-dialog-') as folder:
|
||||
script=function('scripts/utils.sh','translate')+function(path,name)+r'''
|
||||
PMX_ON_PBS=true
|
||||
LOCAL_SCRIPTS='/fixture/scripts'
|
||||
clear() { :; }; sleep() { :; }; stop_spinner() { :; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
bash() { printf forbidden >> "$CAPTURE/actions"; }
|
||||
register_tool() { printf forbidden >> "$CAPTURE/actions"; }
|
||||
dialog() { printf '%s\n' "$*" >> "$CAPTURE/dialog"; return 1; }
|
||||
whiptail() { dialog "$@"; }
|
||||
'''+name+'\n'
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'CAPTURE':folder})
|
||||
self.assertEqual(result.returncode,1 if name=='remove_subscription_banner' else 0,result.stderr)
|
||||
dialog=(Path(folder)/'dialog').read_text()
|
||||
for text in expected:self.assertIn(text,dialog)
|
||||
self.assertFalse((Path(folder)/'actions').exists())
|
||||
if name=='remove_subscription_banner':self.assertIn('bolo zrušené používateľom',result.stdout)
|
||||
|
||||
def test_community_labels_preserve_dispatch_names_and_translate_display(self):
|
||||
script=function('scripts/utils.sh','translate')+function('scripts/menus/menu_post_install.sh','post_install_label')+'\npost_install_label "$1"\n'
|
||||
for text,expected in [('Proxmox Backup Server Post Install','nastavenie po inštalácii'),
|
||||
('Proxmox Backup Server Microcode','mikrokód procesora')]:
|
||||
result=subprocess.run(['bash','-c',script,'bash',text],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang')})
|
||||
self.assertEqual(result.returncode,0,result.stderr);self.assertIn(expected,result.stdout)
|
||||
|
||||
def test_failed_apt_hook_check_removes_only_fixture_hook_with_localized_warning(self):
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-hook-') as folder:
|
||||
script=function('scripts/utils.sh','translate')+function('scripts/global/remove-banner-pve-v3.sh','create_apt_hook')+r'''
|
||||
apt-config() { return 1; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
create_apt_hook
|
||||
'''
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'APT_HOOK':folder+'/hook'})
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
self.assertIn('Nesprávna syntax hooku APT',result.stdout)
|
||||
self.assertFalse((Path(folder)/'hook').exists())
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Offline acquisition fixtures; never source or run the administrative script.
|
||||
|
||||
Requires Bash, jq, curl, wget, gzip and unzip. Only bounded native functions
|
||||
are loaded; HTTP clients are confined to a loopback fixture server. No host
|
||||
package manager, Proxmox command, installer or VM is executed.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
import gzip
|
||||
import http.server
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import warnings
|
||||
import zipfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SOURCE = ROOT / "scripts/vm/synology.sh"
|
||||
RAW = b"tiny offline loader fixture\x00\x01\x02" * 8
|
||||
PLAIN = "alpine-redpill.v1.4.5.6.m-shell.img.gz"
|
||||
VARIANTS = [
|
||||
"alpine-redpill.v1.4.5.6.m-shell-5GB.img.gz",
|
||||
"alpine-redpill.v1.4.5.6.xtcrp.img.gz",
|
||||
"alpine-redpill.v1.4.5.6.m-shell.vmdk.gz",
|
||||
"alpine-redpill.v1.4.5.6.xtcrp-5GB.img.gz",
|
||||
]
|
||||
|
||||
|
||||
def native_function(name):
|
||||
match = re.search(
|
||||
rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}",
|
||||
SOURCE.read_text(), re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
raise AssertionError(f"Native function not found: {name}")
|
||||
return match.group()
|
||||
|
||||
|
||||
def asset(name):
|
||||
return {"name": name, "browser_download_url": f"https://fixtures.invalid/{name}"}
|
||||
|
||||
|
||||
def zip_bytes(member, payload=RAW, extras=()):
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(member, payload)
|
||||
for name, content in extras:
|
||||
archive.writestr(name, content)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def fixture_server(metadata, payload, *, metadata_status=200, asset_status=200,
|
||||
partial=False):
|
||||
requests = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
requests.append(self.path)
|
||||
is_metadata = self.path.startswith("/metadata/")
|
||||
body = metadata if is_metadata else payload
|
||||
status = metadata_status if is_metadata else asset_status
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(body) + (
|
||||
37 if partial and not is_metadata else 0)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
self.close_connection = True
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever,
|
||||
kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}", requests
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
class SynologyLoaderTests(unittest.TestCase):
|
||||
def run_loader(self, loader="tinycore", *, names=None, metadata=None,
|
||||
payload=None, pretty=True, before="", invoke="download_loader",
|
||||
functions=(), **server_options):
|
||||
if names is None:
|
||||
names = [PLAIN, *VARIANTS]
|
||||
if metadata is None:
|
||||
metadata = json.dumps({"assets": [asset(name) for name in names]},
|
||||
indent=2 if pretty else None).encode()
|
||||
if payload is None:
|
||||
payload = gzip.compress(RAW)
|
||||
expected = {"arc": "arc.img", "redpill": "rr.img", "tinycore": "tinycore.img"}[loader]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
images = base / "images with spaces"
|
||||
images.mkdir()
|
||||
previous = images / expected
|
||||
previous.write_bytes(b"previous verified image")
|
||||
bin_dir = base / "bin"
|
||||
bin_dir.mkdir()
|
||||
for command in ("bash", "jq", "curl", "wget", "gzip", "unzip"):
|
||||
self.assertIsNotNone(shutil.which(command), f"Install test prerequisite: {command}")
|
||||
with fixture_server(metadata, payload, **server_options) as (url, requests):
|
||||
for command in ("curl", "wget"):
|
||||
real = shutil.which(command)
|
||||
client_options = ["--no-config", "--tries=1"] if command == "wget" else ["-q"]
|
||||
wrapper = bin_dir / command
|
||||
wrapper.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import os, sys\n"
|
||||
"args = sys.argv[1:]\n"
|
||||
"skip = False\n"
|
||||
"for arg in args:\n"
|
||||
" if skip:\n"
|
||||
" skip = False\n"
|
||||
" continue\n"
|
||||
" if arg in ('-O', '--connect-timeout', '--max-time'):\n"
|
||||
" skip = True\n"
|
||||
" elif not arg.startswith('-') and not arg.startswith(('https://api.github.com/repos/', 'https://fixtures.invalid/')):\n"
|
||||
" sys.exit('Forbidden non-fixture argument: ' + arg)\n"
|
||||
"for i, arg in enumerate(args):\n"
|
||||
" if arg.startswith('https://api.github.com/repos/'):\n"
|
||||
f" args[i] = {url!r} + '/metadata/' + arg.split('/repos/', 1)[1]\n"
|
||||
" elif arg.startswith('https://fixtures.invalid/'):\n"
|
||||
f" args[i] = {url!r} + '/asset/' + arg.rsplit('/', 1)[1]\n"
|
||||
" elif '://' in arg:\n"
|
||||
" sys.exit('Forbidden non-fixture URL: ' + arg)\n"
|
||||
"os.environ['no_proxy'] = os.environ['NO_PROXY'] = '127.0.0.1'\n"
|
||||
f"os.execv({real!r}, [{real!r}, *{client_options!r}, *args])\n"
|
||||
)
|
||||
wrapper.chmod(0o755)
|
||||
# Administrative boundaries are fail-closed even if a test regresses.
|
||||
for command in ("apt-get", "qm", "pct", "pvesm", "pvesh"):
|
||||
wrapper = bin_dir / command
|
||||
wrapper.write_text("#!/bin/bash\nprintf 'FORBIDDEN %s\\n' \"$0\" >&2\nexit 97\n")
|
||||
wrapper.chmod(0o755)
|
||||
definitions = "\n".join(native_function(name) for name in
|
||||
dict.fromkeys(("download_loader", *functions)))
|
||||
script = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_info() { printf 'INFO:%s\n' "$*"; }
|
||||
msg_ok() { printf 'OK:%s\n' "$*"; }
|
||||
msg_error() { printf 'ERROR:%s\n' "$*"; }
|
||||
msg_warn() { printf 'WARN:%s\n' "$*"; }
|
||||
stop_spinner() { :; }
|
||||
sleep() { :; }
|
||||
select_loader() { printf 'UNEXPECTED_RECURSION\n'; return 91; }
|
||||
'''
|
||||
script += definitions + "\n" + before + "\n"
|
||||
script += invoke + r'''
|
||||
result=$?
|
||||
printf 'RESULT:%s\nFILE:%s\nLOADER_FILE:%s\nLOADER_TYPE:%s\nLOADER_NAME:%s\n' "$result" "$FILE" "$LOADER_FILE" "$LOADER_TYPE" "$LOADER_NAME"
|
||||
exit "$result"
|
||||
'''
|
||||
environment = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}",
|
||||
IMAGES_DIR=str(images), LOADER_TYPE=loader,
|
||||
LOADER_NAME="fixture loader", FILE="stale.img",
|
||||
LOADER_FILE="/stale.img")
|
||||
result = subprocess.run(["bash", "-c", script], env=environment,
|
||||
cwd=base, capture_output=True, text=True, timeout=15)
|
||||
state = dict(re.findall(r"^(RESULT|FILE|LOADER_FILE|LOADER_TYPE|LOADER_NAME):(.*)$", result.stdout, re.MULTILINE))
|
||||
return result, state, previous.read_bytes(), list(requests), sorted(
|
||||
str(path.relative_to(images)) for path in images.rglob("*"))
|
||||
|
||||
def test_plain_alpine_selection_ignores_competing_variants(self):
|
||||
for pretty, names in ((True, [*VARIANTS, PLAIN]),
|
||||
(False, [VARIANTS[2], PLAIN, *VARIANTS[:2], VARIANTS[3]])):
|
||||
with self.subTest(pretty=pretty):
|
||||
result, state, contents, requests, files = self.run_loader(names=names, pretty=pretty)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(state["FILE"], "tinycore.img")
|
||||
self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img"))
|
||||
self.assertEqual([path for path in requests if path.startswith("/asset/")],
|
||||
[f"/asset/{PLAIN}"])
|
||||
self.assertEqual(files, ["tinycore.img"])
|
||||
|
||||
def assert_failed_acquisition(self, outcome, expected="tinycore.img"):
|
||||
result, state, contents, requests, files = outcome
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(state["FILE"], "", result.stdout)
|
||||
self.assertEqual(state["LOADER_FILE"], "", result.stdout)
|
||||
self.assertEqual(contents, b"previous verified image")
|
||||
self.assertNotIn("OK:", result.stdout)
|
||||
self.assertNotIn("UNEXPECTED_RECURSION", result.stdout)
|
||||
self.assertNotIn("FORBIDDEN", result.stderr)
|
||||
self.assertEqual(files, [expected])
|
||||
return requests
|
||||
|
||||
def test_zip_loaders_parse_minified_metadata(self):
|
||||
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
with self.subTest(loader=loader):
|
||||
result, state, contents, requests, files = self.run_loader(
|
||||
loader, names=["README.txt", name], pretty=False,
|
||||
payload=zip_bytes(member))
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(state["FILE"], member)
|
||||
self.assertTrue(state["LOADER_FILE"].endswith("/" + member))
|
||||
self.assertEqual(files, [member])
|
||||
self.assertEqual(requests[-1], "/asset/" + name)
|
||||
|
||||
def test_metadata_requires_one_typed_unambiguous_asset(self):
|
||||
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
|
||||
("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
cases = {
|
||||
"none": {"assets": []},
|
||||
"duplicate": {"assets": [asset(name), asset(name)]},
|
||||
"two_versions": {"assets": [asset(name), asset(name.replace("v1.4.5.6", "v2.0.0") if loader == "tinycore" else "another.img.zip")]},
|
||||
"null_name": {"assets": [{"name": None, "browser_download_url": asset(name)["browser_download_url"]}]},
|
||||
"numeric_name": {"assets": [{"name": 7, "browser_download_url": asset(name)["browser_download_url"]}]},
|
||||
"missing_name": {"assets": [{"browser_download_url": asset(name)["browser_download_url"]}]},
|
||||
"null_url": {"assets": [{"name": name, "browser_download_url": None}]},
|
||||
"numeric_url": {"assets": [{"name": name, "browser_download_url": 7}]},
|
||||
"empty_url": {"assets": [{"name": name, "browser_download_url": ""}]},
|
||||
"http_url": {"assets": [{"name": name, "browser_download_url": "http://bad/" + name}]},
|
||||
"mismatched_url": {"assets": [{"name": name, "browser_download_url": "https://bad/other.img.gz"}]},
|
||||
"whitespace_url": {"assets": [{"name": name, "browser_download_url": "https://bad/ has space/" + name}]},
|
||||
"control_url": {"assets": [{"name": name, "browser_download_url": "https://fixtures.invalid/\u0000/" + name}]},
|
||||
"wrong_assets_type": {"assets": {}},
|
||||
"array_root": [asset(name)],
|
||||
"error_object": {"message": "API rate limit exceeded"},
|
||||
}
|
||||
for case, document in cases.items():
|
||||
with self.subTest(loader=loader, case=case):
|
||||
requests = self.assert_failed_acquisition(self.run_loader(
|
||||
loader, metadata=json.dumps(document).encode()), member)
|
||||
self.assertFalse(any(path.startswith("/asset/") for path in requests))
|
||||
|
||||
def test_metadata_transport_or_json_failure_is_not_success(self):
|
||||
for label, metadata, status in (
|
||||
("http_failure_with_valid_body", json.dumps({"assets": [asset(PLAIN)]}).encode(), 503),
|
||||
("html_error_body", b"<html>unavailable</html>", 200),
|
||||
("truncated_json", b'{"assets":[', 200),
|
||||
("multiple_documents", json.dumps({"assets": [asset(PLAIN)]}).encode() * 2, 200),
|
||||
):
|
||||
with self.subTest(case=label):
|
||||
requests = self.assert_failed_acquisition(self.run_loader(
|
||||
metadata=metadata, metadata_status=status))
|
||||
self.assertFalse(any(path.startswith("/asset/") for path in requests))
|
||||
|
||||
def test_failed_empty_or_partial_download_preserves_previous_image(self):
|
||||
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
|
||||
("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
payload = gzip.compress(RAW) if loader == "tinycore" else zip_bytes(member)
|
||||
for label, body, options in (
|
||||
("http_error_with_archive_body", payload, {"asset_status": 503}),
|
||||
("empty_download", b"", {}),
|
||||
("partial_download", payload, {"partial": True}),
|
||||
):
|
||||
with self.subTest(loader=loader, case=label):
|
||||
requests = self.assert_failed_acquisition(self.run_loader(
|
||||
loader, names=[name], payload=body, **options), member)
|
||||
self.assertEqual(requests[-1], "/asset/" + name)
|
||||
|
||||
def test_invalid_or_empty_compressed_image_cannot_replace_cached_image(self):
|
||||
valid_gzip = gzip.compress(RAW)
|
||||
crc_gzip = valid_gzip[:-8] + bytes([valid_gzip[-8] ^ 1]) + valid_gzip[-7:]
|
||||
for label, payload in (
|
||||
("not_gzip", b"<html>not an image</html>"),
|
||||
("truncated_gzip", valid_gzip[:-6]),
|
||||
("gzip_crc_failure", crc_gzip),
|
||||
("empty_raw_image", gzip.compress(b"")),
|
||||
):
|
||||
with self.subTest(case=label):
|
||||
self.assert_failed_acquisition(self.run_loader(payload=payload))
|
||||
|
||||
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
stored = io.BytesIO()
|
||||
with zipfile.ZipFile(stored, "w", zipfile.ZIP_STORED) as archive:
|
||||
archive.writestr(member, RAW)
|
||||
crc_zip = stored.getvalue().replace(RAW, b"?" + RAW[1:], 1)
|
||||
valid_zip = zip_bytes(member)
|
||||
for label, payload in (
|
||||
("not_zip", b"<html>not an archive</html>"),
|
||||
("truncated_zip", valid_zip[:-30]),
|
||||
("zip_crc_failure", crc_zip),
|
||||
("empty_raw_image", zip_bytes(member, b"")),
|
||||
("missing_member", zip_bytes("other.img")),
|
||||
("nested_member", zip_bytes("subdirectory/" + member)),
|
||||
):
|
||||
with self.subTest(loader=loader, case=label):
|
||||
self.assert_failed_acquisition(self.run_loader(
|
||||
loader, names=[name], payload=payload), member)
|
||||
|
||||
def test_alpine_menu_and_selection_name_preserve_internal_contract(self):
|
||||
result, state, contents, requests, files = self.run_loader(
|
||||
functions=("select_loader",), invoke="select_loader",
|
||||
before=r'''whiptail() { printf 'MENU:%s\n' "$*"; printf '3' >&2; }''')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Alpine RedPill Loader (PeterSuh-Q3 M-shell)", result.stderr)
|
||||
self.assertIn("Selected Loader: Alpine RedPill M-shell", result.stdout)
|
||||
self.assertNotIn("TinyCore RedPill", result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(state["FILE"], "tinycore.img")
|
||||
self.assertIn("/metadata/PeterSuh-Q3/tinycore-redpill/releases/latest", requests)
|
||||
|
||||
def test_zip_expected_member_must_be_unique(self):
|
||||
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
duplicate = io.BytesIO()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
with zipfile.ZipFile(duplicate, "w") as archive:
|
||||
archive.writestr(member, RAW)
|
||||
archive.writestr(member, b"competing image")
|
||||
with self.subTest(loader=loader):
|
||||
self.assert_failed_acquisition(self.run_loader(
|
||||
loader, names=[name], payload=duplicate.getvalue()), member)
|
||||
|
||||
def run_wizard(self, loader, mode, *, main=False, **options):
|
||||
functions = (
|
||||
"select_loader", "select_disk_type", "_build_storage_plan_summary",
|
||||
"default_settings", "advanced_settings", "start_script", "create_vm",
|
||||
)
|
||||
before = f"WIZARD_MODE={mode}\nLOADER_CHOICE={ {'arc': 1, 'redpill': 2, 'tinycore': 3}[loader] }\n"
|
||||
# Only UI/storage-entry/capture/host boundaries are replaced. The actual
|
||||
# loader -> disk plan -> settings -> start_script chain runs in Bash.
|
||||
before += r'''
|
||||
NEXTID=123
|
||||
NAME='Synology VM'
|
||||
GEN_MAC='02:00:00:00:00:01'
|
||||
header_info() { :; }
|
||||
start_vm_wizard_capture() { :; }
|
||||
stop_vm_wizard_capture() { :; }
|
||||
prompt_optional_gpu_passthrough() { printf 'GPU_BOUNDARY\n'; }
|
||||
select_virtual_disk() { VIRTUAL_DISKS+=('local:32'); }
|
||||
pct() { return 1; }
|
||||
qm() {
|
||||
[[ "$1" == status ]] && return 1
|
||||
printf 'QM_BOUNDARY:%s\n' "$*"
|
||||
return 97
|
||||
}
|
||||
whiptail() {
|
||||
case "$*" in
|
||||
*'--title SETTINGS --yesno'*) [[ "$WIZARD_MODE" == default ]]; return ;;
|
||||
*'VIRTUAL MACHINE ID'*) printf '123' >&2 ;;
|
||||
*'MACHINE TYPE'*) printf 'q35' >&2 ;;
|
||||
*'BIOS TYPE'*) printf 'ovmf' >&2 ;;
|
||||
*'HOSTNAME'*) printf 'Synology-DSM' >&2 ;;
|
||||
*'CPU MODEL'*) printf '1' >&2 ;;
|
||||
*'CORE COUNT'*) printf '2' >&2 ;;
|
||||
*'--title RAM '*) printf '4096' >&2 ;;
|
||||
*'--title BRIDGE '*) printf 'vmbr0' >&2 ;;
|
||||
*'MAC ADDRESS'*) printf '02:00:00:00:00:01' >&2 ;;
|
||||
*'STORAGE PLAN'*)
|
||||
if [[ ${#VIRTUAL_DISKS[@]} -eq 0 ]]; then printf 'a' >&2; else printf 'd' >&2; fi ;;
|
||||
*'SELECT LOADER'*) printf '%s' "$LOADER_CHOICE" >&2 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
'''
|
||||
invoke = "start_script"
|
||||
if main:
|
||||
match = re.search(r"^# Main execution\n# =+\n(.*)", SOURCE.read_text(),
|
||||
re.MULTILINE | re.DOTALL)
|
||||
if match is None:
|
||||
self.fail("Native main gate not found")
|
||||
invoke = match.group(1)
|
||||
return self.run_loader(loader, functions=functions, before=before,
|
||||
invoke=invoke, **options)
|
||||
|
||||
def test_native_settings_chain_propagates_loader_failure(self):
|
||||
for mode in ("default", "advanced"):
|
||||
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
|
||||
("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
for failure in ("metadata", "archive", "download"):
|
||||
with self.subTest(mode=mode, loader=loader, failure=failure):
|
||||
result, state, contents, requests, files = self.run_wizard(
|
||||
loader, mode, names=[name],
|
||||
metadata=b'{"assets":[]}' if failure == "metadata" else None,
|
||||
payload=b"invalid compressed image",
|
||||
asset_status=503 if failure == "download" else 200)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(state["FILE"], "")
|
||||
self.assertEqual(state["LOADER_FILE"], "")
|
||||
self.assertEqual(contents, b"previous verified image")
|
||||
self.assertNotIn("Downloaded", result.stdout)
|
||||
self.assertNotIn("QM_BOUNDARY", result.stdout)
|
||||
self.assertNotIn("FORBIDDEN", result.stderr)
|
||||
self.assertEqual(files, [member])
|
||||
|
||||
def test_native_main_gate_never_reaches_qm_create_after_loader_failure(self):
|
||||
for mode in ("default", "advanced"):
|
||||
for loader in ("tinycore", "arc", "redpill"):
|
||||
with self.subTest(mode=mode, loader=loader):
|
||||
result, state, contents, requests, files = self.run_wizard(
|
||||
loader, mode, main=True, metadata=b'{"assets":[]}')
|
||||
# The existing cancellation convention exits zero; failure
|
||||
# must still stop the native main before GPU or VM creation.
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("VM creation cancelled before disk planning.", result.stdout)
|
||||
self.assertNotIn("QM_BOUNDARY", result.stdout)
|
||||
self.assertNotIn("GPU_BOUNDARY", result.stdout)
|
||||
self.assertNotIn("FORBIDDEN", result.stderr)
|
||||
self.assertEqual(contents, b"previous verified image")
|
||||
|
||||
def test_native_main_positive_control_reaches_only_stubbed_qm_boundary(self):
|
||||
for mode in ("default", "advanced"):
|
||||
with self.subTest(mode=mode):
|
||||
result, state, contents, requests, files = self.run_wizard("tinycore", mode, main=True)
|
||||
self.assertEqual(result.stdout.count("QM_BOUNDARY:create "), 1, result.stdout + result.stderr)
|
||||
self.assertIn("GPU_BOUNDARY", result.stdout)
|
||||
self.assertEqual(contents, RAW)
|
||||
|
||||
def test_custom_loader_selection_keeps_existing_path_without_network(self):
|
||||
result, state, contents, requests, files = self.run_loader(
|
||||
functions=("select_loader", "select_custom_image"), invoke="select_loader",
|
||||
before=r'''
|
||||
whiptail() {
|
||||
if [[ "$*" == *'SELECT CUSTOM IMAGE'* ]]; then
|
||||
printf '%s/tinycore.img' "$IMAGES_DIR" >&2
|
||||
else
|
||||
printf '4' >&2
|
||||
fi
|
||||
}
|
||||
''')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(state["LOADER_TYPE"], "custom")
|
||||
self.assertEqual(state["FILE"], "tinycore.img")
|
||||
self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img"))
|
||||
self.assertEqual(contents, b"previous verified image")
|
||||
self.assertEqual(requests, [])
|
||||
self.assertEqual(files, ["tinycore.img"])
|
||||
|
||||
def test_zip_extracts_only_expected_image_not_sibling_files(self):
|
||||
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
with self.subTest(loader=loader):
|
||||
result, state, contents, requests, files = self.run_loader(
|
||||
loader, names=[name], payload=zip_bytes(member, extras=(
|
||||
("sha256sum", b"fixture checksum text"),
|
||||
("other.img", b"must not be published"))))
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(files, [member])
|
||||
|
||||
def test_alpine_selection_is_not_pinned_to_current_release(self):
|
||||
name = "alpine-redpill.v2.0.1.m-shell.img.gz"
|
||||
result, state, contents, requests, files = self.run_loader(names=[name, *VARIANTS])
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(requests[-1], "/asset/" + name)
|
||||
|
||||
def test_variants_or_legacy_family_never_substitute_for_plain_alpine(self):
|
||||
self.assert_failed_acquisition(self.run_loader(
|
||||
names=[*VARIANTS, "tinycore-redpill.v1.0.0.m-shell.img.gz"]))
|
||||
|
||||
def test_staging_or_promotion_failure_keeps_previous_image(self):
|
||||
for boundary in ("mktemp", "mv"):
|
||||
with self.subTest(boundary=boundary):
|
||||
self.assert_failed_acquisition(self.run_loader(
|
||||
before=f"{boundary}() {{ return 1; }}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Offline regressions for copyable commands and runtime format fields."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
ROOT = SCRIPTS.parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py')
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC and SPEC.loader
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class TerminalCatalogsTests(unittest.TestCase):
|
||||
def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self):
|
||||
fixtures = {
|
||||
'menu': 'translate "Main launcher"',
|
||||
'install_proxmenux.sh': 'translate "Stable installer"',
|
||||
'install_proxmenux_beta.sh': 'translate "Beta installer"',
|
||||
'scripts/check.sh': 'translate "Host shell script"',
|
||||
'scripts/check.py': 'translate("Host Python script")',
|
||||
'oci/src/check.py': 'N_("OCI Python script")',
|
||||
'oci/remote/check.py': 'translate("Remote Python script")',
|
||||
'oci/remote/check.sh': 'translate "Remote shell script"',
|
||||
'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}),
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for name, content in fixtures.items():
|
||||
file = root / name
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(content, encoding='utf-8')
|
||||
self.assertEqual(set(MODULE.active_sources(root)), {
|
||||
'Main launcher', 'Stable installer', 'Beta installer',
|
||||
'Host shell script', 'Host Python script', 'OCI Python script',
|
||||
'Remote Python script', 'Remote shell script', 'Application tagline',
|
||||
})
|
||||
|
||||
def test_runtime_format_field_cannot_be_translated(self):
|
||||
source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}'
|
||||
with self.assertRaisesRegex(ValueError, 'placeholder'):
|
||||
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}')
|
||||
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}')
|
||||
|
||||
def test_copyable_command_and_environment_variable_are_literal(self):
|
||||
examples = [
|
||||
('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'),
|
||||
("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."),
|
||||
('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'),
|
||||
]
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
|
||||
MODULE.validate_message(source, target)
|
||||
MODULE.validate_message('Example: pct config 110 | grep unprivileged',
|
||||
'Ejemplo: pct config 110 | grep unprivileged')
|
||||
|
||||
def test_dns_capitalization_does_not_relax_file_path_case(self):
|
||||
MODULE.validate_message('Cannot reach download.proxmox.com.',
|
||||
'Download.proxmox.com ist nicht erreichbar.')
|
||||
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
|
||||
MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg')
|
||||
|
||||
def test_shipped_active_catalogs_keep_literal_contracts(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--require-complete'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for locale in MODULE.DEFAULT_LANGUAGES:
|
||||
self.assertIn(f'[{locale}] active=', result.stdout)
|
||||
self.assertNotIn('missing=1', result.stdout)
|
||||
|
||||
def test_optional_completeness_still_rejects_invalid_present_translations(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / 'scripts').mkdir()
|
||||
(root / 'lang').mkdir()
|
||||
(root / 'scripts/check.sh').write_text('translate "Check /etc/pve/storage.cfg"')
|
||||
(root / 'lang/sk.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Skontrolujte /etc/pve/storage.cfg'}))
|
||||
(root / 'lang/es.json').write_text('{}')
|
||||
|
||||
def check(languages, *flags):
|
||||
return subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(root), '--languages', languages, *flags],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
|
||||
self.assertEqual(check('es,sk').returncode, 0)
|
||||
self.assertEqual(check('sk', '--require-complete').returncode, 0)
|
||||
self.assertNotEqual(check('es', '--require-complete').returncode, 0)
|
||||
(root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'}))
|
||||
self.assertNotEqual(check('es,sk').returncode, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Every web terminal of the Monitor can send Ctrl+D from its Ctrl menu, so
|
||||
a pasted text can be finished from a phone or a tablet."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
TERMINALS = {
|
||||
'terminal-panel': 'sendSequence',
|
||||
'script-terminal-modal': 'sendCommand',
|
||||
'lxc-terminal-modal': 'sendKey',
|
||||
}
|
||||
|
||||
|
||||
class TerminalCtrlKeysTests(unittest.TestCase):
|
||||
def test_each_terminal_sends_ctrl_d(self):
|
||||
for name, send in TERMINALS.items():
|
||||
source = (ROOT / f'AppImage/components/{name}.tsx').read_text(encoding='utf-8')
|
||||
with self.subTest(terminal=name):
|
||||
self.assertIn(f'onSelect={{() => {send}("\\x04")}}', source)
|
||||
self.assertIn('t("scriptTerminal.endOfInput")', source)
|
||||
|
||||
def test_the_label_exists_in_english_and_spanish(self):
|
||||
for language in ('en', 'es'):
|
||||
catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8'))
|
||||
with self.subTest(language=language):
|
||||
self.assertTrue(catalog['scriptTerminal'].get('endOfInput'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -15,7 +15,7 @@ class VmBackupJobsWiring(TestCase):
|
||||
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)
|
||||
self.assertEqual(len(routes), 10)
|
||||
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})')
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""The VM wizard must not accept failed ISO downloads as installation media.
|
||||
|
||||
Run its function logic against a loopback HTTP fixture. Redirect the fixed
|
||||
native ISO directory only in the loaded test copy and stub Proxmox/UI services.
|
||||
No product script is sourced, no guest operation runs, and no public network
|
||||
or real ISO storage is accessed.
|
||||
"""
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CREATOR = ROOT / "scripts/vm/vm_creator.sh"
|
||||
PAYLOAD = b"fixture installation media\x00\x01\n"
|
||||
REAL_WGET = shutil.which("wget")
|
||||
|
||||
|
||||
class MediaHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/missing.iso":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/partial.iso":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "1024")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"partial")
|
||||
self.close_connection = True
|
||||
return
|
||||
body = b"" if self.path == "/empty.iso" else PAYLOAD
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Content-Disposition", 'attachment; filename="remote-name.iso"')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
class VmIsoDownloadTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if REAL_WGET is None:
|
||||
raise RuntimeError("These offline tests require wget, as the VM wizard does")
|
||||
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), MediaHandler)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.http = f"http://127.0.0.1:{cls.server.server_port}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join()
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="vm-iso-download-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.directory = Path(self.temp.name)
|
||||
self.iso = self.directory / "installation media.iso"
|
||||
self.qm_log = self.directory / "qm.log"
|
||||
self.wget_log = self.directory / "wget.log"
|
||||
self.bin = self.directory / "bin"
|
||||
self.bin.mkdir()
|
||||
# Route only the optional fixed VirtIO/SourceForge endpoints to our
|
||||
# fixture. Wget still performs the actual HTTP transfer and file I/O.
|
||||
assert REAL_WGET is not None
|
||||
wrapper = self.bin / "wget"
|
||||
wrapper.write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
'printf "%s\\n" "$*" >> "$WGET_LOG"\n'
|
||||
'args=("$@")\n'
|
||||
'for i in "${!args[@]}"; do\n'
|
||||
' case "${args[$i]}" in\n'
|
||||
' https://fedorapeople.org/*) args[$i]="$VIRTIO_FIXTURE_URL";;\n'
|
||||
' https://sourceforge.net/*) args[$i]="$HTTP_FIXTURE/success.iso";;\n'
|
||||
' esac\n'
|
||||
' if [[ "${args[$i]}" == http://* || "${args[$i]}" == https://* ]]; then\n'
|
||||
' [[ "${args[$i]}" == "$HTTP_FIXTURE"/* ]] || exit 98\n'
|
||||
' fi\n'
|
||||
'done\n'
|
||||
'for i in "${!args[@]}"; do\n'
|
||||
' if [[ "${args[$i]}" == -O ]]; then\n'
|
||||
' case "${args[$((i+1))]}" in "$FIXTURE_DIR"/*) ;; *) exit 97;; esac\n'
|
||||
' fi\n'
|
||||
'done\n'
|
||||
f"exec {shlex.quote(REAL_WGET)} --no-config --no-proxy --tries=1 --timeout=1 "
|
||||
'"${args[@]}"\n'
|
||||
)
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
def run_wizard(self, route="/success.iso", windows=False, overwrite=True, url=None,
|
||||
caller_iso_dir=None):
|
||||
source = CREATOR.read_text()
|
||||
functions = "\n\n".join(re.findall(r"^function \w+\(\) \{\n.*?^\}", source, re.M | re.S))
|
||||
self.assertIn("function create_vm()", functions)
|
||||
# Only relocate this external filesystem anchor. Keep all predicates,
|
||||
# transfers and promotion logic unchanged, without a production hook.
|
||||
self.assertIn("/var/lib/vz/template/iso", functions)
|
||||
functions = functions.replace("/var/lib/vz/template/iso", "${FIXTURE_DIR}")
|
||||
prelude = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_ok() { printf 'OK:%s\n' "$*"; }
|
||||
msg_error() { printf 'ERROR:%s\n' "$*"; }
|
||||
msg_warn() { printf 'WARN:%s\n' "$*"; }
|
||||
msg_info() { :; }
|
||||
msg_success() { :; }
|
||||
qm() { printf '%q ' "$@" >> "$QM_LOG"; printf '\n' >> "$QM_LOG"; }
|
||||
pvesm() { printf 'Name Type Status\nfixture dir active\n'; }
|
||||
select_storage_target() { printf fixture; }
|
||||
# No next menu, start, or guest-facing process is launched.
|
||||
bash() { :; }
|
||||
whiptail() {
|
||||
if [[ " $* " == *' --yesno '* ]]; then
|
||||
return "$OVERWRITE_STATUS"
|
||||
fi
|
||||
if [[ ! -f "$DIALOG_ONCE" ]]; then
|
||||
: > "$DIALOG_ONCE"
|
||||
printf '1' >&2
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
IMPORT_DISKS=(); PASSTHROUGH_DISKS=(); VIRTUAL_DISKS=(); CONTROLLER_NVME_PCIS=()
|
||||
VMID=99999; HN=fixture; CORE_COUNT=1; RAM_SIZE=512; BRG=fixture
|
||||
MAC='02:00:00:00:00:01'; VLAN=''; MTU=''; BIOS_TYPE=''; CPU_TYPE=''
|
||||
MACHINE=''; SERIAL_PORT=''; START_VM=no; WIZARD_ADD_GPU=no
|
||||
TAB=''; GN=''; CL=''
|
||||
create_vm
|
||||
'''
|
||||
env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"],
|
||||
ISO_PATH=str(self.iso), ISO_URL=url if url is not None else self.http + route,
|
||||
ISO_VOLID="local:iso/installation media.iso",
|
||||
OS_TYPE="2" if windows else "3",
|
||||
QM_LOG=str(self.qm_log), WGET_LOG=str(self.wget_log),
|
||||
DIALOG_ONCE=str(self.directory / "dialog-once"), FIXTURE_DIR=str(self.directory),
|
||||
OVERWRITE_STATUS="0" if overwrite else "1", HTTP_FIXTURE=self.http,
|
||||
VIRTIO_FIXTURE_URL=self.http + route)
|
||||
env.pop("ISO_DIR", None)
|
||||
if caller_iso_dir is not None:
|
||||
env["ISO_DIR"] = str(caller_iso_dir)
|
||||
return subprocess.run(["bash", "--noprofile", "--norc", "-c", functions + prelude],
|
||||
env=env, stdin=subprocess.DEVNULL, capture_output=True,
|
||||
text=True, timeout=15)
|
||||
|
||||
def qm_calls(self):
|
||||
return self.qm_log.read_text() if self.qm_log.exists() else ""
|
||||
|
||||
def test_http_failure_stops_before_creating_a_guest(self):
|
||||
result = self.run_wizard("/missing.iso")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.qm_calls(), "")
|
||||
self.assertNotIn("OK:ISO image downloaded", result.stdout)
|
||||
self.assertIn("ERROR:Failed to download ISO image", result.stdout)
|
||||
|
||||
def test_partial_transfer_is_not_published_as_cached_media(self):
|
||||
result = self.run_wizard("/partial.iso")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.qm_calls(), "")
|
||||
self.assertFalse(self.iso.exists(), "A failed transfer must not become reusable media")
|
||||
|
||||
def test_an_empty_cached_download_is_retried(self):
|
||||
self.iso.write_bytes(b"")
|
||||
result = self.run_wizard()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.iso.read_bytes(), PAYLOAD)
|
||||
self.assertIn("create ", self.qm_calls())
|
||||
|
||||
def test_virtio_download_keeps_native_storage_despite_caller_environment(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
unrelated = self.directory / "unrelated directory"
|
||||
unrelated.mkdir()
|
||||
result = self.run_wizard(windows=True, caller_iso_dir=unrelated)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
self.assertTrue(driver.is_file(), result.stdout + result.stderr)
|
||||
self.assertEqual(driver.read_bytes(), PAYLOAD)
|
||||
self.assertIn("-ide3 ", self.qm_calls())
|
||||
self.assertEqual(list(unrelated.iterdir()), [])
|
||||
|
||||
def test_failed_virtio_fetch_is_not_selected_for_attachment(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
result = self.run_wizard("/missing.iso", windows=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn("-ide3 ", self.qm_calls())
|
||||
self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout)
|
||||
self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout)
|
||||
self.assertFalse((self.directory / "virtio-win.iso").exists())
|
||||
|
||||
|
||||
def test_successful_virtio_replacement_preserves_existing_permissions(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
driver.write_bytes(b"previous driver")
|
||||
driver.chmod(0o640)
|
||||
result = self.run_wizard(windows=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(driver.read_bytes(), PAYLOAD)
|
||||
self.assertEqual(driver.stat().st_mode & 0o777, 0o640)
|
||||
|
||||
def test_empty_http_success_is_rejected(self):
|
||||
result = self.run_wizard("/empty.iso")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.qm_calls(), "")
|
||||
self.assertFalse(self.iso.exists())
|
||||
self.assertEqual({p.name for p in self.directory.iterdir()}, {"bin", "wget.log"})
|
||||
|
||||
def test_existing_nonempty_media_is_reused_without_fetching(self):
|
||||
self.iso.write_bytes(b"existing installation media")
|
||||
result = self.run_wizard("/missing.iso")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.iso.read_bytes(), b"existing installation media")
|
||||
self.assertFalse(self.wget_log.exists())
|
||||
self.assertIn("-ide2 ", self.qm_calls())
|
||||
|
||||
def test_sourceforge_download_keeps_the_selected_destination_name(self):
|
||||
result = self.run_wizard(url="https://sourceforge.net/projects/fixture/iso/download")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.iso.read_bytes(), PAYLOAD)
|
||||
self.assertFalse((self.directory / "remote-name.iso").exists())
|
||||
self.assertEqual({p.name for p in self.directory.iterdir()},
|
||||
{"bin", "wget.log", "qm.log", self.iso.name})
|
||||
|
||||
def test_failed_virtio_replacement_preserves_previous_media(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
driver.write_bytes(b"previous driver")
|
||||
driver.chmod(0o640)
|
||||
result = self.run_wizard("/partial.iso", windows=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(driver.read_bytes(), b"previous driver")
|
||||
self.assertEqual(driver.stat().st_mode & 0o777, 0o640)
|
||||
self.assertNotIn("-ide3 ", self.qm_calls())
|
||||
self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout)
|
||||
|
||||
def test_declining_virtio_replacement_reuses_previous_media(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
driver.write_bytes(b"previous driver")
|
||||
result = self.run_wizard("/missing.iso", windows=True, overwrite=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(driver.read_bytes(), b"previous driver")
|
||||
self.assertFalse(self.wget_log.exists())
|
||||
self.assertIn("-ide3 ", self.qm_calls())
|
||||
|
||||
def test_empty_cached_virtio_media_is_retried_not_attached(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
driver.write_bytes(b"")
|
||||
result = self.run_wizard("/missing.iso", windows=True, overwrite=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertTrue(self.wget_log.exists())
|
||||
self.assertNotIn("-ide3 ", self.qm_calls())
|
||||
self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout)
|
||||
|
||||
def test_a_directory_cannot_be_published_as_media(self):
|
||||
self.iso.mkdir()
|
||||
result = self.run_wizard()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.qm_calls(), "")
|
||||
self.assertTrue(self.iso.is_dir())
|
||||
self.assertEqual(list(self.iso.iterdir()), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Offline VM source regressions; execute bounded real functions, never selectors.
|
||||
|
||||
Fixtures are reduced official metadata excerpts captured 2026-10-16. Network
|
||||
commands are shell stubs: no ISO downloads, utils sourcing, or host changes.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LINUX = ROOT / "scripts/vm/select_linux_iso.sh"
|
||||
NAS = ROOT / "scripts/vm/select_nas_iso.sh"
|
||||
FIELDS = ("ISO_NAME", "ISO_TYPE", "ISO_URL", "ISO_FILE", "ISO_PATH", "HN")
|
||||
|
||||
|
||||
def extract_function(path, name):
|
||||
"""Only top-level function definitions with an unindented closing brace."""
|
||||
match = re.search(
|
||||
rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}(?=\n|$)",
|
||||
path.read_text(), re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if match is None:
|
||||
raise AssertionError(f"Missing bounded function: {name}")
|
||||
return match.group(0)
|
||||
|
||||
|
||||
class IsoSourceTests(unittest.TestCase):
|
||||
def run_selector(self, path, function, *, menu_name="", metadata="",
|
||||
metadata_url="", fetch_status=0, has_curl=True):
|
||||
with tempfile.TemporaryDirectory(prefix="vm-iso-sources-") as directory:
|
||||
temp = Path(directory)
|
||||
fixture = temp / "metadata.html"
|
||||
fixture.write_text(metadata)
|
||||
trace = temp / "fetches.txt"
|
||||
trace.touch()
|
||||
iso_dir = temp / "iso"
|
||||
iso_dir.mkdir()
|
||||
env = os.environ.copy()
|
||||
env.update(ISO_DIR=str(iso_dir), MENU_NAME=menu_name,
|
||||
METADATA_FILE=str(fixture), METADATA_URL=metadata_url,
|
||||
FETCH_STATUS=str(fetch_status), FETCH_TRACE=str(trace))
|
||||
helpers = "\n".join(
|
||||
extract_function(NAS, name)
|
||||
for name in ("_has_curl", "_latest_version_from_lines")
|
||||
)
|
||||
script = helpers + r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
curl() {
|
||||
local url="${@: -1}"
|
||||
printf '%s\n' "$url" >> "$FETCH_TRACE"
|
||||
[[ "$url" == "$METADATA_URL" ]] || return 97
|
||||
[[ "$FETCH_STATUS" == 0 ]] || return "$FETCH_STATUS"
|
||||
cat "$METADATA_FILE"
|
||||
}
|
||||
wget() { return 97; }
|
||||
dialog() {
|
||||
local arg previous="" name
|
||||
for arg in "$@"; do
|
||||
name="${arg%%│*}"
|
||||
name="${name%"${name##*[![:space:]]}"}"
|
||||
if [[ "$name" == "$MENU_NAME" ]]; then
|
||||
printf '%s' "$previous" >&2
|
||||
return 0
|
||||
fi
|
||||
previous="$arg"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
'''
|
||||
if not has_curl:
|
||||
script += "\n_has_curl() { return 1; }\n"
|
||||
script += extract_function(path, function)
|
||||
script += f"\n{function} || exit $?\n"
|
||||
script += 'printf \'%s\\0\' ' + " ".join(
|
||||
f'"${{{field}:-}}"' for field in FIELDS
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c", script],
|
||||
env=env, capture_output=True, timeout=10,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
values = result.stdout.decode().split("\0")
|
||||
self.assertEqual(len(values), len(FIELDS) + 1, result.stdout)
|
||||
selected = dict(zip(FIELDS, values))
|
||||
self.assertEqual(selected["ISO_PATH"], str(iso_dir / selected["ISO_FILE"]))
|
||||
return selected, trace.read_text().splitlines()
|
||||
|
||||
def test_debian13_dvd_uses_fixed_official_archive(self):
|
||||
selected, calls = self.run_selector(
|
||||
LINUX, "select_linux_iso_official", menu_name="Debian 13",
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-dvd/"
|
||||
"debian-13.6.0-amd64-DVD-1.iso")
|
||||
self.assertEqual(selected["ISO_TYPE"], "Desktop")
|
||||
self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-DVD-1.iso")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_debian13_netinst_uses_fixed_official_archive(self):
|
||||
selected, calls = self.run_selector(
|
||||
LINUX, "select_linux_iso_official", menu_name="Debian 13 Netinst",
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-cd/"
|
||||
"debian-13.6.0-amd64-netinst.iso")
|
||||
self.assertEqual(selected["ISO_TYPE"], "CLI")
|
||||
self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-netinst.iso")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_omv_metadata_unavailable_uses_verified_fallback(self):
|
||||
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
|
||||
for has_curl, fetch_status, metadata in (
|
||||
(False, 0, ""), (True, 22, ""),
|
||||
(True, 0, "<html>No release links</html>"),
|
||||
):
|
||||
with self.subTest(has_curl=has_curl, fetch_status=fetch_status):
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_omv_iso", metadata_url=url, has_curl=has_curl,
|
||||
fetch_status=fetch_status, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
url + "8.3.1/openmediavault_8.3.1-amd64.iso/download")
|
||||
self.assertEqual(selected["ISO_NAME"], "OpenMediaVault 8.3.1")
|
||||
self.assertEqual(selected["ISO_FILE"], "openmediavault_8.3.1-amd64.iso")
|
||||
self.assertEqual(calls, [url] if has_curl else [])
|
||||
|
||||
def test_rockstor_resolves_official_landing_without_directory_listing(self):
|
||||
# Exact installer anchor excerpt from https://rockstor.com/dls/index.html.
|
||||
url = "https://rockstor.com/dls/index.html"
|
||||
metadata = (
|
||||
'<a href="https://rockstor.com/downloads/installer/leap/15.6/x86_64/'
|
||||
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso">'
|
||||
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso</a> <br/>'
|
||||
)
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
|
||||
self.assertEqual(selected["ISO_FILE"],
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
|
||||
self.assertEqual(selected["HN"], "Rockstor")
|
||||
self.assertEqual(calls, [url])
|
||||
|
||||
def test_rockstor_metadata_unavailable_uses_verified_fallback(self):
|
||||
url = "https://rockstor.com/dls/index.html"
|
||||
for has_curl, fetch_status, metadata in (
|
||||
(False, 0, ""), (True, 22, ""),
|
||||
(True, 0, "<html>No installer links</html>"),
|
||||
):
|
||||
with self.subTest(has_curl=has_curl, fetch_status=fetch_status):
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_rockstor_iso", metadata_url=url, has_curl=has_curl,
|
||||
fetch_status=fetch_status, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
|
||||
self.assertEqual(selected["ISO_NAME"], "Rockstor")
|
||||
self.assertEqual(selected["ISO_FILE"],
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
|
||||
self.assertEqual(calls, [url] if has_curl else [])
|
||||
|
||||
def test_omv_current_metadata_resolution_is_preserved(self):
|
||||
# Reduced release links from the official SourceForge ISO index.
|
||||
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
|
||||
metadata = (
|
||||
'<a href="/projects/openmediavault/files/iso/8.3.1/" '
|
||||
'title="Click to enter 8.3.1">8.3.1</a>\n'
|
||||
'<a href="/projects/openmediavault/files/iso/7.4.17/" '
|
||||
'title="Click to enter 7.4.17">7.4.17</a>'
|
||||
)
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
url + "8.3.1/openmediavault_8.3.1-amd64.iso/download")
|
||||
self.assertEqual(calls, [url])
|
||||
|
||||
def test_omv_detected_release_takes_precedence_over_fallback(self):
|
||||
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
|
||||
# Synthetic ordering case: must execute the real version parser.
|
||||
metadata = ('<a href="/projects/openmediavault/files/iso/8.10.0/">new</a>'
|
||||
'<a href="/projects/openmediavault/files/iso/8.9.0/">old</a>')
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
url + "8.10.0/openmediavault_8.10.0-amd64.iso/download")
|
||||
self.assertEqual(calls, [url])
|
||||
|
||||
def test_rockstor_retains_generic_x86_64_leap156_edition(self):
|
||||
url = "https://rockstor.com/dls/index.html"
|
||||
# Synthetic ordering / edition cases; not claims of published images.
|
||||
metadata = "\n".join((
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso",
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.9.0-0.install.iso",
|
||||
"Rockstor-Leap15.6-generic.aarch64-9.0.0-0.install.iso",
|
||||
"Rockstor-Leap16.0-generic.x86_64-9.0.0-0.install.iso",
|
||||
"Rockstor-Leap15.5-generic.x86_64-9.0.0-0.install.iso",
|
||||
))
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso")
|
||||
self.assertEqual(calls, [url])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Validate complete catalogs without rejecting intentional English fallback."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py"
|
||||
|
||||
|
||||
class WebDocsCatalogTests(unittest.TestCase):
|
||||
def run_check(self, root, *options):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root),
|
||||
"--languages", "de", *options],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def write_catalog(self, root, locale, content):
|
||||
file = root / locale / "common.json"
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(json.dumps(content), encoding="utf-8")
|
||||
|
||||
def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."})
|
||||
self.write_catalog(root, "de", {"title": "Aktualisierungen"})
|
||||
partial = self.run_check(root)
|
||||
self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr)
|
||||
self.assertIn("missing=1", partial.stdout)
|
||||
complete = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr)
|
||||
self.assertIn("schema differs", complete.stdout)
|
||||
|
||||
def test_translated_code_is_rejected_in_both_modes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"body": "Run <code>apt update</code>."})
|
||||
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> aus."})
|
||||
for options in ((), ("--require-complete",)):
|
||||
with self.subTest(options=options):
|
||||
result = self.run_check(root, *options)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
|
||||
def test_bare_commands_and_operators_are_not_prose(self):
|
||||
examples = [
|
||||
("Restore with pct restore.", "Mit pct restart wiederherstellen."),
|
||||
("pveversion | grep pve-manager", "pveversion grep pve-manager"),
|
||||
("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
self.write_catalog(root, "en", {"body": source})
|
||||
self.write_catalog(root, "de", {"body": target})
|
||||
result = self.run_check(root)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("literal command or identifier", result.stdout)
|
||||
|
||||
def test_title_separator_can_change_without_relaxing_body_pipes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}})
|
||||
self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}})
|
||||
result = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}})
|
||||
self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}})
|
||||
result = self.run_check(root)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
|
||||
def test_shipped_web_catalogs_keep_literal_contracts(self):
|
||||
messages = SCRIPT.parents[2] / "web" / "messages"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_literal_metadata_is_required_but_labels_can_be_localized(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"})
|
||||
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"})
|
||||
invalid = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr)
|
||||
self.assertIn("rendering metadata differ", invalid.stdout)
|
||||
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"})
|
||||
valid = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Exercise the static-export language repair on real HTML files."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_command_descriptions import require_node
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = ROOT / "web/scripts/localize-export.mjs"
|
||||
|
||||
|
||||
class WebExportLanguageTests(unittest.TestCase):
|
||||
def test_locales_nested_pages_preserved_content_and_idempotence(self):
|
||||
node = require_node()
|
||||
html = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"]
|
||||
for locale in locales:
|
||||
file = root / "out" / locale / "docs/monitor/index.html"
|
||||
file.parent.mkdir(parents=True)
|
||||
file.write_text(html)
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
file = root / relative
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(html)
|
||||
result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("8 localized pages", result.stdout)
|
||||
for locale in locales:
|
||||
actual = (root / "out" / locale / "docs/monitor/index.html").read_text()
|
||||
self.assertEqual(actual, html.replace('<html lang="en"', f'<html lang="{locale}"', 1))
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
self.assertEqual((root / relative).read_text(), html)
|
||||
tracked = root / "out/de/docs/monitor/index.html"
|
||||
before = tracked.stat().st_mtime_ns
|
||||
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertEqual(tracked.stat().st_mtime_ns, before)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Render the real ZRAM CLI paths in isolated files with host effects stubbed."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
DETAILS = {
|
||||
"version": ("Cannot determine installed systemd-zram-generator version", "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator"),
|
||||
"mismatch": ("Existing ZRAM profile is active but does not match its configuration", "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii"),
|
||||
"stop": ("Could not stop existing dev-zram0.swap", "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap"),
|
||||
"directory": ("Could not create ZRAM generator directory", "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM"),
|
||||
"config": ("Could not write ZRAM generator configuration", "Nepodarilo sa zapísať konfiguráciu generátora ZRAM"),
|
||||
"marker": ("Could not write ZRAM package ownership state", "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux"),
|
||||
"start": ("Could not activate dev-zram0.swap", "Nepodarilo sa aktivovať jednotku dev-zram0.swap"),
|
||||
"verify": ("Active ZRAM algorithm, capacity or swap sources did not match the profile", "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu"),
|
||||
}
|
||||
REFUSAL = "Another ZRAM generator configuration exists; refusing to overwrite it:"
|
||||
REFUSAL_SK = "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:"
|
||||
|
||||
|
||||
def function(path, name):
|
||||
text = path.read_text()
|
||||
start = text.index(name + "() {")
|
||||
return text[start:text.index("\n}\n", start) + 3]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
set -u
|
||||
BASE_DIR="$FIXTURE/state"
|
||||
LANG_DIR="$REPO/lang"
|
||||
mkdir -p "$BASE_DIR" "$FIXTURE/etc/systemd/zram-generator.conf.d" "$FIXTURE/proc"
|
||||
CONFIG="$FIXTURE/etc/systemd/zram-generator.conf.d/90-proxmenux.conf"
|
||||
MARKER="$BASE_DIR/zram-swap.state"
|
||||
printf 'Filename\tType\tSize\tUsed\tPriority\n' > "$FIXTURE/proc/swaps"
|
||||
printf '/swapfile none swap sw 0 0\n' > "$FIXTURE/etc/fstab"
|
||||
: > "$FIXTURE/calls"
|
||||
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
|
||||
pmx_journal_context() { :; }
|
||||
msg_error() { printf '%s\n' "$*"; }
|
||||
msg_success() { printf '%s\n' "$*"; }
|
||||
register_tool() { record register "$@"; }
|
||||
pmx_install_pkg() { record package-install; return 1; }
|
||||
apt-get() { record apt "$@"; return 1; }
|
||||
dpkg-query() {
|
||||
record dpkg-query
|
||||
case "$*" in
|
||||
*'${Status}'*) printf 'install ok installed\n';;
|
||||
*'${Version}'*) [[ "$CASE" == version ]] || printf '1.2.0-1\n';;
|
||||
esac
|
||||
}
|
||||
dpkg() { return 0; }
|
||||
install() { record directory; [[ "$CASE" != directory ]]; }
|
||||
pmx_write_file() {
|
||||
record write "$1"
|
||||
if [[ "$CASE" == config && "$1" == "$CONFIG" ]] ||
|
||||
[[ "$CASE" == marker && "$1" == "$MARKER" ]]; then
|
||||
cat >/dev/null
|
||||
return 1
|
||||
fi
|
||||
cat > "$1"
|
||||
}
|
||||
pmx_remove_file() { record remove "$1"; rm -f -- "$1"; }
|
||||
pmx_record_uninstall() { record record-uninstall; }
|
||||
swapoff() { record swapoff; return 0; }
|
||||
systemctl() {
|
||||
record systemctl "$@"
|
||||
[[ "$CASE" != start || "$1" != start ]] &&
|
||||
[[ "$CASE" != stop || "$1" != stop ]]
|
||||
}
|
||||
source "$REPO/scripts/post_install/zram_profile_helpers.sh"
|
||||
zram_profile_verify_active() { [[ "$CASE" != verify && "$CASE" != mismatch ]]; }
|
||||
if [[ "$CASE" == mismatch || "$CASE" == stop ]]; then
|
||||
printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
|
||||
[[ "$CASE" != mismatch ]] || zram_render_generator_config true > "$CONFIG"
|
||||
printf 'installed_by_proxmenux=false\n' > "$MARKER"
|
||||
printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$FIXTURE/proc/swaps"
|
||||
fi
|
||||
if [[ "$CASE" == foreign ]]; then
|
||||
printf '[zram1]\nzram-size = ram / 5\n' > "$FIXTURE/etc/systemd/zram-generator.conf"
|
||||
fi
|
||||
if [[ "$CASE" == unreadable ]]; then
|
||||
zram_foreign_generator_config() { return 2; }
|
||||
fi
|
||||
'''
|
||||
|
||||
|
||||
class ZramLocalizationTests(unittest.TestCase):
|
||||
def test_missing_optional_translation_uses_original_english(self):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-fallback-") as folder:
|
||||
(Path(folder) / 'xx.json').write_text('{}')
|
||||
script = function(ROOT / "scripts/utils.sh", "translate") + '\ntranslate "$1"\n'
|
||||
result = subprocess.run(["bash", "-c", script, "bash", DETAILS['version'][0]],
|
||||
text=True, capture_output=True, timeout=15,
|
||||
env={**os.environ, 'LANGUAGE': 'xx', 'LANG_DIR': folder})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), DETAILS['version'][0])
|
||||
|
||||
def run_case(self, case, language="sk"):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-i18n-") as folder:
|
||||
directory = Path(folder)
|
||||
installer = function(ROOT / "scripts/post_install/customizable_post_install.sh", "configure_zram_swap")
|
||||
installer = installer.replace("/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", folder + "/etc/systemd/zram-generator.conf.d/90-proxmenux.conf")
|
||||
installer = installer.replace("/proc/swaps", folder + "/proc/swaps").replace("/etc/fstab", folder + "/etc/fstab")
|
||||
installer = installer.replace('zram_foreign_generator_config "$config"', 'zram_foreign_generator_config "$config" "$FIXTURE"')
|
||||
installer = installer.replace('zram_list_foreign_systemd_zram_units)', 'zram_list_foreign_systemd_zram_units "$FIXTURE")')
|
||||
script = HARNESS + function(ROOT / "scripts/utils.sh", "translate") + installer + "\nconfigure_zram_swap\n"
|
||||
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
|
||||
env={**os.environ, "REPO": str(ROOT), "FIXTURE": folder, "CASE": case, "LANGUAGE": language}, timeout=15)
|
||||
calls = (directory / "calls").read_text().splitlines()
|
||||
files = {str(p.relative_to(directory)): p.read_text() for p in directory.rglob("*") if p.is_file()}
|
||||
return result, calls, files
|
||||
|
||||
def test_all_activation_details_are_localized_and_english_is_preserved(self):
|
||||
for case, (english, slovak) in DETAILS.items():
|
||||
for language, expected in (("sk", slovak), ("en", english)):
|
||||
with self.subTest(case=case, language=language):
|
||||
result, calls, files = self.run_case(case, language)
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
|
||||
if case in ("version", "mismatch"):
|
||||
self.assertTrue(all(call == "dpkg-query" for call in calls), calls)
|
||||
elif case == "stop":
|
||||
self.assertEqual(files["state/zram-swap.state"], "installed_by_proxmenux=false\n")
|
||||
self.assertIn("# ProxMenux ZRAM profile", files["etc/systemd/zram-generator.conf.d/90-proxmenux.conf"])
|
||||
else:
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
self.assertFalse(any(call.startswith(("apt ", "package-install", "register ")) for call in calls), calls)
|
||||
|
||||
def test_foreign_generator_is_refused_in_both_languages_without_any_effect(self):
|
||||
for language, expected in (("sk", REFUSAL_SK), ("en", REFUSAL)):
|
||||
result, calls, files = self.run_case("foreign", language)
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
self.assertIn("/etc/systemd/zram-generator.conf", result.stdout)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(files["etc/systemd/zram-generator.conf"], "[zram1]\nzram-size = ram / 5\n")
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
|
||||
self.assertEqual(files["proc/swaps"], "Filename\tType\tSize\tUsed\tPriority\n")
|
||||
|
||||
def test_failed_inspection_is_not_mistaken_for_no_conflict(self):
|
||||
result, calls, files = self.run_case("unreadable")
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn("Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", result.stdout)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
|
||||
def test_uninstall_label_and_cancel_do_not_call_uninstall(self):
|
||||
for language, label in (("sk", "Profil swapu ZRAM"), ("en", "ZRAM swap profile")):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-menu-") as folder:
|
||||
tools = Path(folder) / "tools.json"
|
||||
tools.write_text('{"zram_swap":true}')
|
||||
script = function(ROOT / "scripts/utils.sh", "translate")
|
||||
script += function(ROOT / "scripts/post_install/uninstall-tools.sh", "show_uninstall_menu")
|
||||
script += '\nensure_tools_json() { :; }; migrate_installed_tools() { :; }\nuninstall_zram_swap() { printf forbidden; return 1; }\ndialog() { printf "%s\\n" "$@" > "$MENU_CAPTURE"; return 1; }\nshow_uninstall_menu\n'
|
||||
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
|
||||
env={**os.environ, "LANGUAGE": language, "LANG_DIR": str(ROOT / "lang"), "TOOLS_JSON": str(tools), "MENU_CAPTURE": folder + "/dialog.txt"}, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(label, (Path(folder) / "dialog.txt").read_text())
|
||||
self.assertNotIn("forbidden", result.stdout + result.stderr)
|
||||
self.assertEqual(json.loads(tools.read_text()), {"zram_swap": True})
|
||||
@@ -1,14 +1,9 @@
|
||||
name: Build web documentation translations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'web/messages/en/*.json'
|
||||
- 'web/messages/en/**/*.json'
|
||||
- '.github/scripts/build_web_docs_i18n.py'
|
||||
- '.github/scripts/build_translation_cache.py'
|
||||
- '.github/workflows/build-web-docs-i18n.yml'
|
||||
# Source and locale corrections are reviewed together. An automatic run
|
||||
# would treat the changed English fingerprints as stale translations and
|
||||
# overwrite those reviewed locale strings with provider output.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
languages:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Apprise Slack and Matrix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-apprise-services.yml'
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'AppImage/components/notification-settings.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
apprise-services:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
apprise: ['1.7.0', '2.0.1']
|
||||
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 fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'apprise==${{ matrix.apprise }}'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test real provider plugins with mocked HTTP
|
||||
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
|
||||
- name: Render provider help and locale fallback
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_apprise_service_help.cjs
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Backup storage retention
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-backup-storage-retention.yml'
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/components/backup-tab.tsx'
|
||||
- 'AppImage/components/vm-backup-jobs.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
storage-retention:
|
||||
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 fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native job and storage configuration APIs
|
||||
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
|
||||
- name: Test shared retention properties
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_backup_storage_retention.cjs
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Figurine installation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-figurine.yml'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- '.github/workflows/test-figurine.yml'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
installer-and-release:
|
||||
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: Check shell syntax and isolated installer regressions
|
||||
run: |
|
||||
bash -n scripts/post_install/customizable_post_install.sh
|
||||
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
|
||||
- name: Install ARM user-mode emulation for release smoke tests
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends qemu-user
|
||||
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
|
||||
env:
|
||||
FIGURINE_VERSION: '2.1.1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
while read -r arch sha; do
|
||||
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
|
||||
target="$scratch/$arch"
|
||||
mkdir -p "$target"
|
||||
curl --fail --location --retry 3 --output "$target/$archive" \
|
||||
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
|
||||
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
|
||||
tar -xf "$target/$archive" -C "$target"
|
||||
runner=()
|
||||
case "$arch" in
|
||||
arm64) runner=(qemu-aarch64) ;;
|
||||
arm) runner=(qemu-arm) ;;
|
||||
esac
|
||||
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
|
||||
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
|
||||
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
|
||||
test -s "$target/banner"
|
||||
printf 'PASS: %s version and login banner\n' "$arch"
|
||||
done <<'ASSETS'
|
||||
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
|
||||
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
|
||||
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
|
||||
ASSETS
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Guest backup archives
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-guest-backups.yml'
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
- 'AppImage/lib/guest-backup-archives.ts'
|
||||
- 'AppImage/messages/**'
|
||||
- 'tests/test_guest_backup_archives.*'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
- 'AppImage/lib/guest-backup-archives.ts'
|
||||
- 'tests/test_guest_backup_archives.*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
guest-backups:
|
||||
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 fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native deletion, protection, authorization and cache invalidation
|
||||
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
|
||||
- name: Test frontend deletion and completion handling
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_guest_backup_archives.cjs
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Offline i18n tests
|
||||
name: Offline script regressions
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -9,15 +9,20 @@ on:
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'web/**'
|
||||
- 'CONTRIBUTING.md'
|
||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'lang/*.json'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- 'oci/remote/**/*.sh'
|
||||
- 'oci/remote/**/*.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
@@ -27,21 +32,28 @@ on:
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'web/**'
|
||||
- 'CONTRIBUTING.md'
|
||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'lang/*.json'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- 'oci/remote/**/*.sh'
|
||||
- 'oci/remote/**/*.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Preserve this check ID for existing branch protection rules. This suite
|
||||
# includes menu, download and scheduler regressions as well as i18n tests.
|
||||
offline-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
@@ -59,9 +71,9 @@ jobs:
|
||||
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
|
||||
- name: Install OCI wording test dependencies
|
||||
run: python3 -m pip install 'PyYAML>=6,<7' 'jsonschema==4.25.1'
|
||||
- name: Run offline i18n and script regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s .github/scripts/tests -v
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Disk policy and host monitor regressions
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-lvm-disk-policy.yml'
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||
- 'tests/test_lvm_disk_read_policy.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||
- 'tests/test_lvm_disk_read_policy.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
disk-policy:
|
||||
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'
|
||||
- name: Test automatic LVM readers with mocked inventory and policy
|
||||
run: python -I -B -m unittest discover -s tests -p test_lvm_disk_read_policy.py -v
|
||||
- name: Test backup node ownership and former jobs
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_host_backup_node.py -v
|
||||
- name: Test local node lookup without Proxmox API commands
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_proxmox_node_name.py -v
|
||||
- name: Test notification configuration writes against a cluster filesystem fixture
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_pve_notifications_cfg_writes.py -v
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Offline OCI registry tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'oci/**'
|
||||
- 'AppImage/scripts/oci_menu_bridge.py'
|
||||
- 'AppImage/scripts/lxc_apps.py'
|
||||
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- '.github/workflows/test-oci-registries.yml'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'oci/**'
|
||||
- 'AppImage/scripts/oci_menu_bridge.py'
|
||||
- 'AppImage/scripts/lxc_apps.py'
|
||||
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- '.github/workflows/test-oci-registries.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
registry-accounts:
|
||||
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.13'
|
||||
- name: Install OCI test dependencies
|
||||
run: python3 -m pip install -r oci/requirements.txt
|
||||
- name: Run watchdog identity and lifecycle regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p test_watchdog.py -v
|
||||
- name: Run OCI image reuse and resource edit regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
|
||||
- name: Run carried record, recovery and configuration cache regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
|
||||
- name: Run read-only volume preparation regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
test -x /bin/bash
|
||||
test -x /usr/bin/jq
|
||||
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
|
||||
- name: Run offline registry account tests
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
|
||||
- name: Run OCI menu update checks and selection regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_update*.py' -v
|
||||
python3 -m unittest discover -s AppImage/scripts/tests -p 'test_oci*.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreate_registry_digest.py -v
|
||||
- name: Run OCI Modify latency regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||
- name: Run OCI modification menu regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
|
||||
- name: Run external OCI settings regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
|
||||
- name: Run managed volume startup regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p 'test_managed_volume_*.py' -v
|
||||
- name: Run catalog web-link regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p test_catalog_web_links.py -v
|
||||
@@ -11,6 +11,13 @@ on:
|
||||
- 'AppImage/package.json'
|
||||
- 'AppImage/package-lock.json'
|
||||
- 'AppImage/scripts/*.py'
|
||||
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
- 'AppImage/hooks/**'
|
||||
@@ -30,6 +37,13 @@ on:
|
||||
- 'AppImage/package.json'
|
||||
- 'AppImage/package-lock.json'
|
||||
- 'AppImage/scripts/*.py'
|
||||
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
- 'AppImage/hooks/**'
|
||||
@@ -54,10 +68,16 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Run eight qualified Python files
|
||||
- name: Install native discovery route fixture dependency
|
||||
run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1'
|
||||
- name: Run qualified Python files
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
|
||||
- name: Run health recurrence tests without TMPDIR
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: env -u TMPDIR python3 -I -B -m unittest discover -s AppImage/scripts/tests -p test_health_persistence_recurrence.py -v
|
||||
|
||||
offline-node:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -76,7 +96,7 @@ jobs:
|
||||
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
|
||||
- name: Run qualified Node files
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: PBS support
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'AppImage/scripts/tests/test_pbs.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/auth_manager.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/components/pbs-*.tsx'
|
||||
- 'AppImage/components/proxmox-dashboard.tsx'
|
||||
- 'AppImage/tests/pbs-navigation.cjs'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
- 'scripts/global/platform-functions.sh'
|
||||
- 'scripts/global/repository-functions.sh'
|
||||
- 'scripts/menus/pbs_menu.sh'
|
||||
- 'scripts/utilities/pbs_update.sh'
|
||||
- 'scripts/global/update-pbs.sh'
|
||||
- 'scripts/menus/menu_post_install.sh'
|
||||
- 'scripts/post_install/*.sh'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pbs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python: ['3.11', '3.13']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Install test dependencies
|
||||
run: python -m pip install Flask PyJWT cryptography pyotp segno
|
||||
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||
run: |
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||
bash -n "$script"
|
||||
done
|
||||
- uses: actions/setup-node@v6
|
||||
if: matrix.python == '3.11'
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Dashboard platform routing
|
||||
if: matrix.python == '3.11'
|
||||
run: |
|
||||
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
|
||||
5e8afe3b297f21b8646e58eed720ba2eeb5f16681a3d2eb89bcac600065167c9 ProxMenux-1.2.7.AppImage
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// These are examples, never defaults: opening help must not replace a saved
|
||||
// destination or put placeholder credentials into the notification settings.
|
||||
export function AppriseServiceHelp() {
|
||||
const t = useT()
|
||||
return <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
|
||||
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
|
||||
<div className="grid gap-3 pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h4 className="font-medium">Slack</h4>
|
||||
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
|
||||
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
|
||||
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||
{t("settings.notifications.ui.appriseSlackDocs")} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h4 className="font-medium">Matrix</h4>
|
||||
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
|
||||
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
|
||||
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||
{t("settings.notifications.ui.appriseMatrixDocs")} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
|
||||
</details>
|
||||
}
|
||||
@@ -84,7 +84,7 @@ function undoKey(change: { revert: string; exactness: string }): string {
|
||||
|
||||
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").
|
||||
// raw name — and never the source ("auto"/"custom").
|
||||
_update_existing_log2ram_auto: "Install and configure Log2RAM",
|
||||
_update_existing_log2ram_custom: "Install and configure Log2RAM",
|
||||
update_snapshot_schedule: "Install ZFS auto-snapshot",
|
||||
@@ -130,9 +130,10 @@ const FN_LABEL: Record<string, string> = {
|
||||
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"])
|
||||
// Post-install functions run from the automatic and the customizable scripts,
|
||||
// which record themselves as "auto" and "custom"; everything else is a general
|
||||
// host script (nvidia/tpu installers, PVE update, vfio…).
|
||||
const POST_INSTALL_SOURCES = new Set(["auto", "custom"])
|
||||
|
||||
// A few sources deserve a friendly name instead of a raw script identifier;
|
||||
// everything else shows the script it came from.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { HardDrive, Loader2, Pencil, RefreshCw } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { buildRetention, parseRetention, RETENTION } from "../lib/backup-retention"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface StorageRetention { id: string; type: string; prune_backups: string; digest: string }
|
||||
const fetcher = (url: string) => fetchApi<{ storages: StorageRetention[] }>(url)
|
||||
|
||||
export function BackupStorageRetention() {
|
||||
const t = useT()
|
||||
const { data, error, mutate, isValidating } = useSWR("/api/vm-backup-storages", fetcher)
|
||||
const [editing, setEditing] = useState<StorageRetention | null>(null)
|
||||
const [fields, setFields] = useState<Record<string, string>>({})
|
||||
const [keepAll, setKeepAll] = useState(false)
|
||||
const [useDefault, setUseDefault] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [saveError, setSaveError] = useState("")
|
||||
|
||||
function edit(storage: StorageRetention) {
|
||||
const policy = parseRetention(storage.prune_backups)
|
||||
setFields(policy.fields)
|
||||
setKeepAll(policy.keepAll)
|
||||
setUseDefault(!storage.prune_backups)
|
||||
setSaveError("")
|
||||
setMessage("")
|
||||
setEditing(storage)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing || busy) return
|
||||
const prune = useDefault ? "" : buildRetention(fields, keepAll)
|
||||
if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy"))
|
||||
setBusy(true)
|
||||
setSaveError("")
|
||||
try {
|
||||
await fetchApi(`/api/vm-backup-storages/${encodeURIComponent(editing.id)}`, {
|
||||
method: "PUT", body: JSON.stringify({ digest: editing.digest, prune_backups: prune }),
|
||||
})
|
||||
setMessage(t("backup.storageRetention.saved"))
|
||||
setEditing(null)
|
||||
await mutate()
|
||||
} catch (error) {
|
||||
setSaveError(`${t("backup.storageRetention.saveFailed")} ${error instanceof Error ? error.message : ""}`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <Card className="border-border bg-card/50">
|
||||
<CardHeader className="pb-3 flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><HardDrive className="h-4 w-4" />{t("backup.storageRetention.title")}</CardTitle>
|
||||
<Button variant="ghost" size="sm" disabled={isValidating} onClick={() => { void mutate().catch(() => {}) }}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isValidating ? "animate-spin" : ""}`} />{t("backup.storageRetention.refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.description")}</p>
|
||||
{message && <p role="status" className="text-xs text-emerald-500">{message}</p>}
|
||||
{error ? <p role="alert" className="text-xs text-destructive">{t("backup.storageRetention.loadFailed")}</p> : !data ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> : !data.storages.length ?
|
||||
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.empty")}</p> :
|
||||
data.storages.map(storage => <div key={storage.id} className="flex flex-wrap gap-2 items-center border rounded-lg p-3">
|
||||
<span className="font-medium text-sm">{storage.id}</span><Badge variant="outline">{storage.type}</Badge>
|
||||
<span className="text-xs font-mono text-muted-foreground break-all">{storage.prune_backups || t("backup.storageRetention.default")}</span>
|
||||
<Button variant="outline" size="sm" className="ml-auto gap-1" onClick={() => edit(storage)} disabled={!storage.digest || busy}>
|
||||
<Pencil className="h-3 w-3" />{t("backup.storageRetention.edit")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
<Dialog open={!!editing} onOpenChange={open => { if (!open && !busy) setEditing(null) }}>
|
||||
<DialogContent className="bg-accent [&_input]:bg-background">
|
||||
<DialogHeader><DialogTitle>{t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })}</DialogTitle>
|
||||
<DialogDescription>{t("backup.storageRetention.effect")}</DialogDescription></DialogHeader>
|
||||
<Label className="flex items-center gap-2"><Checkbox checked={useDefault} disabled={busy} onCheckedChange={checked => {
|
||||
setUseDefault(checked === true)
|
||||
if (checked) { setKeepAll(false); setFields({}) }
|
||||
}} />{t("backup.storageRetention.useDefault")}</Label>
|
||||
<Label className="flex items-center gap-2"><Checkbox checked={keepAll} disabled={useDefault || busy} onCheckedChange={checked => {
|
||||
setKeepAll(checked === true)
|
||||
if (checked) setFields({})
|
||||
}} />{t("backup.storageRetention.keepAll")}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{RETENTION.map(key => <div key={key} className="space-y-1">
|
||||
<Label htmlFor={`storage-retention-${key}`}>{t(`backup.retention.keep${key[0].toUpperCase()}${key.slice(1)}`)}</Label>
|
||||
<Input id={`storage-retention-${key}`} type="number" min={1} max={99999} step={1} disabled={useDefault || keepAll || busy}
|
||||
value={fields[key] ?? ""} onChange={event => setFields(previous => ({ ...previous, [key]: event.target.value }))} />
|
||||
</div>)}
|
||||
</div>
|
||||
{saveError && <p role="alert" className="text-xs text-destructive break-words">{saveError}</p>}
|
||||
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>{t("backup.storageRetention.cancel")}</Button>
|
||||
<Button disabled={busy} onClick={save}>{busy && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}{t("backup.storageRetention.save")}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import { useState } from "react"
|
||||
import { DatabaseBackup } from "lucide-react"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||
import { PBSPanel } from "./pbs-panel"
|
||||
import { BackupStorageRetention } from "./backup-storage-retention"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type BackupView = "host" | "guests"
|
||||
type BackupView = "host" | "guests" | "pbs"
|
||||
|
||||
export function BackupTab() {
|
||||
const t = useT()
|
||||
@@ -29,7 +31,7 @@ export function BackupTab() {
|
||||
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) => (
|
||||
{(["host", "guests", "pbs"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
@@ -41,7 +43,7 @@ export function BackupTab() {
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`backup.viewSwitch.${key}`)}
|
||||
{key === "pbs" ? t("pbs.title") : t(`backup.viewSwitch.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -51,7 +53,8 @@ export function BackupTab() {
|
||||
<div className={view === "host" ? "" : "hidden"}>
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
|
||||
{view === "pbs" && <PBSPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Archive, Clock, Loader2, LockKeyhole, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives"
|
||||
|
||||
export function GuestBackupList({ vmid, backups, loading, onRefresh, storageColor }: {
|
||||
vmid: number
|
||||
backups: GuestBackup[]
|
||||
loading: boolean
|
||||
onRefresh: () => Promise<void>
|
||||
storageColor?: (storage: string) => { bg: string; text: string; border: string }
|
||||
}) {
|
||||
const t = useT()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [confirm, setConfirm] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => { mounted.current = false }
|
||||
}, [])
|
||||
// A fresh list can remove a selection or reveal a newly protected archive.
|
||||
useEffect(() => {
|
||||
setSelected(previous => previous.filter(volid => backups.some(backup => backup.volid === volid && !backup.protected)))
|
||||
}, [backups])
|
||||
|
||||
const chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected)
|
||||
const selectable = backups.filter(backup => !backup.protected)
|
||||
const disabled = loading || busy || refreshing
|
||||
const storageClass = (storage: string) => {
|
||||
const color = storageColor?.(storage)
|
||||
return color ? `${color.bg} ${color.text} ${color.border}` : ""
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
setRefreshing(true)
|
||||
setError("")
|
||||
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
|
||||
finally { if (mounted.current) setRefreshing(false) }
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!chosen.length || busy) return
|
||||
setBusy(true)
|
||||
setError("")
|
||||
setMessage("")
|
||||
try {
|
||||
const results = await deleteGuestBackups(vmid, chosen)
|
||||
if (!mounted.current) return
|
||||
const deleted = results.filter(result => result.status === "deleted").length
|
||||
const unconfirmed = results.length - deleted
|
||||
setMessage(t("vmLxc.backups.deletedCount", { count: deleted }))
|
||||
if (unconfirmed) setError(t("vmLxc.backups.unconfirmedCount", { count: unconfirmed }))
|
||||
setSelected([])
|
||||
} catch (error) {
|
||||
if (mounted.current) setError(`${t("vmLxc.backups.deleteFailed")} ${error instanceof Error ? error.message : ""}`)
|
||||
} finally {
|
||||
// A timeout can hide a successful native task. Always bypass both caches.
|
||||
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
|
||||
if (mounted.current) { setBusy(false); setConfirm(false) }
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className="flex items-center justify-between gap-2 mb-3 shrink-0 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">{backups.length}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" disabled={disabled} onClick={refresh}>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{t("vmLxc.backups.refresh")}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" disabled={disabled || !chosen.length} onClick={() => setConfirm(true)}>
|
||||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||
{t("vmLxc.backups.deleteSelected", { count: chosen.length })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{message && <p role="status" className="text-xs text-muted-foreground mb-2">{message}</p>}
|
||||
{error && <p role="alert" className="text-xs text-destructive mb-2 break-words">{error}</p>}
|
||||
{loading ? <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.backups.loading")}</span>
|
||||
</div> : backups.length === 0 ? <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
|
||||
<Archive className="h-12 w-12 mb-3 opacity-30" /><span className="text-sm">{t("vmLxc.backups.empty")}</span>
|
||||
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
|
||||
</div> : <>
|
||||
<label className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
|
||||
<Checkbox checked={selectable.length > 0 && chosen.length === Math.min(20, selectable.length)} disabled={disabled || !selectable.length}
|
||||
onCheckedChange={checked => setSelected(checked ? selectable.slice(0, 20).map(backup => backup.volid) : [])} />
|
||||
{t("vmLxc.backups.selectHint")}
|
||||
</label>
|
||||
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
|
||||
{backups.map(backup => <label key={backup.volid} className="flex items-center gap-2 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<Checkbox aria-label={`${t("vmLxc.backups.selectBackup")} ${backup.date} ${backup.storage}`} checked={selected.includes(backup.volid)}
|
||||
disabled={disabled || !!backup.protected || (chosen.length >= 20 && !selected.includes(backup.volid))}
|
||||
onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} />
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm min-w-0 truncate" title={backup.volid}>{backup.date}</span>
|
||||
{backup.protected && <Badge variant="secondary" className="gap-1 text-xs"><LockKeyhole className="h-3 w-3" />{t("vmLxc.backups.protected")}</Badge>}
|
||||
<Badge variant="outline" className={`text-xs ml-auto shrink-0 ${storageClass(backup.storage)}`}>{backup.storage}</Badge>
|
||||
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
|
||||
</label>)}
|
||||
</div>
|
||||
</>}
|
||||
<Dialog open={confirm} onOpenChange={value => { if (!busy) setConfirm(value) }}>
|
||||
<DialogContent onEscapeKeyDown={event => { if (busy) event.preventDefault() }} onPointerDownOutside={event => { if (busy) event.preventDefault() }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vmLxc.backups.confirmTitle", { count: chosen.length })}</DialogTitle>
|
||||
<DialogDescription>{t("vmLxc.backups.confirmDescription", { vmid })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="max-h-48 overflow-auto text-xs space-y-2 break-all">
|
||||
{chosen.map(backup => <li key={backup.volid}>{backup.volid}</li>)}
|
||||
</ul>
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">{t("vmLxc.backups.deleting")}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setConfirm(false)}>{t("vmLxc.backups.cancel")}</Button>
|
||||
<Button variant="destructive" disabled={busy || !chosen.length} onClick={remove}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}{t("vmLxc.backups.confirmDelete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { useIsPbs } from "@/lib/use-platform"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
@@ -119,6 +120,7 @@ const CATEGORIES = [
|
||||
|
||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
@@ -460,6 +462,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
return originalStatus?.toUpperCase() || "UNKNOWN"
|
||||
}
|
||||
|
||||
// Checks the host reports as not applicable (guests on PBS) are not listed or counted.
|
||||
const visibleCategories = CATEGORIES.filter(({ key }) =>
|
||||
!(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable)
|
||||
const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`)
|
||||
|
||||
const getHealthStats = () => {
|
||||
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
|
||||
|
||||
@@ -469,7 +476,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
let critical = 0
|
||||
let unknown = 0
|
||||
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
visibleCategories.forEach(({ key }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
if (categoryData) {
|
||||
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
|
||||
@@ -481,7 +488,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
})
|
||||
|
||||
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
|
||||
return { total: visibleCategories.length, healthy, info, warnings, critical, unknown }
|
||||
}
|
||||
|
||||
const stats = getHealthStats()
|
||||
@@ -759,7 +766,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{/* Category List */}
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map(({ key, Icon }) => {
|
||||
{visibleCategories.map(({ key, Icon }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
const originalStatus = categoryData?.status || "UNKNOWN"
|
||||
const status = getEffectiveStatus(key, originalStatus)
|
||||
@@ -784,7 +791,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{categoryLabel(key)}</p>
|
||||
{hasChecks && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||
@@ -871,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{filteredDismissed.map((item) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
const CatIcon = catMeta?.Icon || BellOff
|
||||
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
|
||||
const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category
|
||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||
|
||||
return (
|
||||
@@ -936,7 +943,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? categoryLabel(catMeta.key) : cs.label}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||
{durationLabel}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Input } from "./ui/input"
|
||||
import {
|
||||
@@ -283,8 +284,12 @@ function computeVisualRange(
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Guests and PVE storages do not exist on a Proxmox Backup Server.
|
||||
const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "lxc_mount", "pve_storage"])
|
||||
|
||||
export function HealthThresholds() {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const tFallback = (key: string, fallback: string) => {
|
||||
const translated = t(key)
|
||||
return translated === key ? fallback : translated
|
||||
@@ -773,7 +778,7 @@ export function HealthThresholds() {
|
||||
Mobile (<md) stays single-column as today.
|
||||
*/}
|
||||
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
|
||||
{SECTIONS.map((section) => {
|
||||
{SECTIONS.filter((section) => !isPbs || !PVE_ONLY_SECTIONS.has(section.id)).map((section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { RestoreProgressCard } from "./restore-progress-card"
|
||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||
import { hostBackupErrorText } from "../lib/host-backup-messages"
|
||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
||||
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
||||
@@ -100,11 +101,15 @@ interface RemoteSnapshot {
|
||||
encrypted?: boolean
|
||||
borg_id?: string // Borg-only fields when available
|
||||
borg_start_iso?: string
|
||||
// The node the backup belongs to, read from its name; null when the name
|
||||
// does not say (another node's backup under a name of its own choosing).
|
||||
node?: string | null
|
||||
}
|
||||
|
||||
interface RemoteArchivesResp {
|
||||
snapshots: RemoteSnapshot[]
|
||||
errors: Array<{ backend: string; repo_name: string; error: string }>
|
||||
hostname?: string
|
||||
}
|
||||
|
||||
interface ExportTask {
|
||||
@@ -132,6 +137,7 @@ interface UnifiedArchive {
|
||||
// One of the two is populated depending on `source`.
|
||||
local?: BackupArchive
|
||||
remote?: RemoteSnapshot // for both pbs and borg
|
||||
node: string | null
|
||||
}
|
||||
|
||||
interface BackupArchive {
|
||||
@@ -146,6 +152,8 @@ interface BackupArchive {
|
||||
kind: "manual" | "scheduled" | "legacy"
|
||||
profile: string | null
|
||||
source_hostname: string | null
|
||||
node?: string | null // the node the backup belongs to
|
||||
former_job?: boolean // made by a job of this node that no longer exists
|
||||
// Which detection path identified this archive. Surfaced as a small tooltip
|
||||
// hint so the operator knows whether the metadata is authoritative
|
||||
// (sidecar) or inferred (filename / tar-peek).
|
||||
@@ -518,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
||||
await mutateInfo()
|
||||
closeUpload()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -535,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
||||
await mutateInfo()
|
||||
closeDelete()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -758,7 +766,7 @@ function KeyfileActionsBar({
|
||||
setPass1("")
|
||||
setPass2("")
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -949,7 +957,7 @@ export function HostBackup() {
|
||||
mutateJobs()
|
||||
setJobToDelete(null)
|
||||
} catch (e) {
|
||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` }))
|
||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` }))
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
@@ -964,11 +972,14 @@ export function HostBackup() {
|
||||
fetcher,
|
||||
{ refreshInterval: 60000 },
|
||||
)
|
||||
const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[] }>(
|
||||
const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[]; hostname?: string }>(
|
||||
"/api/host-backups/archives",
|
||||
fetcher,
|
||||
{ refreshInterval: 30000 },
|
||||
)
|
||||
// On a cluster every PBS holds the backups of all nodes: the list shows
|
||||
// this node's by default, and all of them on request.
|
||||
const [archiveScope, setArchiveScope] = useState<"node" | "all">("node")
|
||||
|
||||
// Background poll for the pending manual backup, independent of the
|
||||
// watch modal being open. Fires mutateArchives/mutateRemoteArchives
|
||||
@@ -1005,6 +1016,7 @@ export function HostBackup() {
|
||||
created_at: a.mtime,
|
||||
source_label: a.path.replace(/\/[^/]+$/, "") || "/",
|
||||
local: a,
|
||||
node: a.node ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1017,12 +1029,21 @@ export function HostBackup() {
|
||||
created_at: s.backup_time,
|
||||
source_label: `${s.backend.toUpperCase()} ${s.repo_name}`,
|
||||
remote: s,
|
||||
node: s.node ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.created_at - a.created_at)
|
||||
return out
|
||||
})()
|
||||
const thisNode = archivesResp?.hostname || remoteArchivesResp?.hostname || null
|
||||
// The choice is offered only when some backup belongs to another node or
|
||||
// its node is not known; a single node sees its list as before.
|
||||
const otherNodeArchives = thisNode ? unifiedArchives.filter((u) => u.node !== thisNode).length : 0
|
||||
const visibleArchives = archiveScope === "all" || otherNodeArchives === 0
|
||||
? unifiedArchives
|
||||
: unifiedArchives.filter((u) => u.node === thisNode)
|
||||
const hiddenArchives = unifiedArchives.length - visibleArchives.length
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
@@ -1187,7 +1208,7 @@ export function HostBackup() {
|
||||
{!j.attached && j.next_run && (
|
||||
<span className="inline-flex items-center gap-1" title={t("backup.jobs.nextRunTitle")}>
|
||||
<Clock className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>{t("backup.jobs.nextRun", { time: formatNext(j.next_run) })}</span>
|
||||
<span>{t("backup.jobs.nextRun")}: {formatNext(j.next_run)}</span>
|
||||
</span>
|
||||
)}
|
||||
{(statusBadge || lastRunWhen) && (
|
||||
@@ -1278,7 +1299,32 @@ export function HostBackup() {
|
||||
<Archive className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle className="text-base font-semibold">{t("backup.archives.title")}</CardTitle>
|
||||
</div>
|
||||
<Badge variant="outline">{unifiedArchives.length}</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
{otherNodeArchives > 0 && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("backup.archives.scopeAriaLabel")}
|
||||
className="inline-flex rounded-lg border border-border bg-muted/40 p-1 gap-1"
|
||||
>
|
||||
{(["node", "all"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={archiveScope === key}
|
||||
onClick={() => setArchiveScope(key)}
|
||||
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
archiveScope === key
|
||||
? "bg-blue-500 text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{key === "node" ? t("backup.archives.scopeThisNode") : t("backup.archives.scopeAllNodes")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Badge variant="outline">{visibleArchives.length}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[11px] text-muted-foreground mb-3">
|
||||
@@ -1289,7 +1335,7 @@ export function HostBackup() {
|
||||
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
|
||||
{remoteArchivesResp.errors.map((e, i) => (
|
||||
<div key={i} className="font-mono break-all">
|
||||
{e.backend}/{e.repo_name}: {e.error}
|
||||
{e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1301,13 +1347,21 @@ export function HostBackup() {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("backup.common.loading")}
|
||||
</div>
|
||||
) : unifiedArchives.length === 0 ? (
|
||||
) : visibleArchives.length === 0 && hiddenArchives === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4">
|
||||
{t("backup.archives.emptyMessage")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{unifiedArchives.map((u) => {
|
||||
{hiddenArchives > 0 && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("backup.archives.hiddenOtherNodes", { count: hiddenArchives })}{" "}
|
||||
<button type="button" className="text-blue-400 hover:underline" onClick={() => setArchiveScope("all")}>
|
||||
{t("backup.archives.scopeShowAll")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{visibleArchives.map((u) => {
|
||||
const localKind = u.local?.kind
|
||||
const localJobId = u.local?.job_id
|
||||
const localHost = u.local?.source_hostname
|
||||
@@ -1366,6 +1420,14 @@ export function HostBackup() {
|
||||
{t("backup.status.manual")}
|
||||
</span>
|
||||
) : null}
|
||||
{u.source === "local" && u.local?.former_job && (
|
||||
<span
|
||||
className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
||||
title={t("backup.archives.formerJobTitle")}
|
||||
>
|
||||
{t("backup.archives.formerJob")}
|
||||
</span>
|
||||
)}
|
||||
{(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && (
|
||||
<span>{u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: <code className="font-mono">{u.remote.backup_id}</code></span>
|
||||
)}
|
||||
@@ -1700,7 +1762,7 @@ function InspectModal({
|
||||
setImportFile(null)
|
||||
setImportPath("")
|
||||
} catch (e) {
|
||||
setImportError(e instanceof Error ? e.message : String(e))
|
||||
setImportError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
@@ -1751,7 +1813,7 @@ function InspectModal({
|
||||
: undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
setRestoreError(e instanceof Error ? e.message : String(e))
|
||||
setRestoreError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setRestorePreparing(false)
|
||||
}
|
||||
@@ -1780,7 +1842,7 @@ function InspectModal({
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
// The download itself runs in the browser's network stack;
|
||||
// we just initiated it. Clear the spinner immediately.
|
||||
@@ -1853,7 +1915,7 @@ function InspectModal({
|
||||
document.body.removeChild(a)
|
||||
setExportTask(null)
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
@@ -1901,7 +1963,7 @@ function InspectModal({
|
||||
setShowDeleteArchiveConfirm(false)
|
||||
if (onDeleted) onDeleted(); else onClose()
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setDeletingArchive(false)
|
||||
}
|
||||
@@ -1928,7 +1990,7 @@ function InspectModal({
|
||||
) as NonNullable<typeof instructions>
|
||||
setInstructions(res)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setFetchingInstructions(false)
|
||||
}
|
||||
@@ -1960,7 +2022,7 @@ function InspectModal({
|
||||
)
|
||||
setReport(res)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("backup.errors.preflightFailed"))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
@@ -2035,7 +2097,19 @@ function InspectModal({
|
||||
<div><span className="text-muted-foreground">{t("backup.fields.createdLabel")}</span> {formatMtime(localArc.mtime)}</div>
|
||||
<div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(localArc.size_bytes)}</div>
|
||||
<div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.pathLabel")}</span> <code className="font-mono break-all">{localArc.path}</code></div>
|
||||
{localArc.job_id && <div><span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code></div>}
|
||||
{localArc.job_id && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code>
|
||||
{localArc.former_job && (
|
||||
<span
|
||||
className="ml-2 uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
||||
title={t("backup.archives.formerJobTitle")}
|
||||
>
|
||||
{t("backup.archives.formerJob")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{localArc.profile && <div><span className="text-muted-foreground">{t("backup.fields.profileLabel")}</span> <code className="font-mono">{localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}</code></div>}
|
||||
{localArc.source_hostname && <div><span className="text-muted-foreground">{t("backup.fields.sourceHostLabel")}</span> <code className="font-mono">{localArc.source_hostname}</code></div>}
|
||||
<div><span className="text-muted-foreground">{t("backup.fields.detectedViaLabel")}</span> <code className="font-mono text-[10px]">{localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}</code></div>
|
||||
@@ -2091,7 +2165,7 @@ function InspectModal({
|
||||
<span className="text-muted-foreground">— {exportTask.message}</span>
|
||||
</div>
|
||||
{exportTask.state === "failed" && exportTask.error && (
|
||||
<div className="text-red-500 mt-1">{exportTask.error}</div>
|
||||
<div className="text-red-500 mt-1">{hostBackupErrorText(exportTask.error, t)}</div>
|
||||
)}
|
||||
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
|
||||
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
|
||||
@@ -2633,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
|
||||
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`font-mono ${color}`}>{c.id}</span>
|
||||
<span className="text-muted-foreground ml-2">{c.message}</span>
|
||||
<span className="text-muted-foreground ml-2">{hostBackupErrorText(c.message, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -3253,9 +3327,9 @@ function CreateJobDialog({
|
||||
} catch (e) {
|
||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||
const detail = err.body?.tool_output
|
||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: err.message
|
||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
||||
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: hostBackupErrorText(err, t)
|
||||
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
@@ -3318,7 +3392,7 @@ function CreateJobDialog({
|
||||
})
|
||||
onCreated()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -3691,7 +3765,7 @@ function CreateJobDialog({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-red-500">
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {hostBackupErrorText(calendarPreview.error, t)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -4623,9 +4697,9 @@ function ManualBackupDialog({
|
||||
} catch (e) {
|
||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||
const detail = err.body?.tool_output
|
||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: err.message
|
||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
||||
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: hostBackupErrorText(err, t)
|
||||
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
@@ -4665,7 +4739,7 @@ function ManualBackupDialog({
|
||||
)
|
||||
onLaunched(resp.job_id)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -5469,7 +5543,7 @@ function DestinationsSection({
|
||||
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5481,7 +5555,7 @@ function DestinationsSection({
|
||||
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5498,7 +5572,7 @@ function DestinationsSection({
|
||||
)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5526,7 +5600,7 @@ function DestinationsSection({
|
||||
})
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5969,7 +6043,7 @@ function DestinationRow({
|
||||
|
||||
{capacity?.error && (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground italic">
|
||||
{t("backup.destinations.capacityUnavailable", { error: capacity.error })}
|
||||
{t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
|
||||
</p>
|
||||
)}
|
||||
{!capacity && (
|
||||
@@ -6268,7 +6342,7 @@ function AddDestinationDialog({
|
||||
)
|
||||
setGeneratedKey(resp)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setGeneratingKey(false)
|
||||
}
|
||||
@@ -6332,7 +6406,7 @@ function AddDestinationDialog({
|
||||
}
|
||||
onSaved(savedRepo)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -6683,7 +6757,7 @@ function UsbPicker({
|
||||
mutate()
|
||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -6711,7 +6785,7 @@ function UsbPicker({
|
||||
setFormatTyped("")
|
||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -6928,7 +7002,7 @@ function ExtraPathsSection() {
|
||||
setNewPath("")
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -6941,7 +7015,7 @@ function ExtraPathsSection() {
|
||||
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyPath(null)
|
||||
}
|
||||
@@ -7107,7 +7181,7 @@ function UsbDrivesSection() {
|
||||
})
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7125,7 +7199,7 @@ function UsbDrivesSection() {
|
||||
})
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7149,7 +7223,7 @@ function UsbDrivesSection() {
|
||||
setFormatTarget(null)
|
||||
setFormatTyped("")
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7435,7 +7509,7 @@ function JobDetailModal({
|
||||
setRunning(false)
|
||||
setRunBaseline(null)
|
||||
setRunBaselineLogPath(null)
|
||||
setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7449,7 +7523,7 @@ function JobDetailModal({
|
||||
await refetch()
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setBusy("")
|
||||
}
|
||||
@@ -8082,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({
|
||||
})
|
||||
onRecovered()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -8269,7 +8343,7 @@ function ArchiveContentsModal({
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((r: any) => setData(r))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.catch((e) => setError(hostBackupErrorText(e, t)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, source, repo_name, snapshot, path])
|
||||
|
||||
@@ -8498,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{blockers.map((b, i) => (
|
||||
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
|
||||
<li key={i} className="text-red-400">{hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8508,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
|
||||
<li key={i} className="text-amber-400">{hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8538,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
|
||||
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{hostBackupErrorText(e.message, t)}</div>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* Secure Gateway.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
Loader2, Save, RefreshCw, Trash2, Package, ExternalLink,
|
||||
AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff,
|
||||
@@ -32,11 +32,12 @@ import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache"
|
||||
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
||||
import { getCategoryLabel } from "../lib/category-label"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { localizeAppEditorError } from "../lib/lxc-app-editor-errors"
|
||||
|
||||
// installed_via is optional now — an empty value means "register only,
|
||||
// no version tracking, no warnings, just a clickable link". Docker
|
||||
@@ -93,6 +94,7 @@ interface AppConfig {
|
||||
health_path?: string
|
||||
logo_url?: string
|
||||
helper_slug?: string
|
||||
discovery_id?: 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
|
||||
@@ -121,7 +123,19 @@ interface PortDetail {
|
||||
logo_url: string
|
||||
}
|
||||
|
||||
interface NativeDiscovery {
|
||||
vmid: number
|
||||
status: "not_scanned" | "complete" | "partial" | "error" | "busy"
|
||||
issues?: string[]
|
||||
stale: boolean
|
||||
candidates: DetectedApp[]
|
||||
}
|
||||
|
||||
interface DetectedApp {
|
||||
discovery_id?: string
|
||||
source?: "native_metadata" | "docker_metadata"
|
||||
installed_version?: string | null
|
||||
evidence?: Array<{ kind: string; value: string }>
|
||||
slug: string
|
||||
name: string
|
||||
logo_url?: string | null
|
||||
@@ -343,6 +357,7 @@ interface ManagedAppInfo {
|
||||
|
||||
interface Props {
|
||||
vmid: number
|
||||
cacheRevision?: number
|
||||
ctIp?: string | null
|
||||
onChange?: () => void
|
||||
managed?: ManagedAppInfo | null
|
||||
@@ -398,7 +413,40 @@ function selfhstThemeLogos(src: string) {
|
||||
return match ? SELFHST_THEME_LOGOS[match[1]] : null
|
||||
}
|
||||
|
||||
const LOCAL_LOGO_CACHE_PATH = /^\/api\/vms\/[1-9]\d*\/apps\/logo-cache\/[a-f0-9]{32}$/
|
||||
|
||||
function CachedLocalLogo({ src, className }: { src: string; className: string }) {
|
||||
const [image, setImage] = useState<{ src: string; url: string } | null>(null)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let active = true
|
||||
let url: string | null = null
|
||||
const token = getAuthToken()
|
||||
fetch(getApiUrl(src), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: controller.signal, cache: "no-store",
|
||||
}).then(async response => {
|
||||
if (!response.ok) throw new Error("cached icon unavailable")
|
||||
const blob = await response.blob()
|
||||
const mime = blob.type.split(";", 1)[0].trim().toLowerCase()
|
||||
if (!["image/png", "image/svg+xml"].includes(mime) || blob.size > 262144) {
|
||||
throw new Error("invalid cached icon")
|
||||
}
|
||||
if (!active) return
|
||||
url = URL.createObjectURL(blob)
|
||||
setImage({ src, url })
|
||||
}).catch(() => { if (active) setImage(null) })
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
}
|
||||
}, [src])
|
||||
return image?.src === src ? <img src={image.url} alt="" className={className} /> : null
|
||||
}
|
||||
|
||||
export function ThemeAwareLogo({ src, className }: { src: string; className: string }) {
|
||||
if (LOCAL_LOGO_CACHE_PATH.test(src)) return <CachedLocalLogo src={src} className={className} />
|
||||
const themed = selfhstThemeLogos(src)
|
||||
const hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
@@ -451,7 +499,7 @@ function parseArgvInput(value: string): string[] {
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
|
||||
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, cacheRevision = 0 }: Props) {
|
||||
const t = useT()
|
||||
const isLightTheme = useIsLightTheme()
|
||||
// Seed from `initialData` first, then fall back to the shared cache
|
||||
@@ -462,12 +510,77 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
const [loading, setLoading] = useState(!seed)
|
||||
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
|
||||
const nativeRequest = useRef(0)
|
||||
const [dockerDiscovery, setDockerDiscovery] = useState<NativeDiscovery | null>(null)
|
||||
const dockerRequest = useRef(0)
|
||||
const targetRef = useRef(vmid)
|
||||
targetRef.current = vmid
|
||||
const targetGeneration = useRef(0)
|
||||
const previousTarget = useRef(vmid)
|
||||
if (previousTarget.current !== vmid) {
|
||||
previousTarget.current = vmid
|
||||
targetGeneration.current += 1
|
||||
}
|
||||
useEffect(() => {
|
||||
setNativeDiscovery(null)
|
||||
setDockerDiscovery(null)
|
||||
setEditing(null)
|
||||
setDetectionNotice(null)
|
||||
setSearchingApplications(false)
|
||||
setSaving(false)
|
||||
return () => { targetGeneration.current += 1 }
|
||||
}, [vmid, managed])
|
||||
useEffect(() => {
|
||||
const generation = targetGeneration.current
|
||||
const request = nativeRequest.current
|
||||
let cancelled = false
|
||||
if (managed) return
|
||||
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
|
||||
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& nativeRequest.current === request) setNativeDiscovery(result)
|
||||
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
||||
return () => { cancelled = true }
|
||||
}, [vmid, managed, cacheRevision])
|
||||
useEffect(() => {
|
||||
const generation = targetGeneration.current
|
||||
const request = dockerRequest.current
|
||||
let cancelled = false
|
||||
if (managed) return
|
||||
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`).then(result => {
|
||||
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& dockerRequest.current === request) setDockerDiscovery(result)
|
||||
}).catch(() => { /* optional cached result; never initiate a scan */ })
|
||||
return () => { cancelled = true }
|
||||
}, [vmid, managed])
|
||||
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
||||
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
||||
// Editor state
|
||||
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
|
||||
const [editing, setEditingState] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null)
|
||||
const editingRef = useRef(editing)
|
||||
editingRef.current = editing
|
||||
const [iconEndpoint, setIconEndpoint] = useState("")
|
||||
const [discoveringLocalLogo, setDiscoveringLocalLogo] = useState(false)
|
||||
const iconRequest = useRef(0)
|
||||
const iconAbort = useRef<AbortController | null>(null)
|
||||
const setEditing: typeof setEditingState = value => {
|
||||
// Invalidate synchronously, including before React commits a batched edit.
|
||||
iconRequest.current += 1
|
||||
iconAbort.current?.abort()
|
||||
setDiscoveringLocalLogo(false)
|
||||
setEditingState(value)
|
||||
}
|
||||
useEffect(() => {
|
||||
iconRequest.current += 1
|
||||
iconAbort.current?.abort()
|
||||
setDiscoveringLocalLogo(false)
|
||||
return () => {
|
||||
iconRequest.current += 1
|
||||
iconAbort.current?.abort()
|
||||
}
|
||||
}, [editing, vmid, iconEndpoint])
|
||||
const [binaryArgsInput, setBinaryArgsInput] = useState("")
|
||||
const [commandArgvInput, setCommandArgvInput] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -522,6 +635,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
// `initialData` or the shared cache, the sidecar is already
|
||||
// populated and the user shouldn't see a flash of "Loading…"
|
||||
// while we revalidate.
|
||||
const generation = targetGeneration.current
|
||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||
if (!sidecar) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -533,17 +648,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
// the "Loading applications…" flash that used to show while a
|
||||
// second, racing fetch caught up.
|
||||
const bundle = await fetchLxcApps(vmid)
|
||||
if (bundle) {
|
||||
if (bundle && isCurrent()) {
|
||||
setSidecar(bundle.sidecar)
|
||||
setSuggestions(bundle.suggestions)
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("vmLxc.appEditor.loadFailed"))
|
||||
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (isCurrent()) setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vmid, managed])
|
||||
}, [vmid, managed, cacheRevision])
|
||||
|
||||
useEffect(() => {
|
||||
if (managed) return
|
||||
@@ -614,10 +729,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
if (lower.startsWith("registry unreachable")) {
|
||||
return t("vmLxc.appEditor.upstreamErrorRegistry")
|
||||
}
|
||||
return msg
|
||||
return localizeAppEditorError(msg, t)
|
||||
}
|
||||
|
||||
const editorOpen = !!editing
|
||||
const editorOpen = !!editing && editing.targetVmid === vmid
|
||||
|
||||
// The picker catalog is only needed after the user opens the editor.
|
||||
useEffect(() => {
|
||||
@@ -653,8 +768,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
// (`extras[]` from the backend). Both use the same DetectedApp
|
||||
// shape so the empty state renders them uniformly.
|
||||
const detectedList: DetectedApp[] = useMemo(() => {
|
||||
if (!suggestions) return []
|
||||
const out: DetectedApp[] = []
|
||||
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : []),
|
||||
...(dockerDiscovery?.vmid === vmid ? dockerDiscovery.candidates || [] : [])]
|
||||
if (!suggestions) return out
|
||||
// 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
|
||||
@@ -679,12 +795,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [suggestions])
|
||||
}, [suggestions, nativeDiscovery, dockerDiscovery, vmid])
|
||||
|
||||
// Registered slugs — used to filter the detection list down to
|
||||
// what the user hasn't already registered on this CT.
|
||||
const registeredSlugs = useMemo(
|
||||
() => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]),
|
||||
() => new Set(apps.flatMap((a) => [a.helper_slug, a.discovery_id]).filter(Boolean) as string[]),
|
||||
[apps],
|
||||
)
|
||||
// Dismissed slugs — persisted in the sidecar. Chips the user
|
||||
@@ -750,14 +866,44 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
|
||||
const searchInstalledApplications = async () => {
|
||||
const generation = targetGeneration.current
|
||||
const request = ++nativeRequest.current
|
||||
++dockerRequest.current
|
||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& nativeRequest.current === request
|
||||
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
||||
setSearchingApplications(true)
|
||||
setDetectionNotice(null)
|
||||
setError(null)
|
||||
try {
|
||||
let native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`, {
|
||||
method: "POST",
|
||||
})
|
||||
if (!isCurrent()) return
|
||||
setNativeDiscovery(native)
|
||||
if (native.status === "error" || native.status === "busy") {
|
||||
const stopped = native.status === "error" && Array.isArray(native.issues)
|
||||
&& native.issues.includes("guest_stopped")
|
||||
setDetectionNotice({ found: false, text: t(stopped
|
||||
? "vmLxc.appEditor.nativeScanStopped" : "vmLxc.appEditor.nativeScanFailed") })
|
||||
return
|
||||
}
|
||||
let docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`, {
|
||||
method: "POST",
|
||||
})
|
||||
if (!isCurrent()) return
|
||||
setDockerDiscovery(docker)
|
||||
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||
method: "POST",
|
||||
})
|
||||
if (!isCurrent()) return
|
||||
// Recompose against the refreshed catalog without another native scan.
|
||||
native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
|
||||
if (!isCurrent()) return
|
||||
setNativeDiscovery(native)
|
||||
docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`)
|
||||
if (!isCurrent()) return
|
||||
setDockerDiscovery(docker)
|
||||
setSuggestions(result)
|
||||
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
|
||||
|
||||
@@ -765,11 +911,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
if (result.helper_slug) detected.add(result.helper_slug)
|
||||
for (const item of result.extras || []) detected.add(item.slug)
|
||||
for (const item of result.docker_workloads || []) detected.add(item.slug)
|
||||
for (const item of native.candidates) detected.add(item.slug)
|
||||
for (const item of docker.candidates || []) detected.add(item.slug)
|
||||
const visible = [...detected].filter(
|
||||
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
||||
)
|
||||
const newCount = visible.filter((slug) => !before.has(slug)).length
|
||||
if (newCount === 1) {
|
||||
if (docker.status === "error" || docker.status === "busy") {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanFailed") })
|
||||
} else if (docker.status === "partial" || docker.stale) {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanPartial") })
|
||||
} else if (native.status === "partial") {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") })
|
||||
} else if (newCount === 1) {
|
||||
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
||||
} else if (newCount > 1) {
|
||||
setDetectionNotice({
|
||||
@@ -785,9 +939,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
|
||||
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
|
||||
} finally {
|
||||
setSearchingApplications(false)
|
||||
if (isCurrent()) setSearchingApplications(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +958,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
existing?: AppEntry,
|
||||
opts?: { withTracking?: boolean, preset?: DetectedApp },
|
||||
) => {
|
||||
const generation = targetGeneration.current
|
||||
let seed: AppConfig
|
||||
if (existing) {
|
||||
seed = {
|
||||
@@ -832,6 +987,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
health_path: existing.health_path || "",
|
||||
logo_url: existing.logo_url || "",
|
||||
helper_slug: existing.helper_slug || "",
|
||||
...(existing.discovery_id ? { discovery_id: existing.discovery_id } : {}),
|
||||
// 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.
|
||||
@@ -862,7 +1018,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
const p = opts.preset
|
||||
seed.name = p.name
|
||||
seed.logo_url = p.logo_url || ""
|
||||
seed.helper_slug = p.slug
|
||||
if (p.discovery_id) {
|
||||
seed.discovery_id = p.discovery_id
|
||||
seed.update_method = "none"
|
||||
seed.update_command = ""
|
||||
} else seed.helper_slug = p.slug
|
||||
// Docker endpoints come from the real published host-port mappings
|
||||
// listed under Web links. Do not pre-save a catalog default such as
|
||||
// 9000; the user explicitly chooses which workload links to add.
|
||||
@@ -943,9 +1103,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
setShowAdvanced(false)
|
||||
}
|
||||
}
|
||||
if (targetRef.current !== vmid || targetGeneration.current !== generation) return
|
||||
setBinaryArgsInput((seed.binary_args || []).join(", "))
|
||||
setCommandArgvInput((seed.command_argv || []).join(", "))
|
||||
setEditing({ appId: existing?.id || null, draft: seed })
|
||||
setEditing({ appId: existing?.id || null, draft: seed, targetVmid: vmid })
|
||||
setDetectorTest(null)
|
||||
setError(null)
|
||||
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
|
||||
@@ -959,7 +1120,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) return
|
||||
if (!editing || editing.targetVmid !== vmid || targetRef.current !== vmid) return
|
||||
const generation = targetGeneration.current
|
||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -973,6 +1136,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
body: JSON.stringify(editing.draft),
|
||||
})
|
||||
if ((r as any).error) throw new Error((r as any).error)
|
||||
if (!isCurrent()) return
|
||||
setSidecar(r)
|
||||
setLxcAppsCached(vmid, r, suggestions)
|
||||
setEditing(null)
|
||||
@@ -980,9 +1144,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
setCommandArgvInput("")
|
||||
onChange?.()
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
||||
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
if (isCurrent()) setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,7 +1213,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
|
||||
const removeOne = async (appId: string) => {
|
||||
if (!confirm("Remove this application from the CT's App tab?")) return
|
||||
if (!confirm(t("vmLxc.appEditor.confirmRemove"))) return
|
||||
setBusyAppId(appId)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -1283,7 +1447,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
|
||||
// ── Editor ─────────────────────────────────────────────────────
|
||||
if (editing) {
|
||||
if (editorOpen && editing) {
|
||||
const draft = editing.draft
|
||||
const method = draft.installed_via || ""
|
||||
const isPackaged = method === "dpkg" || method === "apk"
|
||||
@@ -1291,6 +1455,40 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
setDetectorTest(null)
|
||||
setEditing({ ...editing, draft: { ...draft, ...patch } })
|
||||
}
|
||||
const iconPorts = draft.ports.filter(entry => typeof entry.port === "number" && entry.port > 0 && entry.port <= 65535)
|
||||
const endpointKey = (entry: PortEntry) => `${entry.scheme || defaultSchemeFor(entry.port)}:${entry.port}`
|
||||
const selectedIconPort = iconPorts.find(entry => endpointKey(entry) === iconEndpoint) || iconPorts[0]
|
||||
const discoverLocalLogo = async () => {
|
||||
if (!selectedIconPort || discoveringLocalLogo) return
|
||||
const snapshot = editing
|
||||
const generation = targetGeneration.current
|
||||
const requestId = ++iconRequest.current
|
||||
const controller = new AbortController()
|
||||
iconAbort.current = controller
|
||||
const current = () => iconRequest.current === requestId && editingRef.current === snapshot
|
||||
&& targetRef.current === vmid && targetGeneration.current === generation
|
||||
setDiscoveringLocalLogo(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fetchApi<{ logo_url: string }>(`/api/vms/${vmid}/apps/logo-discovery`, {
|
||||
method: "POST", signal: controller.signal,
|
||||
body: JSON.stringify({ port: selectedIconPort.port, scheme: selectedIconPort.scheme || defaultSchemeFor(selectedIconPort.port) }),
|
||||
})
|
||||
if (!current()) return
|
||||
if (!LOCAL_LOGO_CACHE_PATH.test(result.logo_url) || !result.logo_url.startsWith(`/api/vms/${vmid}/`)) {
|
||||
throw new Error("invalid icon response")
|
||||
}
|
||||
setEditing(previous => previous === snapshot
|
||||
? { ...previous, draft: { ...previous.draft, logo_url: result.logo_url } } : previous)
|
||||
} catch (error: unknown) {
|
||||
if (current()) {
|
||||
const code = (error as { body?: { code?: string } })?.body?.code
|
||||
setError(t(code === "local_logo_stopped" ? "vmLxc.appEditor.localLogoStopped" : "vmLxc.appEditor.localLogoUnavailable"))
|
||||
}
|
||||
} finally {
|
||||
if (current()) setDiscoveringLocalLogo(false)
|
||||
}
|
||||
}
|
||||
// Editing the Name auto-fills the Package field on packaged
|
||||
// methods when it's still empty. Rationale: 90% of the time the
|
||||
// dpkg/apk package name mirrors the friendly app name (jellyfin,
|
||||
@@ -1523,18 +1721,47 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
from https://selfh.st/icons); empty → no logo in the
|
||||
app card header. */}
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor="app-logo" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={!selectedIconPort || discoveringLocalLogo || saving}
|
||||
onClick={discoverLocalLogo}>
|
||||
{discoveringLocalLogo ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Search className="h-3.5 w-3.5 mr-1" />}
|
||||
{t("vmLxc.appEditor.discoverLocalLogo")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
id="app-logo"
|
||||
type="url"
|
||||
type="text"
|
||||
value={draft.logo_url || ""}
|
||||
onChange={(e) => setField({ logo_url: e.target.value })}
|
||||
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
|
||||
maxLength={512}
|
||||
className="text-sm mt-2 font-mono"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
{draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) &&
|
||||
<ThemeAwareLogo src={draft.logo_url} className="h-8 w-8 shrink-0 object-contain" />}
|
||||
</div>
|
||||
{iconPorts.length > 1 && <select
|
||||
aria-label={t("vmLxc.appEditor.localLogoPort")}
|
||||
value={selectedIconPort ? endpointKey(selectedIconPort) : ""}
|
||||
onChange={event => {
|
||||
iconRequest.current += 1
|
||||
iconAbort.current?.abort()
|
||||
setDiscoveringLocalLogo(false)
|
||||
setIconEndpoint(event.target.value)
|
||||
}}
|
||||
className="mt-2 rounded border bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
{iconPorts.map(entry => <option key={endpointKey(entry)} value={endpointKey(entry)}>
|
||||
{endpointKey(entry)}
|
||||
</option>)}
|
||||
</select>}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t("vmLxc.appEditor.localLogoHelp")}</p>
|
||||
</div>
|
||||
|
||||
{/* Web links — each port becomes a clickable link (built as
|
||||
@@ -1843,14 +2070,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
id="app-package"
|
||||
value={draft.package || ""}
|
||||
onChange={(e) => setField({ package: e.target.value })}
|
||||
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
|
||||
placeholder={t("vmLxc.appEditor.packageExample", { package: method === "dpkg" ? "jellyfin-server" : "tailscale" })}
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
Auto-filled from Name. Verify with{" "}
|
||||
{t("vmLxc.appEditor.packageHelpBefore")}{" "}
|
||||
<code className="text-foreground/70">
|
||||
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
|
||||
</code>{" "}
|
||||
inside the CT.
|
||||
{t("vmLxc.appEditor.packageHelpAfter")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1866,8 +2093,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
Absolute path. Find it with{" "}
|
||||
<code className="text-foreground/70">which <app></code> or{" "}
|
||||
{t("vmLxc.appEditor.binaryHelpBefore")}{" "}
|
||||
<code className="text-foreground/70">which <app></code>{" "}{t("vmLxc.appEditor.binaryHelpOr")}{" "}
|
||||
<code className="text-foreground/70">systemctl show <service> -p ExecStart</code>.
|
||||
</div>
|
||||
</div>
|
||||
@@ -2332,7 +2559,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
{detectorTest.installed.version ? (
|
||||
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
|
||||
) : (
|
||||
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.installed.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||
)}
|
||||
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
|
||||
{detectorTest.installed.method || "—"}
|
||||
@@ -2348,7 +2575,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
) : detectorTest.upstream.version ? (
|
||||
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
|
||||
) : (
|
||||
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.upstream.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||
)}
|
||||
{detectorTest.upstream.type && (
|
||||
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
|
||||
@@ -2361,7 +2588,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
<span>{localizeAppEditorError(error, t)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2452,6 +2679,31 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
||||
: t("vmLxc.appEditor.detectedInContainer")}
|
||||
</div>
|
||||
{(d.source === "native_metadata" || d.source === "docker_metadata") && (
|
||||
<div className="text-xs text-muted-foreground mt-1 break-all">
|
||||
<div>{d.installed_version
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
|
||||
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
|
||||
{(d.source === "docker_metadata"
|
||||
? dockerDiscovery?.stale || dockerDiscovery?.status === "partial"
|
||||
: nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
|
||||
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
|
||||
)}
|
||||
{d.evidence?.map((e, index) => <div key={index}>
|
||||
{t(e.kind === "docker_container" ? "vmLxc.appEditor.dockerEvidenceContainer"
|
||||
: e.kind === "docker_image" ? "vmLxc.appEditor.dockerEvidenceImage"
|
||||
: e.kind === "compose_project" ? "vmLxc.appEditor.dockerEvidenceProject"
|
||||
: e.kind === "tcp_listener" ? (d.source === "docker_metadata"
|
||||
? "vmLxc.appEditor.dockerEvidencePort" : "vmLxc.appEditor.nativeEvidencePort")
|
||||
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
|
||||
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
|
||||
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
|
||||
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
|
||||
</div>)}
|
||||
<div>{t(d.source === "docker_metadata"
|
||||
? "vmLxc.appEditor.dockerEndpointReview" : "vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||
</div>
|
||||
)}
|
||||
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
||||
<div className="text-[10px] text-amber-400/90 mt-0.5">
|
||||
{t("vmLxc.appEditor.legacyDetectorUsed")}
|
||||
@@ -3092,7 +3344,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
<span>{localizeAppEditorError(error, t)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -382,6 +382,19 @@ export function LxcTerminalModal({
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The host prompt came back: pct enter could not enter the
|
||||
// container. Show why and close, so the session is never left
|
||||
// on the host shell under the container's name.
|
||||
const reason = afterPctEnter.split(/\r?\n/).slice(0, -1)
|
||||
.map((line) => line.trim()).filter(Boolean).join("\r\n")
|
||||
if (reason) term.writeln(`\x1b[31m${reason}\x1b[0m`)
|
||||
term.writeln(`\r\n\x1b[33m${/Failed to exec/.test(reason)
|
||||
? t("lxcTerminal.errors.noShell")
|
||||
: t("lxcTerminal.errors.enterFailed")}\x1b[0m`)
|
||||
isInsideLxcRef.current = true
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -688,6 +701,10 @@ export function LxcTerminalModal({
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { viewedUpdateJobs, type LxcUpdateJob } from "../lib/lxc-update-jobs"
|
||||
|
||||
export function LxcUpdateJobs({ activeRunId, onResume }: {
|
||||
activeRunId?: string
|
||||
onResume: (job: LxcUpdateJob) => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const [jobs, setJobs] = useState<LxcUpdateJob[]>([])
|
||||
const [seen, setSeen] = useState<string[]>([])
|
||||
useEffect(() => {
|
||||
let closed = false
|
||||
const read = () => {
|
||||
void fetchApi<{ jobs: LxcUpdateJob[] }>("/api/lxc-update-jobs")
|
||||
.then((data) => { if (!closed) setJobs(Array.isArray(data.jobs) ? data.jobs : []) })
|
||||
.catch(() => {}) // Auth recovery belongs to fetchApi, never to a job retry.
|
||||
}
|
||||
const viewed = () => setSeen(viewedUpdateJobs())
|
||||
viewed()
|
||||
read()
|
||||
const timer = setInterval(read, 5000)
|
||||
window.addEventListener("proxmenux-update-jobs-viewed", viewed)
|
||||
return () => {
|
||||
closed = true
|
||||
clearInterval(timer)
|
||||
window.removeEventListener("proxmenux-update-jobs-viewed", viewed)
|
||||
}
|
||||
}, [])
|
||||
const visible = jobs.filter((job) => job.id !== activeRunId
|
||||
&& (job.state !== "complete" || !seen.includes(job.id)))
|
||||
if (!visible.length) return null
|
||||
return <div className="space-y-2" role="status">
|
||||
{visible.map((job) => <div key={job.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
|
||||
<p className="text-sm">{t(job.state === "complete"
|
||||
? "vmLxc.updates.backgroundFinished" : "vmLxc.updates.backgroundRunning",
|
||||
{ vmid: job.vmid, name: job.ct_name || `CT-${job.vmid}` })}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => onResume(job)}>
|
||||
{t("vmLxc.updates.viewBackgroundJob")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import useSWR, { mutate as refreshCache } from "swr"
|
||||
import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Globe, Pencil, Plus, Server, Trash2 } from "lucide-react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { monitorLinkError } from "../lib/monitor-link-errors"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface MonitorLink {
|
||||
key: string
|
||||
name: string
|
||||
product: "pve" | "pbs"
|
||||
group: "cluster" | "standalone" | "pbs"
|
||||
url: string | null
|
||||
status: "online" | "offline" | "unknown" | "local"
|
||||
native_url?: string | null
|
||||
manual: boolean
|
||||
// A PBS installed on this same host.
|
||||
local?: boolean
|
||||
hidden: boolean
|
||||
custom_url: boolean
|
||||
}
|
||||
interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] }
|
||||
|
||||
const GROUPS = ["cluster", "standalone", "pbs"] as const
|
||||
const fetcher = (url: string) => fetchApi<MonitorLinks>(url)
|
||||
const ORIGIN_KEY = "proxmenux-came-from"
|
||||
const DECLINED_KEY = "proxmenux-links-declined"
|
||||
const MAX_CARRIED = 16
|
||||
|
||||
interface Carried { url: string; name: string; product: "pve" | "pbs" }
|
||||
interface Origin extends Carried { links?: Carried[] }
|
||||
|
||||
// A Monitor address is accepted only as a bare http(s) origin.
|
||||
function bareOrigin(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
return /^https?:$/.test(parsed.protocol) && parsed.origin === value ? parsed.origin : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// localhost names the computer that opens the address, never another node.
|
||||
function isLoopback(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
||||
return host === "localhost" || host.endsWith(".localhost") || /^127\./.test(host)
|
||||
|| host === "::1" || /^::ffff:127\./.test(host) || /^::ffff:7f/.test(host)
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
// The Monitors the source already links, carried in the jump so this one can add them too.
|
||||
function readCarried(raw: string | null): Carried[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const rows = JSON.parse(decodeURIComponent(escape(atob(raw.replace(/-/g, "+").replace(/_/g, "/")))))
|
||||
if (!Array.isArray(rows)) return []
|
||||
const seen = new Set<string>()
|
||||
const out: Carried[] = []
|
||||
for (const row of rows.slice(0, MAX_CARRIED)) {
|
||||
const url = bareOrigin(row?.u)
|
||||
if (!url || url === window.location.origin || isLoopback(url) || seen.has(url)) continue
|
||||
seen.add(url)
|
||||
out.push({ url, name: String(row?.n || new URL(url).hostname).slice(0, 64), product: row?.p === "pbs" ? "pbs" : "pve" })
|
||||
}
|
||||
return out
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function writeCarried(rows: Carried[]): string {
|
||||
const json = JSON.stringify(rows.slice(0, MAX_CARRIED).map(row => ({ n: row.name, u: row.url, p: row.product })))
|
||||
return btoa(unescape(encodeURIComponent(json))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
// The Monitor a jump came from, as the browser reached it. Only a bare http(s)
|
||||
// origin is accepted, and the parameters leave the address at once.
|
||||
function takeOrigin(): (Origin & { fresh?: boolean }) | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const from = params.get("pmx_from")
|
||||
if (from !== null) {
|
||||
const name = params.get("pmx_name") || ""
|
||||
const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve"
|
||||
const links = readCarried(params.get("pmx_links"))
|
||||
for (const key of ["pmx_from", "pmx_name", "pmx_product", "pmx_links"]) params.delete(key)
|
||||
const query = params.toString()
|
||||
window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash)
|
||||
try {
|
||||
const parsed = new URL(from)
|
||||
if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) {
|
||||
const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product,
|
||||
links: links.filter(link => link.url !== parsed.origin) }
|
||||
try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ }
|
||||
return { ...origin, fresh: true }
|
||||
}
|
||||
} catch { /* not an address */ }
|
||||
}
|
||||
try {
|
||||
const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null")
|
||||
return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
|
||||
function destination(link: MonitorLink): { href: string; native: boolean } | null {
|
||||
if (link.local && !link.url) return { href: `https://${window.location.hostname}:8007`, native: true }
|
||||
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
|
||||
return link.url ? { href: link.url, native: false } : null
|
||||
}
|
||||
|
||||
function ProductIcon({ product, className }: { product: string; className?: string }) {
|
||||
return product === "pbs" ? <DatabaseBackup className={className} /> : <Server className={className} />
|
||||
}
|
||||
|
||||
export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) {
|
||||
const t = useT()
|
||||
const [manageOpen, setManageOpen] = useState(false)
|
||||
// The backend keeps the list ready, so the menu opens already filled.
|
||||
const { data, error, isLoading } = useSWR<MonitorLinks>("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 })
|
||||
|
||||
const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null)
|
||||
const [offerOrigin, setOfferOrigin] = useState(false)
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [offered, setOffered] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState("")
|
||||
useEffect(() => { setOrigin(takeOrigin()) }, [])
|
||||
const known = (url: string) => !!data?.links.some(link => link.url === url)
|
||||
const originKnown = !!origin && known(origin.url)
|
||||
// The source Monitor and the Monitors it links that this one does not know yet.
|
||||
const candidates: Carried[] = origin && data
|
||||
? [origin, ...(origin.links || [])].filter(row => !known(row.url) && !isLoopback(row.url))
|
||||
: []
|
||||
const declined = (url: string) => {
|
||||
try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false }
|
||||
}
|
||||
useEffect(() => {
|
||||
// Offered right after arriving, until every node the source links is known here.
|
||||
if (!offered && origin?.fresh && data && candidates.length > 0 && !declined(origin.url)) {
|
||||
setOffered(true)
|
||||
setSelected(candidates.map(row => row.url))
|
||||
setOfferOrigin(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [origin, data])
|
||||
|
||||
// The address carries where the jump comes from and the Monitors this one links,
|
||||
// so the other Monitor can offer the way back and the rest of the nodes.
|
||||
const withOrigin = (href: string) => {
|
||||
const target = bareOrigin(href.replace(/\/+$/, ""))
|
||||
const carried = (data?.links || [])
|
||||
.filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target
|
||||
&& !isLoopback(link.url))
|
||||
.map(link => ({ url: link.url as string, name: link.name, product: link.product }))
|
||||
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
|
||||
if (carried.length > 0) params.set("pmx_links", writeCarried(carried))
|
||||
return `${href}/?${params.toString()}`
|
||||
}
|
||||
const open = (href: string, newTab: boolean) => {
|
||||
if (newTab) window.open(href, "_blank", "noopener,noreferrer")
|
||||
else window.location.assign(href)
|
||||
}
|
||||
const go = (link: MonitorLink, newTab: boolean) => {
|
||||
const target = destination(link)
|
||||
if (!target) return
|
||||
if (target.native) open(target.href, true)
|
||||
else open(withOrigin(target.href), newTab)
|
||||
}
|
||||
const addSelected = async () => {
|
||||
setAdding(true); setAddError("")
|
||||
try {
|
||||
for (const row of candidates.filter(row => selected.includes(row.url))) {
|
||||
await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: row.name, url: row.url, product: row.product }) })
|
||||
}
|
||||
await refreshCache("/api/monitor-links")
|
||||
setOfferOrigin(false)
|
||||
} catch (err) {
|
||||
setAddError(monitorLinkError(err, t))
|
||||
try { await refreshCache("/api/monitor-links") }
|
||||
catch { /* SWR shows the load error; keep the localized mutation error. */ }
|
||||
} finally { setAdding(false) }
|
||||
}
|
||||
const notNow = () => {
|
||||
if (origin) {
|
||||
try {
|
||||
const list = JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]
|
||||
sessionStorage.setItem(DECLINED_KEY, JSON.stringify([...list.filter(url => url !== origin.url), origin.url]))
|
||||
} catch { /* private mode */ }
|
||||
}
|
||||
setOfferOrigin(false)
|
||||
}
|
||||
|
||||
return <div onClick={event => event.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className={`gap-2 border-border bg-transparent hover:bg-secondary ${compact ? "h-8 px-2.5 text-xs" : ""}`}>
|
||||
<Server className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
|
||||
<span className="max-w-[12rem] truncate">{t("status.node", { node: nodeName })}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t("monitorLinks.thisNode")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem disabled className="gap-2 opacity-100">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<ProductIcon product={data?.self.product || ""} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{nodeName}</span>
|
||||
</DropdownMenuItem>
|
||||
{origin && !originKnown && <DropdownMenuItem className="gap-2" onSelect={() => open(withOrigin(origin.url), false)}>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("monitorLinks.backTo", { name: origin.name })}</span>
|
||||
</DropdownMenuItem>}
|
||||
{isLoading && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{error && <p className="px-2 py-1.5 text-xs text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data && data.links.length === 0 && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data && GROUPS.map(group => {
|
||||
const rows = data.links.filter(link => link.group === group)
|
||||
if (rows.length === 0) return null
|
||||
return <div key={group}>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
|
||||
{rows.map(link => {
|
||||
const target = destination(link)
|
||||
// A PBS without a Monitor opens its own web interface, not another Monitor.
|
||||
if (target?.native) return <DropdownMenuItem key={link.key} className="gap-2" onSelect={() => go(link, false)}>
|
||||
<span className="h-2 w-2 shrink-0" />
|
||||
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
||||
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
|
||||
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||
{link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
||||
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>}
|
||||
</DropdownMenuItem>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2" onSelect={() => setManageOpen(true)}>
|
||||
<Plus className="h-4 w-4" />{t("monitorLinks.manage")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
|
||||
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto">
|
||||
{candidates.map(row => <label key={row.url} className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-card px-3 py-2">
|
||||
<Checkbox className="mt-0.5" checked={selected.includes(row.url)} disabled={adding}
|
||||
onCheckedChange={checked => setSelected(current => checked === true ? [...current, row.url] : current.filter(url => url !== row.url))} />
|
||||
<ProductIcon product={row.product} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{row.name}</span>
|
||||
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
|
||||
</span>
|
||||
</label>)}
|
||||
</div>
|
||||
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={adding} onClick={notNow}>{t("monitorLinks.notNow")}</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" disabled={adding || selected.length === 0} onClick={addSelected}>
|
||||
<Plus className="mr-2 h-4 w-4" />{t("monitorLinks.addSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useT()
|
||||
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true); setError("")
|
||||
try {
|
||||
await action()
|
||||
await mutate()
|
||||
await refreshCache("/api/monitor-links")
|
||||
} catch (err) {
|
||||
setError(monitorLinkError(err, t))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const send = (path: string, method: string, body?: unknown) =>
|
||||
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
||||
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-url">{t("monitorLinks.address")}</Label>
|
||||
<Input id="monitor-link-url" value={form.url} disabled={busy} placeholder="https://192.168.1.20:8008"
|
||||
onChange={event => setForm({ ...form, url: event.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-product">{t("monitorLinks.type")}</Label>
|
||||
<Select value={form.product} disabled={busy} onValueChange={product => setForm({ ...form, product })}>
|
||||
<SelectTrigger id="monitor-link-product" className="w-full sm:w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pve">Proxmox VE</SelectItem>
|
||||
<SelectItem value="pbs">Proxmox Backup Server</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
||||
await send("/api/monitor-links", "POST", form)
|
||||
setForm({ name: "", url: "", product: "pve" })
|
||||
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const own = link.manual || link.custom_url
|
||||
const shown = link.url || ""
|
||||
const draft = addresses[link.key] ?? (own ? shown : "")
|
||||
const isEditing = editing === link.key
|
||||
// A read-only card is a panel; while its address is edited it lets the field stand out.
|
||||
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${isEditing ? "" : "bg-card"} ${link.hidden ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")}</span>
|
||||
{!link.manual && <label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{t("monitorLinks.visible")}
|
||||
<Switch checked={!link.hidden} disabled={busy} className="data-[state=checked]:bg-blue-600"
|
||||
onCheckedChange={checked => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { hidden: !checked }))} />
|
||||
</label>}
|
||||
{link.manual && <Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.remove")}
|
||||
onClick={() => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "DELETE"))}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>}
|
||||
</div>
|
||||
{link.local && !link.custom_url
|
||||
? <div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||
</div>
|
||||
: isEditing
|
||||
? <div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||
disabled={busy || addresses[link.key] === undefined || (link.manual && !draft.trim())}
|
||||
onClick={() => run(async () => {
|
||||
await send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { url: addresses[link.key] })
|
||||
setAddresses(({ [link.key]: _, ...rest }) => rest)
|
||||
setEditing(null)
|
||||
})}>{t("monitorLinks.save")}</Button>
|
||||
<Button variant="outline" size="sm" className="h-10" disabled={busy}
|
||||
onClick={() => { setAddresses(({ [link.key]: _, ...rest }) => rest); setEditing(null) }}>{t("monitorLinks.cancel")}</Button>
|
||||
</div>
|
||||
: <div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-sm text-foreground">{shown || "—"}</span>
|
||||
{!link.manual && !link.custom_url && <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.autoAddress")}</span>}
|
||||
<Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.changeAddress")} title={t("monitorLinks.changeAddress")}
|
||||
onClick={() => setEditing(link.key)}>
|
||||
<Pencil className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { AppriseServiceHelp } from "./apprise-service-help"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
@@ -12,13 +13,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import {
|
||||
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
||||
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
||||
Trash2, ChevronRight, TestTube2, Mail, Webhook,
|
||||
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
|
||||
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
|
||||
Moon, Newspaper
|
||||
Moon, Newspaper, History, ChevronDown
|
||||
} from "lucide-react"
|
||||
|
||||
interface ChannelConfig {
|
||||
@@ -109,7 +111,8 @@ interface HistoryEntry {
|
||||
title: string
|
||||
severity: string
|
||||
sent_at: string
|
||||
success: boolean
|
||||
success: boolean | number
|
||||
message?: string | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
@@ -152,6 +155,8 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
|
||||
}
|
||||
|
||||
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
|
||||
// Guests, vzdump backups and the cluster do not exist on Proxmox Backup Server.
|
||||
const PVE_ONLY_CATEGORIES = new Set(["vm_ct", "backup", "cluster"])
|
||||
|
||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
||||
|
||||
@@ -328,10 +333,12 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
|
||||
export function NotificationSettings() {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
|
||||
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [history, setHistory] = useState<HistoryEntry[] | null>(null)
|
||||
const [historyError, setHistoryError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -452,12 +459,12 @@ export function NotificationSettings() {
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20")
|
||||
if (data.success) {
|
||||
setHistory(data.history || [])
|
||||
}
|
||||
const data = await fetchApi<{ history?: HistoryEntry[]; error?: string }>("/api/notifications/history?limit=20")
|
||||
if (data.error || !Array.isArray(data.history)) throw new Error("history unavailable")
|
||||
setHistory(data.history)
|
||||
setHistoryError(false)
|
||||
} catch {
|
||||
// Ignore
|
||||
setHistoryError(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -700,7 +707,7 @@ export function NotificationSettings() {
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.notificationCategories")}</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
|
||||
{EVENT_CATEGORIES.filter(cat => cat.key !== "other" && !(isPbs && PVE_ONLY_CATEGORIES.has(cat.key))).map(cat => {
|
||||
const isEnabled = overrides.categories[cat.key] ?? true
|
||||
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
|
||||
// Hide the LXC update toggle when the user has disabled the
|
||||
@@ -919,7 +926,7 @@ export function NotificationSettings() {
|
||||
const wasEnabled = originalConfig.enabled
|
||||
const isNowDisabled = !nextConfig.enabled
|
||||
|
||||
if (wasEnabled && isNowDisabled) {
|
||||
if (wasEnabled && isNowDisabled && !isPbs) {
|
||||
try {
|
||||
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
|
||||
} catch {
|
||||
@@ -1097,15 +1104,6 @@ export function NotificationSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
await fetchApi("/api/notifications/history", { method: "DELETE" })
|
||||
setHistory([])
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Maps each eye-button local key to the body shape the backend
|
||||
// expects for /api/notifications/reveal-secret. Centralised here so
|
||||
// the JSX call site stays a one-liner — `toggleSecret(key)`.
|
||||
@@ -1223,8 +1221,10 @@ export function NotificationSettings() {
|
||||
setConfig(newConfig)
|
||||
setOriginalConfig(newConfig)
|
||||
|
||||
// 2) Auto-configure PVE webhook
|
||||
try {
|
||||
// 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications
|
||||
if (isPbs) {
|
||||
setWebhookSetup({ status: "idle", fallback_commands: [], error: "" })
|
||||
} else try {
|
||||
const setup = await fetchApi<{
|
||||
configured: boolean
|
||||
secret?: string
|
||||
@@ -1530,7 +1530,7 @@ export function NotificationSettings() {
|
||||
<Input
|
||||
type={showSecrets["tg_token"] ? "text" : "password"}
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
|
||||
placeholder={t("settings.notifications.ui.botTokenPlaceholder")}
|
||||
value={secretValue("tg_token", config.channels.telegram?.bot_token || "")}
|
||||
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
||||
disabled={!editMode}
|
||||
@@ -1542,6 +1542,7 @@ export function NotificationSettings() {
|
||||
{showSecrets["tg_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.botTokenHelp")}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.chatId")}</Label>
|
||||
@@ -2068,6 +2069,7 @@ export function NotificationSettings() {
|
||||
Enable toggle + Test button pattern as the other
|
||||
channels. */}
|
||||
<TabsContent value="apprise" className="space-y-3 pt-2">
|
||||
<AppriseServiceHelp />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
|
||||
@@ -2761,6 +2763,75 @@ export function NotificationSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Notification history ── */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-start gap-3 py-1 text-left"
|
||||
onClick={() => setShowHistory(open => !open)}
|
||||
aria-expanded={showHistory}
|
||||
>
|
||||
<History className="h-5 w-5 text-blue-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{t("settings.notifications.history.title")}</span>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.description")}</p>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 mt-1 shrink-0 text-muted-foreground transition-transform ${showHistory ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{showHistory && (
|
||||
historyError ? (
|
||||
<p className="text-xs sm:text-sm text-red-400">{t("settings.notifications.history.loadFailed")}</p>
|
||||
) : history === null ? (
|
||||
<div className="flex justify-center py-3"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
|
||||
) : history.length === 0 ? (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.empty")}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
|
||||
{history.map(entry => {
|
||||
const sent = Boolean(entry.success)
|
||||
const severity = (entry.severity || "").toLowerCase()
|
||||
const detail = !sent ? entry.error_message : entry.event_type === "digest" ? entry.message : null
|
||||
return (
|
||||
<div key={entry.id} className="flex items-start gap-2.5 p-2.5 rounded-md bg-muted/30 border border-border/50">
|
||||
{sent ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.sent")} />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.notSent")} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs sm:text-sm font-medium block truncate">{entry.title || entry.event_type}</span>
|
||||
<span className="text-[11px] sm:text-xs text-muted-foreground">
|
||||
{entry.channel ? entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1) : ""} · {new Date(entry.sent_at).toLocaleString()}
|
||||
</span>
|
||||
{detail && (
|
||||
<p className={`text-[11px] sm:text-xs mt-0.5 break-words ${sent ? "text-muted-foreground" : "text-red-400"}`}>{detail}</p>
|
||||
)}
|
||||
</div>
|
||||
{(severity === "critical" || severity === "warning" || severity === "info") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${
|
||||
severity === "critical"
|
||||
? "border-red-500/30 text-red-400"
|
||||
: severity === "warning"
|
||||
? "border-amber-500/30 text-amber-400"
|
||||
: "border-blue-500/30 text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{severity === "critical"
|
||||
? t("settings.notifications.history.severityCritical")
|
||||
: severity === "warning"
|
||||
? t("settings.notifications.history.severityWarning")
|
||||
: t("settings.notifications.history.severityInfo")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarClock, Database } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
|
||||
export interface PBSDatastore {
|
||||
name: string
|
||||
path?: string
|
||||
total?: number | null
|
||||
used?: number | null
|
||||
avail?: number | null
|
||||
error?: string
|
||||
error_code?: string
|
||||
"backend-type"?: string
|
||||
"estimated-full-date"?: number
|
||||
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
|
||||
}
|
||||
|
||||
export function datastorePercent(store: PBSDatastore): number | null {
|
||||
if (store.total == null || store.used == null || store.total <= 0) return null
|
||||
return (store.used / store.total) * 100
|
||||
}
|
||||
|
||||
// Same thresholds and colours as the PVE storage cards.
|
||||
export function usageTone(percent: number): { bar: string; text: string; used: string } {
|
||||
if (percent > 90) return { bar: "[&>div]:bg-red-500", text: "text-red-500", used: "text-red-400" }
|
||||
if (percent > 75) return { bar: "[&>div]:bg-yellow-500", text: "text-yellow-500", used: "text-yellow-400" }
|
||||
return { bar: "[&>div]:bg-blue-500", text: "text-muted-foreground", used: "text-blue-400" }
|
||||
}
|
||||
|
||||
const gib = (bytes: number) => formatStorage(bytes / 1024 ** 3)
|
||||
|
||||
// A datastore without a readable capacity keeps its message: it is never shown as empty.
|
||||
export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatastore; compact?: boolean }) {
|
||||
const t = useT()
|
||||
const percent = datastorePercent(store)
|
||||
if (percent == null) {
|
||||
return <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
</div>
|
||||
<PBSProblemMessage className="text-xs text-amber-500" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
</div>
|
||||
}
|
||||
const tone = usageTone(percent)
|
||||
return <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className={`text-xs font-semibold tabular-nums ${tone.text}`}>{percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={percent} className={`${compact ? "h-1.5" : "h-2"} ${tone.bar}`} />
|
||||
{!compact && <div className="flex justify-between text-xs text-muted-foreground tabular-nums">
|
||||
<span>{gib(store.used!)} / {gib(store.total!)}</span>
|
||||
<span>{t("pbs.free")}: <span className="font-semibold text-green-500">{gib(store.avail ?? store.total! - store.used!)}</span></span>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
|
||||
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const percent = datastorePercent(store)
|
||||
const tone = percent == null ? null : usageTone(percent)
|
||||
const fullDate = store["estimated-full-date"]
|
||||
const type = store["backend-type"] || t("pbs.datastoreType")
|
||||
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="font-semibold text-base md:text-lg truncate">{store.name}</h3>
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">{type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge className={store.error ? "bg-red-500/10 text-red-500 border-red-500/20" : "bg-green-500/10 text-green-500 border-green-500/20"}>
|
||||
{store.error ? t("storage.status.error") : t("storage.status.active")}
|
||||
</Badge>
|
||||
{percent != null && <span className="text-sm font-medium">{percent.toFixed(2)}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
{percent != null && tone ? (
|
||||
<div className="space-y-2">
|
||||
<Progress value={percent} className={`h-2 ${tone.bar}`} />
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.total")}</p>
|
||||
<p className="font-medium">{gib(store.total!)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.used")}</p>
|
||||
<p className={`font-medium ${tone.used}`}>{gib(store.used!)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.available")}</p>
|
||||
<p className="font-medium text-green-400">{gib(store.avail ?? store.total! - store.used!)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PBSProblemMessage className="text-sm text-muted-foreground" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
)}
|
||||
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
|
||||
{fullDate !== undefined && <span className="flex items-center">
|
||||
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString(language) : t("pbs.estimatedFullUnknown")}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
Activity, ArchiveRestore, Database, DatabaseBackup, Download, ListChecks,
|
||||
RefreshCw, Scissors, Server, Settings2, ShieldCheck, Terminal, Trash2,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic, pbsResultText, pbsServiceState, pbsTaskType, formatPBSDuration } from "../lib/pbs-messages"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||
interface Detected { name: string; url: string; fingerprint: string }
|
||||
interface Connections { local_available: boolean; servers: Connection[]; detected?: Detected[] }
|
||||
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
|
||||
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
|
||||
interface PBSStatus {
|
||||
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
|
||||
datastores: PBSDatastore[] | null; tasks: Task[] | null;
|
||||
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
|
||||
error_details?: Record<string, { code: string; params?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
|
||||
type Method = "password" | "token"
|
||||
const NEW_CONNECTION = "__new__"
|
||||
const badgeTone = {
|
||||
ok: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||
warn: "bg-amber-500/10 text-amber-500 border-amber-500/20",
|
||||
error: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
running: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
muted: "bg-muted text-muted-foreground border-border",
|
||||
}
|
||||
|
||||
const TASK_ICONS: Record<string, typeof Activity> = {
|
||||
backup: DatabaseBackup, reader: ArchiveRestore, verify: ShieldCheck, verificationjob: ShieldCheck,
|
||||
verify_group: ShieldCheck, verify_snapshot: ShieldCheck, prune: Scissors, prunejob: Scissors,
|
||||
garbage_collection: Trash2, sync: RefreshCw, syncjob: RefreshCw, aptupdate: Download,
|
||||
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
|
||||
}
|
||||
|
||||
// PBS reports "unknown", "OK", "WARNINGS: n" or an error; no state is still running.
|
||||
function taskState(task: Task): keyof typeof badgeTone {
|
||||
if (!task.status) return task.endtime ? "muted" : "running"
|
||||
if (task.status === "unknown") return "muted"
|
||||
if (task.status === "OK") return "ok"
|
||||
if (task.status.startsWith("WARNINGS")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text of the last run.
|
||||
function jobState(state?: string): keyof typeof badgeTone {
|
||||
if (!state || state === "unknown") return "muted"
|
||||
if (state.toLowerCase() === "ok") return "ok"
|
||||
if (state.toLowerCase().startsWith("warn")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
// `localOnly` is the PBS host's own Monitor: no connection picker, the
|
||||
// header Refresh already reloads the page data. Its tabs show one `view` each.
|
||||
export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
|
||||
const showStores = view !== "tasks"
|
||||
const showTasks = view !== "datastores"
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||
const [selected, setSelected] = useState("")
|
||||
const [manage, setManage] = useState(false)
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [method, setMethod] = useState<Method>("password")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [errorFallback, setErrorFallback] = useState("pbs.saveFailed")
|
||||
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
|
||||
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
|
||||
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (!connections) return
|
||||
if (selected === "local" && connections.local_available) return
|
||||
if (connections.servers.some(row => row.id === selected)) return
|
||||
setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "")
|
||||
}, [connections, selected])
|
||||
|
||||
const openManage = (next = emptyForm) => {
|
||||
setForm(next); setMethod(next.id ? "token" : "password"); setError(""); setMessage(""); setManage(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setErrorFallback("pbs.saveFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let saved = false
|
||||
try {
|
||||
// A password is sent once to create a read-only token; only the token is kept.
|
||||
const { username, password, ...connection } = form
|
||||
const result = !form.id && method === "password"
|
||||
? await fetchApi<{ id: string }>("/api/pbs/servers/login", { method: "POST", body: JSON.stringify({
|
||||
name: form.name, url: form.url, fingerprint: form.fingerprint, username, password,
|
||||
}) })
|
||||
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
|
||||
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
|
||||
}) })
|
||||
saved = true
|
||||
await reloadConnections()
|
||||
setSelected(result.id)
|
||||
setForm(emptyForm)
|
||||
setManage(false)
|
||||
} catch (err) { setError(saved ? { body: { code: "pbs_saved_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
|
||||
setErrorFallback("pbs.removeFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let removed = false
|
||||
try {
|
||||
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
|
||||
removed = true
|
||||
setForm(emptyForm)
|
||||
await reloadConnections()
|
||||
} catch (err) { setError(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const now = Date.now() / 1000
|
||||
|
||||
return <div className="space-y-4 md:space-y-6">
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="flex items-center text-2xl font-semibold text-foreground"><Server className="mr-2 h-5 w-5" />{t("pbs.title")}</h2>
|
||||
<Select value={selected || undefined} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.thisNode")}</SelectItem>}
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => openManage()}>
|
||||
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
|
||||
</Button>
|
||||
</div>}
|
||||
{(connectionError || statusError) && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={connectionError || statusError} raw={pbsDiagnostic(connectionError || statusError)} />
|
||||
</div>}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><DatabaseBackup className="mr-2 h-5 w-5" />{t("pbs.detectedTitle")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.detectedHelp")}</p>
|
||||
{connections?.detected?.map(row => <div key={row.url} className="flex flex-wrap items-center gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{row.name}</span>
|
||||
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
|
||||
</span>
|
||||
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => openManage({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })}>
|
||||
{t("pbs.connect")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pbs.manage")}</DialogTitle>
|
||||
<DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("pbs.editConnection")}</Label>
|
||||
<Select value={form.id || NEW_CONNECTION} disabled={busy} onValueChange={value => {
|
||||
const row = connections?.servers.find(item => item.id === value)
|
||||
setForm(row ? { ...emptyForm, ...row, token_secret: "" } : emptyForm)
|
||||
setMethod(row ? "token" : "password"); setError("")
|
||||
}}>
|
||||
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{!form.id && <div className="space-y-2">
|
||||
<Label>{t("pbs.signInWith")}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["password", "token"] as const).map(option =>
|
||||
<Button key={option} type="button" variant="outline" disabled={busy} onClick={() => { setMethod(option); setError("") }}
|
||||
className={method === option ? "border-blue-600 bg-blue-600/15 text-foreground hover:bg-blue-600/20" : "bg-background"}>
|
||||
{t(option === "password" ? "pbs.methodPassword" : "pbs.methodToken")}
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
|
||||
const secret = field === "token_secret" || field === "password"
|
||||
const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp"
|
||||
: field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret"
|
||||
? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined
|
||||
return <div key={field} className="min-w-0 space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`} className="block leading-5">{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
|
||||
aria-describedby={help ? `pbs-${field}-help` : undefined}
|
||||
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
|
||||
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
{help && <p id={`pbs-${field}-help`} className="text-xs text-muted-foreground">{t(help)}</p>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
|
||||
{!!error && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={error} raw={pbsDiagnostic(error)} fallback={errorFallback} />
|
||||
</div>}
|
||||
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />{t("pbs.remove")}
|
||||
</Button>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => setManage(false)}>{t("pbs.cancel")}</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||
disabled={busy || !form.url.trim() || !form.name.trim()} onClick={save}>
|
||||
{busy ? t("pbs.testing") : t("pbs.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
{data && <>
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })}</span>
|
||||
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
|
||||
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
|
||||
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {pbsServiceState(state, t)}</Badge>)}
|
||||
</div>}
|
||||
{Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks)
|
||||
.map(([section, detail]) => <div key={section} role="alert" className="text-sm text-amber-500">
|
||||
<p className="font-medium">{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}</p>
|
||||
<PBSProblemMessage problem={data.error_details?.[section]} raw={detail} />
|
||||
</div>)}
|
||||
|
||||
{showStores && <Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
|
||||
{data.datastores && data.datastores.length > 0 && <div className="space-y-4">
|
||||
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{showTasks && <>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
|
||||
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.task")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.started")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.duration")}</th>
|
||||
<th className="py-2 font-medium">{t("pbs.status")}</th>
|
||||
</tr></thead>
|
||||
<tbody>{data.tasks.map(row => {
|
||||
const Icon = TASK_ICONS[row.worker_type || ""] || Activity
|
||||
const state = taskState(row)
|
||||
const end = row.endtime ?? (state === "running" ? now : undefined)
|
||||
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-foreground" title={row.worker_type}>{pbsTaskType(row.worker_type, t)}</span>
|
||||
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
|
||||
</span></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"}</td>
|
||||
<td className="py-2">
|
||||
<PBSResultBadge status={row.status} running={state === "running"} tone={badgeTone[state]} />
|
||||
</td>
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><ListChecks className="mr-2 h-5 w-5" />{t("pbs.jobs")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row })))
|
||||
if (jobs.length === 0) return <p className="text-sm text-muted-foreground">{t("pbs.noJobs")}</p>
|
||||
return <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.job")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.datastores")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.schedule")}</th>
|
||||
<th className="py-2 font-medium">{t("pbs.status")}</th>
|
||||
</tr></thead>
|
||||
<tbody>{jobs.map(({ kind, row }) => <tr key={`${kind}-${row.id}`} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
|
||||
<td className="py-2"><PBSResultBadge status={row["last-run-state"]} tone={badgeTone[jobState(row["last-run-state"])]} /></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) {
|
||||
const t = useT()
|
||||
const badge = <Badge variant="outline" className={`${tone} max-w-[16rem] truncate`}>
|
||||
{running ? t("pbs.running") : pbsResultText(status, t)}
|
||||
</Badge>
|
||||
if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge
|
||||
return <details className="max-w-xs">
|
||||
<summary className="cursor-pointer" aria-label={t("pbs.technicalDetails")}>{badge}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">{status}</pre>
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Known application messages are already explained in the active language.
|
||||
// Preserve unrecognized external diagnostics as escaped technical details.
|
||||
export function PBSProblemMessage({ problem, raw, fallback, className = "" }: {
|
||||
problem?: unknown; raw?: string; fallback?: string; className?: string
|
||||
}) {
|
||||
const t = useT()
|
||||
return <div className={`min-w-0 space-y-1 ${className}`}>
|
||||
<p>{pbsErrorText(problem, t, fallback)}</p>
|
||||
{raw && !pbsIsKnownError(problem) && <details className="text-xs">
|
||||
<summary className="cursor-pointer">{t("pbs.technicalDetails")}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{raw}</pre>
|
||||
</details>}
|
||||
</div>
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { Security } from "./security"
|
||||
import { Profile } from "./profile"
|
||||
import { About } from "./about"
|
||||
import { BackupTab } from "./backup-tab"
|
||||
import { PBSPanel } from "./pbs-panel"
|
||||
import { MonitorSwitcher } from "./monitor-switcher"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Server,
|
||||
ListChecks,
|
||||
Menu,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
@@ -45,7 +48,9 @@ import {
|
||||
ClipboardCheck,
|
||||
Info,
|
||||
DatabaseBackup,
|
||||
Database,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
@@ -97,9 +102,34 @@ const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
||||
|
||||
export function ProxmoxDashboard() {
|
||||
const t = useT()
|
||||
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
const { order: tabOrder } = useTabOrder()
|
||||
const { data, error } = useSWR<{ product: string }>("/api/platform", url => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
|
||||
if (!data && !error) return <p role="status" className="p-8">{t("app.loading")}</p>
|
||||
if (data?.product === "pbs") return <PBSDashboard />
|
||||
return <PVEDashboard />
|
||||
}
|
||||
|
||||
function PVEDashboard() {
|
||||
return <MonitorDashboard product="pve" />
|
||||
}
|
||||
|
||||
function PBSDashboard() {
|
||||
return <MonitorDashboard product="pbs" />
|
||||
}
|
||||
|
||||
type Product = "pve" | "pbs"
|
||||
type TopTab = TabId | "datastores" | "pbs-tasks"
|
||||
|
||||
// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the
|
||||
// same header, health and administration with its own top-level sections.
|
||||
const PBS_TABS: TopTab[] = ["overview", "datastores", "pbs-tasks", "terminal", "admin"]
|
||||
|
||||
function MonitorDashboard({ product }: { product: Product }) {
|
||||
const t = useT()
|
||||
const isPbs = product === "pbs"
|
||||
useSWR(isPbs ? null : "/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
useSWR(isPbs ? null : "/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
const { order: userTabOrder } = useTabOrder()
|
||||
const tabOrder: TopTab[] = isPbs ? PBS_TABS : userTabOrder
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||
status: "healthy",
|
||||
uptime: "Loading...",
|
||||
@@ -117,8 +147,8 @@ export function ProxmoxDashboard() {
|
||||
// runs post-hydration; the tiny flash is acceptable and matches the
|
||||
// pattern next-themes uses for the same reason.
|
||||
useEffect(() => {
|
||||
setActiveTab(firstActualTab())
|
||||
}, [])
|
||||
setActiveTab(isPbs ? "overview" : firstActualTab())
|
||||
}, [isPbs])
|
||||
const [infoCount, setInfoCount] = useState(0)
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [showNavigation, setShowNavigation] = useState(true)
|
||||
@@ -391,6 +421,8 @@ export function ProxmoxDashboard() {
|
||||
const getActiveTabLabel = () => {
|
||||
switch (activeTab) {
|
||||
case "overview": return t("navigation.overview")
|
||||
case "datastores": return t("pbs.datastores")
|
||||
case "pbs-tasks": return t("pbs.tasksTab")
|
||||
case "apps": return t("navigation.apps")
|
||||
case "vms": return t("navigation.virtualMachines")
|
||||
case "storage": return t("navigation.storage")
|
||||
@@ -410,8 +442,9 @@ export function ProxmoxDashboard() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<OnboardingCarousel />
|
||||
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
|
||||
{/* Both describe the PVE Monitor. */}
|
||||
{!isPbs && <OnboardingCarousel />}
|
||||
{!isPbs && <ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />}
|
||||
|
||||
{!isServerConnected && (
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
|
||||
@@ -464,22 +497,17 @@ export function ProxmoxDashboard() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t(isPbs ? "app.descriptionPbs" : "app.description")}</p>
|
||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Server className="h-3 w-3" />
|
||||
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span className="truncate">{t("status.uptime", { uptime: displayUptime })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MonitorSwitcher nodeName={displayServerName} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
@@ -580,9 +608,7 @@ export function ProxmoxDashboard() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t("status.uptime", { uptime: displayUptime })}
|
||||
</span>
|
||||
<MonitorSwitcher nodeName={displayServerName} compact />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -623,7 +649,7 @@ export function ProxmoxDashboard() {
|
||||
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
|
||||
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
||||
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
||||
]
|
||||
].filter(item => !isPbs || item.value !== "audit")
|
||||
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
||||
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
||||
const isNodeActive = activeNodeItem !== undefined
|
||||
@@ -689,9 +715,11 @@ export function ProxmoxDashboard() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
const renderTop = (id: TabId) => {
|
||||
const renderTop = (id: TopTab) => {
|
||||
switch (id) {
|
||||
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
|
||||
case "pbs-tasks": return renderDirect("pbs-tasks", ListChecks, t("pbs.tasksTab"))
|
||||
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
|
||||
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
|
||||
@@ -701,7 +729,7 @@ export function ProxmoxDashboard() {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
|
||||
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-5" : "grid-cols-7"} bg-card border border-border`}>
|
||||
{tabOrder.map(renderTop)}
|
||||
</TabsList>
|
||||
)
|
||||
@@ -762,6 +790,8 @@ export function ProxmoxDashboard() {
|
||||
{tabOrder.map((id): React.ReactNode => {
|
||||
switch (id) {
|
||||
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
|
||||
case "pbs-tasks": return btn("pbs-tasks", ListChecks, t("pbs.tasksTab"))
|
||||
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
|
||||
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
|
||||
@@ -777,7 +807,7 @@ export function ProxmoxDashboard() {
|
||||
<React.Fragment key="admin">
|
||||
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
||||
{btn("security", ShieldCheck, t("navigation.security"))}
|
||||
{btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||
{!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
||||
{btn("about", Info, t("navigation.about"))}
|
||||
</React.Fragment>
|
||||
@@ -811,9 +841,22 @@ export function ProxmoxDashboard() {
|
||||
5 s / 59 s refresh intervals keep the data fresh in the
|
||||
background — reopening the tab is instant. */}
|
||||
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
|
||||
<SystemOverview key={`overview-${componentKey}`} />
|
||||
<SystemOverview key={`overview-${componentKey}`} product={product} />
|
||||
</TabsContent>
|
||||
|
||||
{isPbs && (
|
||||
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`datastores-${componentKey}`} localOnly view="datastores" />
|
||||
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPbs && (
|
||||
<TabsContent value="pbs-tasks" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`pbs-tasks-${componentKey}`} localOnly view="tasks" />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{!isPbs && <>
|
||||
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AppsDashboard key={`apps-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -839,11 +882,13 @@ export function ProxmoxDashboard() {
|
||||
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Hardware key={`hardware-${componentKey}`} />
|
||||
</TabsContent>
|
||||
</>}
|
||||
|
||||
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
{!isPbs && <>
|
||||
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AuditReport key={`audit-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -851,13 +896,14 @@ export function ProxmoxDashboard() {
|
||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||
<BackupTab key={`backup-${componentKey}`} />
|
||||
</TabsContent>
|
||||
</>}
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Security key={`security-${componentKey}`} />
|
||||
<Security key={`security-${componentKey}`} hostType={product} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Profile tab — not surfaced in the top tabs nav. The only
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
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, Package, ScrollText, ArrowUpCircle } 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, Search } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { APP_VERSION } from "../lib/version"
|
||||
@@ -18,6 +18,35 @@ interface ReleaseNote {
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.2.7": {
|
||||
date: "October 26, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Audit & Report — a new page under Admin that assesses the node against forty-three Proxmox checks, keeps a journal of everything ProxMenux changed on the host, lets the expected state be declared as a policy and prints focused reports.",
|
||||
"OCI manager Apps (Technology Preview) — applications installed from their official container images as native Proxmox LXC, with no Docker engine: a catalog of 514 applications, multi-container applications, hardware acceleration, updates that keep the data and recovery after a restore.",
|
||||
"OCI applications in the Monitor — registered in the App tab, updated and modified from the Updates tab, with a Logs tab that follows their console and a watchdog switch.",
|
||||
"ProxMenux on Proxmox Backup Server — the same installer, a menu, post-install and a Monitor of its own; a remote PBS can also be watched from PVE with a read-only token (contributed by @jcastro).",
|
||||
"Search in the main menu across ProxMenux options, OCI applications and Helper-Scripts, and Helper-Scripts addons run on the host or inside the LXC they belong to.",
|
||||
"Backup jobs of VMs and LXCs listed, created, edited and run from the Backup tab (contributed by @MattiaC46); VM and CT backups can be deleted and the retention of each backup storage edited (contributed by @jcastro).",
|
||||
"Notifications in the reader's language (contributed by @Vaso73), a delivery history in the notification settings and grouped application update notifications.",
|
||||
"The NVIDIA Container Toolkit installs with the driver, a ZRAM swap profile joins the customizable post-install (contributed by @phyrexia) and FygoOS joins the NAS systems of Create VM.",
|
||||
],
|
||||
changed: [
|
||||
"Read-only API tokens only read: every action that changes something, opening a terminal and revealing a stored credential require a full-admin token (reported by @f3rs3n).",
|
||||
"Docker applications are registered with their own identity while their updates stay tied to the image (contributed by @byGarcia).",
|
||||
"The observations of a disk belong to its serial number, and idle mechanical disks are left to spin down (reported by @spectante).",
|
||||
"Find applications recognises services installed by hand and Docker containers with published ports, and LXC package checks and installs are separate steps (contributed by @Vaso73).",
|
||||
"The node name in the header opens the other Monitors of the cluster and the configured PBS servers.",
|
||||
],
|
||||
fixed: [
|
||||
"Proxmox notifications arrive with HTTPS enabled on the Monitor, through a local listener that needs no certificate.",
|
||||
"The Monitor leaves /etc/pve alone while Proxmox restarts its cluster service (reported by @shtefko).",
|
||||
"Host backups work on hosts with SELinux (reported by @sbaghdadi), and the node web certificate is restored on the node it belongs to (reported by @tropicaljoe).",
|
||||
"Log2RAM keeps the newest records of the journal and compresses rotated logs at once (reported by @f3rs3n).",
|
||||
"Changing the mode of a GPU leaves the devices that share its slot alone (reported by @LucasR3C3).",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.6": {
|
||||
date: "September 2, 2026",
|
||||
changes: {
|
||||
@@ -304,10 +333,30 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
// build-i18n-messages workflow feeds to Google Translate for locales
|
||||
// that haven't been curated by hand.
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.auditAssessment",
|
||||
text: "Audit & Report — a new page that documents and assesses the node. The Assessment runs a catalogue of checks across security, backups, capacity, storage, network, hardware, guests and system, classifying each finding as critical, warning, observation or conformant and stating what it read rather than judging it.",
|
||||
},
|
||||
{
|
||||
icon: <ScrollText className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.changeJournal",
|
||||
text: "Change journal — a Changes view that lists exactly what ProxMenux modified on this host: every configuration file, package and service it touched, with the prior state of each, deduplicated to a current-state view that shows the host as it stands now rather than a log of every run.",
|
||||
},
|
||||
{
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.auditReports",
|
||||
text: "Reports — a full audit plus focused Security review, Backup assurance and Capacity & wear reports, each opening on its own posture header, and a printable Inventory; all export to PDF. A security assessment asks before running Lynis so a run stays fast.",
|
||||
},
|
||||
{
|
||||
icon: <Sliders className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.auditPolicyBaseline",
|
||||
text: "Policy and baseline — declare what the host is expected to be (backup requirements, firewall, root SSH login) so findings grade against it, mark a run as the reference, and see what changed since, with new, resolved and accepted findings kept apart.",
|
||||
},
|
||||
{
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociManager",
|
||||
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.",
|
||||
key: "releaseNotes.currentFeatures.ociCatalog",
|
||||
text: "OCI manager Apps (Technology Preview) — 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 514 applications.",
|
||||
},
|
||||
{
|
||||
icon: <ScrollText className="h-5 w-5" />,
|
||||
@@ -325,14 +374,14 @@ const CURRENT_VERSION_FEATURES = [
|
||||
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: <RefreshCw className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociWatchdog",
|
||||
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.pbsSupport",
|
||||
text: "ProxMenux on Proxmox Backup Server — it installs with the same command and opens a menu of its own, with post-install and a Monitor showing the health, datastores and tasks of the server (contributed by @jcastro).",
|
||||
},
|
||||
{
|
||||
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: <Search className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.menuSearch",
|
||||
text: "Search in the main menu — a name or part of it finds a ProxMenux option, an OCI application or a Helper-Script, and opens it at its first screen.",
|
||||
},
|
||||
{
|
||||
icon: <Calendar className="h-5 w-5" />,
|
||||
@@ -344,11 +393,6 @@ const CURRENT_VERSION_FEATURES = [
|
||||
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).",
|
||||
},
|
||||
]
|
||||
|
||||
// Turn any "@handle" mention inside a release-notes string into a
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { scriptCompletion, scriptCompletionOutput } from "@/lib/script-completion"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface WebInteraction {
|
||||
@@ -52,6 +53,9 @@ interface ScriptTerminalModalProps {
|
||||
params?: Record<string, string>
|
||||
completedSuccessfullyMessage?: string
|
||||
completedWithErrorMessage?: (exitCode: number) => string
|
||||
cancelledExitCode?: number
|
||||
// Only LXC Updates opts into a separate server-owned job transport.
|
||||
backgroundUpdate?: { runId: string; vmid: number; resume: boolean }
|
||||
// Optional callback fired when the script's WebSocket closes
|
||||
// (script_runner sends an exit code and then closes). Lets the
|
||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||
@@ -69,6 +73,8 @@ export function ScriptTerminalModal({
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
completedSuccessfullyMessage,
|
||||
completedWithErrorMessage,
|
||||
cancelledExitCode,
|
||||
backgroundUpdate,
|
||||
onComplete,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const t = useT()
|
||||
@@ -78,6 +84,7 @@ export function ScriptTerminalModal({
|
||||
// after dynamic imports resolve — captures the latest value without
|
||||
// re-binding the closure.
|
||||
const isOpenRef = useRef<boolean>(false)
|
||||
const openGenerationRef = useRef(0)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
@@ -90,6 +97,7 @@ export function ScriptTerminalModal({
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const completionReceivedRef = useRef(false)
|
||||
const outputOffsetRef = useRef(0)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
|
||||
@@ -103,10 +111,12 @@ export function ScriptTerminalModal({
|
||||
|
||||
const getCompletionMessage = useCallback(
|
||||
(exitCode: number) =>
|
||||
exitCode === 0
|
||||
scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
|
||||
? t("scriptTerminal.cancelled")
|
||||
: exitCode === 0
|
||||
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
|
||||
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, t],
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
|
||||
)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -125,7 +135,7 @@ export function ScriptTerminalModal({
|
||||
}, [onComplete])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
if (!isOpenRef.current || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,12 +149,20 @@ export function ScriptTerminalModal({
|
||||
reconnectTimeoutRef.current = setTimeout(async () => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
|
||||
if (wsRef.current) {
|
||||
// Replacing a stale socket is not a script result. Its close event
|
||||
// must not complete the operation before the new socket reports it.
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onerror = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
const generation = openGenerationRef.current
|
||||
const ticketedUrl = await getTicketedWsUrl(wsUrl)
|
||||
if (!isOpenRef.current || !termRef.current || generation !== openGenerationRef.current) return
|
||||
const ws = new WebSocket(ticketedUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -160,9 +178,11 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
|
||||
offset: outputOffsetRef.current } : {}),
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
@@ -176,6 +196,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (completionReceivedRef.current) return
|
||||
// Filter out pong responses from heartbeat
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
@@ -184,15 +205,15 @@ const initMessage = {
|
||||
// The PTY worker always emits this final line. Treat it as a
|
||||
// completion fallback because some WebSocket servers tear down the
|
||||
// connection before the following structured message is flushed.
|
||||
const exitMatch = typeof event.data === "string"
|
||||
const exitMatch = !backgroundUpdate && typeof event.data === "string"
|
||||
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
termRef.current?.write(event.data)
|
||||
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -200,6 +221,31 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "job_output") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
if (msg.truncated) termRef.current?.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
termRef.current?.write(msg.text)
|
||||
outputOffsetRef.current = msg.offset
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_unavailable") {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
return
|
||||
}
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
@@ -220,10 +266,15 @@ const initMessage = {
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
if (backgroundUpdate) {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -244,6 +295,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isOpenRef.current) return
|
||||
setConnectionStatus("offline")
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
@@ -255,13 +307,17 @@ const initMessage = {
|
||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
if (backgroundUpdate) {
|
||||
termRef.current?.writeln(t("vmLxc.updates.backgroundDetached"))
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t, backgroundUpdate])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
@@ -289,12 +345,13 @@ const initMessage = {
|
||||
// otherwise we attach a Terminal to a stale ref and open a WS that
|
||||
// nobody reads.
|
||||
const wasOpenAtCall = isOpenRef.current
|
||||
const generation = ++openGenerationRef.current
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
import("xterm/css/xterm.css"),
|
||||
])
|
||||
if (!wasOpenAtCall || !isOpenRef.current) return
|
||||
if (!wasOpenAtCall || !isOpenRef.current || generation !== openGenerationRef.current) return
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
@@ -355,7 +412,9 @@ const initMessage = {
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
const ticketedUrl = await getTicketedWsUrl(wsUrl)
|
||||
if (!isOpenRef.current || termRef.current !== term) return
|
||||
const ws = new WebSocket(ticketedUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -373,6 +432,8 @@ const initMessage = {
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
|
||||
vmid: backgroundUpdate.vmid, offset: outputOffsetRef.current } : {}),
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
@@ -392,6 +453,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (completionReceivedRef.current) return
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
@@ -400,15 +462,15 @@ const initMessage = {
|
||||
// See the reconnect handler above. The exit line is guaranteed to be
|
||||
// sent with the PTY output and is therefore a robust fallback when a
|
||||
// final JSON frame is lost during server-side socket teardown.
|
||||
const exitMatch = typeof event.data === "string"
|
||||
const exitMatch = !backgroundUpdate && typeof event.data === "string"
|
||||
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
term.write(event.data)
|
||||
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -416,7 +478,32 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "job_output") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
if (msg.truncated) term.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
term.write(msg.text)
|
||||
outputOffsetRef.current = msg.offset
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_unavailable") {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
return
|
||||
}
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
@@ -438,10 +525,15 @@ const initMessage = {
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
if (backgroundUpdate) {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -466,6 +558,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isOpenRef.current) return
|
||||
setConnectionStatus("offline")
|
||||
if (!completionReceivedRef.current) {
|
||||
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
|
||||
@@ -477,8 +570,13 @@ const initMessage = {
|
||||
}
|
||||
|
||||
if (!completionReceivedRef.current && !isComplete) {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
if (backgroundUpdate) {
|
||||
term.writeln(t("vmLxc.updates.backgroundDetached"))
|
||||
attemptReconnect()
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +638,7 @@ const initMessage = {
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
openGenerationRef.current++
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
@@ -566,6 +665,7 @@ const initMessage = {
|
||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||
reconnectAttemptsRef.current = 0
|
||||
completionReceivedRef.current = false
|
||||
outputOffsetRef.current = 0
|
||||
setIsComplete(false)
|
||||
setInteractionInput("")
|
||||
setCurrentInteraction(null)
|
||||
@@ -574,6 +674,28 @@ const initMessage = {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (!backgroundUpdate) return // Transport mode is fixed for this instance.
|
||||
// Conditional parents can unmount an open modal. Detach that viewer too;
|
||||
// only the background job owns its process and completion result.
|
||||
isOpenRef.current = false
|
||||
openGenerationRef.current++
|
||||
for (const ref of [checkConnectionInterval, keepAliveIntervalRef]) {
|
||||
if (ref.current) clearInterval(ref.current)
|
||||
}
|
||||
for (const ref of [waitingTimeoutRef, reconnectTimeoutRef]) {
|
||||
if (ref.current) clearTimeout(ref.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
termRef.current?.dispose()
|
||||
termRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
const width = window.innerWidth
|
||||
@@ -629,8 +751,11 @@ const initMessage = {
|
||||
}, [isOpen, isComplete, attemptReconnect])
|
||||
|
||||
const getScriptWebSocketUrl = (sid: string): string => {
|
||||
const route = backgroundUpdate
|
||||
? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}`
|
||||
: `/ws/script/${sid}`
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://localhost:${API_PORT}/ws/script/${sid}`
|
||||
return `ws://localhost:${API_PORT}${route}`
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
@@ -638,9 +763,9 @@ const initMessage = {
|
||||
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
if (isStandardPort) {
|
||||
return `${wsProtocol}//${hostname}/ws/script/${sid}`
|
||||
return `${wsProtocol}//${hostname}${route}`
|
||||
} else {
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}${route}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,6 +777,10 @@ const initMessage = {
|
||||
if (value === "cancel" || value === "") {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
if (backgroundUpdate) {
|
||||
wsRef.current.send(JSON.stringify({ type: "cancel" }))
|
||||
return
|
||||
}
|
||||
handleCloseModal()
|
||||
return
|
||||
}
|
||||
@@ -814,7 +943,7 @@ const initMessage = {
|
||||
)}
|
||||
|
||||
{(isMobile || isTablet) && (
|
||||
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -918,6 +1047,10 @@ const initMessage = {
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
@@ -66,7 +66,7 @@ function validatePasswordStrength(pw: string, t: (key: string) => string): strin
|
||||
return null
|
||||
}
|
||||
|
||||
export function Security() {
|
||||
export function Security({ hostType = "pve" }: { hostType?: "pve" | "pbs" } = {}) {
|
||||
const { language, t } = useI18n()
|
||||
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
|
||||
const interfaceTypeLabel = (type: string) =>
|
||||
@@ -292,9 +292,11 @@ export function Security() {
|
||||
checkAuthStatus()
|
||||
loadApiTokens()
|
||||
loadSslStatus()
|
||||
loadFirewallStatus()
|
||||
loadNetworkInterfaces()
|
||||
loadSecurityTools()
|
||||
if (hostType === "pve") {
|
||||
loadFirewallStatus()
|
||||
loadNetworkInterfaces()
|
||||
loadSecurityTools()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadFirewallStatus = async () => {
|
||||
@@ -2778,6 +2780,7 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
)}
|
||||
|
||||
{/* ── Proxmox VE Security Group ── */}
|
||||
{hostType === "pve" && <>
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-orange-500">Proxmox VE</h2>
|
||||
<div className="flex-1 h-px bg-orange-500/20" />
|
||||
@@ -4833,6 +4836,8 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>}
|
||||
|
||||
<TwoFactorSetup
|
||||
open={show2FASetup}
|
||||
onClose={() => setShow2FASetup(false)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
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"
|
||||
@@ -9,6 +9,7 @@ import { NotificationSettings } from "./notification-settings"
|
||||
import { HealthThresholds } from "./health-thresholds"
|
||||
import { LxcUpdateDetection } from "./lxc-update-detection"
|
||||
import { DiskExclusions } from "./disk-exclusions"
|
||||
import { optimizationDescription, optimizationToolName } from "../lib/optimization-descriptions"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Switch } from "./ui/switch"
|
||||
@@ -16,8 +17,10 @@ import { Input } from "./ui/input"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import type { LanguageCode } from "../lib/i18n/languages"
|
||||
import { NavTabOrderCard } from "./nav-tab-order-card"
|
||||
import { optimizationSourceError, optimizationSourceName, type OptimizationSourceError, type OptimizationSourceResponse } from "../lib/optimization-source-errors"
|
||||
|
||||
// GitHub Dark color palette for bash syntax highlighting
|
||||
const BASH_KEYWORDS = new Set([
|
||||
@@ -312,6 +315,7 @@ interface NetworkInterface {
|
||||
|
||||
export function Settings() {
|
||||
const { language, setLanguage, t } = useI18n()
|
||||
const isPbs = useIsPbs()
|
||||
const tFallback = (key: string, fallback: string) => {
|
||||
const translated = t(key)
|
||||
return translated === key ? fallback : translated
|
||||
@@ -352,15 +356,31 @@ export function Settings() {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
toolName: string
|
||||
toolKey: string
|
||||
version: string
|
||||
availableVersion: string
|
||||
functionName: string
|
||||
source: string
|
||||
script: string
|
||||
error: string
|
||||
error: OptimizationSourceError | null
|
||||
deprecated: boolean
|
||||
}>({ open: false, loading: false, toolName: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: '', deprecated: false })
|
||||
}>({ open: false, loading: false, toolName: '', toolKey: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: null, deprecated: false })
|
||||
const [codeCopied, setCodeCopied] = useState(false)
|
||||
const codeRequest = useRef(0)
|
||||
const closeCodeModal = () => {
|
||||
codeRequest.current += 1
|
||||
setCodeModal(prev => ({ ...prev, open: false }))
|
||||
setCodeCopied(false)
|
||||
}
|
||||
useEffect(() => () => { codeRequest.current += 1 }, [])
|
||||
useEffect(() => {
|
||||
if (!codeModal.open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeCodeModal()
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [codeModal.open])
|
||||
|
||||
// Health Monitor suppression settings
|
||||
const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([])
|
||||
@@ -578,7 +598,7 @@ export function Settings() {
|
||||
if (entries.length === 0) return
|
||||
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
|
||||
const title = entries.length === 1
|
||||
? t("settings.optimizations.updateOneTitle", { name: entries[0].name })
|
||||
? t("settings.optimizations.updateOneTitle", { name: optimizationToolName(entries[0].key, entries[0].name, t) })
|
||||
: t("settings.optimizations.updateManyTitle", { count: entries.length })
|
||||
const description = entries.length === 1
|
||||
? t("settings.optimizations.updateOneDescription", { functionName: entries[0].function, source: entries[0].source })
|
||||
@@ -661,31 +681,39 @@ export function Settings() {
|
||||
}
|
||||
|
||||
const viewToolSource = async (tool: ProxMenuxTool) => {
|
||||
const request = ++codeRequest.current
|
||||
setCodeCopied(false)
|
||||
setCodeModal({
|
||||
open: true,
|
||||
loading: true,
|
||||
toolName: tool.name,
|
||||
toolKey: tool.key,
|
||||
version: tool.version || '1.0',
|
||||
availableVersion: tool.available_version || tool.version || '1.0',
|
||||
functionName: '',
|
||||
source: '',
|
||||
script: '',
|
||||
error: '',
|
||||
error: null,
|
||||
deprecated: !!tool.deprecated,
|
||||
})
|
||||
try {
|
||||
const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`)
|
||||
if (data.success) {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, functionName: data.function, source: data.source, script: data.script, deprecated: !!data.deprecated }))
|
||||
const data = await fetchApi<OptimizationSourceResponse | null>(`/api/proxmenux/tool-source/${tool.key}`)
|
||||
if (request !== codeRequest.current) return
|
||||
if (data?.success === true && typeof data.source === "string" && data.source.trim()) {
|
||||
const source = data.source
|
||||
setCodeModal(prev => ({ ...prev, loading: false, functionName: typeof data.function === "string" ? data.function : '', source, script: typeof data.script === "string" ? data.script : '', deprecated: !!data.deprecated }))
|
||||
} else {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: data.error || 'Source code not available' }))
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "unavailable", detail: typeof data?.error === "string" ? data.error : undefined } }))
|
||||
}
|
||||
} catch {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' }))
|
||||
} catch (error: any) {
|
||||
if (request !== codeRequest.current) return
|
||||
const detail = typeof error?.body?.error === "string" ? error.body.error : undefined
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "loadFailed", detail } }))
|
||||
}
|
||||
}
|
||||
|
||||
const copySourceCode = async () => {
|
||||
const request = codeRequest.current
|
||||
const text = codeModal.source
|
||||
let ok = false
|
||||
|
||||
@@ -719,9 +747,9 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
if (ok && request === codeRequest.current) {
|
||||
setCodeCopied(true)
|
||||
setTimeout(() => setCodeCopied(false), 2000)
|
||||
setTimeout(() => { if (request === codeRequest.current) setCodeCopied(false) }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1124,7 +1152,7 @@ export function Settings() {
|
||||
</Card>
|
||||
|
||||
{/* Navigation Tab Order — user-orderable top-level tabs */}
|
||||
<NavTabOrderCard />
|
||||
{!isPbs && <NavTabOrderCard />}
|
||||
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
@@ -1280,7 +1308,7 @@ export function Settings() {
|
||||
|
||||
{/* Per-category rows */}
|
||||
<div className="divide-y divide-border/50">
|
||||
{suppressionCategories.map((cat) => {
|
||||
{suppressionCategories.filter(cat => !isPbs || healthCategoryKey(cat) !== "vms").map((cat) => {
|
||||
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
|
||||
const effectiveHours = getEffectiveHours(cat)
|
||||
const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
|
||||
@@ -1288,7 +1316,8 @@ export function Settings() {
|
||||
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
|
||||
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
|
||||
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
|
||||
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
|
||||
const categoryKey = isPbs && healthCategoryKey(cat) === "services" ? "pbsServices" : healthCategoryKey(cat)
|
||||
const catLabel = tFallback(`settings.healthMonitor.categories.${categoryKey}`, cat.label)
|
||||
|
||||
return (
|
||||
<div key={cat.key}>
|
||||
@@ -1496,6 +1525,7 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isPbs && <>
|
||||
{/* Remote Storage Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1678,6 +1708,7 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Network Interface Exclusions */}
|
||||
<Card>
|
||||
@@ -1873,8 +1904,9 @@ export function Settings() {
|
||||
the matching toggle in NotificationSettings is hidden (the
|
||||
preference is preserved in the DB and reappears when detection
|
||||
is re-enabled). */}
|
||||
<LxcUpdateDetection />
|
||||
{!isPbs && <LxcUpdateDetection />}
|
||||
|
||||
{!isPbs && <>
|
||||
{/* GitHub API — optional authentication for app upstream checks. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1981,15 +2013,16 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Notification Settings */}
|
||||
<NotificationSettings />
|
||||
{/* Notification Settings (PVE only for now) */}
|
||||
{!isPbs && <NotificationSettings />}
|
||||
|
||||
{/* Issue #195: snippets storage selector. Only renders when more
|
||||
than one storage advertises content=snippets — on a typical
|
||||
standalone host with just `local` there's nothing to choose,
|
||||
so showing an empty selector would be noise. */}
|
||||
{snippetsCandidates.length > 1 && (
|
||||
{!isPbs && snippetsCandidates.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -2110,7 +2143,7 @@ export function Settings() {
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
hasUpdate ? 'bg-purple-400' : (isDeprecated ? 'bg-amber-500' : 'bg-green-500')
|
||||
}`} />
|
||||
<span className="text-sm font-medium truncate">{tool.name}</span>
|
||||
<span className="text-sm font-medium truncate">{optimizationToolName(tool.key, tool.name, t)}</span>
|
||||
{isDeprecated && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
{t("settings.optimizations.legacy")}
|
||||
@@ -2126,7 +2159,7 @@ export function Settings() {
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
|
||||
className="text-purple-300 hover:text-purple-200 transition-colors"
|
||||
title={t("settings.optimizations.updateToolTitle", { name: tool.name, version: tool.available_version || "?" })}
|
||||
title={t("settings.optimizations.updateToolTitle", { name: optimizationToolName(tool.key, tool.name, t), version: tool.available_version || "?" })}
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -2146,10 +2179,13 @@ export function Settings() {
|
||||
|
||||
{/* Code Viewer Modal */}
|
||||
{codeModal.open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ margin: 0 }} onClick={closeCodeModal}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("settings.optimizations.sourceDialogTitle", { name: optimizationSourceName(codeModal.toolKey, codeModal.toolName, t) })}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -2158,7 +2194,7 @@ export function Settings() {
|
||||
<Code className={`h-5 w-5 flex-shrink-0 ${codeModal.deprecated ? 'text-amber-500' : 'text-orange-500'}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
|
||||
<h3 className="text-sm font-semibold truncate">{optimizationSourceName(codeModal.toolKey, codeModal.toolName, t)}</h3>
|
||||
{codeModal.deprecated && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
{t("settings.optimizations.legacy")}
|
||||
@@ -2194,7 +2230,8 @@ export function Settings() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}
|
||||
onClick={closeCodeModal}
|
||||
aria-label={t("actions.close")}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -2204,13 +2241,13 @@ export function Settings() {
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-0">
|
||||
{codeModal.loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center justify-center py-16" role="status" aria-label={t("settings.optimizations.sourceLoading")}>
|
||||
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : codeModal.error ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Code className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">{codeModal.error}</p>
|
||||
<p className="text-sm" role="alert">{optimizationSourceError(codeModal.error, t)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
@@ -2282,13 +2319,13 @@ export function Settings() {
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{tool.name}</span>
|
||||
<span className="text-sm font-medium">{optimizationToolName(tool.key, tool.name, t)}</span>
|
||||
<span className="text-[10px] text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.5 rounded font-mono">
|
||||
v{tool.version || '1.0'} → v{tool.available_version || '?'}
|
||||
</span>
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-snug">{tool.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-snug">{optimizationDescription(tool.description, t)}</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -231,7 +231,8 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
|
||||
return out.length ? out.join('; ') : null
|
||||
}
|
||||
|
||||
export function StorageOverview() {
|
||||
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
|
||||
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
|
||||
const t = useT()
|
||||
|
||||
// User-configurable disk temperature thresholds (Settings → Health
|
||||
@@ -284,7 +285,7 @@ export function StorageOverview() {
|
||||
try {
|
||||
const [data, proxmoxData, mountsData] = await Promise.all([
|
||||
fetchApi<StorageData>("/api/storage"),
|
||||
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
|
||||
disksOnly ? Promise.resolve(null) : fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
|
||||
// Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch
|
||||
// so a failure here doesn't blank the whole storage tab.
|
||||
fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
|
||||
@@ -1134,7 +1135,7 @@ export function StorageOverview() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{!disksOnly && <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
|
||||
{(() => {
|
||||
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
|
||||
@@ -1355,9 +1356,9 @@ export function StorageOverview() {
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
{!disksOnly && proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
|
||||
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic } from "../lib/pbs-messages"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
@@ -12,7 +16,7 @@ import { ProcessDetailModal } from "./process-detail-modal"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { formatBytes as formatByteSize, formatStorage } from "../lib/utils"
|
||||
import { getCountFormKey, useI18n, useT } from "../lib/i18n/provider"
|
||||
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
||||
|
||||
@@ -21,6 +25,19 @@ interface TempDataPoint {
|
||||
value: number
|
||||
}
|
||||
|
||||
interface ZramDevice {
|
||||
name: string
|
||||
active: boolean
|
||||
capacity_bytes: number
|
||||
swap_used_bytes: number
|
||||
original_bytes: number
|
||||
compressed_bytes: number
|
||||
memory_used_bytes: number
|
||||
algorithm: string | null
|
||||
compression_ratio: number | null
|
||||
saved_bytes: number
|
||||
}
|
||||
|
||||
interface SystemData {
|
||||
cpu_usage: number
|
||||
cpu_user?: number // preview restyle
|
||||
@@ -29,6 +46,7 @@ interface SystemData {
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_cached?: number // preview restyle
|
||||
zram?: { available: boolean; devices: ZramDevice[] }
|
||||
temperature: number
|
||||
temperature_sparkline?: TempDataPoint[]
|
||||
uptime: string
|
||||
@@ -171,9 +189,23 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
export function SystemOverview() {
|
||||
interface PBSOverviewStatus {
|
||||
datastores: PBSDatastore[] | null
|
||||
errors?: Record<string, string>
|
||||
error_details?: Record<string, { code: string }>
|
||||
}
|
||||
|
||||
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const isPbs = product === "pbs"
|
||||
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<PBSOverviewStatus>(
|
||||
isPbs ? "/api/pbs/status?server=local" : null,
|
||||
(url: string) => fetchApi<PBSOverviewStatus>(url),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false },
|
||||
)
|
||||
const datastores = pbsStatus?.datastores ?? null
|
||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||
const [vmData, setVmData] = useState<VMData[]>([])
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
@@ -198,8 +230,8 @@ export function SystemOverview() {
|
||||
const fetchAllData = async () => {
|
||||
const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([
|
||||
fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))),
|
||||
fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
|
||||
Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() =>
|
||||
(isPbs ? Promise.resolve([]) : fetchVMData()).finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
|
||||
Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]).finally(() =>
|
||||
setLoadingStates((prev) => ({ ...prev, storage: false })),
|
||||
),
|
||||
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
|
||||
@@ -234,12 +266,13 @@ export function SystemOverview() {
|
||||
}, 5000)
|
||||
|
||||
const vmInterval = setInterval(async () => {
|
||||
if (isPbs) return
|
||||
const data = await fetchVMData()
|
||||
setVmData(data)
|
||||
}, 59000)
|
||||
|
||||
const storageInterval = setInterval(async () => {
|
||||
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()])
|
||||
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()])
|
||||
if (storage) setStorageData(storage)
|
||||
if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage)
|
||||
}, 59000)
|
||||
@@ -264,7 +297,7 @@ export function SystemOverview() {
|
||||
clearInterval(networkInterval)
|
||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}
|
||||
}, [t])
|
||||
}, [t, isPbs])
|
||||
|
||||
if (!hasAttemptedLoad || loadingStates.system) {
|
||||
return (
|
||||
@@ -489,10 +522,72 @@ export function SystemOverview() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{systemData.zram?.available && systemData.zram.devices.length > 0 && (
|
||||
<div className="mt-4 border-t border-border pt-3 space-y-3">
|
||||
{systemData.zram.devices.map((device) => (
|
||||
<div key={device.name} className="text-xs">
|
||||
<div className="font-medium mb-2">{t("overview.zram")} <span className="font-mono text-muted-foreground">{device.name}</span></div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<span className="text-muted-foreground">{t("overview.zramCapacity")}</span>
|
||||
<span className="text-right font-mono">{formatByteSize(device.capacity_bytes)}</span>
|
||||
<span className="text-muted-foreground">{t("overview.zramSwapUsed")}</span>
|
||||
<span className="text-right font-mono">{formatByteSize(device.swap_used_bytes)}</span>
|
||||
<span className="text-muted-foreground">{t("overview.zramAlgorithm")}</span>
|
||||
<span className="text-right font-mono">{device.algorithm ?? "—"}</span>
|
||||
<span className="text-muted-foreground">{t("overview.zramCompressionRatio")}</span>
|
||||
<span className="text-right font-mono">{device.compression_ratio === null ? "—" : `${device.compression_ratio.toFixed(2)}:1`}</span>
|
||||
<span className="text-muted-foreground">{t("overview.zramPhysicalMemory")}</span>
|
||||
<span className="text-right font-mono">{formatByteSize(device.memory_used_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||
{isPbs ? (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("pbs.datastores")}</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : !datastores ? (
|
||||
<div role="alert" className="text-sm text-amber-500">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-4xl font-bold leading-none text-foreground">{datastores.length}</span>
|
||||
{datastores.some(store => store.error) ? (
|
||||
<Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
||||
{t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{t("pbs.datastoresOnline")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{datastores.slice(0, 2).map(store => <DatastoreUsageRow key={store.name} store={store} compact />)}
|
||||
{datastores.length > 2 && (
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.moreDatastores", { count: datastores.length - 2 })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
|
||||
@@ -530,6 +625,7 @@ export function SystemOverview() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
|
||||
@@ -673,7 +769,20 @@ export function SystemOverview() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
{isPbs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.loading")}</div>
|
||||
) : !datastores ? (
|
||||
<div role="alert" className="py-4 text-amber-500 text-sm">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
|
||||
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
|
||||
</div>
|
||||
) : vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -709,7 +818,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localStorage && (
|
||||
{!isPbs && localStorage && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -864,7 +973,7 @@ export function SystemOverview() {
|
||||
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
|
||||
<span className="text-muted-foreground">{t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")}</span>
|
||||
<span className="text-foreground">{systemData.proxmox_version || t("app.notAvailable")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -1038,6 +1038,10 @@ const handleClose = () => {
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import {
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { RETENTION, parseRetention, buildRetention } from "../lib/backup-retention"
|
||||
|
||||
interface VmBackupJob {
|
||||
id: string
|
||||
@@ -70,24 +71,6 @@ const EMPTY_FORM: FormState = {
|
||||
mode: "snapshot", compress: "zstd", retention: {}, keepAll: false, notes: "{{guestname}}", attached: [],
|
||||
}
|
||||
|
||||
// The limits a job can keep, in the order Proxmox lists them.
|
||||
const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
const parseRetention = (prune: string) => {
|
||||
const fields: Record<string, string> = {}
|
||||
let keepAll = false
|
||||
for (const part of prune.split(",")) {
|
||||
const [key, value] = part.trim().split("=")
|
||||
if (key === "keep-all") keepAll = value === "1"
|
||||
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
|
||||
}
|
||||
return { fields, keepAll }
|
||||
}
|
||||
|
||||
const buildRetention = (fields: Record<string, string>, keepAll: boolean) => {
|
||||
const parts = RETENTION.filter((key) => Number(fields[key]) > 0).map((key) => `keep-${key}=${Number(fields[key])}`)
|
||||
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
|
||||
}
|
||||
|
||||
const isOn = (value: unknown) => value === 1 || value === true || value === "1"
|
||||
const jobEnabled = (job: VmBackupJob) => job.enabled === undefined || isOn(job.enabled)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
|
||||
"_updated": "2026-09-02",
|
||||
"_updated": "2026-10-26",
|
||||
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
|
||||
"groq": {
|
||||
"models": [
|
||||
"allam-2-7b",
|
||||
"qwen/qwen3.8-27b",
|
||||
"openai/gpt-oss-20b",
|
||||
"groq/compound-mini"
|
||||
"openai/gpt-oss-120b"
|
||||
],
|
||||
"recommended": "openai/gpt-oss-20b",
|
||||
"_note": "Verified 2026-09-02 with the Groq API. 4 of 12 tested pass. `llama-3.3-70b-versatile` no longer served by Groq. Recommended kept as `openai/gpt-oss-20b` (english-capable) — `allam-2-7b` is fastest but Arabic-focused; add manually if serving Arabic notifications."
|
||||
"_note": "Verified 2026-10-26 with the Groq API. 4 of 8 tested pass. `groq/compound-mini` is no longer served and was dropped; `openai/gpt-oss-120b` added. Recommended kept as `openai/gpt-oss-20b` (english-capable) — `allam-2-7b` is fastest but Arabic-focused."
|
||||
},
|
||||
"gemini": {
|
||||
"models": [
|
||||
@@ -21,7 +21,10 @@
|
||||
"gemini-flash-lite-latest",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash"
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.7-flash",
|
||||
"gemini-3.8-flash"
|
||||
],
|
||||
"recommended": "gemini-2.5-flash-lite",
|
||||
"_deprecated": [
|
||||
@@ -31,50 +34,46 @@
|
||||
"gemini-1.0-pro",
|
||||
"gemini-pro"
|
||||
],
|
||||
"_note": "Verified 2026-09-02. gemini-flash-lite-latest passes consistently but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift. gemini-3.1-flash-lite is the stable successor to 3-flash-preview. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation."
|
||||
"_note": "Verified 2026-10-26. Added gemini-3.6-flash, 3.7-flash and 3.8-flash. gemini-flash-lite-latest passes consistently but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation; gemini-4-argon passes but takes ~20s per message."
|
||||
},
|
||||
"openai": {
|
||||
"models": [
|
||||
"gpt-4.1-nano-2025-04-14",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-2025-04-14",
|
||||
"gpt-4.1",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-mini",
|
||||
"gpt-3.5-turbo-16k",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-5-nano",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo-0125",
|
||||
"gpt-5-nano-2025-08-07",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-mini-2025-04-14",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-0613"
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-mini-2026-03-17",
|
||||
"gpt-6-sol",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-2025-12-11",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-2026-03-05",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-2026-04-23",
|
||||
"gpt-6-astra"
|
||||
],
|
||||
"recommended": "gpt-4.1-nano",
|
||||
"_note": "Verified 2026-09-02 with the OpenAI API — 22 of 74 tested pass. Includes stable aliases (gpt-4.1-nano, gpt-4o-mini, ...), dated snapshots for pinning (gpt-4o-2024-11-20, gpt-4.1-nano-2025-04-14) and legacy families (gpt-3.5-turbo, gpt-4) for cost-optimised use cases. Recommended kept as `gpt-4.1-nano` (stable alias, 1.39s)."
|
||||
"recommended": "gpt-6-luna",
|
||||
"_note": "Verified 2026-10-26 against the live API. Recommended `gpt-6-luna` (cheapest current model). Dropped every model that is retired (gpt-4.1-nano, gpt-3.5-turbo, gpt-4, gpt-4-turbo, ...) or has a shutdown date (gpt-4.1, gpt-4o, gpt-4o-mini, gpt-5-nano, gpt-5.1, gpt-5.4-nano)."
|
||||
},
|
||||
"anthropic": {
|
||||
"models": [
|
||||
"claude-haiku-5-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-5-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-opus-4-6"
|
||||
"claude-fable-5-1"
|
||||
],
|
||||
"recommended": "claude-haiku-4-5-20251001",
|
||||
"_note": "Verified 2026-09-02 — 9 of 11 tested pass. The `claude-haiku-4-5` alias no longer resolves upstream; the working snapshot is `claude-haiku-4-5-20251001` — used as recommended (2.89s). Full family surfaced: haiku/sonnet/opus/fable across generations 4.5/4.6/4.7/4.8/5."
|
||||
"recommended": "claude-haiku-5-5",
|
||||
"_note": "Verified 2026-10-26 against the live API. Added the 5.x family, recommended `claude-haiku-5-5`. Dropped `claude-sonnet-4-5` (retires 2026-11-30). `claude-opus-5` left out: false refusals on plain notifications."
|
||||
},
|
||||
"openrouter": {
|
||||
"models": [
|
||||
@@ -113,13 +112,10 @@
|
||||
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
||||
"nvidia/nemotron-3-super-120b-a12b:free",
|
||||
"google/gemma-4-26b-a4b-it:free",
|
||||
"nvidia/nemotron-nano-12b-v2-vl:free",
|
||||
"nvidia/nemotron-3-nano-30b-a3b:free",
|
||||
"poolside/laguna-s-2.1:free",
|
||||
"openai/gpt-oss-20b:free"
|
||||
"poolside/laguna-s-2.1:free"
|
||||
],
|
||||
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
||||
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test."
|
||||
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test. Reviewed 2026-10-26: three `:free` variants no longer served by OpenRouter were dropped."
|
||||
},
|
||||
"ollama": {
|
||||
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
* API Configuration for ProxMenux Monitor
|
||||
* Handles API URL generation with automatic proxy detection
|
||||
*/
|
||||
// Resolve wording only for a generic transport failure. Normal responses and
|
||||
// backend error payloads keep the transport free of locale-loading work.
|
||||
async function apiFailure(...args: Parameters<typeof import("./api-errors").apiFailure>): Promise<string> {
|
||||
const messages = await import("./api-errors")
|
||||
return messages.apiFailure(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* API Server Port Configuration
|
||||
@@ -114,7 +120,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
hadToken = true
|
||||
}
|
||||
if (!hadToken) {
|
||||
throw new Error(`Unauthorized: ${endpoint}`)
|
||||
throw new Error(await apiFailure("unauthorized", { endpoint }))
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
@@ -131,7 +137,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
throw new Error(`Unauthorized: ${endpoint}`)
|
||||
throw new Error(await apiFailure("unauthorized", { endpoint }))
|
||||
}
|
||||
// Try to surface the backend's JSON error payload instead of a
|
||||
// bare `500 INTERNAL SERVER ERROR`. The Flask routes consistently
|
||||
@@ -168,7 +174,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
throw parseErr
|
||||
}
|
||||
}
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
throw new Error(await apiFailure("http", { status: response.status, statusText: response.statusText }))
|
||||
}
|
||||
|
||||
// Check content type to ensure we're getting JSON
|
||||
@@ -176,13 +182,13 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text()
|
||||
console.error("fetchApi: Expected JSON but got:", contentType, "- Body preview:", text.substring(0, 200))
|
||||
throw new Error(`Expected JSON response but got ${contentType || "unknown content type"}`)
|
||||
throw new Error(await apiFailure("unexpectedContentType", { contentType: contentType || await apiFailure("unknownContentType") }))
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json()
|
||||
} catch (jsonError) {
|
||||
console.error("fetchApi: JSON parse error for", endpoint, "-", jsonError)
|
||||
throw new Error(`Invalid JSON response from ${endpoint}`)
|
||||
throw new Error(await apiFailure("invalidJson", { endpoint }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MESSAGE_CATALOG } from "./i18n/catalogs"
|
||||
import { detectBrowserLanguage, isSupportedLanguage, LANGUAGE_STORAGE_KEY } from "./i18n/languages"
|
||||
|
||||
type Failure = keyof (typeof import("../messages/en/common.json"))["apiErrors"]
|
||||
|
||||
// fetchApi also runs outside React hooks. Read the provider's current document
|
||||
// language, with its stored/browser preference as a fallback.
|
||||
export function apiFailure(key: Failure, params: Record<string, string | number> = {}): string {
|
||||
let preference = typeof document === "undefined" ? "" : document.documentElement?.lang
|
||||
if (!isSupportedLanguage(preference) && typeof window !== "undefined") {
|
||||
try { preference = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) || "" } catch { /* private browsing */ }
|
||||
}
|
||||
const language = isSupportedLanguage(preference) ? preference : detectBrowserLanguage()
|
||||
const messages = MESSAGE_CATALOG[language].apiErrors
|
||||
const localized = messages && typeof messages === "object" ? (messages as Record<string, unknown>)[key] : undefined
|
||||
const fallback = (MESSAGE_CATALOG.en.apiErrors as Record<Failure, string>)[key]
|
||||
const template = typeof localized === "string" ? localized : fallback
|
||||
return template.replace(/\{(\w+)\}/g, (match, name) => params[name] === undefined ? match : String(params[name]))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Native Proxmox retention properties, shared by storage and job editors.
|
||||
export const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
export function parseRetention(prune: string) {
|
||||
const fields: Record<string, string> = {}
|
||||
let keepAll = false
|
||||
for (const part of prune.split(",")) {
|
||||
const [key, value] = part.trim().split("=")
|
||||
if (key === "keep-all") keepAll = value === "1"
|
||||
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
|
||||
}
|
||||
return { fields, keepAll }
|
||||
}
|
||||
|
||||
export function buildRetention(fields: Record<string, string>, keepAll: boolean) {
|
||||
const parts = RETENTION.filter(key => Number(fields[key]) > 0).map(key => `keep-${key}=${Number(fields[key])}`)
|
||||
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fetchApi } from './api-config'
|
||||
|
||||
export interface GuestBackup {
|
||||
volid: string
|
||||
storage: string
|
||||
type: string
|
||||
size: number
|
||||
size_human: string
|
||||
timestamp: number
|
||||
date: string
|
||||
notes?: string
|
||||
protected?: boolean
|
||||
}
|
||||
|
||||
export interface BackupDeletionResult {
|
||||
volid: string
|
||||
status: 'deleted' | 'pending' | 'unconfirmed'
|
||||
task?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Exposed separately so callers can invalidate their inventory even after a
|
||||
// lost POST response. Never retry the destructive request automatically.
|
||||
export async function deleteGuestBackups(vmid: number, backups: GuestBackup[]): Promise<BackupDeletionResult[]> {
|
||||
const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirm: true, backups: backups.map(({ volid, storage }) => ({ volid, storage })) }),
|
||||
// Up to 20 archives, checked first and then deleted four at a time.
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
})
|
||||
if (!Array.isArray(response.results) || response.results.length !== backups.length ||
|
||||
response.results.some((result, index) => result.volid !== backups[index].volid ||
|
||||
!['deleted', 'pending', 'unconfirmed'].includes(result.status))) throw new Error('deletionUnconfirmed')
|
||||
|
||||
return Promise.all(response.results.map(async result => {
|
||||
if (result.status !== 'pending' || !result.task) return result
|
||||
const deadline = Date.now() + 120_000
|
||||
let failures = 0
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
try {
|
||||
const status = await fetchApi<BackupDeletionResult>(
|
||||
`/api/vms/${vmid}/backups/delete-status?task=${encodeURIComponent(result.task!)}`,
|
||||
{ signal: AbortSignal.timeout(10_000) },
|
||||
)
|
||||
if (status.volid !== result.volid || !['deleted', 'pending', 'unconfirmed'].includes(status.status)) break
|
||||
if (status.status !== 'pending') return status
|
||||
failures = 0
|
||||
} catch {
|
||||
if (++failures >= 3) break
|
||||
}
|
||||
}
|
||||
return { ...result, status: 'unconfirmed', error: 'deletionUnconfirmed' }
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Legacy HostBackup API messages are an English wire contract shared with CLI callers.
|
||||
// Match complete, source-verified messages only; never derive a translation key from input.
|
||||
// This presentation-only adapter keeps unknown tool diagnostics and parameter text intact.
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const fixed = new Map<string, string>([
|
||||
["path must be absolute", "backup.apiErrors.absolutePath"],
|
||||
["this path is already configured", "backup.apiErrors.pathConfigured"],
|
||||
["path is required", "backup.apiErrors.pathRequired"],
|
||||
["path query parameter is required", "backup.apiErrors.pathQuery"],
|
||||
["path not found", "backup.apiErrors.pathNotFound"],
|
||||
["invalid job id", "backup.apiErrors.invalidJobId"],
|
||||
["job not found", "backup.apiErrors.jobNotFound"],
|
||||
["runner script not installed", "backup.apiErrors.runnerMissing"],
|
||||
["no restore state to dismiss", "backup.apiErrors.noRestore"],
|
||||
["invalid file name", "backup.apiErrors.invalidFile"],
|
||||
["not found", "backup.apiErrors.notFound"],
|
||||
["log path outside allowed directory", "backup.apiErrors.logOutside"],
|
||||
["targets must be a list", "backup.apiErrors.targetsList"],
|
||||
["empty expression", "backup.apiErrors.emptyExpression"],
|
||||
["invalid id (use letters, digits, _ or -)", "backup.apiErrors.invalidId"],
|
||||
["backend must be pbs, local or borg", "backup.apiErrors.backend"],
|
||||
["backend must be pbs or borg", "backup.apiErrors.remoteBackend"],
|
||||
["backend must be 'pbs' or 'borg'", "backup.apiErrors.remoteBackendQuoted"],
|
||||
["profile_mode must be default or custom", "backup.apiErrors.profileMode"],
|
||||
["borg cannot be attached (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttached"],
|
||||
["borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttachedLong"],
|
||||
["pve_parent_job_id and pve_storage are required for attached", "backup.apiErrors.parentRequired"],
|
||||
["paths must be a list of absolute paths", "backup.apiErrors.absolutePaths"],
|
||||
["custom profile needs at least one path", "backup.apiErrors.customPath"],
|
||||
["could not resolve default profile paths", "backup.apiErrors.defaultPaths"],
|
||||
["retention must be an object", "backup.apiErrors.retentionObject"],
|
||||
["pbs_repository is required", "backup.apiErrors.pbsRepository"],
|
||||
["pbs_encrypt_mode must be 'none', 'new' or 'existing'", "backup.apiErrors.pbsEncryption"],
|
||||
["failed to create PBS encryption keyfile", "backup.apiErrors.pbsKeyFailed"],
|
||||
["failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", "backup.apiErrors.pbsClientMissing"],
|
||||
["borg_repo is required", "backup.apiErrors.borgRepository"],
|
||||
["borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.borgEncryption"],
|
||||
["encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.encryption"],
|
||||
["borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", "backup.apiErrors.borgPassphrase"],
|
||||
["passphrase is required when encryption is enabled", "backup.apiErrors.passphrase"],
|
||||
["password is required", "backup.apiErrors.password"],
|
||||
["confirm_device must match device exactly (typo / safety check)", "backup.apiErrors.deviceConfirmation"],
|
||||
["invalid device path", "backup.apiErrors.invalidDevice"],
|
||||
["absolute mountpoint required", "backup.apiErrors.absoluteMountpoint"],
|
||||
["archive layout not recognized — no rootfs/ found", "backup.apiErrors.archiveLayout"],
|
||||
["archive not found", "backup.apiErrors.archiveMissing"],
|
||||
["archive path is not a regular file", "backup.apiErrors.archiveNotFile"],
|
||||
["invalid name", "backup.apiErrors.invalidName"],
|
||||
["invalid staging_path", "backup.apiErrors.invalidStaging"],
|
||||
["job name cannot be changed (delete + recreate to rename)", "backup.apiErrors.jobRename"],
|
||||
["key_path must be absolute", "backup.apiErrors.absoluteKey"],
|
||||
["mode must be \"local\" or \"ssh\"", "backup.apiErrors.mode"],
|
||||
["name is required (letters, digits, _ or -)", "backup.apiErrors.nameRequired"],
|
||||
["no log available for this job yet", "backup.apiErrors.noLog"],
|
||||
["no manual PBS configs file", "backup.apiErrors.noPbsConfig"],
|
||||
["output file missing", "backup.apiErrors.outputMissing"],
|
||||
["parser output was not valid JSON", "backup.apiErrors.parserJson"],
|
||||
["run_restore output was not valid JSON", "backup.apiErrors.restoreJson"],
|
||||
["repo is required", "backup.apiErrors.repoRequired"],
|
||||
["repo_name and snapshot are required", "backup.apiErrors.repoSnapshot"],
|
||||
["repo_name and snapshot are required for borg", "backup.apiErrors.borgRepoSnapshot"],
|
||||
["repo_name and snapshot are required for pbs", "backup.apiErrors.pbsRepoSnapshot"],
|
||||
["repo_name is required", "backup.apiErrors.repoName"],
|
||||
["restore tooling not installed on this host", "backup.apiErrors.restoreMissing"],
|
||||
["server and datastore are required", "backup.apiErrors.serverDatastore"],
|
||||
["snapshot is required", "backup.apiErrors.snapshotRequired"],
|
||||
["source must be pbs, borg, or local", "backup.apiErrors.source"],
|
||||
["ssh_port must be an integer between 1 and 65535", "backup.apiErrors.sshPort"],
|
||||
["ssh_user, ssh_host and ssh_remote_path are required for ssh mode", "backup.apiErrors.sshRequired"],
|
||||
["target has no repository path", "backup.apiErrors.repositoryPath"],
|
||||
["task not found or expired", "backup.apiErrors.taskExpired"],
|
||||
["invalid expression", "backup.apiErrors.invalidExpression"],
|
||||
["no report emitted", "backup.apiErrors.noReport"],
|
||||
["parse_manifest exited non-zero", "backup.apiErrors.parseManifestFailed"],
|
||||
])
|
||||
|
||||
const parameterized: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[new RegExp("^job already exists: ([\\s\\S]+)$"), "backup.apiErrors.jobExists"],
|
||||
[new RegExp("^path does not exist on this host: ([\\s\\S]+)$"), "backup.apiErrors.pathMissing"],
|
||||
[new RegExp("^device does not exist: ([\\s\\S]+)$"), "backup.apiErrors.deviceMissing"],
|
||||
[new RegExp("^not a mountpoint: ([\\s\\S]+)$"), "backup.apiErrors.mountpoint"],
|
||||
[new RegExp("^local archive not found: ([\\s\\S]+)$"), "backup.apiErrors.localArchiveMissing"],
|
||||
[new RegExp("^Borg destination '([\\s\\S]+)' not found$"), "backup.apiErrors.borgDestination"],
|
||||
[new RegExp("^PBS destination '([\\s\\S]+)' not found$"), "backup.apiErrors.pbsDestination"],
|
||||
[new RegExp("^no password available for PBS repo '([\\s\\S]+)'$"), "backup.apiErrors.pbsPassword"],
|
||||
[new RegExp("^Borg repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.borgRepoMissing"],
|
||||
[new RegExp("^PBS repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.pbsRepoMissing"],
|
||||
[new RegExp("^Borg target not found: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetMissing"],
|
||||
[new RegExp("^PBS repo not found: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetMissing"],
|
||||
[new RegExp("^PVE vzdump job not found: ([\\s\\S]+)$"), "backup.apiErrors.pveJobMissing"],
|
||||
[new RegExp("^cannot persist state: ([\\s\\S]+)$"), "backup.apiErrors.statePersist"],
|
||||
[new RegExp("^cannot prepare ssh dir: ([\\s\\S]+)$"), "backup.apiErrors.sshDirectory"],
|
||||
[new RegExp("^cannot read log: ([\\s\\S]+)$"), "backup.apiErrors.logRead"],
|
||||
[new RegExp("^could not update \\.env: ([\\s\\S]+)$"), "backup.apiErrors.envUpdate"],
|
||||
[new RegExp("^failed to persist PBS config: ([\\s\\S]+)$"), "backup.apiErrors.pbsConfigWrite"],
|
||||
[new RegExp("^failed to persist: ([\\s\\S]+)$"), "backup.apiErrors.persist"],
|
||||
[new RegExp("^failed to remove archive: ([\\s\\S]+)$"), "backup.apiErrors.archiveRemove"],
|
||||
[new RegExp("^failed to start: ([\\s\\S]+)$"), "backup.apiErrors.start"],
|
||||
[new RegExp("^failed to update borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsUpdate"],
|
||||
[new RegExp("^failed to update pbs-manual-configs\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetsUpdate"],
|
||||
[new RegExp("^failed to update systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsUpdate"],
|
||||
[new RegExp("^failed to write borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsWrite"],
|
||||
[new RegExp("^failed to write job files: ([\\s\\S]+)$"), "backup.apiErrors.jobWrite"],
|
||||
[new RegExp("^failed to write local-target\\.conf: ([\\s\\S]+)$"), "backup.apiErrors.localTargetWrite"],
|
||||
[new RegExp("^failed to write systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsWrite"],
|
||||
[new RegExp("^format failed: ([\\s\\S]+)$"), "backup.apiErrors.format"],
|
||||
[new RegExp("^invalid on_calendar: ([\\s\\S]+)$"), "backup.apiErrors.calendar"],
|
||||
[new RegExp("^mount failed: ([\\s\\S]+)$"), "backup.apiErrors.mount"],
|
||||
[new RegExp("^parser invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.parserInvocation"],
|
||||
[new RegExp("^preflight invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.preflightInvocation"],
|
||||
[new RegExp("^public key not found after generation: ([\\s\\S]+)$"), "backup.apiErrors.publicKeyMissing"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be an integer$"), "backup.apiErrors.retentionInteger"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be non-negative$"), "backup.apiErrors.retentionNegative"],
|
||||
[new RegExp("^ssh-keygen failed: ([\\s\\S]+)$"), "backup.apiErrors.keygen"],
|
||||
[new RegExp("^systemctl failed: ([\\s\\S]+)$"), "backup.apiErrors.systemctl"],
|
||||
[new RegExp("^umount failed: ([\\s\\S]+)$"), "backup.apiErrors.unmount"],
|
||||
[new RegExp("^task is in state \"([\\s\\S]+)\", not completed$"), "backup.apiErrors.taskIncomplete"],
|
||||
[new RegExp("^unknown kind: ([\\s\\S]+)$"), "backup.apiErrors.unknownKind"],
|
||||
[new RegExp("^unknown mode \"([\\s\\S]+)\"$"), "backup.apiErrors.unknownMode"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default and cannot be removed$"), "backup.apiErrors.defaultRemove"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default; no need to add it$"), "backup.apiErrors.defaultAdd"],
|
||||
[new RegExp("^pbs_encrypt_mode='existing' but no keyfile is installed at ([\\s\\S]+) — import one first with POST /api/host-backups/pbs-encryption/import$"), "backup.apiErrors.keyImport"],
|
||||
[new RegExp("^PVE job storage mismatch \\(job uses ([\\s\\S]+), payload says ([\\s\\S]+)\\)$"), "backup.apiErrors.storageMismatch"],
|
||||
]
|
||||
|
||||
export function hostBackupErrorText(problem: unknown, t: Translate): string {
|
||||
const message = typeof problem === "string" ? problem
|
||||
: problem && typeof problem === "object" && "message" in problem && typeof problem.message === "string"
|
||||
? problem.message : ""
|
||||
if (!message) return t("backup.apiErrors.requestFailed")
|
||||
const key = fixed.get(message)
|
||||
if (key) return t(key)
|
||||
// Keep unusually large tool output verbatim, without running pattern matching over it.
|
||||
if (message.length > 16384) return message
|
||||
for (const [pattern, patternKey] of parameterized) {
|
||||
const match = pattern.exec(message)
|
||||
if (match) return t(patternKey, { value: match[1], other: match[2] ?? "" })
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import enMessages from "../../messages/en/common.json"
|
||||
import deMessages from "../../messages/de/common.json"
|
||||
import esMessages from "../../messages/es/common.json"
|
||||
import frMessages from "../../messages/fr/common.json"
|
||||
import itMessages from "../../messages/it/common.json"
|
||||
import ptMessages from "../../messages/pt/common.json"
|
||||
import skMessages from "../../messages/sk/common.json"
|
||||
import svMessages from "../../messages/sv/common.json"
|
||||
import type { LanguageCode } from "./languages"
|
||||
|
||||
type MessageTree = Record<string, unknown>
|
||||
|
||||
// Shared by React translations and error handling outside React hooks.
|
||||
export const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
|
||||
en: enMessages as MessageTree,
|
||||
de: deMessages as MessageTree,
|
||||
es: esMessages as MessageTree,
|
||||
fr: frMessages as MessageTree,
|
||||
it: itMessages as MessageTree,
|
||||
pt: ptMessages as MessageTree,
|
||||
sk: skMessages as MessageTree,
|
||||
sv: svMessages as MessageTree,
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
||||
import enMessages from "../../messages/en/common.json"
|
||||
import deMessages from "../../messages/de/common.json"
|
||||
import esMessages from "../../messages/es/common.json"
|
||||
import frMessages from "../../messages/fr/common.json"
|
||||
import itMessages from "../../messages/it/common.json"
|
||||
import ptMessages from "../../messages/pt/common.json"
|
||||
import skMessages from "../../messages/sk/common.json"
|
||||
import svMessages from "../../messages/sv/common.json"
|
||||
import { MESSAGE_CATALOG } from "./catalogs"
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_STORAGE_KEY,
|
||||
@@ -21,17 +14,6 @@ import {
|
||||
type MessageTree = Record<string, unknown>
|
||||
type TranslationParams = Record<string, string | number>
|
||||
|
||||
const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
|
||||
en: enMessages as MessageTree,
|
||||
de: deMessages as MessageTree,
|
||||
es: esMessages as MessageTree,
|
||||
fr: frMessages as MessageTree,
|
||||
it: itMessages as MessageTree,
|
||||
pt: ptMessages as MessageTree,
|
||||
sk: skMessages as MessageTree,
|
||||
sv: svMessages as MessageTree,
|
||||
}
|
||||
|
||||
interface I18nContextValue {
|
||||
language: LanguageCode
|
||||
setLanguage: (language: LanguageCode) => void
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
// Exact lxc_apps validation/persistence messages; never change their API contract.
|
||||
// Anchored patterns leave unknown diagnostics intact, including paths and commands.
|
||||
const RULES: [RegExp, string, string[]][] = [
|
||||
[/^an OCI operation on this container is running$/, "ociBusy", []],
|
||||
[/^([\s\S]+) is not installed via dpkg$/, "dpkgMissing", ["package"]],
|
||||
[/^dpkg\-query failed$/, "dpkgFailed", []],
|
||||
[/^no version matched in dpkg output$/, "dpkgNoVersion", []],
|
||||
[/^apk info failed$/, "apkFailed", []],
|
||||
[/^no version matched in apk output$/, "apkNoVersion", []],
|
||||
[/^could not read ([\s\S]+)$/, "fileRead", ["path"]],
|
||||
[/^file_regex did not match ([\s\S]+)$/, "fileNoMatch", ["path"]],
|
||||
[/^binary invocation failed$/, "binaryFailed", []],
|
||||
[/^no version matched in binary output$/, "binaryNoVersion", []],
|
||||
[/^python \-c importlib\.metadata failed$/, "pythonFailed", []],
|
||||
[/^no version matched in Python distribution output$/, "pythonNoVersion", []],
|
||||
[/^could not resolve the container's installed image$/, "dockerImageResolve", []],
|
||||
[/^docker inspect failed$/, "dockerInspectFailed", []],
|
||||
[/^container has no ([\s\S]+) label$/, "dockerLabelMissing", ["label"]],
|
||||
[/^docker label reports ([\s\S]+) \(mutable tag, not a version\)$/, "dockerLabelMutable", ["tag"]],
|
||||
[/^no version matched in docker label output$/, "dockerLabelNoVersion", []],
|
||||
[/^docker exec failed$/, "dockerExecFailed", []],
|
||||
[/^no version matched in docker exec output$/, "dockerExecNoVersion", []],
|
||||
[/^command_argv is empty$/, "commandEmpty", []],
|
||||
[/^command failed$/, "commandFailed", []],
|
||||
[/^no version matched in command output$/, "commandNoVersion", []],
|
||||
[/^unsupported method: ([\s\S]+)$/, "unsupportedMethod", ["method"]],
|
||||
[/^unknown upstream_type: ([\s\S]+)$/, "unknownUpstream", ["type"]],
|
||||
[/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+): not found$/, "githubNotFound", ["repo"]],
|
||||
[/^github rejected the request \(403\)$/, "githubRejected", []],
|
||||
[/^github error (\d+)$/, "githubError", ["code"]],
|
||||
[/^no tag \/ release name in response$/, "noRelease", []],
|
||||
[/^tag_regex did not match '([\s\S]+)'$/, "tagNoMatch", ["tag"]],
|
||||
[/^http error (\d+)$/, "httpError", ["code"]],
|
||||
[/^invalid JSON response: ([\s\S]+)$/, "invalidJson", ["detail"]],
|
||||
[/^json_path '([\s\S]+)' did not resolve$/, "jsonPathUnresolved", ["path"]],
|
||||
[/^empty value at json_path$/, "jsonEmpty", []],
|
||||
[/^no tags matched \(docker_hub\)$/, "dockerNoTags", []],
|
||||
[/^tag_regex matched only moving tags; use Docker image digest tracking$/, "dockerMovingOnly", []],
|
||||
[/^docker hub error (\d+)$/, "dockerHubError", ["code"]],
|
||||
[/^invalid docker hub JSON: ([\s\S]+)$/, "dockerHubJson", ["detail"]],
|
||||
[/^no tags returned by docker hub$/, "dockerHubEmpty", []],
|
||||
[/^payload must be a JSON object$/, "payload", []],
|
||||
[/^name is required and must be 1\-64 chars of letters\/digits\/spaces\/\._\+\-\(\)\/$/, "name", []],
|
||||
[/^installed_via must be one of: ([\s\S]+) or empty$/, "method", ["values"]],
|
||||
[/^package is required \(letters\/digits\/\._\+@:\/ up to 127 chars\)$/, "package", []],
|
||||
[/^file_path is required and must be an absolute path$/, "filePath", []],
|
||||
[/^file_regex is required$/, "fileRegex", []],
|
||||
[/^file_regex is not a valid regex: ([\s\S]+)$/, "fileRegexInvalid", ["detail"]],
|
||||
[/^binary_path is required and must be an absolute path$/, "binaryPath", []],
|
||||
[/^python_path is required and must be an absolute path$/, "pythonPath", []],
|
||||
[/^distribution is required \(PEP 503 name\)$/, "distribution", []],
|
||||
[/^container_name is required \(docker naming rules\)$/, "container", []],
|
||||
[/^label is required \(OCI label naming rules\)$/, "label", []],
|
||||
[/^binary_path is required \(absolute path or bare command\)$/, "dockerBinary", []],
|
||||
[/^installed_version is required for the manual method$/, "manualVersion", []],
|
||||
[/^installed_version exceeds (\d+) chars$/, "versionLength", ["max"]],
|
||||
[/^installed_version contains a forbidden control character$/, "versionControl", []],
|
||||
[/^installed_regex is not a valid regex: ([\s\S]+)$/, "installedRegexInvalid", ["detail"]],
|
||||
[/^upstream_type must be one of: ([\s\S]+)$/, "upstreamType", ["values"]],
|
||||
[/^tag_regex is not a valid regex: ([\s\S]+)$/, "tagRegexInvalid", ["detail"]],
|
||||
[/^repo is required for upstream_type=github$/, "repoRequired", []],
|
||||
[/^repo must be 'owner\/name'$/, "repoFormat", []],
|
||||
[/^github_source must be one of: ([\s\S]+)$/, "githubSource", ["values"]],
|
||||
[/^upstream_url is required for upstream_type=http_json$/, "upstreamUrlRequired", []],
|
||||
[/^upstream_url must be an http\(s\) URL$/, "upstreamUrl", []],
|
||||
[/^upstream_url exceeds (\d+) chars$/, "upstreamUrlLength", ["max"]],
|
||||
[/^upstream_json_path is required for upstream_type=http_json$/, "jsonPathRequired", []],
|
||||
[/^upstream_json_path exceeds (\d+) chars$/, "jsonPathLength", ["max"]],
|
||||
[/^upstream_json_path uses forbidden characters$/, "jsonPathCharacters", []],
|
||||
[/^docker_image is required for upstream_type=docker_hub$/, "dockerImageRequired", []],
|
||||
[/^docker_image exceeds (\d+) chars$/, "dockerImageLength", ["max"]],
|
||||
[/^docker_image must match Docker Hub naming \(owner\/name or name\)$/, "dockerImageFormat", []],
|
||||
[/^health_path must be a valid URL path \(max 255 chars\)$/, "healthPath", []],
|
||||
[/^logo_url must be an http\(s\) URL or this VM's cached local icon$/, "logoUrl", []],
|
||||
[/^invalid discovery_id$/, "discoveryId", []],
|
||||
[/^helper_slug must be a lowercase slug \(letters\/digits\/\._\-\)$/, "helperSlug", []],
|
||||
[/^update_via only accepts 'docker'$/, "updateVia", []],
|
||||
[/^update_via=docker requires a docker_label or docker_exec detector$/, "dockerDetector", []],
|
||||
[/^update_via=docker cannot be combined with ([\s\S]+)$/, "dockerConflict", ["field"]],
|
||||
[/^update_command must be a string$/, "updateCommandType", []],
|
||||
[/^update_command exceeds (\d+) chars$/, "updateCommandLength", ["max"]],
|
||||
[/^update_command contains a null byte$/, "updateCommandNull", []],
|
||||
[/^update_method must be none, helper or custom$/, "updateMethod", []],
|
||||
[/^update_command is required for update_method=custom$/, "updateCommandRequired", []],
|
||||
[/^update_command is only allowed for update_method=custom$/, "updateCommandOnlyCustom", []],
|
||||
[/^a supported helper_slug is required for update_method=helper$/, "helperRequired", []],
|
||||
[/^Docker\-delegated apps update through their image, not a separate app updater$/, "dockerUpdater", []],
|
||||
[/^command_argv is required \(non\-empty list\)$/, "commandRequired", []],
|
||||
[/^command_argv must be a non\-empty list of strings$/, "commandList", []],
|
||||
[/^command_argv accepts at most (\d+) entries$/, "commandCount", ["max"]],
|
||||
[/^command_argv\[(\d+)\] must be a non\-empty string$/, "commandEntry", ["index"]],
|
||||
[/^command_argv\[(\d+)\] exceeds (\d+) chars$/, "commandEntryLength", ["index", "max"]],
|
||||
[/^command_argv\[(\d+)\] contains a forbidden control character$/, "commandEntryControl", ["index"]],
|
||||
[/^binary_args must be a list of strings$/, "binaryArgsList", []],
|
||||
[/^binary_args accepts at most (\d+) entries$/, "binaryArgsCount", ["max"]],
|
||||
[/^binary_args\[(\d+)\] must be a non\-empty string$/, "binaryArgEntry", ["index"]],
|
||||
[/^binary_args\[(\d+)\] exceeds (\d+) chars$/, "binaryArgLength", ["index", "max"]],
|
||||
[/^binary_args\[(\d+)\] contains a forbidden control character$/, "binaryArgControl", ["index"]],
|
||||
[/^ports must be a list of \{port, description, scheme\}$/, "portsList", []],
|
||||
[/^ports\[(\d+)\] must be an object$/, "portObject", ["index"]],
|
||||
[/^ports\[(\d+)\]\.port is required$/, "portRequired", ["index"]],
|
||||
[/^ports\[(\d+)\]\.port must be an integer$/, "portInteger", ["index"]],
|
||||
[/^ports\[(\d+)\]\.port must be 1\-65535$/, "portRange", ["index"]],
|
||||
[/^port (\d+) appears more than once for this app$/, "portDuplicate", ["port"]],
|
||||
[/^ports\[(\d+)\]\.description has invalid characters$/, "portDescription", ["index"]],
|
||||
[/^ports\[(\d+)\]\.scheme must be 'http' or 'https'$/, "portScheme", ["index"]],
|
||||
[/^ports\[(\d+)\]\.web_path must be a valid URL path \(max 255 chars\)$/, "portPath", ["index"]],
|
||||
[/^ports\[(\d+)\]\.logo_url must be an http\(s\) URL \(max 512 chars\)$/, "portLogo", ["index"]],
|
||||
[/^ports\[(\d+)\]\.category has invalid characters or is too long$/, "portCategory", ["index"]],
|
||||
[/^ports\[(\d+)\]\.custom_url must be an http\(s\) URL \(max 512 chars\)$/, "portCustomUrl", ["index"]],
|
||||
[/^tag_regex exceeds 512 chars$/, "tagRegexLength", []],
|
||||
[/^could not persist sidecar \(permission\?\)$/, "persistPermission", []],
|
||||
[/^could not persist sidecar$/, "persist", []],
|
||||
[/^no apps registered for this vmid$/, "noApps", []],
|
||||
[/^app_id '([\s\S]+)' not found$/, "appMissing", ["appId"]],
|
||||
[/^app not found$/, "appNotFound", []],
|
||||
]
|
||||
|
||||
export function localizeAppEditorError(message: string | null | undefined, t: Translate): string {
|
||||
if (!message) return ""
|
||||
for (const [pattern, key, names] of RULES) {
|
||||
const match = pattern.exec(message)
|
||||
if (!match) continue
|
||||
const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]]))
|
||||
return t(`vmLxc.appEditor.errors.${key}`, params)
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Opaque viewer metadata only; updater commands and credentials stay server-side. */
|
||||
export type LxcUpdateJob = {
|
||||
id: string
|
||||
vmid: number
|
||||
state: "running" | "finalizing" | "complete"
|
||||
target: "os" | "app" | "both"
|
||||
ct_name: string
|
||||
script_path: string
|
||||
created_at: number
|
||||
exit_code: number | null
|
||||
}
|
||||
|
||||
const SEEN = "proxmenux-viewed-update-jobs"
|
||||
|
||||
export function viewedUpdateJobs(): string[] {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(SEEN) || "[]")
|
||||
return Array.isArray(value) ? value.filter((id) => typeof id === "string").slice(-128) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function markUpdateJobViewed(id: string) {
|
||||
try {
|
||||
localStorage.setItem(SEEN, JSON.stringify([...new Set([...viewedUpdateJobs(), id])].slice(-128)))
|
||||
window.dispatchEvent(new Event("proxmenux-update-jobs-viewed"))
|
||||
} catch {
|
||||
// Unavailable browser storage never controls or restarts server work.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { fetchApi } from "./api-config"
|
||||
|
||||
interface UpdateObservation {
|
||||
last_check?: string | null
|
||||
stale?: boolean
|
||||
stale_after_seconds?: number
|
||||
available?: boolean
|
||||
count?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function getLxcOsUpdateState(observation?: UpdateObservation, now = Date.now()): "unknown" | "pending" | "current" {
|
||||
if (isLxcUpdateStale(observation, now) || observation?.error
|
||||
|| typeof observation?.available !== "boolean"
|
||||
|| !Number.isInteger(observation.count) || (observation.count ?? -1) < 0
|
||||
|| observation.available !== ((observation.count ?? 0) > 0)) return "unknown"
|
||||
return observation.available ? "pending" : "current"
|
||||
}
|
||||
|
||||
export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean {
|
||||
if (!observation || observation.stale === true) return true
|
||||
const checked = Date.parse(observation.last_check || "")
|
||||
const age = now - checked
|
||||
const limit = (observation.stale_after_seconds ?? 172800) * 1000
|
||||
return !Number.isFinite(checked) || age < 0 || age >= limit
|
||||
}
|
||||
|
||||
// Called only by the OS section's explicit button. Uses installed package
|
||||
// state and existing indexes; fetching this never upgrades or refreshes APT.
|
||||
export function checkLxcUpdates<T>(vmid: number): Promise<T> {
|
||||
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
|
||||
}
|
||||
|
||||
// Explicit action: refresh indexes and check an already-running CT. The
|
||||
// backend owns the job even if the browser closes or navigates away.
|
||||
export async function refreshCheckLxcUpdates<T>(vmid: number): Promise<T> {
|
||||
const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" })
|
||||
const deadline = Date.now() + 8 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const result = await fetchApi<{ state: string; error?: string; update_check?: T }>(
|
||||
`/api/vms/${vmid}/updates/refresh-check/${job.job_id}`)
|
||||
if (result.state === "failed") throw new Error("osStatusUnavailable")
|
||||
if (result.state === "complete") return result.update_check as T
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
throw new Error("checkStillRunning")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user