mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-04 20:02:07 +00:00
Compare commits
927
Commits
v1.2.6.2-beta
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6324d41a1d | ||
|
|
ef00a34654 | ||
|
|
143c7db610 | ||
|
|
17786e0f54 | ||
|
|
5c91e1c661 | ||
|
|
f0871607fa | ||
|
|
8e1b2ccc9d | ||
|
|
b7167ac498 | ||
|
|
e916c3b8bb | ||
|
|
64e9e0e8dd | ||
|
|
7cbe92ad9f | ||
|
|
8ca445ff3b | ||
|
|
a026ab5926 | ||
|
|
1dca76aaa5 | ||
|
|
dcb1d65ec8 | ||
|
|
7bb99d30ff | ||
|
|
a89eb921cc | ||
|
|
97da4321c8 | ||
|
|
11c0c683d7 | ||
|
|
6628744900 | ||
|
|
194a959492 | ||
|
|
74e5b7a4ff | ||
|
|
244181f9df | ||
|
|
e3f85b3e09 | ||
|
|
c0067d35a6 | ||
|
|
cd3d4061d0 | ||
|
|
e9272c90a0 | ||
|
|
e51553ada8 | ||
|
|
c07b328bb9 | ||
|
|
9a9d0f2379 | ||
|
|
389a07c265 | ||
|
|
c5df337db6 | ||
|
|
3e2b8d72d1 | ||
|
|
3bf4c0d82f | ||
|
|
c3024b8448 | ||
|
|
ab76ab161b | ||
|
|
f61c59d093 | ||
|
|
7f59ee5103 | ||
|
|
cef1dc7422 | ||
|
|
888b99330b | ||
|
|
af0dd74067 | ||
|
|
75a96b2658 | ||
|
|
114882fcba | ||
|
|
79c0f268fd | ||
|
|
7ec022af81 | ||
|
|
07f7f9a6a1 | ||
|
|
fec97c57d9 | ||
|
|
4ba0d6a236 | ||
|
|
96b3a919ba | ||
|
|
c628e65e2f | ||
|
|
1e08a5939b | ||
|
|
163c7c3505 | ||
|
|
eb46fffd45 | ||
|
|
a4c2c34597 | ||
|
|
aa19af66eb | ||
|
|
f19c300387 | ||
|
|
c1183bdeed | ||
|
|
19e35a567f | ||
|
|
f428dc76f0 | ||
|
|
e412d3161d | ||
|
|
13a8ac1001 | ||
|
|
f0d54fca13 | ||
|
|
d9973635fb | ||
|
|
75b13d87da | ||
|
|
ac97e2c5a6 | ||
|
|
700f72babd | ||
|
|
0ab40b4eea | ||
|
|
9940f05adf | ||
|
|
8328d5d366 | ||
|
|
7e6a942d16 | ||
|
|
03928c4277 | ||
|
|
8aefd70e52 | ||
|
|
6bee13114c | ||
|
|
6a1513a014 | ||
|
|
fb083e92cb | ||
|
|
781d2500ad | ||
|
|
98931fe4a7 | ||
|
|
46337f162f | ||
|
|
064c2a6c39 | ||
|
|
3feb5ca5cb | ||
|
|
b0215d4bbb | ||
|
|
9f3fffd249 | ||
|
|
511211f10b | ||
|
|
41081121f8 | ||
|
|
e83794b7e2 | ||
|
|
1f56d6b1ce | ||
|
|
7df9fdf381 | ||
|
|
9084df7ebd | ||
|
|
0ae3fd19f4 | ||
|
|
8d8ae53a75 | ||
|
|
fa8f021ee9 | ||
|
|
d6f6b4c914 | ||
|
|
28b23c8ffd | ||
|
|
9976183a54 | ||
|
|
f2328624ab | ||
|
|
93e2161355 | ||
|
|
fa58d2f7fe | ||
|
|
0251dd3c7a | ||
|
|
253e71a85f | ||
|
|
d4cc80cb4c | ||
|
|
3c9dc45d68 | ||
|
|
b9ee0514c6 | ||
|
|
537bb5a815 | ||
|
|
2e227a87c5 | ||
|
|
6fe5c8e25c | ||
|
|
7fe03d96f5 | ||
|
|
2c59356e66 | ||
|
|
c68d57feb5 | ||
|
|
5395dd488a | ||
|
|
3048ed8e5b | ||
|
|
468f57ebed | ||
|
|
e42fca75ec | ||
|
|
1a18432cee | ||
|
|
b2e84fde08 | ||
|
|
cf7269c123 | ||
|
|
ef1e6932b4 | ||
|
|
aadd90810f | ||
|
|
fc2a5ff4fe | ||
|
|
ff78912764 | ||
|
|
256df1ef3e | ||
|
|
0333d95479 | ||
|
|
418afe385b | ||
|
|
9ed09cc28e | ||
|
|
e3113df0a2 | ||
|
|
4261019549 | ||
|
|
5dc98e9daf | ||
|
|
488044e0b9 | ||
|
|
7320fc6ff6 | ||
|
|
398ca55247 | ||
|
|
4f415c98cf | ||
|
|
24d069e1dd | ||
|
|
3a000458b2 | ||
|
|
d5b7d386ea | ||
|
|
624ff628bd | ||
|
|
5ea0645e31 | ||
|
|
ac2bd28d25 | ||
|
|
2327f96479 | ||
|
|
ead77f0d1f | ||
|
|
52a9034676 | ||
|
|
e8e9e07e6b | ||
|
|
cb97fc4c98 | ||
|
|
75fd803443 | ||
|
|
c0a0cdca34 | ||
|
|
d75d44c601 | ||
|
|
276a116696 | ||
|
|
b1010a194a | ||
|
|
0faa781dd1 | ||
|
|
60b66c4668 | ||
|
|
393e6095fa | ||
|
|
380a9b653c | ||
|
|
1920dc2937 | ||
|
|
bfdcd4a42c | ||
|
|
139a4b4089 | ||
|
|
14e2718275 | ||
|
|
6befea0757 | ||
|
|
ed2a696c01 | ||
|
|
6968409ae5 | ||
|
|
a6ada401b5 | ||
|
|
98d8e78dcb | ||
|
|
f9fe4d2793 | ||
|
|
81cc746e19 | ||
|
|
aa4adc34a9 | ||
|
|
dc823bc21b | ||
|
|
297a41d58a | ||
|
|
72908e55aa | ||
|
|
668b557fd5 | ||
|
|
f5ce0438f9 | ||
|
|
21616fe53d | ||
|
|
8e6e9d5cab | ||
|
|
3b15e14625 | ||
|
|
89531bbab8 | ||
|
|
c13d3de023 | ||
|
|
dd9c21ed75 | ||
|
|
aaffe4e08a | ||
|
|
d3fedeb201 | ||
|
|
bd42df3ead | ||
|
|
cf7b8363da | ||
|
|
d3752f0417 | ||
|
|
64fa4f8115 | ||
|
|
88ba698758 | ||
|
|
72491b2445 | ||
|
|
8eef466949 | ||
|
|
30034e7aa3 | ||
|
|
c26eb49e2c | ||
|
|
db12972253 | ||
|
|
fac7ac5021 | ||
|
|
512b268815 | ||
|
|
e52460e991 | ||
|
|
5db3ae9c92 | ||
|
|
79bc8d33fd | ||
|
|
581eed61e5 | ||
|
|
d27dc834db | ||
|
|
2ab83c0b88 | ||
|
|
bdf9075639 | ||
|
|
65b10cea09 | ||
|
|
9147ec2f52 | ||
|
|
ba3fb1197f | ||
|
|
889409de8d | ||
|
|
43973e1d06 | ||
|
|
e1be5c22c8 | ||
|
|
79cdf814ed | ||
|
|
707a45e639 | ||
|
|
52cd21dcb1 | ||
|
|
503a946d85 | ||
|
|
ac9565fa19 | ||
|
|
f40e0a53e1 | ||
|
|
2d4b0cb746 | ||
|
|
616ba129b2 | ||
|
|
6a91487d21 | ||
|
|
8ee3b29991 | ||
|
|
52d44a7435 | ||
|
|
5e89b2bdf5 | ||
|
|
a259b945eb | ||
|
|
bf8c495d3b | ||
|
|
05790b494e | ||
|
|
a7786a6874 | ||
|
|
3d1c542a05 | ||
|
|
e587aa7d79 | ||
|
|
6a30d92ba7 | ||
|
|
3a0a7eaf14 | ||
|
|
b383fb9311 | ||
|
|
68ae23df82 | ||
|
|
f23f1912c2 | ||
|
|
692c7f7256 | ||
|
|
a079f3fc90 | ||
|
|
cdb1bd0cbf | ||
|
|
87c48830c5 | ||
|
|
ecc8017f71 | ||
|
|
fc8c23fd66 | ||
|
|
5adafeaa96 | ||
|
|
aeb55236c1 | ||
|
|
f623d29e12 | ||
|
|
9ab744e784 | ||
|
|
9fef8d89a7 | ||
|
|
85857acaf9 | ||
|
|
f5c98fb700 | ||
|
|
f79c35b038 | ||
|
|
6173c012e6 | ||
|
|
4cefc0484b | ||
|
|
ac9b8c2705 | ||
|
|
085bb084eb | ||
|
|
8f8a80785a | ||
|
|
e6d39f6015 | ||
|
|
ec5fbd668f | ||
|
|
688b7f54bc | ||
|
|
c676d883d9 | ||
|
|
6feb11ffb9 | ||
|
|
2b1ae0be72 | ||
|
|
28d9ff1c9e | ||
|
|
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",
|
||||
@@ -90,6 +102,12 @@ PROTECTED_TECHNICAL_TERMS = (
|
||||
"WebUI",
|
||||
"Intel",
|
||||
"CUDA",
|
||||
"ROCm",
|
||||
"Vulkan",
|
||||
"SYCL",
|
||||
"QSV",
|
||||
"OpenVINO",
|
||||
"OpenCL",
|
||||
"KFD",
|
||||
"GPU",
|
||||
"CPU",
|
||||
@@ -102,6 +120,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())
|
||||
@@ -2,7 +2,8 @@
|
||||
"""Build a verified ProxMenux LXC application-version tracking catalog.
|
||||
|
||||
The source of truth is a pinned snapshot of community-scripts/ProxmoxVE,
|
||||
downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
|
||||
plus community-scripts/core for its shared version helpers after the split.
|
||||
Both are downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
|
||||
|
||||
An operational hint is emitted only when two independent pieces of the helper
|
||||
scripts agree:
|
||||
@@ -37,6 +38,7 @@ from typing import Any, Iterable
|
||||
|
||||
|
||||
DEFAULT_REPOSITORY = "community-scripts/ProxmoxVE"
|
||||
DEFAULT_CORE_REPOSITORY = "community-scripts/core"
|
||||
DEFAULT_REF = "main"
|
||||
API_VERSION = "2022-11-28"
|
||||
USER_AGENT = "ProxMenux-app-tracking-catalog/1.0"
|
||||
@@ -169,6 +171,7 @@ def obtain_snapshot(
|
||||
repository: str,
|
||||
ref: str,
|
||||
cache_dir: Path,
|
||||
required_paths: tuple[str, ...] = ("ct",),
|
||||
) -> tuple[Path, str, str]:
|
||||
commit = client.json(f"/repos/{repository}/commits/{ref}")
|
||||
commit_sha = str(commit.get("sha") or "")
|
||||
@@ -177,7 +180,7 @@ def obtain_snapshot(
|
||||
|
||||
snapshot_dir = cache_dir / repository.replace("/", "--") / commit_sha
|
||||
marker = snapshot_dir / ".snapshot-complete"
|
||||
if marker.exists() and (snapshot_dir / "ct").is_dir():
|
||||
if marker.exists() and all((snapshot_dir / path).exists() for path in required_paths):
|
||||
return snapshot_dir, commit_sha, marker.read_text(encoding="utf-8").strip()
|
||||
|
||||
payload = client.request(
|
||||
@@ -190,6 +193,8 @@ def obtain_snapshot(
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=f"{commit_sha}.tmp-", dir=temp_parent))
|
||||
try:
|
||||
safe_extract_tar(payload, temp_dir)
|
||||
if not all((temp_dir / path).exists() for path in required_paths):
|
||||
raise CatalogError(f"Invalid source snapshot for {repository}@{commit_sha}")
|
||||
(temp_dir / ".snapshot-complete").write_text(archive_sha + "\n", encoding="utf-8")
|
||||
if snapshot_dir.exists():
|
||||
shutil.rmtree(snapshot_dir)
|
||||
@@ -521,23 +526,66 @@ def repo_from_helper(item: dict[str, Any]) -> str:
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def shared_helper_path(source: Path, core_source: Path | None = None) -> Path:
|
||||
"""Locate source text only; never source or execute upstream shell code."""
|
||||
candidates = [source / "misc" / "tools.func"]
|
||||
if core_source is not None:
|
||||
candidates.extend(core_source / path for path in ("lib/forge.func", "lib/tools.func"))
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise CatalogError(
|
||||
"Shared version helpers not found. Older trees use misc/tools.func; "
|
||||
"current trees require community-scripts/core (--core-source-dir for a local checkout)."
|
||||
)
|
||||
|
||||
|
||||
def shell_function(text: str, name: str) -> str:
|
||||
match = re.search(
|
||||
rf"(?ms)^(?:function\s+)?{re.escape(name)}\s*\(\)\s*\{{[^\n]*\n(.*?)^\}}",
|
||||
text,
|
||||
)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def verify_shared_helper(path: Path) -> None:
|
||||
"""Check both ends of the cache contract, including the new forge wrappers."""
|
||||
text = read_text(path)
|
||||
read_body = shell_function(text, "check_for_gh_release")
|
||||
write_body = shell_function(text, "fetch_and_deploy_gh_release")
|
||||
if re.search(r'\bcheck_for_release\s+github\s+"\$1"\s+"\$2"', read_body):
|
||||
read_body = shell_function(text, "check_for_release")
|
||||
if re.search(r'\bfetch_and_deploy_release\s+github\s+"\$1"\s+"\$2"', write_body):
|
||||
write_body = shell_function(text, "fetch_and_deploy_release")
|
||||
normalization = r'app_lc="\$\(echo "\$\{app,,\}" \| tr -d [\'\"] [\'\"]\)"'
|
||||
valid = (
|
||||
re.search(normalization, read_body)
|
||||
and re.search(normalization, write_body)
|
||||
and re.search(r'(?:local\s+)?current_file="\$HOME/\.\$\{app_lc\}"', read_body)
|
||||
and re.search(r'(?:local\s+)?version_file="\$HOME/\.\$\{app_lc\}"', write_body)
|
||||
and re.search(r'echo "\$version" >"\$version_file"', write_body)
|
||||
and re.search(r'\$current_file', read_body)
|
||||
)
|
||||
if not valid:
|
||||
raise CatalogError(f"Could not verify the shared GitHub release cache contract in {path}")
|
||||
|
||||
|
||||
def build_catalog(
|
||||
source: Path,
|
||||
helpers: dict[str, dict[str, Any]],
|
||||
*,
|
||||
core_source: Path | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
catalog: dict[str, Any] = {}
|
||||
v2_apps: dict[str, Any] = {}
|
||||
records: list[dict[str, Any]] = []
|
||||
launchers = sorted((source / "ct").glob("*.sh"))
|
||||
tools_func = source / "misc" / "tools.func"
|
||||
tools_text = read_text(tools_func)
|
||||
shared_helper_verified = bool(
|
||||
re.search(r'local version_file="\$HOME/\.\$\{app_lc\}"', tools_text)
|
||||
and re.search(r'echo "\$version" >"\$version_file"', tools_text)
|
||||
and re.search(r'local current_file="\$HOME/\.\$\{app_lc\}"', tools_text)
|
||||
tools_func = shared_helper_path(source, core_source)
|
||||
verify_shared_helper(tools_func)
|
||||
helper_contract = (
|
||||
"misc/tools.func" if tools_func == source / "misc" / "tools.func"
|
||||
else f"core/{tools_func.relative_to(core_source).as_posix()}"
|
||||
)
|
||||
if not shared_helper_verified:
|
||||
raise CatalogError("Could not verify the shared GitHub release cache contract in misc/tools.func")
|
||||
|
||||
for launcher_path in launchers:
|
||||
slug = launcher_path.stem
|
||||
@@ -696,7 +744,7 @@ def build_catalog(
|
||||
"evidence": {
|
||||
"install": f"install/{installer_path.name}: matching deploy helper",
|
||||
"update": f"ct/{launcher_path.name}: matching release check",
|
||||
"contract": "misc/tools.func: shared version-cache contract",
|
||||
"contract": f"{helper_contract}: shared version-cache contract",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -712,7 +760,7 @@ def build_catalog(
|
||||
"evidence": {
|
||||
"install": f"install/{installer_path.name}: fetch_and_deploy_gh_release({release.app}, {release.repo})",
|
||||
"update": f"ct/{launcher_path.name}: check_for_gh_release({release.app}, {release.repo})",
|
||||
"contract": "misc/tools.func writes and reads /root/.<normalized-app>",
|
||||
"contract": f"{helper_contract} writes and reads /root/.<normalized-app>",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -874,7 +922,7 @@ def build_catalog(
|
||||
"status_counts": status_counts,
|
||||
"method_counts": method_counts,
|
||||
"reason_counts": reason_counts,
|
||||
"shared_release_cache_contract_verified": shared_helper_verified,
|
||||
"shared_release_cache_contract_verified": True,
|
||||
"helper_upstream_tag_validation": tag_validation,
|
||||
},
|
||||
"records": records,
|
||||
@@ -1307,6 +1355,12 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", default=DEFAULT_REPOSITORY)
|
||||
parser.add_argument("--ref", default=DEFAULT_REF)
|
||||
parser.add_argument("--core-repository", default=DEFAULT_CORE_REPOSITORY)
|
||||
parser.add_argument("--core-ref", default=DEFAULT_REF)
|
||||
parser.add_argument(
|
||||
"--core-source-dir", type=Path,
|
||||
help="Local community-scripts/core checkout for split source trees (no download)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-dir",
|
||||
type=Path,
|
||||
@@ -1346,6 +1400,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
client = GitHubClient()
|
||||
commit_sha = "local-source"
|
||||
archive_sha = "not-applicable"
|
||||
core_provenance = None
|
||||
try:
|
||||
if args.source_dir:
|
||||
source = args.source_dir.resolve()
|
||||
@@ -1355,10 +1410,27 @@ def main(argv: list[str] | None = None) -> int:
|
||||
source, commit_sha, archive_sha = obtain_snapshot(
|
||||
client, args.repository, args.ref, args.cache_dir.expanduser().resolve()
|
||||
)
|
||||
if not (source / "ct").is_dir() or not (source / "misc" / "tools.func").is_file():
|
||||
if not (source / "ct").is_dir() or not (source / "install").is_dir():
|
||||
raise CatalogError(f"Not a valid ProxmoxVE source tree: {source}")
|
||||
core_source = None
|
||||
if not (source / "misc" / "tools.func").is_file():
|
||||
if args.core_source_dir:
|
||||
core_source = args.core_source_dir.resolve()
|
||||
core_commit, core_archive = "local-source", "not-applicable"
|
||||
elif args.source_dir:
|
||||
raise CatalogError("Split local source tree: provide --core-source-dir for its shared helpers")
|
||||
else:
|
||||
core_source, core_commit, core_archive = obtain_snapshot(
|
||||
client, args.core_repository, args.core_ref,
|
||||
args.cache_dir.expanduser().resolve(), required_paths=("lib/forge.func",),
|
||||
)
|
||||
core_provenance = {
|
||||
"repository": args.core_repository, "ref": args.core_ref,
|
||||
"commit_sha": core_commit, "archive_sha256": core_archive,
|
||||
"source_dir": str(core_source),
|
||||
}
|
||||
helpers = load_helper_catalog(args.helpers_cache)
|
||||
catalog, audit, v2 = build_catalog(source, helpers)
|
||||
catalog, audit, v2 = build_catalog(source, helpers, core_source=core_source)
|
||||
audit["demoted_helper_markers"] = (
|
||||
[] if args.include_helper_markers else demote_generic_helper_markers(catalog, v2, audit)
|
||||
)
|
||||
@@ -1376,6 +1448,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"ref": args.ref,
|
||||
"commit_sha": commit_sha,
|
||||
"archive_sha256": archive_sha,
|
||||
"core": core_provenance,
|
||||
"source_dir": str(source),
|
||||
"generated_at_unix": int(time.time()),
|
||||
"generator_sha256": sha256_file(Path(__file__).resolve()),
|
||||
|
||||
@@ -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,28 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"tests/test_host_update_jobs.py",
|
||||
"tests/test_host_update_transport.py",
|
||||
"tests/test_host_update_shell.py",
|
||||
"tests/test_host_update_auth.py",
|
||||
"tests/test_kernel_reboot.py",
|
||||
"tests/test_reboot_health_refresh.py",
|
||||
"tests/test_host_update_controls.py",
|
||||
"tests/test_host_update_packaging.py",
|
||||
"tests/test_host_update_version.py",
|
||||
"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 +40,27 @@ 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_host_update_ui.cjs",
|
||||
"tests/test_reboot_health_refresh.cjs",
|
||||
"tests/test_host_update_controls.cjs",
|
||||
"tests/test_reboot_recovery.cjs",
|
||||
"tests/test_proxmenux_version_row.cjs",
|
||||
"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",
|
||||
@@ -33,6 +72,7 @@ NODE_FILES = (
|
||||
"tests/test_borg_ssh_guidance.cjs",
|
||||
"tests/test_storage_messages.cjs",
|
||||
"tests/test_health_text.cjs",
|
||||
"tests/test_overview_chart_axis_gutters.cjs",
|
||||
)
|
||||
|
||||
# unittest's CLI accepts a missing pattern as a successful zero-test run.
|
||||
@@ -69,9 +109,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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
|
||||
import ast
|
||||
import datetime
|
||||
import sqlite3
|
||||
import tempfile
|
||||
@@ -36,6 +37,10 @@ def restore_event(warnings):
|
||||
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
|
||||
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
|
||||
captured = []
|
||||
delivery_errors = []
|
||||
def record_history(event_type, channel, title, body, severity, success, error, source):
|
||||
if not success:
|
||||
delivery_errors.append(f'{channel}: {error}')
|
||||
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
|
||||
def sink(title, body, severity, data):
|
||||
markup = channel._format_html(title, body, severity, data)
|
||||
@@ -48,12 +53,25 @@ def deliver(event_type, data, severity='INFO', language='en', manual=False, quie
|
||||
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
|
||||
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
|
||||
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
|
||||
_record_history=lambda *a:None, _stats={'total_sent':0,'total_errors':0},
|
||||
_record_history=record_history, _stats={'total_sent':0,'total_errors':0},
|
||||
is_event_enabled=lambda event:True)
|
||||
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
|
||||
resolve_notification_hostname=lambda host, config:host or 'node-a',
|
||||
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
|
||||
_should_bypass_ai=lambda event:True, _AI_BYPASS_EVENTS=frozenset({'backup_complete','backup_fail'}))
|
||||
format_backup_diagnostics_with_ai=templates.format_backup_diagnostics_with_ai,
|
||||
format_with_ai_full=templates.format_with_ai_full,
|
||||
# AI is disabled in this fixture. Execute the actual formatting helpers
|
||||
# synchronously, without a worker pool, providers or operational imports.
|
||||
_format_with_ai_bounded=lambda function,*args,**kwargs:function(*args,**kwargs),
|
||||
enrich_context_for_ai=lambda **kwargs:'')
|
||||
manager_path = SCRIPTS/'notification_manager.py'
|
||||
for node in ast.parse(manager_path.read_text()).body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in ('_AI_BYPASS_EVENTS', '_AI_NO_SUGGESTION_EVENTS'):
|
||||
ns[target.id] = frozenset(ast.literal_eval(node.value.args[0]))
|
||||
for name in ('_should_bypass_ai', '_format_backup_with_ai_bounded'):
|
||||
extract(manager_path, name, None, ns)
|
||||
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
|
||||
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
|
||||
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
|
||||
@@ -73,10 +91,11 @@ def deliver(event_type, data, severity='INFO', language='en', manual=False, quie
|
||||
if manual:
|
||||
assert not quiet
|
||||
result = manager.send_notification(event_type, severity, '', '', dict(data))
|
||||
assert result['success']
|
||||
assert result['success'], result.get('errors')
|
||||
else:
|
||||
event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert')
|
||||
manager._dispatch_event(event)
|
||||
assert not delivery_errors, '; '.join(delivery_errors)
|
||||
if quiet:
|
||||
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
|
||||
assert len(rows)==1+len(quiet_before) and not captured
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Source layout regressions for the Helper-Scripts/core repository split."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "generate_app_tracking_catalog.py"
|
||||
SPEC = importlib.util.spec_from_file_location("tracking_sources_under_test", SCRIPT)
|
||||
generator = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = generator
|
||||
SPEC.loader.exec_module(generator)
|
||||
|
||||
NORMALIZE = ''' app_lc="$(echo "${app,,}" | tr -d ' ')"'''
|
||||
READ_BODY = NORMALIZE + '''
|
||||
current_file="$HOME/.${app_lc}"
|
||||
current="$(<"$current_file")"
|
||||
'''
|
||||
WRITE_BODY = NORMALIZE + '''
|
||||
version_file="$HOME/.${app_lc}"
|
||||
echo "$version" >"$version_file"
|
||||
'''
|
||||
|
||||
|
||||
def function(name, body):
|
||||
return f"{name}() {{\n{body}\n}}\n"
|
||||
|
||||
|
||||
LEGACY = (
|
||||
function("check_for_gh_release", READ_BODY.replace(' current_file=', ' local current_file='))
|
||||
+ function("fetch_and_deploy_gh_release", WRITE_BODY.replace(' version_file=', ' local version_file='))
|
||||
)
|
||||
SPLIT = (
|
||||
function("check_for_gh_release", ' check_for_release github "$1" "$2" "${3:-}"')
|
||||
+ function("fetch_and_deploy_gh_release", ' fetch_and_deploy_release github "$1" "$2" "${3:-tarball}"')
|
||||
+ function("check_for_release", READ_BODY)
|
||||
+ function("fetch_and_deploy_release", WRITE_BODY)
|
||||
)
|
||||
|
||||
|
||||
class TrackingSourcesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.apps = self.root / "apps"
|
||||
self.core = self.root / "core"
|
||||
self.write(self.apps / "ct/example.sh", '''APP="Example"
|
||||
check_for_gh_release "Example" "example/example"
|
||||
''')
|
||||
self.write(self.apps / "install/example-install.sh", '''
|
||||
fetch_and_deploy_gh_release "Example" "example/example" "tarball" "latest"
|
||||
''')
|
||||
|
||||
def write(self, path, content):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def run_main(self, *extra):
|
||||
output = self.root / "output.json"
|
||||
args = ["--source-dir", str(self.apps), "--output", str(output),
|
||||
"--include-helper-markers",
|
||||
"--audit-output", str(self.root / "audit.json"),
|
||||
"--v2-output", str(self.root / "v2.json"), *extra]
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
result = generator.main(args)
|
||||
return result, output
|
||||
|
||||
def test_legacy_monolithic_contract_is_supported(self):
|
||||
self.write(self.apps / "misc/tools.func", LEGACY)
|
||||
result, output = self.run_main()
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(json.loads(output.read_text())["example"]["file_path"], "/root/.example")
|
||||
|
||||
def test_split_contract_builds_catalog_and_records_actual_evidence(self):
|
||||
self.write(self.core / "lib/forge.func", SPLIT)
|
||||
result, output = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(json.loads(output.read_text())["example"]["repo"], "example/example")
|
||||
audit = json.loads((self.root / "audit.json").read_text())
|
||||
self.assertEqual(audit["provenance"]["core"]["source_dir"], str(self.core.resolve()))
|
||||
v2 = (self.root / "v2.json").read_text()
|
||||
self.assertIn("core/lib/forge.func", v2)
|
||||
self.assertNotIn("misc/tools.func", v2)
|
||||
|
||||
def test_unsplit_core_tools_library_is_supported(self):
|
||||
path = self.write(self.core / "lib/tools.func", LEGACY)
|
||||
self.assertEqual(generator.shared_helper_path(self.apps, self.core), path)
|
||||
generator.verify_shared_helper(path)
|
||||
|
||||
def test_changed_contract_never_overwrites_existing_outputs(self):
|
||||
for old, new in [('echo "$version" >"$version_file"', 'echo "$version" >/tmp/version'),
|
||||
('current_file="$HOME/.${app_lc}"', 'current_file="/tmp/version"'),
|
||||
('tr -d \' \'', 'tr -d \'-\'')]:
|
||||
with self.subTest(change=old):
|
||||
self.write(self.core / "lib/forge.func", SPLIT.replace(old, new))
|
||||
output = self.write(self.root / "output.json", '{"keep": true}\n')
|
||||
result, _ = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(output.read_text(), '{"keep": true}\n')
|
||||
|
||||
def test_matching_text_in_unrelated_functions_is_not_evidence(self):
|
||||
path = self.write(self.core / "lib/forge.func", SPLIT.replace(
|
||||
function("check_for_release", READ_BODY), function("unrelated", READ_BODY)))
|
||||
with self.assertRaises(generator.CatalogError):
|
||||
generator.verify_shared_helper(path)
|
||||
|
||||
def test_split_local_tree_requires_explicit_local_core_without_network(self):
|
||||
with patch.object(generator.GitHubClient, "request", side_effect=AssertionError("Network")):
|
||||
result, output = self.run_main()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_invalid_local_core_fails_without_writing_a_catalog(self):
|
||||
result, output = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 1)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_snapshot_cache_accepts_core_without_ct_directory(self):
|
||||
sha = "a" * 40
|
||||
source = self.root / "cache/community-scripts--core" / sha
|
||||
self.write(source / "lib/forge.func", SPLIT)
|
||||
self.write(source / ".snapshot-complete", "archive-hash\n")
|
||||
client = Mock()
|
||||
client.json.return_value = {"sha": sha}
|
||||
path, commit, archive = generator.obtain_snapshot(
|
||||
client, "community-scripts/core", "main", self.root / "cache",
|
||||
required_paths=("lib/forge.func",))
|
||||
self.assertEqual((path, commit, archive), (source, sha, "archive-hash"))
|
||||
client.request.assert_not_called()
|
||||
|
||||
def test_remote_split_source_pins_both_repositories(self):
|
||||
self.write(self.core / "lib/forge.func", SPLIT)
|
||||
output = self.root / "remote.json"
|
||||
with patch.object(generator, "obtain_snapshot", side_effect=[
|
||||
(self.apps, "a" * 40, "apps-archive"),
|
||||
(self.core, "b" * 40, "core-archive"),
|
||||
]) as snapshots, contextlib.redirect_stdout(io.StringIO()):
|
||||
result = generator.main([
|
||||
"--output", str(output), "--include-helper-markers",
|
||||
"--audit-output", str(self.root / "remote-audit.json"),
|
||||
"--v2-output", str(self.root / "remote-v2.json"),
|
||||
])
|
||||
self.assertEqual(result, 0)
|
||||
provenance = json.loads((self.root / "remote-audit.json").read_text())["provenance"]
|
||||
self.assertEqual(provenance["commit_sha"], "a" * 40)
|
||||
self.assertEqual(provenance["core"]["commit_sha"], "b" * 40)
|
||||
self.assertEqual(provenance["core"]["archive_sha256"], "core-archive")
|
||||
self.assertEqual(snapshots.call_args_list[1].args[1], "community-scripts/core")
|
||||
self.assertEqual(snapshots.call_args_list[1].kwargs["required_paths"], ("lib/forge.func",))
|
||||
|
||||
def test_invalid_download_is_not_marked_complete(self):
|
||||
client = Mock()
|
||||
client.json.return_value = {"sha": "b" * 40}
|
||||
client.request.return_value = b"invalid-layout"
|
||||
with patch.object(generator, "safe_extract_tar"):
|
||||
with self.assertRaises(generator.CatalogError):
|
||||
generator.obtain_snapshot(client, "community-scripts/core", "main", self.root / "cache",
|
||||
required_paths=("lib/forge.func",))
|
||||
self.assertEqual(list((self.root / "cache").rglob(".snapshot-complete")), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""A schedule systemd rejects leaves a timer that never fires and reports no
|
||||
error, so the wizard asks for it again instead of saving it."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCHEDULER = Path(__file__).resolve().parents[3] / 'scripts/backup_restore/backup_scheduler.sh'
|
||||
ANALYZE = '#!/bin/sh\ncase "$2" in "daily 02:00") exit 1;; esac\nexit 0\n'
|
||||
|
||||
|
||||
def function(name):
|
||||
text = SCHEDULER.read_text()
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}', start) + 2] + '\n'
|
||||
|
||||
|
||||
class ScheduleValidation(unittest.TestCase):
|
||||
def valid(self, expression, **environment):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
analyze = Path(directory) / 'systemd-analyze'
|
||||
analyze.write_text(ANALYZE)
|
||||
analyze.chmod(0o755)
|
||||
return subprocess.run(
|
||||
['/bin/bash', '-c', function('_calendar_is_valid') + '_calendar_is_valid "$1"', 'test', expression],
|
||||
env={'PATH': f'{directory}:/usr/bin:/bin', **environment},
|
||||
).returncode == 0
|
||||
|
||||
def test_systemd_decides_what_is_valid(self):
|
||||
self.assertTrue(self.valid('02:30'))
|
||||
self.assertFalse(self.valid('daily 02:00'))
|
||||
|
||||
def test_a_scheduler_without_systemctl_is_not_checked(self):
|
||||
self.assertTrue(self.valid('daily 02:00', PMX_BACKUP_NO_SYSTEMCTL='1'))
|
||||
|
||||
def test_the_wizard_asks_again_before_saving_the_job(self):
|
||||
text = SCHEDULER.read_text()
|
||||
check = text.index('while ! _calendar_is_valid "$on_calendar"; do')
|
||||
self.assertLess(check, text.index('"ON_CALENDAR=$on_calendar"'))
|
||||
self.assertGreater(check, text.index('on_calendar=$(dialog'))
|
||||
|
||||
|
||||
STUB_ANALYZE = '#!/bin/sh\ncase "$2" in daily\\ *|weekly\\ *|monthly\\ *) exit 1;; esac\nexit 0\n'
|
||||
JOBS = {
|
||||
'nas': 'ON_CALENDAR=daily\\ 02:00\nENABLED=1\n',
|
||||
'week': 'ON_CALENDAR=weekly\\ 03:15\nENABLED=1\n',
|
||||
'month': 'ON_CALENDAR=monthly\\ 04:00\nENABLED=0\n',
|
||||
'good': 'ON_CALENDAR=Mon..Fri\\ 03:00\nENABLED=1\n',
|
||||
'manual': 'ON_CALENDAR=daily\\ 05:00\nMANUAL_RUN=1\n',
|
||||
'attached': 'ON_CALENDAR=daily\\ 06:00\nPVE_STORAGE=local\n',
|
||||
'custom': 'ON_CALENDAR=every\\ night\nENABLED=1\n',
|
||||
}
|
||||
|
||||
|
||||
class ScheduleRepair(unittest.TestCase):
|
||||
def run_repair(self):
|
||||
names = ('_calendar_is_valid', '_scheduler_systemctl', '_job_env_get', '_job_is_attached',
|
||||
'_write_job_units', '_repaired_calendar', '_repair_job_schedules')
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
root = Path(self.folder.name)
|
||||
for name, body in (('systemd-analyze', STUB_ANALYZE),
|
||||
('systemctl', f'#!/bin/sh\necho "$@" >> {root}/systemctl.log\n')):
|
||||
(root / name).write_text(body)
|
||||
(root / name).chmod(0o755)
|
||||
jobs, units = root / 'jobs', root / 'units'
|
||||
jobs.mkdir()
|
||||
for job, body in JOBS.items():
|
||||
(jobs / f'{job}.env').write_text(f'JOB_ID={job}\n{body}')
|
||||
script = (f'JOBS_DIR={jobs}\nSYSTEMD_DIR={units}\nLOCAL_SCRIPTS={root}\nSCRIPT_DIR={root}\n'
|
||||
'_job_file() { echo "${JOBS_DIR}/$1.env"; }\n'
|
||||
'_service_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.service"; }\n'
|
||||
'_timer_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.timer"; }\n'
|
||||
+ ''.join(function(name) for name in names) + '_repair_job_schedules\n')
|
||||
subprocess.run(['/bin/bash', '-c', script], check=True,
|
||||
env={'PATH': f'{root}:/usr/bin:/bin'})
|
||||
return jobs, units, root
|
||||
|
||||
def test_a_time_after_daily_weekly_or_monthly_is_repaired(self):
|
||||
jobs, units, root = self.run_repair()
|
||||
self.assertIn('ON_CALENDAR=02:00\n', (jobs / 'nas.env').read_text())
|
||||
self.assertIn('OnCalendar=02:00\n', (units / 'proxmenux-backup-nas.timer').read_text())
|
||||
self.assertIn('OnCalendar=Mon 03:15\n', (units / 'proxmenux-backup-week.timer').read_text())
|
||||
self.assertIn('OnCalendar=*-*-01 04:00\n', (units / 'proxmenux-backup-month.timer').read_text())
|
||||
log = (root / 'systemctl.log').read_text()
|
||||
self.assertIn('enable --now proxmenux-backup-nas.timer', log)
|
||||
self.assertNotIn('proxmenux-backup-month.timer', log)
|
||||
|
||||
def test_the_rest_of_the_jobs_are_left_as_they_are(self):
|
||||
jobs, units, _ = self.run_repair()
|
||||
for job in ('good', 'manual', 'attached', 'custom'):
|
||||
self.assertEqual((jobs / f'{job}.env').read_text(), f'JOB_ID={job}\n{JOBS[job]}', job)
|
||||
self.assertFalse((units / f'proxmenux-backup-{job}.timer').exists(), job)
|
||||
|
||||
def test_both_installers_repair_on_update(self):
|
||||
root = SCHEDULER.parents[2]
|
||||
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
self.assertIn('backup_scheduler.sh" --repair-schedules', (root / installer).read_text())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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,311 @@
|
||||
"""Log2RAM keeps /var/log in RAM while the journal and the task logs of
|
||||
Proxmox VE and Proxmox Backup Server stay on disk behind a link. An update keeps the size
|
||||
and interval the host already has."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
KEEP = ROOT / "scripts/global/log2ram_keep_on_disk.sh"
|
||||
SETUP = ROOT / "scripts/global/log2ram_setup.sh"
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class KeepOnDiskTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.root = Path(folder.name)
|
||||
self.log = self.root / "var/log"
|
||||
(self.log / "journal/abc").mkdir(parents=True)
|
||||
(self.log / "journal/abc/system.journal").write_text("entries")
|
||||
(self.log / "pveproxy").mkdir()
|
||||
(self.log / "pveproxy/access.log").write_text("GET /")
|
||||
self.config = self.root / "etc/log2ram.conf"
|
||||
self.config.parent.mkdir()
|
||||
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=true\nNOTIFICATION=true\n")
|
||||
|
||||
def run_setup(self, edit_failure=False):
|
||||
# Extract only the shared setup function, never an administrative caller.
|
||||
body = SETUP.read_text().split("pmx_log2ram_setup() {", 1)[1].split("\n}\n", 1)[0]
|
||||
body = body.replace("/etc/", str(self.root / "etc") + "/")
|
||||
for directory in ("cron.d", "tmpfiles.d"):
|
||||
(self.root / "etc" / directory).mkdir(exist_ok=True)
|
||||
stubs = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_info() { :; }
|
||||
msg_ok() { :; }
|
||||
pmx_install_pkg() { :; }
|
||||
_pmx_log2ram_install_release() { :; }
|
||||
_pmx_log2ram_pbs_logrotate() { :; }
|
||||
_pmx_log2ram_remove_legacy() { :; }
|
||||
pmx_edit_file() { local file="$1"; shift; sed -i "$@" "$file"; }
|
||||
pmx_append_file() { cat >> "$1"; }
|
||||
pmx_write_file() { cat > "$1"; }
|
||||
pmx_remove_file() { rm -f "$1"; }
|
||||
pmx_apply_setting() { :; }
|
||||
systemctl() { printf '%s\n' "$*" >> "$SYSTEMCTL_TRACE"; }
|
||||
'''
|
||||
if edit_failure:
|
||||
# A failed configuration writer must be handled even by root runners.
|
||||
stubs += "\npmx_edit_file() { return 1; }\n"
|
||||
script = (stubs + "\npmx_log2ram_setup() {" + body + "\n}\n"
|
||||
+ "pmx_log2ram_setup 256 3 false\n")
|
||||
env = dict(os.environ, SYSTEMCTL_TRACE=str(self.root / "systemctl.trace"),
|
||||
PMX_L2R_TAG="1.7.2", PMX_L2R_SCRIPTS=str(KEEP.parent),
|
||||
PMX_L2R_REVERT_UNIT=str(self.root / "etc/revert.service"))
|
||||
return subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=10)
|
||||
|
||||
def test_setup_keeps_journal_aware_syncing_until_boot(self):
|
||||
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=true\n")
|
||||
result = self.run_setup()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertIn("SIZE=256M\n", self.config.read_text())
|
||||
self.assertFalse((self.log / "journal").is_symlink())
|
||||
self.assertEqual((self.root / "systemctl.trace").read_text(), "daemon-reload\n")
|
||||
|
||||
def test_boot_disables_journal_aware_syncing_after_relocation(self):
|
||||
self.assertEqual(self.run_setup().returncode, 0)
|
||||
result = self.run_script()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||
self.assertIn("SIZE=256M\n", self.config.read_text())
|
||||
# Reapplying the repair to an already-relocated installation is safe.
|
||||
self.assertEqual(self.run_setup().returncode, 0)
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
|
||||
|
||||
def test_boot_migration_failure_is_not_ignored_by_startup(self):
|
||||
self.assertEqual(self.run_setup().returncode, 0)
|
||||
# A non-directory destination causes a real copy failure, without mounts.
|
||||
conflict = self.root / "var/hdd.journal"
|
||||
conflict.write_text("blocked")
|
||||
unit = self.root / "etc/systemd/system/log2ram.service.d/proxmenux.conf"
|
||||
command = next(line.split("=", 1)[1] for line in unit.read_text().splitlines()
|
||||
if line.startswith("ExecStartPre="))
|
||||
result = subprocess.run(["bash", *shlex.split(command.lstrip("-"))],
|
||||
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)),
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertFalse(command.startswith("-"), "systemd must not ignore the helper's failure")
|
||||
conflict.unlink()
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||
|
||||
def test_setup_adds_journal_aware_setting_when_missing(self):
|
||||
self.config.write_text("SIZE=128M\n#JOURNALD_AWARE=false\n")
|
||||
result = self.run_setup()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("\nJOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertIn("\nJOURNALD_AWARE=false\n", self.config.read_text())
|
||||
|
||||
def check_missing_setting_without_final_newline(self, content):
|
||||
self.config.write_text(content)
|
||||
result = self.run_setup()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
for expected in ("true", "false"):
|
||||
with self.subTest(journal_aware=expected):
|
||||
# Read shell semantics in a fresh Bash, not a substring match.
|
||||
sourced = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c",
|
||||
'unset JOURNALD_AWARE NOTIFICATION; source "$1"; '
|
||||
'printf "%s\\n%s\\n" "${JOURNALD_AWARE-UNSET}" "${NOTIFICATION-UNSET}"',
|
||||
"fixture", str(self.config)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(sourced.returncode, 0, sourced.stderr)
|
||||
self.assertEqual(sourced.stdout, expected + "\nfalse\n")
|
||||
|
||||
if expected == "true":
|
||||
self.assertFalse((self.log / "journal").is_symlink())
|
||||
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
|
||||
result = self.run_script()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||
|
||||
def test_missing_setting_after_comment_without_final_newline(self):
|
||||
self.check_missing_setting_without_final_newline(
|
||||
"SIZE=128M\nNOTIFICATION=false\n#JOURNALD_AWARE=false")
|
||||
|
||||
def test_missing_setting_after_assignment_without_final_newline(self):
|
||||
self.check_missing_setting_without_final_newline("SIZE=128M\nNOTIFICATION=false")
|
||||
|
||||
def test_setup_reports_configuration_edit_failure(self):
|
||||
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=false\n")
|
||||
result = self.run_setup(edit_failure=True)
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||
|
||||
def test_an_existing_link_with_an_unusable_disk_target_fails(self):
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.config.write_text("JOURNALD_AWARE=true\n")
|
||||
disk = self.root / "var/hdd.journal"
|
||||
shutil.rmtree(disk)
|
||||
disk.write_text("blocked")
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=true\n")
|
||||
|
||||
def test_an_unexpected_journal_link_does_not_complete_migration(self):
|
||||
shutil.rmtree(self.log / "journal")
|
||||
(self.log / "journal").symlink_to("pveproxy")
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "pveproxy")
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
|
||||
def test_a_missing_journal_with_an_unusable_disk_target_fails(self):
|
||||
shutil.rmtree(self.log / "journal")
|
||||
(self.root / "var/hdd.journal").write_text("blocked")
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertFalse((self.log / "journal").is_symlink())
|
||||
|
||||
def test_failed_source_removal_does_not_complete_migration(self):
|
||||
(self.root / "var/hdd.journal").mkdir()
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'rm() { return 1; }; export -f rm; exec bash "$1"', "fixture", str(KEEP)],
|
||||
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)), capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertTrue((self.log / "journal/abc/system.journal").is_file())
|
||||
|
||||
def test_boot_configuration_failure_is_reported_and_can_be_retried(self):
|
||||
self.config.unlink()
|
||||
self.config.mkdir()
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.config.rmdir()
|
||||
self.config.write_text("JOURNALD_AWARE=true\n")
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=false\n")
|
||||
|
||||
def run_script(self, revert=False):
|
||||
env = dict(os.environ, PMX_L2R_ROOT=str(self.root), PMX_L2R_REVERT="1" if revert else "0")
|
||||
return subprocess.run(["bash", str(KEEP)], env=env, capture_output=True, text=True, timeout=10)
|
||||
|
||||
def test_the_journal_moves_to_disk_behind_a_link(self):
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
journal = self.log / "journal"
|
||||
self.assertTrue(journal.is_symlink())
|
||||
self.assertEqual(os.readlink(journal), "/var/hdd.journal")
|
||||
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||
self.assertTrue((self.log / "pveproxy/access.log").exists())
|
||||
self.assertFalse((self.root / "var/hdd.proxmox-backup").exists())
|
||||
|
||||
def test_running_again_changes_nothing(self):
|
||||
self.run_script()
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
|
||||
|
||||
def test_the_task_logs_of_backup_server_stay_on_disk_too(self):
|
||||
(self.log / "proxmox-backup/tasks").mkdir(parents=True)
|
||||
(self.log / "proxmox-backup/tasks/active").write_text("task")
|
||||
self.run_script()
|
||||
self.assertEqual(os.readlink(self.log / "proxmox-backup"), "/var/hdd.proxmox-backup")
|
||||
self.assertEqual((self.root / "var/hdd.proxmox-backup/tasks/active").read_text(), "task")
|
||||
|
||||
def test_files_written_back_to_the_folder_join_the_copy_on_disk(self):
|
||||
self.run_script()
|
||||
(self.log / "journal").unlink()
|
||||
(self.log / "journal/def").mkdir(parents=True)
|
||||
(self.log / "journal/def/system.journal").write_text("later")
|
||||
self.run_script()
|
||||
disk = self.root / "var/hdd.journal"
|
||||
self.assertEqual(sorted(p.name for p in disk.iterdir()), ["abc", "def"])
|
||||
self.assertTrue((self.log / "journal").is_symlink())
|
||||
|
||||
def test_a_host_without_a_journal_folder_gets_one_on_disk(self):
|
||||
shutil.rmtree(self.log / "journal")
|
||||
self.run_script()
|
||||
self.assertTrue((self.root / "var/hdd.journal").is_dir())
|
||||
self.assertTrue((self.log / "journal").is_symlink())
|
||||
|
||||
def test_the_task_logs_of_proxmox_ve_stay_on_disk(self):
|
||||
(self.log / "pve/tasks").mkdir(parents=True)
|
||||
(self.log / "pve/tasks/index").write_text("UPID")
|
||||
self.run_script()
|
||||
self.assertEqual(os.readlink(self.log / "pve"), "/var/hdd.pve")
|
||||
self.assertEqual((self.root / "var/hdd.pve/tasks/index").read_text(), "UPID")
|
||||
|
||||
def test_removing_log2ram_puts_the_folders_back(self):
|
||||
(self.log / "proxmox-backup").mkdir()
|
||||
self.run_script()
|
||||
self.config.unlink() # Revert must not require or rewrite Log2RAM settings.
|
||||
self.assertEqual(self.run_script(revert=True).returncode, 0)
|
||||
for name in ("journal", "proxmox-backup"):
|
||||
self.assertFalse((self.log / name).is_symlink(), name)
|
||||
self.assertTrue((self.log / name).is_dir(), name)
|
||||
self.assertFalse((self.root / f"var/hdd.{name}").exists(), name)
|
||||
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class KeptSettingsTests(unittest.TestCase):
|
||||
def read(self, function, files):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script = SETUP.read_text()
|
||||
for path, content in files.items():
|
||||
target = Path(folder) / path.lstrip("/")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content)
|
||||
script = script.replace(path, str(target))
|
||||
library = Path(folder) / "setup.sh"
|
||||
library.write_text(script)
|
||||
result = subprocess.run(["bash", "-c", f'source "{library}"; {function}'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
return result.stdout.strip()
|
||||
|
||||
def test_the_size_comes_from_the_installed_configuration(self):
|
||||
self.assertEqual(self.read("pmx_log2ram_current_size", {"/etc/log2ram.conf": "SIZE=256M\n"}), "256")
|
||||
|
||||
def test_the_interval_comes_from_the_timer_of_this_version(self):
|
||||
timer = "[Timer]\nOnCalendar=\nOnCalendar=*-*-* 00/6:55:00\n"
|
||||
self.assertEqual(self.read("pmx_log2ram_current_hours",
|
||||
{"/etc/systemd/system/log2ram-daily.timer.d/proxmenux.conf": timer}), "6")
|
||||
|
||||
def test_the_interval_of_an_earlier_version_is_kept(self):
|
||||
cron = "MAILTO=\"\"\n0 */3 * * * root /usr/local/bin/log2ram write >/dev/null 2>&1\n"
|
||||
self.assertEqual(self.read("pmx_log2ram_current_hours", {"/etc/cron.d/log2ram": cron}), "3")
|
||||
|
||||
def test_both_modes_offer_a_repair_to_version_2_0_installs(self):
|
||||
for filename, function, updater in (
|
||||
("auto_post_install.sh", "install_log2ram_auto", "_update_existing_log2ram_auto"),
|
||||
("customizable_post_install.sh", "configure_log2ram", "_update_existing_log2ram_custom"),
|
||||
):
|
||||
with self.subTest(mode=filename), tempfile.TemporaryDirectory() as folder:
|
||||
config = Path(folder) / "log2ram.conf"
|
||||
config.write_text("JOURNALD_AWARE=false\n")
|
||||
source = (ROOT / "scripts/post_install" / filename).read_text()
|
||||
body = source.split(function + "() {", 1)[1].split("\n}\n", 1)[0]
|
||||
body = body.replace("/etc/log2ram.conf", str(config))
|
||||
script = ("pmx_journal_context() { :; }\n"
|
||||
+ updater + "() { printf '%s\\n' \"$1\"; }\n"
|
||||
+ function + "() {" + body + "\n}\n" + function)
|
||||
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "2.1")
|
||||
|
||||
def test_the_release_is_pinned_to_a_verified_commit(self):
|
||||
text = SETUP.read_text()
|
||||
self.assertIn('PMX_L2R_TAG="1.7.2"', text)
|
||||
self.assertIn('PMX_L2R_COMMIT="f171449530f8984374c8f69fff3c92af813a694b"', text)
|
||||
self.assertNotIn("install.sh", text.split("_pmx_log2ram_install_release() {", 1)[1].split("\n}\n", 1)[0])
|
||||
|
||||
|
||||
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,50 @@
|
||||
"""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 = ''.join(re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', text, re.M | re.S).group(0)
|
||||
for name in ('pve_manager_version', 'pve_supports_oci'))
|
||||
cls.text = text
|
||||
|
||||
def supported(self, version):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
fake = Path(folder, 'dpkg-query')
|
||||
# Without Proxmox the package is not installed: dpkg-query fails.
|
||||
fake.write_text('#!/bin/sh\nexit 1\n' if version is None else
|
||||
f'#!/bin/sh\nprintf %s "{version}"\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_the_version_is_read_without_the_slow_pveversion(self):
|
||||
self.assertNotIn('pveversion', self.function)
|
||||
|
||||
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
|
||||
@@ -28,6 +29,10 @@ TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
if name == 'remove':
|
||||
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
|
||||
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
|
||||
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
@@ -52,7 +57,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}):
|
||||
@@ -69,6 +75,9 @@ class InventoryMessages(unittest.TestCase):
|
||||
self.assertNotIn('Host directories that are kept, with their content:', text)
|
||||
|
||||
def test_removal_notice_qualifies_saved_record_source(self):
|
||||
# Extracted operational functions must never inspect the real pmxcfs.
|
||||
host = Path(self.enterContext(tempfile.TemporaryDirectory()))
|
||||
inert_path = lambda value: host / Path(value).name if str(value).startswith('/etc/pve/') else Path(value)
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
@@ -79,7 +88,7 @@ class InventoryMessages(unittest.TestCase):
|
||||
'remove_owned_host_firewall': lambda primary: True,
|
||||
'run': lambda *args: events.append(('run', args)),
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'Path': inert_path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
|
||||
@@ -114,7 +123,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()
|
||||
@@ -20,7 +20,7 @@ INSTALL = ROOT / 'oci/remote/install_oci.sh'
|
||||
PREVIEW = 'Private network targeted for release if no other guest uses it:'
|
||||
CONFIRM = 'Remove the application? Its container disks are deleted, and only a backup can bring them back.'
|
||||
RESULT = 'Removal command finished; review any warnings above.'
|
||||
BRIDGE = 'Private network release attempted:'
|
||||
BRIDGE = 'Private network of the application released:'
|
||||
BRIDGE_FAILURE = 'Could not complete private network release:'
|
||||
SUCCESS = 'The application was removed'
|
||||
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
|
||||
@@ -35,6 +35,10 @@ KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, POR
|
||||
|
||||
|
||||
def extract(path, name, scope):
|
||||
if name == 'remove':
|
||||
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
|
||||
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
|
||||
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
|
||||
node = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
@@ -56,7 +60,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 +92,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)
|
||||
|
||||
@@ -255,7 +261,7 @@ class RemovalWordings(unittest.TestCase):
|
||||
self.assertIn(('warn', FIREWALL), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_ignored_bridge_command_failures_do_not_claim_release(self):
|
||||
def test_a_failed_bridge_release_warns_instead_of_claiming_it(self):
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
@@ -272,11 +278,12 @@ class RemovalWordings(unittest.TestCase):
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
|
||||
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
|
||||
'translate': lambda text: text, 'msg_info': lambda text: None,
|
||||
'msg_ok': lambda text: events.append(('ok', text)), 'msg_warn': lambda text: None}
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
|
||||
self.assertIn(('attempt', 'vmbr9'), events)
|
||||
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||
self.assertNotIn(('ok', 'Private network of the application released: vmbr9'), events)
|
||||
self.assertIn(('warn', BRIDGE_FAILURE + ' vmbr9'), events)
|
||||
self.assertNotIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||
|
||||
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
|
||||
events = []
|
||||
|
||||
@@ -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'
|
||||
@@ -89,7 +133,7 @@ class SelectionSetupWording(TestCase):
|
||||
with self.subTest(app=app):
|
||||
path = ROOT / f'oci/remote/install_{app}_stack.sh'
|
||||
lines = path.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('curl -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
|
||||
# Actual Bash failure expression, never source the parent installer.
|
||||
harness = '''set -u
|
||||
@@ -97,6 +141,7 @@ APPLICATION_LAN_IP=192.0.2.1
|
||||
APPLICATION_ID=101
|
||||
OCI_LOG=/dev/null
|
||||
curl() { return 7; }
|
||||
oci_curl() { shift; curl "$@"; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
print_first_boot_diagnostics() { :; }
|
||||
die() { printf 'ERROR:%s\\n' "$1"; }
|
||||
|
||||
@@ -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,216 @@
|
||||
"""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 catalog_refresh, 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, \
|
||||
patch.object(catalog_refresh,'CatalogRefresh') as catalog_checker:
|
||||
# Keep this wording/error-handler test offline and free of writes.
|
||||
# The real background worker has its own catalog lifecycle tests.
|
||||
catalog_check=catalog_checker.return_value.__enter__.return_value
|
||||
catalog_check.active=False;catalog_check.consume.return_value=False
|
||||
catalog_check.describe.return_value=''
|
||||
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,172 @@
|
||||
"""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):
|
||||
def declined(unattended, *_args, **_kwargs):
|
||||
unattended.declined = 'review'
|
||||
return False
|
||||
with patch.object(reconcile, 'propose', return_value={'details': ['external change'], 'review': True}), \
|
||||
patch.object(menu.UnattendedUI, 'review', declined), 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,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
@@ -65,9 +69,45 @@ class OciWatchdogInstaller(TestCase):
|
||||
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
|
||||
self.assertLess(asked, install.index('approved = wizard.review('))
|
||||
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
|
||||
self.assertIn('set_watchdog(PROJECT_ROOT, sorted(vmids), True)', install)
|
||||
self._assert_jobs_are_excluded_from_installed_watchdog()
|
||||
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
|
||||
|
||||
def _assert_jobs_are_excluded_from_installed_watchdog(self):
|
||||
node = next(item for item in ast.parse(self.CLI).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'install_template')
|
||||
class InertImports(ast.NodeTransformer):
|
||||
def visit_ImportFrom(self, item):
|
||||
return None
|
||||
node = InertImports().visit(node)
|
||||
deployment = {'services': [
|
||||
{'name': 'migration', 'deployment': {'execution_mode': 'one-shot'}},
|
||||
{'name': 'database', 'deployment': {}},
|
||||
{'name': 'worker', 'deployment': {}}]}
|
||||
result = {'vmid': 4800, 'stack_vmids': {'web': 4800, 'migration': 4801,
|
||||
'database': 4802, 'worker': 4803}}
|
||||
set_watchdog = Mock(return_value=True)
|
||||
class UI:
|
||||
def confirm(self, *args, **kwargs): return True
|
||||
def review(self, *args, **kwargs): return True
|
||||
def close(self): pass
|
||||
def install(project, template, plan, target):
|
||||
self.assertNotIn('watchdog', plan)
|
||||
return result
|
||||
scope = {'Any': Any, 'copy': copy, 'BacktrackUI': lambda ui: ui,
|
||||
'RestartWizard': RuntimeError, 'InstallError': ValueError,
|
||||
'build_deployment': lambda *args: copy.deepcopy(deployment),
|
||||
'watchdog_default': lambda *args: False,
|
||||
'translate': lambda value: value, '_deployment_summary_text': lambda *args: '',
|
||||
'PROJECT_ROOT': Path('/inert'), 'source_text': lambda value: 'test', 'APP_TITLE': 'test',
|
||||
'console': SimpleNamespace(**{name: lambda *args: None for name in
|
||||
('show_logo', 'msg_title', 'msg_error', 'msg_ok', 'msg_warn', 'wait_for_enter')}),
|
||||
'run_remote_install': install, 'carry_records': lambda *args, **kwargs: None,
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: (False, None)),
|
||||
'_print_installation_summary': lambda *args: None, 'set_watchdog': set_watchdog}
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), 'cli.py', 'exec'), scope)
|
||||
scope['install_template'](UI(), {'catalog_ui': {'title': {'en_US': 'test'}}}, 'test', 'advanced')
|
||||
set_watchdog.assert_called_once_with(Path('/inert'), [4800, 4802, 4803], True)
|
||||
|
||||
def test_both_management_menus_offer_it(self):
|
||||
self.assertEqual(self.MENU.count("('watchdog', "), 2)
|
||||
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
|
||||
|
||||
@@ -17,13 +17,14 @@ 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.',
|
||||
'Containers of the application:',
|
||||
'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 +45,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)
|
||||
@@ -74,15 +76,19 @@ class OciWordingTests(TestCase):
|
||||
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
|
||||
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
|
||||
|
||||
def test_multiple_services_named_not_images(self):
|
||||
def test_multiple_services_keep_their_names_and_enter_stack_conversion(self):
|
||||
# Reusing one image twice still creates two distinct Compose services.
|
||||
document = {'services': {'alpha': {'image': 'same:latest'},
|
||||
'beta': {'image': 'same:latest'}}}
|
||||
converted = object()
|
||||
stack = Mock(return_value=converted)
|
||||
template = function(CUSTOM, 'template_from_compose',
|
||||
_services=lambda doc: doc['services'], _check_mounts=lambda _: None)
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
template(json.dumps({'services': {'alpha': {'image': 'same:latest'},
|
||||
'beta': {'image': 'same:latest'}}}))
|
||||
self.assertEqual(str(caught.exception),
|
||||
'The Compose file describes several services: alpha, beta. '
|
||||
'Only one service at a time can be installed this way.')
|
||||
_services=lambda doc: doc['services'], _template_from_stack=stack)
|
||||
self.assertIs(template(json.dumps(document)), converted)
|
||||
stack.assert_called_once()
|
||||
self.assertEqual(stack.call_args.args, (document, None))
|
||||
self.assertFalse(stack.call_args.kwargs['interpolated'])
|
||||
self.assertIsNone(stack.call_args.kwargs.get('image_entrypoints'))
|
||||
|
||||
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
|
||||
for title, expected in ((None, 'No Compose file was given'),
|
||||
@@ -105,7 +111,8 @@ class OciWordingTests(TestCase):
|
||||
def test_registry_nonzero_and_success_command_unchanged(self):
|
||||
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
|
||||
run = Mock(return_value=result)
|
||||
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run))
|
||||
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run),
|
||||
_registry_lookup=lambda reference: reference)
|
||||
self.assertEqual(report('example:latest'),
|
||||
(False, 'Could not inspect the image in its registry: example:latest'))
|
||||
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
|
||||
@@ -116,16 +123,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')+function('scripts/global/common-functions.sh','pmx_confirm_update')+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,113 @@
|
||||
"""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)],
|
||||
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)
|
||||
# New strings are reviewed in Slovak. Other locales keep the runtime
|
||||
# English fallback until the translation bot supplies their values;
|
||||
# still validate every translation already present in those locales.
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--languages', 'sk', '--require-complete'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn('missing=0', 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()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""A label made only of product and API names is right when it comes back
|
||||
unchanged, so the translation bot keeps it instead of retrying and dropping it."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import build_translation_cache as cache # noqa: E402
|
||||
|
||||
|
||||
class ProtectedTermsTests(unittest.TestCase):
|
||||
def test_acceleration_profile_labels_are_kept_unchanged(self):
|
||||
for label in ("Intel/AMD (Vulkan)", "AMD (ROCm)", "Intel (SYCL)", "NVIDIA (CUDA)",
|
||||
"Intel/AMD (VA-API)", "Intel (OpenVINO)"):
|
||||
with self.subTest(label=label):
|
||||
self.assertTrue(cache.is_fully_protected(label))
|
||||
self.assertFalse(cache.looks_untranslated(label, label))
|
||||
|
||||
def test_a_sentence_coming_back_unchanged_is_still_a_failure(self):
|
||||
sentence = "The GPU is used through Vulkan when it is available"
|
||||
self.assertTrue(cache.looks_untranslated(sentence, sentence))
|
||||
|
||||
|
||||
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})')
|
||||
@@ -24,7 +24,10 @@ class VmBackupJobsWiring(TestCase):
|
||||
source = ROUTES.read_text()
|
||||
self.assertNotIn('shell=True', source)
|
||||
self.assertNotIn('os.system', source)
|
||||
self.assertEqual(source.count('subprocess.run('), 1)
|
||||
# pvesh, and the transient unit a backup started by hand runs in.
|
||||
programs = re.findall(r'subprocess\.run\(\s*\[\s*"([a-z-]+)"', source)
|
||||
self.assertEqual(len(programs), source.count('subprocess.run('))
|
||||
self.assertEqual(set(programs), {'pvesh', 'systemd-run', 'systemctl'})
|
||||
self.assertIn('subprocess.run(["pvesh", *args]', source)
|
||||
|
||||
def test_the_monitor_ships_and_registers_it(self):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -33,7 +33,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -25,6 +25,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same branch or pull request replaces the run in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,7 +37,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -45,5 +49,6 @@ jobs:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$BASE"
|
||||
git show "$BASE:oci/catalog/verification.json" > "$RUNNER_TEMP/base.json" 2>/dev/null || : > "$RUNNER_TEMP/base.json"
|
||||
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
name: Feature tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-features.yml'
|
||||
- 'AppImage/scripts/notification_manager.py'
|
||||
- 'AppImage/scripts/notification_templates.py'
|
||||
- 'AppImage/scripts/ai_context_enrichment.py'
|
||||
- 'AppImage/scripts/ai_providers/base.py'
|
||||
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
|
||||
- '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'
|
||||
- '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'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
- '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.*'
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- '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'
|
||||
- 'oci/**'
|
||||
- 'AppImage/scripts/oci_menu_bridge.py'
|
||||
- 'AppImage/scripts/lxc_apps.py'
|
||||
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'AppImage/scripts/tests/test_pbs.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/auth_manager.py'
|
||||
- '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:
|
||||
- '.github/workflows/test-features.yml'
|
||||
- 'AppImage/scripts/notification_manager.py'
|
||||
- 'AppImage/scripts/notification_templates.py'
|
||||
- 'AppImage/scripts/ai_context_enrichment.py'
|
||||
- 'AppImage/scripts/ai_providers/base.py'
|
||||
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
- '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'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
- '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.*'
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.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'
|
||||
- 'oci/**'
|
||||
- 'AppImage/scripts/oci_menu_bridge.py'
|
||||
- 'AppImage/scripts/lxc_apps.py'
|
||||
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same branch or pull request replaces the run in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
- name: Test AI diagnostic explanations and backup inventory integrity
|
||||
run: |
|
||||
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_backup_ai_diagnostics.py -v
|
||||
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_vzdump_ai_integrity.py -v
|
||||
|
||||
registry-accounts:
|
||||
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.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
|
||||
- name: Run full OCI engine test suite
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -v
|
||||
|
||||
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
|
||||
@@ -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,33 @@ 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
|
||||
|
||||
# A newer push to the same branch or pull request replaces the run in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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 +76,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
|
||||
|
||||
@@ -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/**'
|
||||
@@ -43,6 +57,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same branch or pull request replaces the run in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
offline-python:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -54,10 +73,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' 'PyJWT>=2,<3' 'gevent>=25,<27' 'flask-sock==0.7.0' requests
|
||||
- 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 +101,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'
|
||||
|
||||
@@ -14,6 +14,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/scripts/generate_app_tracking_catalog.py"
|
||||
- ".github/scripts/tests/test_app_tracking_sources.py"
|
||||
- ".github/workflows/update-app-tracking-hints.yml"
|
||||
- "json/runtime_verified_overrides.json"
|
||||
|
||||
@@ -37,6 +38,9 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Test Helper-Scripts source layouts
|
||||
run: python -B -m unittest discover -s .github/scripts/tests -p test_app_tracking_sources.py -v
|
||||
|
||||
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -54,6 +54,7 @@ Thumbs.db
|
||||
|
||||
# Cache
|
||||
.cache
|
||||
/oci/.catalog-cache/
|
||||
/web/.cache
|
||||
|
||||
# IDE/editor files
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
|
||||
92084408d97ec3c2c17499960cc77ba603d87affe0a648720d69909b1dee8b95 ProxMenux-1.2.8.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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ interface DiskEntry {
|
||||
rotational: boolean
|
||||
excluded: boolean
|
||||
excluded_at: string | null
|
||||
idle: boolean
|
||||
present: boolean
|
||||
}
|
||||
|
||||
@@ -206,15 +205,6 @@ export function DiskExclusions() {
|
||||
{t("settings.diskExclusions.excluded")}
|
||||
</Badge>
|
||||
)}
|
||||
{!excluded && disk.idle && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground"
|
||||
title={t("storage.idleTitle")}
|
||||
>
|
||||
{t("storage.idle")}
|
||||
</Badge>
|
||||
)}
|
||||
{!disk.present && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
|
||||
{t("settings.diskExclusions.notConnected")}
|
||||
@@ -252,8 +242,6 @@ export function DiskExclusions() {
|
||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("settings.diskExclusions.help")}
|
||||
<br />
|
||||
{t("settings.diskExclusions.idleHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
@@ -633,6 +633,13 @@ export default function Hardware() {
|
||||
return realtimeGPUData.has_monitoring_tool === true
|
||||
}
|
||||
|
||||
const diskCountBySlot = new Map<string, number>()
|
||||
for (const disk of hardwareData?.storage_devices ?? []) {
|
||||
if (disk.controller_slot) {
|
||||
diskCountBySlot.set(disk.controller_slot, (diskCountBySlot.get(disk.controller_slot) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
@@ -2358,14 +2365,23 @@ return (
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{hardwareData.pci_devices.map((device, index) => (
|
||||
{hardwareData.pci_devices.map((device, index) => {
|
||||
const diskCount = diskCountBySlot.get(device.slot) ?? 0
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setSelectedPCIDevice(device)}
|
||||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
|
||||
{diskCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{t("hardware.counts.devices", { count: diskCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
|
||||
</div>
|
||||
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
|
||||
@@ -2374,7 +2390,8 @@ return (
|
||||
<p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -2434,6 +2451,37 @@ return (
|
||||
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const attachedDisks = (hardwareData?.storage_devices ?? []).filter(
|
||||
(disk) => disk.controller_slot === selectedPCIDevice.slot,
|
||||
)
|
||||
if (attachedDisks.length === 0) return null
|
||||
return (
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("hardware.labels.attachedDisks")} ({attachedDisks.length})
|
||||
</span>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{attachedDisks.map((disk) => (
|
||||
<button
|
||||
key={disk.name}
|
||||
type="button"
|
||||
title={disk.model}
|
||||
onClick={() => {
|
||||
setSelectedPCIDevice(null)
|
||||
setSelectedDisk(disk)
|
||||
}}
|
||||
>
|
||||
<Badge variant="outline" className="font-mono hover:bg-accent">
|
||||
{disk.name}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -2827,6 +2875,34 @@ return (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDisk.controller_slot && (() => {
|
||||
// Fall back to the bare slot when the controller's PCI class isn't one we list
|
||||
const controller = hardwareData?.pci_devices?.find((d) => d.slot === selectedDisk.controller_slot)
|
||||
return (
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.storageController")}</span>
|
||||
<span className="text-sm text-right">
|
||||
{controller ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-400 hover:underline text-right"
|
||||
onClick={() => {
|
||||
setSelectedDisk(null)
|
||||
setSelectedPCIDevice(controller)
|
||||
}}
|
||||
>
|
||||
{/* lspci's "Device 84ca" placeholder means no subsystem name, so use the chip name */}
|
||||
{controller.sdevice && !/^Device [0-9a-f]{4}$/.test(controller.sdevice)
|
||||
? controller.sdevice
|
||||
: controller.device}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="ml-2 font-mono text-muted-foreground">{selectedDisk.controller_slot}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.rotationRate")}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { getAuthToken } from "@/lib/api-config"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -41,7 +41,11 @@ import {
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
|
||||
import { HostUpdateResult } from "./host-update-result"
|
||||
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { useIsPbs } from "@/lib/use-platform"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
@@ -119,17 +123,29 @@ 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[]>([])
|
||||
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [snapshotUnavailable, setSnapshotUnavailable] = useState(false)
|
||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
||||
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
|
||||
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
|
||||
const [resultJobId, setResultJobId] = useState<string | null>(null)
|
||||
const healthRequest = useRef<AbortController | null>(null)
|
||||
const previousAutoExpanded = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchHealthDetails = useCallback(async (force = false) => {
|
||||
setLoading(true)
|
||||
const fetchHealthDetails = useCallback(async (force = false, cachedOnly = false) => {
|
||||
if (cachedOnly && healthRequest.current) return
|
||||
healthRequest.current?.abort()
|
||||
const controller = new AbortController()
|
||||
healthRequest.current = controller
|
||||
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
|
||||
if (!cachedOnly) setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
@@ -142,14 +158,26 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
authHeaders["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders })
|
||||
const path = cachedOnly ? "/api/health/full?cached=1" : force ? "/api/health/full?refresh=1" : "/api/health/full"
|
||||
const response = await fetch(getApiUrl(path), { headers: authHeaders, signal: controller.signal })
|
||||
if (healthRequest.current !== controller) return
|
||||
let infoCount = 0
|
||||
|
||||
if (!response.ok) {
|
||||
if (cachedOnly && response.status === 503) {
|
||||
// Completion invalidates the snapshot before the collector refills it.
|
||||
// Keep the dated result, explicitly unavailable; never start an audit.
|
||||
setSnapshotUnavailable(true)
|
||||
return
|
||||
}
|
||||
// Polling cannot fall back to a legacy endpoint that runs a full audit.
|
||||
if (cachedOnly) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||
// Fallback to legacy endpoint
|
||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders, signal: controller.signal })
|
||||
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||
const data = await legacyResponse.json()
|
||||
if (healthRequest.current !== controller) return
|
||||
setSnapshotUnavailable(false)
|
||||
setHealthData(data)
|
||||
setDismissedItems([])
|
||||
setCustomSuppressions([])
|
||||
@@ -166,6 +194,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
} else {
|
||||
const fullData: FullHealthData = await response.json()
|
||||
if (healthRequest.current !== controller) return
|
||||
if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||
setSnapshotUnavailable(false)
|
||||
setHealthData(fullData.health)
|
||||
setDismissedItems(fullData.dismissed || [])
|
||||
setCustomSuppressions(fullData.custom_suppressions || [])
|
||||
@@ -205,9 +236,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||
if (healthRequest.current === controller) {
|
||||
setSnapshotUnavailable(false)
|
||||
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
if (healthRequest.current === controller) {
|
||||
healthRequest.current = null
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [getApiUrl, t])
|
||||
|
||||
@@ -223,9 +261,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHealthDetails()
|
||||
// Auto-refresh every 5 minutes while modal is open
|
||||
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
|
||||
return () => clearInterval(refreshInterval)
|
||||
// Recheck reboot evidence promptly without running APT/full health checks.
|
||||
const refresh = () => {
|
||||
if (document.visibilityState === "visible") void fetchHealthDetails(false, true)
|
||||
}
|
||||
const refreshInterval = setInterval(refresh, 30000)
|
||||
window.addEventListener("focus", refresh)
|
||||
document.addEventListener("visibilitychange", refresh)
|
||||
return () => {
|
||||
clearInterval(refreshInterval)
|
||||
window.removeEventListener("focus", refresh)
|
||||
document.removeEventListener("visibilitychange", refresh)
|
||||
const controller = healthRequest.current
|
||||
healthRequest.current = null
|
||||
controller?.abort()
|
||||
}
|
||||
}
|
||||
}, [open, fetchHealthDetails])
|
||||
|
||||
@@ -243,7 +293,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
nonOkCategories.add(key)
|
||||
}
|
||||
})
|
||||
setExpandedCategories(nonOkCategories)
|
||||
const newlyDegraded = [...nonOkCategories].filter(key => !previousAutoExpanded.current.has(key))
|
||||
previousAutoExpanded.current = nonOkCategories
|
||||
if (newlyDegraded.length) {
|
||||
setExpandedCategories(previous => new Set([...previous, ...newlyDegraded]))
|
||||
}
|
||||
}
|
||||
}, [healthData])
|
||||
|
||||
@@ -298,6 +352,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
const translateHealthText = (value?: string): string => {
|
||||
if (!value) return ""
|
||||
// Health summaries include the category before the same detail text.
|
||||
if (value.startsWith("Updates: ")) {
|
||||
return `${t("healthStatus.categories.updates")}: ${translateHealthText(value.slice(9))}`
|
||||
}
|
||||
const exact: Record<string, string> = {
|
||||
"No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"),
|
||||
"Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"),
|
||||
@@ -328,6 +386,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
"Active": t("healthStatus.details.active"),
|
||||
"UP": t("healthStatus.details.up"),
|
||||
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
|
||||
"No reboot required": t("healthStatus.hostUpdate.noReboot"),
|
||||
"No pending updates": t("healthStatus.details.noPendingUpdates"),
|
||||
"Reboot required by the operating system": t("healthStatus.hostUpdate.rebootMarker"),
|
||||
"Could not determine whether a reboot is required": t("healthStatus.hostUpdate.rebootUnknown"),
|
||||
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
|
||||
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
|
||||
"No container startup errors": t("healthStatus.details.noContainerErrors"),
|
||||
@@ -339,6 +401,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
|
||||
|
||||
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
|
||||
const reboot = value.match(/^Reboot required: running (.+); installed (.+)$/)
|
||||
if (reboot) return t("healthStatus.hostUpdate.rebootKernel", { running: reboot[1], installed: reboot[2] })
|
||||
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
|
||||
match = value.match(/^(\d+) failed login attempts in 24h$/)
|
||||
if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
|
||||
@@ -460,6 +524,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 +538,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 +550,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()
|
||||
@@ -616,7 +685,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
|
||||
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
||||
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
|
||||
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
|
||||
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", "reboot_required",
|
||||
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
|
||||
])
|
||||
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
|
||||
@@ -627,6 +696,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
const translateHealthSummary = (snapshot: HealthDetails): string => {
|
||||
if (snapshot.summary === "Updates") {
|
||||
const reboot = snapshot.details?.updates?.checks?.reboot_required
|
||||
if (reboot?.status === "INFO" && typeof reboot.detail === "string" && reboot.detail) {
|
||||
return translateHealthText(reboot.detail)
|
||||
}
|
||||
return t("healthStatus.categories.updates")
|
||||
}
|
||||
return translateHealthText(snapshot.summary)
|
||||
}
|
||||
|
||||
const renderChecks = (
|
||||
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
|
||||
categoryKey: string
|
||||
@@ -638,6 +718,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{Object.entries(checks)
|
||||
.filter(([, checkData]) => checkData.installed !== false)
|
||||
.map(([checkKey, checkData]) => {
|
||||
if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") {
|
||||
return <RestartAfterUpdate key={checkKey} getApiUrl={getApiUrl}
|
||||
unavailable={loading || snapshotUnavailable || !!error}
|
||||
disabled={hostJobs.some(job => job.state !== "complete")} label={formatCheckLabel(checkKey)}
|
||||
detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} />
|
||||
}
|
||||
const isDismissable = checkData.dismissable === true
|
||||
const checkStatus = checkData.status?.toUpperCase() || "OK"
|
||||
|
||||
@@ -704,6 +790,14 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
|
||||
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
|
||||
onResume={job => {
|
||||
if (job.state === "complete") { setResultJobId(job.id); return }
|
||||
setHostJob({ runId: job.id, resume: true, kind: "host" })
|
||||
setShowUpdateTerminal(true)
|
||||
}} />
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
@@ -717,8 +811,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData && !loading && (
|
||||
<div className="space-y-4">
|
||||
{snapshotUnavailable && (
|
||||
<div role="status" className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<p>{t("healthStatus.snapshotUnavailable")}</p>
|
||||
{healthData && <p className="mt-1">{t("healthStatus.lastKnownResults")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData && (
|
||||
<fieldset disabled={loading} className="space-y-4 min-w-0">
|
||||
{/* Overall Stats Summary */}
|
||||
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
|
||||
<div className="text-center">
|
||||
@@ -753,13 +854,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{healthData.summary && healthData.summary !== "All systems operational" && (
|
||||
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
|
||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthSummary(healthData)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 +885,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})
|
||||
@@ -838,18 +939,30 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
pending prevents the operator from spawning a
|
||||
terminal that would only report "System is
|
||||
already up to date". */}
|
||||
{key === "updates" && status?.toUpperCase() !== "OK" && (
|
||||
<div className="flex justify-end px-3 py-2 pt-1">
|
||||
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
|
||||
<p className="px-2 sm:px-3 mt-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
{t("healthStatus.packagePlanNote")}
|
||||
</p>
|
||||
)}
|
||||
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
|
||||
<div className="flex justify-end px-2 sm:px-3 pt-3 pb-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowUpdateTerminal(true)}
|
||||
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
disabled={hostJobs.some(job => job.state !== "complete")}
|
||||
onClick={() => {
|
||||
const runId = window.crypto?.randomUUID?.()
|
||||
|| `host-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
setHostJob({ runId, resume: false, kind: "host" })
|
||||
setShowUpdateTerminal(true)
|
||||
}}
|
||||
className="h-10 sm:h-[34px] bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
{t("healthStatus.updateNow")}
|
||||
{t("healthStatus.updateSystem")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{key === "updates" && <ProxMenuxVersionCard getApiUrl={getApiUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -871,7 +984,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 +1049,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}
|
||||
@@ -957,11 +1070,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</DialogContent>
|
||||
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
|
||||
<ScriptTerminalModal
|
||||
key={hostJob?.runId || "host-update"}
|
||||
open={showUpdateTerminal}
|
||||
backgroundUpdate={hostJob || undefined}
|
||||
cancelledExitCode={130}
|
||||
onComplete={() => { void fetchHealthDetails(true) }}
|
||||
onClose={() => {
|
||||
setShowUpdateTerminal(false)
|
||||
// Force a fresh read (cache-busting via ?refresh=1) so the
|
||||
|
||||
@@ -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 {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
FolderOpen,
|
||||
Database,
|
||||
Waves,
|
||||
Monitor,
|
||||
} from "lucide-react"
|
||||
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
@@ -84,6 +86,7 @@ interface ThresholdsTree {
|
||||
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
|
||||
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
vm_disk: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
disk_temperature: {
|
||||
hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
@@ -201,6 +204,17 @@ const SECTIONS: SectionDef[] = [
|
||||
{ path: ["lxc_rootfs", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "vm_disk",
|
||||
title: "Disk space — VMs",
|
||||
icon: Monitor,
|
||||
description:
|
||||
"Filesystems of running VMs as the QEMU guest agent reports them, passthrough drives included. VMs without the guest agent are not evaluated.",
|
||||
fields: [
|
||||
{ path: ["vm_disk", "warning"], label: "Warning" },
|
||||
{ path: ["vm_disk", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lxc_mount",
|
||||
title: "LXC mount points",
|
||||
@@ -283,8 +297,12 @@ function computeVisualRange(
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Guests and PVE storages do not exist on a Proxmox Backup Server.
|
||||
const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "vm_disk", "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 +791,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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export type HostUpdateJob = {
|
||||
id: string
|
||||
state: "starting" | "running" | "attention" | "finalizing" | "complete"
|
||||
exit_code: number | null
|
||||
}
|
||||
|
||||
export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }: {
|
||||
open: boolean
|
||||
activeId?: string
|
||||
onResume: (job: HostUpdateJob) => void
|
||||
onJobs: (jobs: HostUpdateJob[]) => void
|
||||
onSettled: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||
const callbacks = useRef({ onJobs, onSettled })
|
||||
callbacks.current = { onJobs, onSettled }
|
||||
const observed = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let closed = false
|
||||
const read = async () => {
|
||||
try {
|
||||
const data = await fetchApi<{ jobs: HostUpdateJob[] }>("/api/host-update-jobs")
|
||||
if (closed) return
|
||||
const next = Array.isArray(data.jobs) ? data.jobs : []
|
||||
setJobs(next)
|
||||
callbacks.current.onJobs(next)
|
||||
for (const job of next) {
|
||||
if (job.state === "complete" && !observed.current.has(job.id)) {
|
||||
observed.current.add(job.id)
|
||||
callbacks.current.onSettled()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Authentication recovery belongs to fetchApi. Never retry a start.
|
||||
}
|
||||
}
|
||||
void read()
|
||||
const timer = setInterval(() => { void read() }, 5000)
|
||||
return () => { closed = true; clearInterval(timer) }
|
||||
}, [open])
|
||||
const visible = jobs.filter(job => job.id !== activeId && !(job.state === "complete" && hidden.has(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(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
|
||||
{t(job.state === "complete" ? "healthStatus.hostUpdate.viewResult" : "healthStatus.hostUpdate.view")}
|
||||
</Button>
|
||||
{job.state === "complete" && <Button size="sm" variant="ghost" onClick={() => setHidden(previous => new Set([...previous, job.id]))}>
|
||||
{t("healthStatus.hostUpdate.hideResult")}
|
||||
</Button>}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { Button } from "./ui/button"
|
||||
import { getAuthToken } from "../lib/api-config"
|
||||
import { scriptCompletion } from "../lib/script-completion"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import "xterm/css/xterm.css"
|
||||
|
||||
export function HostUpdateResult({ jobId, getApiUrl, onClose }: {
|
||||
jobId: string | null
|
||||
getApiUrl: (path: string) => string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [exitCode, setExitCode] = useState<number | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!jobId) return
|
||||
const controller = new AbortController()
|
||||
let terminal: import("xterm").Terminal | undefined
|
||||
let observer: ResizeObserver | undefined
|
||||
let disposed = false
|
||||
setExitCode(null); setFailed(false)
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
void (async () => {
|
||||
try {
|
||||
const token = getAuthToken()
|
||||
const response = await fetch(getApiUrl(`/api/host-update-jobs/${encodeURIComponent(jobId)}/result`), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) throw Error("unavailable")
|
||||
const value = await response.json()
|
||||
if (value.state !== "complete" || !Number.isSafeInteger(value.exit_code)
|
||||
|| typeof value.text !== "string" || value.text.length > 256 * 1024
|
||||
|| typeof value.truncated !== "boolean") throw Error("invalid result")
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([import("xterm"), import("xterm-addon-fit")])
|
||||
if (controller.signal.aborted || !container.current) return
|
||||
terminal = new Terminal({ disableStdin: true, convertEol: true, fontSize: 12,
|
||||
screenReaderMode: true, theme: { background: "#000000", foreground: "#dddddd" } })
|
||||
const fit = new FitAddon()
|
||||
terminal.loadAddon(fit); terminal.open(container.current); fit.fit()
|
||||
if (value.truncated) terminal.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
terminal.write(value.text)
|
||||
observer = new ResizeObserver(() => { if (!controller.signal.aborted) fit.fit() })
|
||||
observer.observe(container.current)
|
||||
setExitCode(value.exit_code)
|
||||
} catch {
|
||||
if (!disposed) setFailed(true)
|
||||
} finally { clearTimeout(timeout) }
|
||||
})()
|
||||
return () => { disposed = true; controller.abort(); clearTimeout(timeout); observer?.disconnect(); terminal?.dispose() }
|
||||
}, [jobId, getApiUrl, revision, t])
|
||||
const kind = exitCode === null ? null : scriptCompletion(exitCode, 130).kind
|
||||
return <Dialog open={jobId !== null} onOpenChange={open => { if (!open) onClose() }}>
|
||||
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto p-4 sm:p-5">
|
||||
<DialogHeader><DialogTitle>{t("healthStatus.hostUpdate.resultTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("healthStatus.hostUpdate.resultReadOnly")}</DialogDescription></DialogHeader>
|
||||
{failed && <div role="alert" className="text-sm text-amber-500">
|
||||
<p>{t("healthStatus.hostUpdate.resultUnavailable")}</p>
|
||||
<Button size="sm" variant="outline" className="mt-3" onClick={() => setRevision(value => value + 1)}>{t("healthStatus.hostUpdate.retryResult")}</Button>
|
||||
</div>}
|
||||
<div ref={container} className="h-[45vh] min-h-[180px] bg-black rounded overflow-hidden" />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p role="status" className={`text-xs ${exitCode === 0 ? "text-green-500" : "text-muted-foreground"}`}>
|
||||
{kind === null ? t(failed ? "healthStatus.hostUpdate.resultUnavailable" : "healthStatus.hostUpdate.loadingResult")
|
||||
: kind === "cancelled" ? t("scriptTerminal.cancelled") : exitCode === 0
|
||||
? t("scriptTerminal.completedSuccessfully") : t("scriptTerminal.completedWithError", { code: exitCode })}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>{t("healthStatus.hostUpdate.closeResult")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||
import * as Confirm from "@radix-ui/react-alert-dialog"
|
||||
import { Button } from "./ui/button"
|
||||
import { getAuthToken } from "@/lib/api-config"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { CheckCircle2, AlertTriangle, Info, ChevronRight } from "lucide-react"
|
||||
|
||||
function headers() {
|
||||
const token = getAuthToken()
|
||||
return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }
|
||||
}
|
||||
type Url = (path: string) => string
|
||||
type Context = { hostname: string; boot_id: string; allowed: boolean; reason: string }
|
||||
const compact = "h-10 sm:h-[34px] px-3 text-xs"
|
||||
|
||||
export function RestartAfterUpdate({ getApiUrl, disabled, unavailable = false, label, detail, icon }: {
|
||||
getApiUrl: Url; disabled: boolean; unavailable?: boolean; label: string; detail: string; icon: ReactNode
|
||||
}) {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [context, setContext] = useState<Context | null>(null)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const sending = useRef(false)
|
||||
const alive = useRef(true)
|
||||
const postRequest = useRef<AbortController | null>(null)
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, [])
|
||||
useEffect(() => {
|
||||
let disposed = false, reading = false
|
||||
let active: AbortController | undefined
|
||||
setContext(null); setSubmitted(false); setError(false); sending.current = false
|
||||
const read = async () => {
|
||||
if (reading) return
|
||||
reading = true
|
||||
const controller = new AbortController(); active = controller
|
||||
const timeout = setTimeout(() => controller.abort(), 6000)
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/reboot-context"), { headers: headers(), signal: controller.signal })
|
||||
if (!response.ok) throw Error("unavailable")
|
||||
const value: Context = await response.json()
|
||||
if (!value || typeof value.hostname !== "string" || typeof value.allowed !== "boolean" || typeof value.boot_id !== "string" || !value.boot_id) throw Error("invalid")
|
||||
if (!disposed) { setContext(value); setError(false) }
|
||||
} catch { if (!disposed) { setContext(null); setError(true) } }
|
||||
finally { clearTimeout(timeout); reading = false }
|
||||
}
|
||||
void read()
|
||||
// A retained health snapshot must never grant permission after reconnect
|
||||
// or remount. This cheap read does not run APT or a full health check.
|
||||
const interval = setInterval(read, open ? 2000 : 30000)
|
||||
return () => { disposed = true; clearInterval(interval); active?.abort() }
|
||||
}, [open, getApiUrl])
|
||||
useEffect(() => {
|
||||
if (!accepted) return
|
||||
let disposed = false, reading = false
|
||||
const controller = new AbortController()
|
||||
const interval = setInterval(async () => {
|
||||
if (reading) return
|
||||
reading = true
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/reboot-after-update/status"), { headers: headers(), signal: controller.signal })
|
||||
if (!response.ok) return
|
||||
const value = await response.json()
|
||||
if (!disposed && (value.state === "failed" || value.state === "cancelled")) { setAccepted(false); setError(true) }
|
||||
} catch { /* Loss of connection does not prove reboot completion. */ }
|
||||
finally { reading = false }
|
||||
}, 2000)
|
||||
const timeout = setTimeout(() => { clearInterval(interval); controller.abort() }, 10000)
|
||||
return () => { disposed = true; controller.abort(); clearInterval(interval); clearTimeout(timeout) }
|
||||
}, [accepted, getApiUrl])
|
||||
const confirm = async () => {
|
||||
if (disabled || unavailable || !context?.allowed || sending.current || submitted) return
|
||||
sending.current = true; setSubmitted(true); setPending(true); setError(false)
|
||||
const controller = new AbortController(); postRequest.current = controller
|
||||
const timeout = setTimeout(() => controller.abort(), 15000)
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/reboot-after-update"), {
|
||||
method: "POST", headers: headers(), body: JSON.stringify({ confirm: true, hostname: context.hostname, boot_id: context.boot_id }), signal: controller.signal,
|
||||
})
|
||||
if (response.status !== 202 || (await response.json()).accepted !== true) throw Error("unconfirmed")
|
||||
if (alive.current) { setAccepted(true); setOpen(false) }
|
||||
} catch { if (alive.current) setError(true) }
|
||||
finally { clearTimeout(timeout); postRequest.current = null; if (alive.current) setPending(false) }
|
||||
}
|
||||
return <section className="py-1.5 px-2 sm:px-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0 text-[10px] sm:text-xs"><span className="shrink-0">{icon}</span>
|
||||
<div><span className="font-medium mr-2">{label}</span><span className="text-muted-foreground break-words">{detail}</span></div></div>
|
||||
<Button variant="outline" className={`${compact} self-start sm:shrink-0 border-amber-500/40 text-amber-500`}
|
||||
disabled={disabled || unavailable || !context?.allowed || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}</Button>
|
||||
</div>
|
||||
{disabled && <p className="mt-2 text-xs text-muted-foreground">{t("healthStatus.restart.blocked")}</p>}
|
||||
{accepted && <p role="status" className="mt-2 text-xs text-blue-500">{t("healthStatus.restart.accepted")}</p>}
|
||||
{error && !open && <p role="alert" className="mt-2 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
|
||||
<Confirm.Root open={open} onOpenChange={setOpen}><Confirm.Portal>
|
||||
<Confirm.Overlay className="fixed inset-0 bg-black/75 z-[70]" />
|
||||
<Confirm.Content className="fixed z-[80] w-[92vw] max-w-[520px] rounded-lg border bg-card text-foreground p-5 shadow-lg" style={{ top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
|
||||
<Confirm.Title className="text-base font-semibold">{t("healthStatus.restart.title", { server: context?.hostname ?? "…" })}</Confirm.Title>
|
||||
<Confirm.Description className="mt-3 text-sm text-muted-foreground">{t("healthStatus.restart.impact")}</Confirm.Description>
|
||||
{(disabled || context?.reason === "busy") && <p className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.blocked")}</p>}
|
||||
{context?.reason === "current" && <p className="mt-3 text-xs text-muted-foreground">{t("healthStatus.hostUpdate.noReboot")}</p>}
|
||||
{(error || context?.reason === "unknown") && <p role="alert" className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2.5">
|
||||
<Confirm.Cancel asChild><Button variant="outline" className={compact} disabled={pending}>{t("healthStatus.restart.cancel")}</Button></Confirm.Cancel>
|
||||
<Confirm.Action asChild><Button variant="destructive" className={compact} disabled={disabled || unavailable || !context?.allowed || pending || submitted}
|
||||
onClick={event => { event.preventDefault(); void confirm() }}>{t("healthStatus.restart.confirm")}</Button></Confirm.Action>
|
||||
</div>
|
||||
</Confirm.Content>
|
||||
</Confirm.Portal></Confirm.Root>
|
||||
</section>
|
||||
}
|
||||
|
||||
type Versions = { installed: string | null; available: string | null; state: "available" | "current" | "unknown"; checked_at: string | null; channel: "stable" | "beta" }
|
||||
export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) {
|
||||
const t = useT()
|
||||
const [value, setValue] = useState<Versions | null>(null)
|
||||
useEffect(() => {
|
||||
let disposed = false, reading = false
|
||||
let active: AbortController | undefined
|
||||
const read = async () => {
|
||||
if (reading || document.visibilityState !== "visible") return
|
||||
reading = true
|
||||
const controller = new AbortController(); active = controller
|
||||
const timeout = setTimeout(() => controller.abort(), 6000)
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/proxmenux-version"), { headers: headers(), signal: controller.signal })
|
||||
if (!response.ok) throw Error("unavailable")
|
||||
const data: Versions = await response.json()
|
||||
if (!["available", "current", "unknown"].includes(data?.state)) throw Error("invalid")
|
||||
const validVersion = (version: unknown) => typeof version === "string" && /^\d+(?:\.\d+){1,5}$/.test(version)
|
||||
if (!["stable", "beta"].includes(data.channel) ||
|
||||
(data.installed !== null && !validVersion(data.installed)) ||
|
||||
(data.available !== null && !validVersion(data.available)) ||
|
||||
(data.state !== "unknown" && (!validVersion(data.installed) || !validVersion(data.available) || !data.checked_at)) ||
|
||||
(data.checked_at !== null && !Number.isFinite(Date.parse(data.checked_at)))) throw Error("invalid")
|
||||
if (!disposed) setValue(data)
|
||||
} catch { if (!disposed) setValue(previous => previous ? { ...previous, state: "unknown" } : null) }
|
||||
finally { clearTimeout(timeout); reading = false }
|
||||
}
|
||||
void read()
|
||||
const interval = setInterval(read, 300000)
|
||||
window.addEventListener("focus", read); document.addEventListener("visibilitychange", read)
|
||||
return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) }
|
||||
}, [getApiUrl])
|
||||
const state = value?.state ?? "unknown"
|
||||
const versions = <dl className="grid grid-cols-2 gap-4">
|
||||
<div><dt className="text-muted-foreground mb-1">{t("healthStatus.versions.installed")}</dt><dd className="font-mono font-semibold">{value?.installed ?? t("healthStatus.versions.unverified")}</dd></div>
|
||||
<div><dt className="text-muted-foreground mb-1">{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}</dt><dd className="font-mono font-semibold">{value?.available ?? t("healthStatus.versions.unverified")}</dd></div>
|
||||
</dl>
|
||||
const metadata = <>
|
||||
{value && <p className="mt-3 text-muted-foreground">{t(`healthStatus.versions.${value.channel}`)}</p>}
|
||||
{value?.checked_at && <p className="mt-1 text-muted-foreground">{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}</p>}
|
||||
</>
|
||||
if (state !== "available") return <details aria-label={t("healthStatus.versions.title")} className="group text-[10px] sm:text-xs">
|
||||
<summary className="flex items-start gap-2 py-1.5 px-2 sm:px-3 rounded-md hover:bg-muted/40 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
{state === "current" ? <CheckCircle2 aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-green-500" /> : <AlertTriangle aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-amber-500" />}
|
||||
<span className="font-medium shrink-0">ProxMenux</span>
|
||||
<span className={`flex-1 min-w-0 break-words ${state === "current" ? "text-muted-foreground" : "text-amber-500"}`}>
|
||||
{state === "current" ? t("healthStatus.versions.currentVersion", { version: value!.installed! }) : t("healthStatus.versions.unknown")}
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||
</summary>
|
||||
<div className="ml-7 mr-2 sm:mr-3 pb-2">
|
||||
{state === "unknown" && versions}
|
||||
{metadata}
|
||||
</div>
|
||||
</details>
|
||||
return <section aria-label={t("healthStatus.versions.title")} className="mt-3 mx-2 sm:mx-3 rounded-lg border bg-muted/10 p-3 text-[10px] sm:text-xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3"><h3 className="font-semibold">{t("healthStatus.versions.title")}</h3>
|
||||
<span className="flex items-center gap-1.5 text-blue-500"><Info aria-hidden="true" className="h-3.5 w-3.5" />{t("healthStatus.versions.available")}</span></div>
|
||||
{versions}
|
||||
{metadata}
|
||||
</section>
|
||||
}
|
||||
@@ -256,21 +256,24 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code" className="text-sm">
|
||||
{t("login.authenticationCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 9))}
|
||||
className="text-center text-lg tracking-widest font-mono text-base"
|
||||
maxLength={9}
|
||||
disabled={loading}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
{/* Password managers read the field's siblings and skip it when they mention a backup code. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code" className="text-sm">
|
||||
{t("login.authenticationCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 9))}
|
||||
className="text-center text-lg tracking-widest font-mono text-base"
|
||||
maxLength={9}
|
||||
disabled={loading}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("login.backupCodeHint")}
|
||||
</p>
|
||||
|
||||
@@ -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
|
||||
@@ -382,10 +397,18 @@ const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: strin
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/frigate-dark.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/frigate-light.webp`,
|
||||
},
|
||||
"llama-cpp": {
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/llama-cpp.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/llama-cpp-light.webp`,
|
||||
},
|
||||
portainer: {
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/portainer-dark.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/portainer-light.webp`,
|
||||
},
|
||||
tailscale: {
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/tailscale.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/tailscale-light.webp`,
|
||||
},
|
||||
vaultwarden: {
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/vaultwarden.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/vaultwarden-light.webp`,
|
||||
@@ -394,11 +417,44 @@ const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: strin
|
||||
|
||||
function selfhstThemeLogos(src: string) {
|
||||
if (!src.toLowerCase().includes("cdn.jsdelivr.net/gh/selfhst/icons")) return null
|
||||
const match = src.toLowerCase().match(/\/(frigate|portainer|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
|
||||
const match = src.toLowerCase().match(/\/(frigate|llama-cpp|portainer|tailscale|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
|
||||
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 +507,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 +518,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 +643,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 +656,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 +737,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 +776,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 +803,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 +874,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 +919,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 +947,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 +966,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 +995,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 +1026,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 +1111,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 +1128,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 +1144,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 +1152,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 +1221,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 +1455,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 +1463,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 +1729,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 +2078,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 +2101,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 +2567,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 +2583,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 +2596,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 +2687,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 +3352,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>
|
||||
}
|
||||
@@ -291,7 +291,7 @@ export function NetworkTrafficChart({
|
||||
<>
|
||||
<MetricsCacheNotice lastChecked={staleAt} />
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<AreaChart data={data} margin={{ bottom: 80, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
|
||||
@@ -426,7 +426,7 @@ export function NodeMetricsCharts() {
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 10, right: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
@@ -509,7 +509,7 @@ export function NodeMetricsCharts() {
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pr-2 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 10, right: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
|
||||
@@ -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
|
||||
@@ -867,7 +874,7 @@ export function NotificationSettings() {
|
||||
// Flatten per-provider API keys
|
||||
if (cfg.ai_api_keys) {
|
||||
for (const [provider, key] of Object.entries(cfg.ai_api_keys)) {
|
||||
if (key) {
|
||||
if (key || provider === "ollama") {
|
||||
flat[`ai_api_key_${provider}`] = key
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -2303,6 +2305,12 @@ export function NotificationSettings() {
|
||||
|
||||
// Restore previously saved model for the new provider (if any)
|
||||
const savedModel = config.ai_models?.[v] || ''
|
||||
setShowSecrets(p => ({ ...p, ai_key: false }))
|
||||
setRevealedSecrets(p => {
|
||||
const next = { ...p }
|
||||
delete next.ai_key
|
||||
return next
|
||||
})
|
||||
|
||||
updateConfig(p => ({
|
||||
...p,
|
||||
@@ -2362,11 +2370,13 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key (not shown for Ollama) */}
|
||||
{config.ai_provider !== "ollama" && (
|
||||
{/* Ollama gateways may require a key; native servers do not. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
|
||||
<Label htmlFor="notification-ai-api-key" className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
|
||||
{config.ai_provider === "ollama" && (
|
||||
<span className="text-xs text-muted-foreground">({t("settings.notifications.ui.optional")})</span>
|
||||
)}
|
||||
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl && (
|
||||
<a
|
||||
href={AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl}
|
||||
@@ -2380,9 +2390,10 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="notification-ai-api-key"
|
||||
type={showSecrets["ai_key"] ? "text" : "password"}
|
||||
className="h-9 text-sm font-mono"
|
||||
placeholder="sk-..."
|
||||
placeholder={config.ai_provider === "ollama" ? "" : "sk-..."}
|
||||
value={secretValue("ai_key", config.ai_api_keys?.[config.ai_provider] || "")}
|
||||
onChange={e => updateConfig(p => ({
|
||||
...p,
|
||||
@@ -2400,8 +2411,12 @@ export function NotificationSettings() {
|
||||
{showSecrets["ai_key"] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{config.ai_provider === "ollama" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.notifications.ai.ollamaApiKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model - selector with Load button for all providers */}
|
||||
<div className="space-y-2">
|
||||
@@ -2761,6 +2776,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>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
@@ -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)
|
||||
@@ -155,10 +185,18 @@ export function ProxmoxDashboard() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const healthRequest = useRef<AbortController | null>(null)
|
||||
// Fetch health info count independently (for initial load and refresh)
|
||||
const fetchHealthInfoCount = useCallback(async () => {
|
||||
const fetchHealthInfoCount = useCallback(async (cachedOnly = false) => {
|
||||
if (cachedOnly && healthRequest.current) return
|
||||
healthRequest.current?.abort()
|
||||
const controller = new AbortController()
|
||||
healthRequest.current = controller
|
||||
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
|
||||
try {
|
||||
const response = await fetchApi("/api/health/full")
|
||||
const response = await fetchApi(cachedOnly ? "/api/health/full?cached=1" : "/api/health/full", { signal: controller.signal })
|
||||
if (healthRequest.current !== controller) return
|
||||
if (!response?.health?.details) throw new Error("Invalid health snapshot")
|
||||
let calculatedInfoCount = 0
|
||||
|
||||
if (response && response.health?.details) {
|
||||
@@ -188,7 +226,10 @@ export function ProxmoxDashboard() {
|
||||
|
||||
setInfoCount(calculatedInfoCount)
|
||||
} catch (error) {
|
||||
// Silently fail - infoCount will remain at 0
|
||||
// Preserve the last known count; a failed poll is not a healthy result.
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
if (healthRequest.current === controller) healthRequest.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -239,20 +280,29 @@ export function ProxmoxDashboard() {
|
||||
fetchHealthInfoCount()
|
||||
fetchUpdateStatus()
|
||||
|
||||
// Overview polls health every 30 s; the other tabs every 60 s.
|
||||
// Header reboot information stays current on every tab, without full audits.
|
||||
const refreshHealth = () => {
|
||||
if (document.visibilityState === "visible") void fetchHealthInfoCount(true)
|
||||
}
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let healthInterval: ReturnType<typeof setInterval> | null = null
|
||||
if (activeTab === "overview") {
|
||||
interval = setInterval(fetchSystemData, 30000) // 30 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
|
||||
} else {
|
||||
interval = setInterval(fetchSystemData, 60000) // 60 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count
|
||||
}
|
||||
|
||||
healthInterval = setInterval(refreshHealth, 30000)
|
||||
window.addEventListener("focus", refreshHealth)
|
||||
document.addEventListener("visibilitychange", refreshHealth)
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
if (healthInterval) clearInterval(healthInterval)
|
||||
window.removeEventListener("focus", refreshHealth)
|
||||
document.removeEventListener("visibilitychange", refreshHealth)
|
||||
const controller = healthRequest.current
|
||||
healthRequest.current = null
|
||||
controller?.abort()
|
||||
}
|
||||
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
|
||||
|
||||
@@ -361,6 +411,7 @@ export function ProxmoxDashboard() {
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
await fetchSystemData()
|
||||
await fetchHealthInfoCount()
|
||||
setComponentKey((prev) => prev + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setIsRefreshing(false)
|
||||
@@ -391,6 +442,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 +463,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 +518,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 +629,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 +670,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 +736,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 +750,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 +811,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 +828,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 +862,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 +903,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 +917,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,64 @@ interface ReleaseNote {
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.2.8": {
|
||||
date: "November 2, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Custom image installs Compose files with several services, one LXC per service on a private network (contributed by @jcastro).",
|
||||
"OCI: a VLAN tag for the access interface (requested by @xistoso), and Ollama and llama.cpp on Intel and AMD integrated GPUs.",
|
||||
"Seventeen new OCI applications, among them Authentik, TeslaMate, Zigbee2MQTT, Umami, Scrypted and NetAlertX (contributed by @jcastro) and Scanopy (suggested by @gabglus).",
|
||||
"Proxmox updates run as a job on the server and carry on after signing out, and a pending reboot is shown after a new kernel (contributed by @Vaso73).",
|
||||
"ZFS pool layout, scrub and device health, and the storage controller of each disk (contributed by @DrOwl).",
|
||||
"VM disk thresholds in Settings (suggested by @byte21516), and an optional API key for local Ollama.",
|
||||
],
|
||||
changed: [
|
||||
"Variables added or changed in Proxmox stay in an OCI container: they are saved in its oci-compose and the update carries on with them, manual or scheduled, on single applications and on every member of a stack.",
|
||||
"OCI updates keep the rest of what is changed in Proxmox: start command, network options, DNS, hostname, features, protection, disk sizes and lxc.* lines.",
|
||||
"Log2RAM 2.0: the journal and the task logs of Proxmox stay on disk with their history (reported by @shtefko).",
|
||||
"Every disk is monitored unless it is excluded in Settings (reported by @ovikiss and @peterurbanec).",
|
||||
"A service reloading or restarting counts as running, and nothing is concluded during a package update (reported by @Joshua1264).",
|
||||
],
|
||||
fixed: [
|
||||
"Host backup jobs run on their schedule, and updating ProxMenux repairs saved jobs.",
|
||||
"The Intel GPU view ends intel_gpu_top with each reading (reported by @FromTheCToTheG).",
|
||||
"VM disk usage counts every filesystem of the guest (fix proposed by @TheFoxCraft).",
|
||||
"The LVM check keeps the Proxmox filter (reported by @osipovdm-dot), and Secure Gateway installs on BTRFS hosts (reported by @78v5).",
|
||||
"The Monitor installs as an app (PWA) from the browser, with its own web app manifest and service worker.",
|
||||
"A disk I/O error is recorded on its disk.",
|
||||
"The Y-axis titles stay inside the overview charts (contributed by @f3rs3n).",
|
||||
],
|
||||
},
|
||||
},
|
||||
"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: {
|
||||
@@ -306,48 +364,38 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
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.",
|
||||
},
|
||||
{
|
||||
icon: <ScrollText className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociInMonitor",
|
||||
text: "OCI applications in the Monitor — the App tab recognises them, the Updates tab updates and modifies them as the menu does, and a Logs tab follows the console output of the application live.",
|
||||
},
|
||||
{
|
||||
icon: <Cpu className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociAcceleration",
|
||||
text: "Hardware acceleration for OCI applications — the installer offers only the profiles the host can run: NVIDIA, Intel and AMD GPUs and, for Frigate, an Intel NPU or a Coral. The NVIDIA Container Toolkit installs with the driver.",
|
||||
},
|
||||
{
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociRecovery",
|
||||
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.",
|
||||
key: "releaseNotes.currentFeatures.composeStacks",
|
||||
text: "Compose files with several services — Custom image installs one LXC per service on a private network, started in dependency order. Updates, backups and removal work on the whole application.",
|
||||
},
|
||||
{
|
||||
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.",
|
||||
key: "releaseNotes.currentFeatures.keepProxmoxChanges",
|
||||
text: "OCI updates keep what you change in Proxmox — variables, start command, network options, DNS, features, disk sizes and lxc.* lines stay on the rebuilt container, also in scheduled updates and on every member of a stack.",
|
||||
},
|
||||
{
|
||||
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: <Globe className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociVlan",
|
||||
text: "VLAN tag for OCI applications — the advanced installation asks for it next to the address, and Modify changes it on a single application. The host needs no access to that VLAN.",
|
||||
},
|
||||
{
|
||||
icon: <Calendar className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.guestBackupJobs",
|
||||
text: "Backup jobs of VMs and LXCs — the Backup tab lists the scheduled jobs of Proxmox and creates, edits, enables and runs them; they are the same ones as Datacenter → Backup (contributed by @MattiaC46).",
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociNewApps",
|
||||
text: "New in the OCI catalog — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX and ten more applications, and Ollama and llama.cpp run on Intel and AMD integrated GPUs.",
|
||||
},
|
||||
{
|
||||
icon: <Languages className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.notificationLanguage",
|
||||
text: "Notifications in your language — titles, bodies and daily digests are written in the language chosen for notifications (contributed by @Vaso73).",
|
||||
icon: <ArrowUpCircle className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.hostUpdateJob",
|
||||
text: "Proxmox updates that carry on after signing out — the update runs as a job on the server and signing back in reopens it. A pending reboot is shown after a new kernel, with a restart button that checks the host first.",
|
||||
},
|
||||
{
|
||||
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).",
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.storageDetails",
|
||||
text: "Storage details — ZFS pools show their layout, scrub and device health, and each disk shows the storage controller it hangs off, also in the Hardware page. VM disk thresholds join Settings.",
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.log2ramV2",
|
||||
text: "Log2RAM 2.0 — the journal and the task logs of Proxmox stay on disk with their history, and only the logs that change all the time go to RAM. Installed hosts update to it with ProxMenux.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
// Explicit server-owned transports; ordinary /ws/script keeps its contract.
|
||||
backgroundUpdate?: { runId: string; vmid?: number; resume: boolean; kind?: "host" }
|
||||
// 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,13 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
const initMessage = backgroundUpdate?.kind === "host" ? {
|
||||
resume: true, offset: outputOffsetRef.current,
|
||||
} : {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
|
||||
offset: outputOffsetRef.current } : {}),
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
@@ -176,6 +198,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 +207,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 +223,35 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "host_update_state") {
|
||||
if (msg.state === "attention") termRef.current?.writeln(t("healthStatus.hostUpdate.attention"))
|
||||
return
|
||||
}
|
||||
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 +272,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 +301,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isOpenRef.current) return
|
||||
setConnectionStatus("offline")
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
@@ -255,13 +313,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 +351,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 +418,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 = () => {
|
||||
@@ -370,9 +435,13 @@ const initMessage = {
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
const initMessage = backgroundUpdate?.kind === "host" ? {
|
||||
resume: backgroundUpdate.resume, offset: outputOffsetRef.current,
|
||||
} : {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
|
||||
vmid: backgroundUpdate.vmid, offset: outputOffsetRef.current } : {}),
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
@@ -392,6 +461,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 +470,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 +486,36 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "host_update_state") {
|
||||
if (msg.state === "attention") term.writeln(t("healthStatus.hostUpdate.attention"))
|
||||
return
|
||||
}
|
||||
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 +537,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 +570,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 +582,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 +650,7 @@ const initMessage = {
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
openGenerationRef.current++
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
@@ -566,6 +677,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 +686,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 +763,11 @@ const initMessage = {
|
||||
}, [isOpen, isComplete, attemptReconnect])
|
||||
|
||||
const getScriptWebSocketUrl = (sid: string): string => {
|
||||
const route = backgroundUpdate
|
||||
? `/ws/${backgroundUpdate.kind === "host" ? "host-update" : "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 +775,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 +789,10 @@ const initMessage = {
|
||||
if (value === "cancel" || value === "") {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
if (backgroundUpdate) {
|
||||
wsRef.current.send(JSON.stringify({ type: "cancel" }))
|
||||
return
|
||||
}
|
||||
handleCloseModal()
|
||||
return
|
||||
}
|
||||
@@ -814,7 +955,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 +1059,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,11 @@ 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 { StorageDisplayCard } from "./storage-display-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 +316,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 +357,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 +599,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 +682,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 +748,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 +1153,7 @@ export function Settings() {
|
||||
</Card>
|
||||
|
||||
{/* Navigation Tab Order — user-orderable top-level tabs */}
|
||||
<NavTabOrderCard />
|
||||
{!isPbs && <NavTabOrderCard />}
|
||||
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
@@ -1205,6 +1234,9 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Storage Display: group physical disks by controller */}
|
||||
<StorageDisplayCard />
|
||||
|
||||
{/* Health Monitor Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1280,7 +1312,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 +1320,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 +1529,7 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isPbs && <>
|
||||
{/* Remote Storage Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1678,6 +1712,7 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Network Interface Exclusions */}
|
||||
<Card>
|
||||
@@ -1873,8 +1908,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 +2017,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 +2147,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 +2163,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 +2183,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 +2198,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 +2234,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 +2245,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 +2323,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>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Check, HardDrive, Settings2 } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useGroupDisksByController } from "../lib/storage-display"
|
||||
|
||||
export function StorageDisplayCard() {
|
||||
const t = useT()
|
||||
const [groupByController, setGroupByController] = useGroupDisksByController()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [pending, setPending] = useState<boolean | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const handleCancel = () => {
|
||||
setPending(null)
|
||||
setEditMode(false)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (pending !== null && pending !== groupByController) {
|
||||
setGroupByController(pending)
|
||||
}
|
||||
setPending(null)
|
||||
setEditMode(false)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>{t("settings.storageDisplay.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSave}
|
||||
disabled={pending === null || pending === groupByController}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>{t("settings.storageDisplay.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className={editMode ? "bg-accent" : undefined}>
|
||||
<div className="text-foreground flex items-center justify-between gap-4">
|
||||
<div className="flex items-center">{t("settings.storageDisplay.groupByController")}</div>
|
||||
<Switch
|
||||
checked={pending ?? groupByController}
|
||||
disabled={!editMode}
|
||||
onCheckedChange={(checked) => setPending(checked)}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, Moon, EyeOff } from "lucide-react"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, EyeOff } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -14,6 +14,7 @@ import { DiskTemperatureCard } from "./disk-temperature-card"
|
||||
import { getDiskType as resolveDiskType } from "../lib/disk-type"
|
||||
import { APP_VERSION } from "../lib/version"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { useGroupDisksByController } from "../lib/storage-display"
|
||||
import {
|
||||
useDiskTempThresholds,
|
||||
loadDiskTempThresholds,
|
||||
@@ -70,7 +71,6 @@ interface DiskInfo {
|
||||
// badge AND to suppress the (stale) temperature value, so the
|
||||
// operator understands the graph is frozen on purpose — issue #232.
|
||||
standby?: boolean
|
||||
idle?: boolean
|
||||
excluded?: boolean
|
||||
health: string
|
||||
power_on_hours?: number
|
||||
@@ -102,6 +102,7 @@ interface DiskInfo {
|
||||
}
|
||||
observations_count?: number
|
||||
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
|
||||
controller_slot?: string | null // PCI slot of the storage controller, key into StorageData.controllers
|
||||
removable?: boolean
|
||||
is_system_disk?: boolean
|
||||
system_usage?: string[]
|
||||
@@ -121,12 +122,65 @@ interface DiskObservation {
|
||||
model: string
|
||||
}
|
||||
|
||||
// Pool layout from `zpool status -j` (OpenZFS 2.3+). Only present when
|
||||
// details_available is true; older ZFS gets the basic fields above.
|
||||
interface ZFSDevice {
|
||||
path: string
|
||||
disk: string | null // base block device (e.g. "sdb"), null for file vdevs
|
||||
state: string // ONLINE | DEGRADED | FAULTED | OFFLINE | UNAVAIL | REMOVED | AVAIL | INUSE
|
||||
read_errors: number
|
||||
write_errors: number
|
||||
checksum_errors: number
|
||||
slow_ios: number
|
||||
via: string | null // "spare-0" / "replacing-0" while a device is being swapped
|
||||
}
|
||||
|
||||
interface ZFSVdev {
|
||||
name: string
|
||||
type: string // mirror | raidz | draid | disk
|
||||
parity: number | null
|
||||
class: "normal" | "special" | "dedup" | "log" | "cache" | "spare"
|
||||
state: string
|
||||
read_errors: number
|
||||
write_errors: number
|
||||
checksum_errors: number
|
||||
devices: ZFSDevice[]
|
||||
}
|
||||
|
||||
interface ZFSScan {
|
||||
function: "SCRUB" | "RESILVER" | string
|
||||
state: "SCANNING" | "FINISHED" | "CANCELED" | string
|
||||
start_time: number | null
|
||||
end_time: number | null
|
||||
errors: number
|
||||
repaired_bytes: number
|
||||
percent: number | null // only while SCANNING
|
||||
}
|
||||
|
||||
interface ZFSPool {
|
||||
name: string
|
||||
size: string
|
||||
allocated: string
|
||||
free: string
|
||||
health: string
|
||||
fragmentation?: number | null
|
||||
capacity?: number | null
|
||||
dedup_ratio?: number | null
|
||||
details_available?: boolean
|
||||
status?: string | null
|
||||
action?: string | null
|
||||
error_count?: number
|
||||
scan?: ZFSScan | null
|
||||
vdevs?: ZFSVdev[]
|
||||
}
|
||||
|
||||
interface StorageController {
|
||||
slot: string
|
||||
class: string
|
||||
vendor: string
|
||||
device: string
|
||||
sdevice?: string | null
|
||||
driver?: string | null
|
||||
}
|
||||
|
||||
interface StorageData {
|
||||
@@ -134,6 +188,7 @@ interface StorageData {
|
||||
used: number
|
||||
available: number
|
||||
disks: DiskInfo[]
|
||||
controllers?: Record<string, StorageController>
|
||||
zfs_pools: ZFSPool[]
|
||||
disk_count: number
|
||||
healthy_disks: number
|
||||
@@ -231,7 +286,44 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
|
||||
return out.length ? out.join('; ') : null
|
||||
}
|
||||
|
||||
export function StorageOverview() {
|
||||
// Bucket disks by controller PCI slot, ordered by slot so groups follow
|
||||
// the bus layout. Disks with no resolvable controller go last.
|
||||
function groupDisksBySlot(disks: DiskInfo[]): [string | null, DiskInfo[]][] {
|
||||
const groups = new Map<string | null, DiskInfo[]>()
|
||||
for (const disk of disks) {
|
||||
const slot = disk.controller_slot ?? null
|
||||
groups.set(slot, [...(groups.get(slot) ?? []), disk])
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => {
|
||||
if (a === null) return 1
|
||||
if (b === null) return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
|
||||
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
|
||||
// Same ring as the disk wear gauge, with a faint track so 0% still reads.
|
||||
// Used for the fragmentation of a ZFS pool.
|
||||
function PercentRing({ value, color, label }: { value: number; color: string; label: string }) {
|
||||
const pct = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<svg width="88" height="88" viewBox="0 0 88 88" className="flex-shrink-0">
|
||||
<circle cx="44" cy="44" r="35" fill="none" stroke={color} strokeOpacity="0.15" strokeWidth="6" />
|
||||
<circle cx="44" cy="44" r="35" fill="none" stroke={color} strokeWidth="6"
|
||||
strokeDasharray={`${pct * 2.199} 219.9`}
|
||||
strokeLinecap="round" transform="rotate(-90 44 44)" />
|
||||
<text x="44" y="40" textAnchor="middle" fill={color} fontSize="20" fontWeight="700">{pct}%</text>
|
||||
<text x="44" y="56" textAnchor="middle" fill="currentColor" fontSize="12" className="text-muted-foreground">{label}</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// OpenZFS reports a pool without its newer features enabled, which needs no action.
|
||||
const zfsStatusIsInformational = (status?: string | null) => /features are not enabled/i.test(status ?? "")
|
||||
|
||||
const zfsFragmentationColor = (pct: number) => (pct > 80 ? "#ef4444" : pct > 50 ? "#eab308" : "#06b6d4")
|
||||
|
||||
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
|
||||
const t = useT()
|
||||
|
||||
// User-configurable disk temperature thresholds (Settings → Health
|
||||
@@ -239,6 +331,7 @@ export function StorageOverview() {
|
||||
// sensible defaults from `lib/health-thresholds`, so first paint
|
||||
// never blocks on the network.
|
||||
const dtThresholds = useDiskTempThresholds()
|
||||
const [groupDisksByController] = useGroupDisksByController()
|
||||
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null)
|
||||
@@ -249,6 +342,7 @@ export function StorageOverview() {
|
||||
const [mountDetail, setMountDetail] = useState<RemoteMount | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
|
||||
const [selectedPool, setSelectedPool] = useState<ZFSPool | null>(null)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
|
||||
const [loadingObservations, setLoadingObservations] = useState(false)
|
||||
@@ -258,6 +352,12 @@ export function StorageOverview() {
|
||||
// 85 vh capped by the visual viewport otherwise). Keeps every
|
||||
// detail modal at a matching size across the app.
|
||||
const [isStandalone, setIsStandalone] = useState(false)
|
||||
// Disk, ZFS pool and remote mount details share one size.
|
||||
const detailDialogClass = `max-w-4xl flex flex-col p-0 overflow-hidden ${
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
||||
}`
|
||||
|
||||
useEffect(() => {
|
||||
const checkStandalone = () => {
|
||||
@@ -284,7 +384,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)),
|
||||
@@ -396,6 +496,57 @@ export function StorageOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
// ZFS device/vdev states are shown verbatim, as `zpool status` prints them.
|
||||
const getZfsStateBadge = (state: string) => {
|
||||
const style =
|
||||
state === "ONLINE"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: state === "AVAIL" || state === "INUSE"
|
||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||
: state === "DEGRADED"
|
||||
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
return <Badge className={`font-mono text-xs ${style}`}>{state}</Badge>
|
||||
}
|
||||
|
||||
const getZfsVdevLabel = (vdev: ZFSVdev) => {
|
||||
if (vdev.type === "mirror") return t("storage.zfs.mirror")
|
||||
if (vdev.type === "raidz") return t("storage.zfs.raidz", { parity: vdev.parity ?? 1 })
|
||||
if (vdev.type === "draid") return t("storage.zfs.draid", { parity: vdev.parity ?? 1 })
|
||||
return t("storage.zfs.singleDisk")
|
||||
}
|
||||
|
||||
// One-line pool shape for the card, e.g. "RAIDZ2", "2 x Mirror", "Stripe".
|
||||
const getZfsLayoutSummary = (pool: ZFSPool) => {
|
||||
const data = (pool.vdevs ?? []).filter((v) => v.class === "normal")
|
||||
if (data.length === 0) return null
|
||||
if (data.every((v) => v.type === "disk")) {
|
||||
return data.length === 1 ? t("storage.zfs.singleDisk") : t("storage.zfs.stripe")
|
||||
}
|
||||
const labels = [...new Set(data.map(getZfsVdevLabel))]
|
||||
if (labels.length === 1 && data.length > 1) return `${data.length} x ${labels[0]}`
|
||||
return labels.join(" + ")
|
||||
}
|
||||
|
||||
const formatZfsTime = (epoch: number | null | undefined) =>
|
||||
epoch ? new Date(epoch * 1000).toLocaleString() : t("common.notAvailable")
|
||||
|
||||
const getZfsScanSummary = (scan: ZFSScan | null | undefined) => {
|
||||
if (!scan) return t("storage.zfs.neverScrubbed")
|
||||
const kind = scan.function === "RESILVER" ? t("storage.zfs.resilver") : t("storage.zfs.scrub")
|
||||
if (scan.state === "SCANNING") {
|
||||
return t("storage.zfs.scanRunning", { kind, percent: scan.percent ?? 0 })
|
||||
}
|
||||
if (scan.state === "CANCELED") return t("storage.zfs.scanCanceled", { kind })
|
||||
return t("storage.zfs.scanFinished", {
|
||||
kind,
|
||||
date: scan.end_time ? new Date(scan.end_time * 1000).toLocaleDateString() : "",
|
||||
})
|
||||
}
|
||||
|
||||
const zfsDeviceErrors = (d: { read_errors: number; write_errors: number; checksum_errors: number }) =>
|
||||
d.read_errors + d.write_errors + d.checksum_errors
|
||||
|
||||
// Tiny coloured dot that prefixes status / counter values. Adds
|
||||
// accessibility-friendly redundancy (colour + position) to fields
|
||||
// that today rely on colour alone, so an "all OK" disk reads as
|
||||
@@ -436,7 +587,7 @@ export function StorageOverview() {
|
||||
// in 4 different disk-list views (system / data / pool / other) and we
|
||||
// want them all to behave identically — issue #232 fix.
|
||||
// Why a disk shows no live temperature, when it doesn't: excluded by the
|
||||
// user, parked, or idle and deliberately not read. Shared by every view
|
||||
// user, or parked. Shared by every view
|
||||
// that paints a disk's temperature, so they cannot disagree.
|
||||
const renderNoReadingBadge = (disk: DiskInfo) => {
|
||||
if (disk.excluded) {
|
||||
@@ -461,17 +612,6 @@ export function StorageOverview() {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (disk.idle) {
|
||||
return (
|
||||
<Badge
|
||||
className="bg-muted text-muted-foreground border-border gap-1"
|
||||
title={t("storage.idleTitle")}
|
||||
>
|
||||
<Moon className="h-3 w-3" />
|
||||
{t("storage.idle")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -664,7 +804,7 @@ export function StorageOverview() {
|
||||
</div>
|
||||
)}
|
||||
{/* Reallocated only meaningful on rotating disks. */}
|
||||
{disk.reallocated_sectors !== undefined && (disk.rotation_rate ?? 0) > 0 && (
|
||||
{disk.reallocated_sectors !== undefined && resolveDiskType(disk.name, disk.rotation_rate) === "HDD" && (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t("storage.reallocatedShort")}
|
||||
@@ -720,12 +860,7 @@ export function StorageOverview() {
|
||||
// Resolve disk class → threshold pair from the user-configurable
|
||||
// backend (single source of truth). The semantics: temp BELOW warn
|
||||
// is green, between warn and hot is amber, hot or above is red.
|
||||
let cls: keyof DiskTempMap = "HDD"
|
||||
if (diskName?.startsWith("nvme")) {
|
||||
cls = "NVMe"
|
||||
} else if (!rotationRate || rotationRate === 0) {
|
||||
cls = "SSD"
|
||||
}
|
||||
const cls: keyof DiskTempMap = resolveDiskType(diskName ?? "", rotationRate)
|
||||
const t = dtThresholds[cls]
|
||||
if (temp >= t.hot) return "text-red-500"
|
||||
if (temp >= t.warn) return "text-yellow-500"
|
||||
@@ -756,9 +891,9 @@ export function StorageOverview() {
|
||||
return t("storage.duration.daysShort", { days: totalDays })
|
||||
}
|
||||
|
||||
const formatRotationRate = (rpm: number | undefined) => {
|
||||
if (!rpm || rpm === 0) return "SSD"
|
||||
return `${rpm.toLocaleString()} RPM`
|
||||
const formatRotationRate = (diskName: string, rpm: number | undefined) => {
|
||||
if (getDiskType(diskName, rpm) !== "HDD") return "SSD"
|
||||
return rpm && rpm > 0 ? `${rpm.toLocaleString()} RPM` : t("common.notAvailable")
|
||||
}
|
||||
|
||||
// Thin wrapper over the shared classifier so the rest of the file
|
||||
@@ -975,7 +1110,7 @@ export function StorageOverview() {
|
||||
|
||||
storageData.disks.forEach((disk) => {
|
||||
// Match disk rows: policy-suppressed cached readings are not live temperatures.
|
||||
if (disk.standby || disk.idle || disk.excluded || typeof disk.temperature !== "number" ||
|
||||
if (disk.standby || disk.excluded || typeof disk.temperature !== "number" ||
|
||||
!Number.isFinite(disk.temperature) || disk.temperature <= 0) {
|
||||
unavailable++
|
||||
return
|
||||
@@ -1134,7 +1269,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 +1490,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">
|
||||
@@ -1610,7 +1745,7 @@ export function StorageOverview() {
|
||||
overlay with text-xs/text-[10px] all over and looked
|
||||
shrunken next to the rest of the modals. */}
|
||||
<Dialog open={!!mountDetail} onOpenChange={(open) => { if (!open) setMountDetail(null) }}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
|
||||
<DialogContent className={detailDialogClass}>
|
||||
{mountDetail && (() => {
|
||||
const m = mountDetail
|
||||
const isStale = m.status === "stale"
|
||||
@@ -1657,7 +1792,7 @@ export function StorageOverview() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-auto space-y-5">
|
||||
<div className="flex-1 min-h-0 px-6 pb-6 overflow-y-auto space-y-5">
|
||||
{/* Type + tags */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={getStorageTypeBadge(m.fstype)}>{m.fstype}</Badge>
|
||||
@@ -1768,14 +1903,58 @@ export function StorageOverview() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.zfs_pools.map((pool) => (
|
||||
<div key={pool.name} className="border rounded-lg p-4">
|
||||
<div
|
||||
key={pool.name}
|
||||
className="border border-white/10 rounded-lg p-4 cursor-pointer bg-card hover:bg-white/5 transition-colors"
|
||||
onClick={() => setSelectedPool(pool)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg">{pool.name}</h3>
|
||||
{getHealthBadge(pool.health)}
|
||||
</div>
|
||||
{getHealthIcon(pool.health)}
|
||||
<div className="flex items-center gap-2">
|
||||
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
|
||||
{getHealthIcon(pool.health)}
|
||||
</div>
|
||||
</div>
|
||||
{pool.details_available && (() => {
|
||||
const dataDevices = (pool.vdevs ?? [])
|
||||
.filter((v) => v.class === "normal")
|
||||
.reduce((n, v) => n + v.devices.length, 0)
|
||||
const problems = (pool.vdevs ?? []).some(
|
||||
(v) => v.devices.some((d) => zfsDeviceErrors(d) > 0) || zfsDeviceErrors(v) > 0,
|
||||
) || (pool.error_count ?? 0) > 0
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mb-3 flex flex-wrap items-center gap-x-2">
|
||||
<span className="font-medium text-foreground">{getZfsLayoutSummary(pool)}</span>
|
||||
<span>|</span>
|
||||
<span>
|
||||
{t(dataDevices === 1 ? "storage.zfs.diskCountOne" : "storage.zfs.diskCountMany", { count: dataDevices })}
|
||||
</span>
|
||||
<span>|</span>
|
||||
<span>{getZfsScanSummary(pool.scan)}</span>
|
||||
{problems && (
|
||||
<span className="flex items-center gap-1 text-red-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{t("storage.zfs.errorsReported")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
{pool.capacity != null && (
|
||||
<Progress
|
||||
value={pool.capacity}
|
||||
className={`h-2 mb-2 ${
|
||||
pool.capacity > 90
|
||||
? "[&>div]:bg-red-500"
|
||||
: pool.capacity > 75
|
||||
? "[&>div]:bg-yellow-500"
|
||||
: "[&>div]:bg-blue-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.size")}</p>
|
||||
@@ -1797,6 +1976,221 @@ export function StorageOverview() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ZFS Pool Details Dialog: re-resolved from storageData so it follows
|
||||
the background refresh while open (e.g. resilver progress). */}
|
||||
<Dialog open={selectedPool !== null} onOpenChange={(open) => { if (!open) setSelectedPool(null) }}>
|
||||
<DialogContent className={detailDialogClass}>
|
||||
{selectedPool && (() => {
|
||||
const pool = storageData.zfs_pools.find((p) => p.name === selectedPool.name) ?? selectedPool
|
||||
const classOrder: ZFSVdev["class"][] = ["normal", "special", "dedup", "log", "cache", "spare"]
|
||||
const vdevs = [...(pool.vdevs ?? [])].sort(
|
||||
(a, b) => classOrder.indexOf(a.class) - classOrder.indexOf(b.class),
|
||||
)
|
||||
const showSlowIos = vdevs.some((v) => v.devices.some((d) => d.slow_ios > 0))
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5" />
|
||||
{pool.name}
|
||||
{getHealthBadge(pool.health)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("storage.zfs.poolDetails")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 px-6 pb-6 overflow-y-auto flex flex-col gap-5">
|
||||
{(pool.status || pool.action) && (
|
||||
<div className={`rounded-lg border p-3 text-sm space-y-1 ${
|
||||
zfsStatusIsInformational(pool.status)
|
||||
? "border-blue-500/30 bg-blue-500/10"
|
||||
: "border-yellow-500/30 bg-yellow-500/10"
|
||||
}`}>
|
||||
{pool.status && <p><span className="font-medium">{t("storage.zfs.status")}:</span> {pool.status}</p>}
|
||||
{pool.action && <p><span className="font-medium">{t("storage.zfs.action")}:</span> {pool.action}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-border p-4 flex flex-col justify-center gap-3">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.free")}</p>
|
||||
<p className="text-lg font-semibold">{pool.free}</p>
|
||||
</div>
|
||||
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
|
||||
</div>
|
||||
{pool.capacity != null && (
|
||||
<Progress
|
||||
value={pool.capacity}
|
||||
className={`h-2 ${
|
||||
pool.capacity > 90
|
||||
? "[&>div]:bg-red-500"
|
||||
: pool.capacity > 75
|
||||
? "[&>div]:bg-yellow-500"
|
||||
: "[&>div]:bg-blue-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.allocated")}</p>
|
||||
<p className="font-medium">{pool.allocated}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-muted-foreground">{t("storage.zfs.totalSize")}</p>
|
||||
<p className="font-medium">{pool.size}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(pool.fragmentation != null || pool.dedup_ratio != null) && (
|
||||
<div className="rounded-lg border border-border p-4 flex items-center gap-5">
|
||||
{pool.fragmentation != null && (
|
||||
<PercentRing value={pool.fragmentation} color={zfsFragmentationColor(pool.fragmentation)} label={t("storage.zfs.fragShort")} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-3 text-sm">
|
||||
{pool.fragmentation != null && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.fragmentation")}</p>
|
||||
<p className="font-medium">{pool.fragmentation}%</p>
|
||||
</div>
|
||||
)}
|
||||
{pool.dedup_ratio != null && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.dedupRatio")}</p>
|
||||
<p className="font-medium">{pool.dedup_ratio.toFixed(2)}x</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!pool.details_available ? (
|
||||
<p className="text-sm text-muted-foreground italic">{t("storage.zfs.detailsUnavailable")}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Scrub / resilver */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{t("storage.zfs.scanTitle")}
|
||||
</h3>
|
||||
<p className="text-sm">{getZfsScanSummary(pool.scan)}</p>
|
||||
{pool.scan?.state === "SCANNING" && <Progress value={pool.scan.percent ?? 0} className="h-2" />}
|
||||
{pool.scan && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.started")}</p>
|
||||
<p className="font-medium">{formatZfsTime(pool.scan.start_time)}</p>
|
||||
</div>
|
||||
{pool.scan.state !== "SCANNING" && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.ended")}</p>
|
||||
<p className="font-medium">{formatZfsTime(pool.scan.end_time)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.repaired")}</p>
|
||||
<p className="font-medium">{formatStorage(pool.scan.repaired_bytes / 1024 ** 3)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.scanErrors")}</p>
|
||||
<p className={`font-medium ${pool.scan.errors > 0 ? "text-red-500" : ""}`}>{pool.scan.errors}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className={`text-sm ${(pool.error_count ?? 0) > 0 ? "text-red-500" : "text-green-500"}`}>
|
||||
{(pool.error_count ?? 0) > 0
|
||||
? t("storage.zfs.dataErrors", { count: pool.error_count ?? 0 })
|
||||
: t("storage.zfs.noDataErrors")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Layout: one block per top-level vdev; the last one
|
||||
reaches the bottom of the dialog. */}
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{t("storage.zfs.layout")}
|
||||
</h3>
|
||||
{vdevs.map((vdev, i) => (
|
||||
<div key={`${vdev.class}-${vdev.name}-${i}`} className={`border rounded-lg ${i === vdevs.length - 1 ? "flex-1" : ""}`}>
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b bg-muted/30">
|
||||
<Badge variant="outline" className="text-xs">{t(`storage.zfs.classes.${vdev.class}`)}</Badge>
|
||||
<span className="text-sm font-medium">{getZfsVdevLabel(vdev)}</span>
|
||||
{vdev.type !== "disk" && (
|
||||
<span className="font-mono text-xs text-muted-foreground">{vdev.name}</span>
|
||||
)}
|
||||
<span className="ml-auto">{getZfsStateBadge(vdev.state)}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground">
|
||||
<th className="text-left font-normal px-3 py-1.5">{t("storage.zfs.device")}</th>
|
||||
<th className="text-left font-normal px-2 py-1.5">{t("storage.zfs.disk")}</th>
|
||||
<th className="text-left font-normal px-2 py-1.5">{t("storage.zfs.state")}</th>
|
||||
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.readErrors")}>{t("storage.zfs.readShort")}</th>
|
||||
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.writeErrors")}>{t("storage.zfs.writeShort")}</th>
|
||||
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.checksumErrors")}>{t("storage.zfs.checksumShort")}</th>
|
||||
{showSlowIos && (
|
||||
<th className="text-right font-normal px-3 py-1.5">{t("storage.zfs.slowIos")}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vdev.devices.map((dev) => {
|
||||
const diskInfo = dev.disk ? storageData.disks.find((d) => d.name === dev.disk) : undefined
|
||||
const errCls = (n: number) => `text-right px-2 py-1.5 font-mono ${n > 0 ? "text-red-500 font-semibold" : "text-muted-foreground"}`
|
||||
return (
|
||||
<tr key={`${dev.path}-${dev.via ?? ""}`} className="border-t border-border/50">
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all" title={dev.path}>
|
||||
{dev.path.split("/").pop()}
|
||||
{dev.via && (
|
||||
<span className="ml-2 text-muted-foreground">({t("storage.zfs.via", { name: dev.via })})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{diskInfo ? (
|
||||
<button
|
||||
type="button"
|
||||
title={diskInfo.model}
|
||||
onClick={() => {
|
||||
setSelectedPool(null)
|
||||
handleDiskClick(diskInfo)
|
||||
}}
|
||||
>
|
||||
<Badge variant="outline" className="font-mono hover:bg-accent">{diskInfo.name}</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted-foreground">{dev.disk ?? "-"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{getZfsStateBadge(dev.state)}</td>
|
||||
<td className={errCls(dev.read_errors)}>{dev.read_errors}</td>
|
||||
<td className={errCls(dev.write_errors)}>{dev.write_errors}</td>
|
||||
<td className={errCls(dev.checksum_errors)}>{dev.checksum_errors}</td>
|
||||
{showSlowIos && (
|
||||
<td className={`text-right px-3 py-1.5 font-mono ${dev.slow_ios > 0 ? "text-yellow-500" : "text-muted-foreground"}`}>
|
||||
{dev.slow_ios}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Physical Disks (internal only) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1806,11 +2200,43 @@ export function StorageOverview() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{storageData.disks
|
||||
.filter((d) => d.connection_type !== 'usb')
|
||||
.map((disk) => renderDiskCardV2(disk))}
|
||||
</div>
|
||||
{groupDisksByController ? (
|
||||
<div className="space-y-6">
|
||||
{groupDisksBySlot(storageData.disks.filter((d) => d.connection_type !== 'usb')).map(([slot, disks]) => {
|
||||
const controller = slot ? storageData.controllers?.[slot] : undefined
|
||||
return (
|
||||
<div key={slot ?? "none"}>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3 pb-2 border-b border-border/50">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{controller
|
||||
? controller.sdevice || controller.device
|
||||
: slot ?? t("storage.noController")}
|
||||
</span>
|
||||
{slot && <span className="font-mono text-xs text-muted-foreground">{slot}</span>}
|
||||
{controller?.driver && (
|
||||
<Badge variant="outline" className="font-mono text-xs text-green-500">
|
||||
{controller.driver}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{t(disks.length === 1 ? "storage.diskCountOne" : "storage.diskCountMany", { count: disks.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{disks.map((disk) => renderDiskCardV2(disk))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{storageData.disks
|
||||
.filter((d) => d.connection_type !== 'usb')
|
||||
.map((disk) => renderDiskCardV2(disk))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1842,13 +2268,7 @@ export function StorageOverview() {
|
||||
setSmartJsonData(null)
|
||||
}
|
||||
}}>
|
||||
<DialogContent
|
||||
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
||||
}`}
|
||||
>
|
||||
<DialogContent className={detailDialogClass}>
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedDisk?.connection_type === 'usb' ? (
|
||||
@@ -1965,6 +2385,28 @@ export function StorageOverview() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDisk.controller_slot && (() => {
|
||||
const controller = storageData.controllers?.[selectedDisk.controller_slot]
|
||||
return (
|
||||
<div className="col-span-2">
|
||||
<p className="text-sm text-muted-foreground">{t("storage.storageController")}</p>
|
||||
<p className="font-medium">
|
||||
{controller ? controller.sdevice || controller.device : selectedDisk.controller_slot}
|
||||
{controller && (
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">{selectedDisk.controller_slot}</span>
|
||||
)}
|
||||
{controller?.driver && (
|
||||
<Badge variant="outline" className="ml-2 font-mono text-xs text-green-500">
|
||||
{controller.driver}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
{controller && controller.sdevice && (
|
||||
<p className="text-xs text-muted-foreground">{controller.vendor} - {controller.device}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */}
|
||||
@@ -2179,7 +2621,7 @@ export function StorageOverview() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.rotationRate")}</p>
|
||||
<p className="font-medium">{formatRotationRate(selectedDisk.rotation_rate)}</p>
|
||||
<p className="font-medium">{formatRotationRate(selectedDisk.name, selectedDisk.rotation_rate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.powerCycles")}</p>
|
||||
@@ -2424,14 +2866,12 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
||||
|
||||
// Determine disk type (SAS detected via backend flag or connection_type)
|
||||
const isSasDisk = sd?.is_sas === true || disk.connection_type === 'sas'
|
||||
let diskType = "HDD"
|
||||
if (disk.name.startsWith("nvme")) {
|
||||
diskType = "NVMe"
|
||||
} else if (isSasDisk) {
|
||||
let diskType: string = resolveDiskType(disk.name, disk.rotation_rate)
|
||||
if (diskType !== "NVMe" && isSasDisk) {
|
||||
diskType = "SAS"
|
||||
} else if (!disk.rotation_rate || disk.rotation_rate === 0) {
|
||||
diskType = "SSD"
|
||||
}
|
||||
const rpm = disk.rotation_rate && disk.rotation_rate > 0 ? disk.rotation_rate : 0
|
||||
const known = (value?: string) => (value && value !== "Unknown" ? value : undefined)
|
||||
|
||||
// Health status styling
|
||||
const healthStatus = String(testStatus.smart_status || (testStatus.smart_data?.smart_status) || 'unknown')
|
||||
@@ -3377,11 +3817,11 @@ function pmxPrint(){
|
||||
<div class="grid-4">
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.model")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${disk.model || sd?.model || t("app.unknown")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${known(disk.model) || known(sd?.model) || t("app.unknown")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.serial")}</div>
|
||||
<div class="card-value" style="font-size:11px;font-family:monospace;">${disk.serial || sd?.serial || t("app.unknown")}</div>
|
||||
<div class="card-value" style="font-size:11px;font-family:monospace;">${known(disk.serial) || known(sd?.serial) || t("app.unknown")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.capacity")}</div>
|
||||
@@ -3389,7 +3829,7 @@ function pmxPrint(){
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.type")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (disk.rotation_rate ? `SAS ${disk.rotation_rate} RPM` : 'SAS SSD') : diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}</div>
|
||||
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (rpm ? `SAS ${rpm} RPM` : disk.rotation_rate === -1 ? 'SAS HDD' : 'SAS SSD') : diskType === 'HDD' && rpm ? `HDD ${rpm} RPM` : diskType}</div>
|
||||
</div>
|
||||
</div>
|
||||
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user