Compare commits

...
927 Commits
Author SHA1 Message Date
ProxMenuxBot 6324d41a1d chore(lang): auto-rebuild translation cache
Source: ef00a34
Triggered by: push
2026-11-04 18:05:56 +00:00
MacRimi ef00a34654 feat(oci): show new and installed application counts in the OCI menu entries, lined up in one column 2026-11-04 19:02:03 +01:00
ProxMenuxBot 143c7db610 chore(lang): auto-rebuild translation cache
Source: 17786e0
Triggered by: push
2026-11-04 17:20:16 +00:00
MacRimi 17786e0f54 perf(oci): read the Proxmox version from the package database when opening the OCI menu 2026-11-04 18:19:13 +01:00
MacRimi 5c91e1c661 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-04 18:18:17 +01:00
MacRimi f0871607fa i18n: translate the Selkies NVIDIA acceleration label 2026-11-04 18:16:33 +01:00
ProxMenuxBot 8e1b2ccc9d chore(lang): auto-rebuild translation cache
Source: b7167ac
Triggered by: push
2026-11-04 17:14:35 +00:00
MacRimi b7167ac498 feat(oci): offer GPU acceleration to the ArtCraft apps built on the LinuxServer Selkies base 2026-11-04 18:11:41 +01:00
MacRimi e916c3b8bb fix(oci): offer what the AMD GPU can run without warning about ROCm support 2026-11-04 17:25:08 +01:00
MacRimi 64e9e0e8dd fix(monitor): read the error frequency for AI context from the real errors table columns 2026-11-04 17:05:44 +01:00
MacRimi 7cbe92ad9f fix(monitor): read the error frequency for AI context from the real errors table columns 2026-11-04 16:57:51 +01:00
MacRimi 8ca445ff3b fix(oci): let AdGuard Home pass its update check on the web port once it is set up 2026-11-04 16:47:35 +01:00
github-actions[bot] a026ab5926 Update AppImage beta build (2026-11-03 22:24:19) 2026-11-03 22:24:20 +00:00
MacRimi 1dca76aaa5 fix(oci): check high availability in the preflight so tests without Proxmox keep working 2026-11-03 23:16:25 +01:00
MacRimi dcb1d65ec8 fix(monitor): keep the theme-aware logo list and add Tailscale 2026-11-03 23:08:30 +01:00
ProxMenuxBot 7bb99d30ff chore(lang): auto-rebuild translation cache
Source: a89eb92
Triggered by: push
2026-11-03 22:00:23 +00:00
MacRimi a89eb921cc fix(oci): refuse an HA-managed container before downloading the image and explain how to proceed 2026-11-03 22:49:47 +01:00
github-actions[bot] 97da4321c8 Update AppImage beta build (2026-11-03 20:27:40) 2026-11-03 20:27:40 +00:00
MacRimi 11c0c683d7 fix(monitor): run scheduled backup jobs detached and follow them by their task UPID 2026-11-03 21:15:40 +01:00
MacRimi 6628744900 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-03 21:02:17 +01:00
MacRimi 194a959492 fix(monitor): show the light variant of selfh.st logos in the dark theme 2026-11-03 21:02:06 +01:00
MacRimiandGitHub 74e5b7a4ff Merge pull request #587 from MaEscalanteHe/docs/drift-utils
docs(web): fix Utils drift
2026-11-03 20:58:55 +01:00
MacRimiandGitHub 244181f9df Merge pull request #586 from MaEscalanteHe/docs/drift-backup-restore
docs(web): fix Backup & Restore drift
2026-11-03 20:58:33 +01:00
Manuel Escalante e3f85b3e09 docs(web): translate utils drift fixes 2026-11-03 20:00:03 +01:00
Manuel Escalante c0067d35a6 docs(web): fix Utils drift found in docs audit 2026-11-03 19:59:52 +01:00
Manuel Escalante cd3d4061d0 docs(web): translate backup-restore drift fixes 2026-11-03 19:52:20 +01:00
Manuel Escalante e9272c90a0 docs(web): fix Backup & Restore drift found in docs audit 2026-11-03 19:52:20 +01:00
MacRimi e51553ada8 test: give the cached health check a safe margin before the cache expires 2026-11-03 19:50:03 +01:00
MacRimiandGitHub c07b328bb9 Merge pull request #582 from jcastro/feature/oci-catalog-refresh
Refresh the OCI application catalog automatically with a local cache
2026-11-03 19:46:54 +01:00
MacRimiandGitHub 9a9d0f2379 Merge pull request #576 from dodog/main
Refine Slovak translations in common.json
2026-11-03 19:42:30 +01:00
MacRimiandGitHub 389a07c265 Merge pull request #575 from MaEscalanteHe/docs/drift-monitor
docs(web): fix Monitor drift
2026-11-03 19:41:56 +01:00
MacRimiandGitHub c5df337db6 Merge pull request #574 from MaEscalanteHe/docs/drift-settings-intro
docs(web): fix Settings, Introduction and Installation drift
2026-11-03 19:41:29 +01:00
ProxMenuxBot 3e2b8d72d1 chore(lang): auto-rebuild translation cache
Source: 3bf4c0d
Triggered by: push
2026-11-03 18:14:42 +00:00
MacRimiandGitHub 3bf4c0d82f Merge pull request #585 from jcastro/feature/oci-artcraft-suite
feat(oci): add thirteen ArtCraft desktop applications
2026-11-03 19:00:47 +01:00
MacRimi c3024b8448 Merge develop into feature/oci-artcraft-suite 2026-11-03 18:52:37 +01:00
ProxMenuxBot ab76ab161b chore(lang): auto-rebuild translation cache
Source: 7f59ee5
Triggered by: push
2026-11-03 17:50:46 +00:00
MacRimi f61c59d093 Merge develop into feature/oci-artcraft-suite
Resolve the catalog conflicts and regenerate VALIDATION.md.
2026-11-03 18:43:52 +01:00
MacRimiandGitHub 7f59ee5103 Merge pull request #580 from jcastro/feature/oci-app-catalog-additions
Add KitchenOwl, Krusader, Spoolman, Unbound and Z-Wave JS UI
2026-11-03 18:30:15 +01:00
github-actions[bot] cef1dc7422 Update AppImage beta build (2026-11-03 17:25:45) 2026-11-03 17:25:45 +00:00
MacRimi 888b99330b Merge develop into feature/oci-app-catalog-additions 2026-11-03 18:20:08 +01:00
ProxMenuxBot af0dd74067 chore(lang): auto-rebuild translation cache
Source: 7ec022a
Triggered by: push
2026-11-03 17:13:55 +00:00
Jonatan Castro 75a96b2658 Complete Swedish Authelia label after syncing develop 2026-11-03 18:06:58 +01:00
MacRimi 114882fcba Merge develop into feature/oci-app-catalog-additions
Resolve the catalog conflicts and regenerate VALIDATION.md.
2026-11-03 18:06:57 +01:00
Jonatan Castro 79c0f268fd Merge develop and refresh the OCI validation summary 2026-11-03 18:05:19 +01:00
MacRimiandGitHub 7ec022af81 Merge pull request #584 from jcastro/feature/oci-airdcpp
feat(oci): add AirDC++ Web Client
2026-11-03 18:02:36 +01:00
Jonatan Castro 07f7f9a6a1 Apply profile compatibility gate using effective menu visibility 2026-11-03 17:58:52 +01:00
Jonatan Castro fec97c57d9 Prune catalog snapshots and defer recipes requiring newer profile keys 2026-11-03 17:56:15 +01:00
MacRimi 4ba0d6a236 Merge develop into feature/oci-airdcpp
Resolve the catalog index conflict and regenerate VALIDATION.md.
2026-11-03 17:47:35 +01:00
ProxMenuxBot 96b3a919ba chore(lang): auto-rebuild translation cache
Source: c628e65
Triggered by: push
2026-11-03 16:46:28 +00:00
MacRimiandGitHub c628e65e2f Merge pull request #581 from jcastro/feature/oci-authelia
Add Authelia to the OCI catalog
2026-11-03 17:42:30 +01:00
MacRimi 1e08a5939b ci: cancel superseded test runs on the same branch or pull request 2026-11-03 17:10:11 +01:00
MacRimiandGitHub 163c7c3505 Merge pull request #583 from jcastro/fix/oci-failed-update-recovery
fix(oci): recover automatically after failed image replacements
2026-11-03 16:50:36 +01:00
MacRimi eb46fffd45 fix(oci): apply the llama.cpp context size and stable Vulkan settings for integrated GPUs 2026-11-03 16:46:38 +01:00
Manuel Escalante a4c2c34597 fix(i18n): use localized UI labels in Monitor docs 2026-11-03 16:04:15 +01:00
Jonatan Castro aa19af66eb Add thirteen ArtCraft desktop applications to the OCI catalog 2026-11-03 14:50:20 +01:00
Jonatan Castro f19c300387 Restore the previous OCI installation after a failed image replacement 2026-11-03 13:32:16 +01:00
Jonatan Castro c1183bdeed Use the AirDC++ index page for startup health checks 2026-11-03 13:22:55 +01:00
Jonatan Castro 19e35a567f Add AirDC++ Web Client OCI recipe 2026-11-03 12:51:00 +01:00
Jonatan Castro f428dc76f0 Isolate catalog worker in offline Slovak menu regression 2026-11-03 12:38:57 +01:00
Jonatan Castro e412d3161d Harden OCI catalog refresh recovery and retries 2026-11-03 12:30:54 +01:00
Jonatan Castro 13a8ac1001 Refresh OCI catalog automatically with local cache and app counters 2026-11-03 12:20:23 +01:00
Jonatan Castro f0d54fca13 Leave lab verification to maintainers 2026-11-03 11:35:59 +01:00
Jonatan Castro d9973635fb Add Authelia to OCI catalog 2026-11-03 11:34:27 +01:00
Manuel Escalante 75b13d87da fix(i18n/de): correct the backups tab alt text in the guest detail dialog 2026-11-03 10:25:41 +01:00
Manuel Escalante ac97e2c5a6 docs(web): apply review feedback on Monitor docs 2026-11-03 09:54:33 +01:00
Manuel Escalante 700f72babd docs(web): fix release channel wording from review 2026-11-03 09:45:39 +01:00
Jonatan Castro 0ab40b4eea feat(oci): add five application recipes 2026-11-03 00:27:00 +01:00
MacRimi 9940f05adf fix(oci): llama.cpp lists the models in /models, with resources and notes to add one 2026-11-02 23:45:10 +01:00
Jozef GaalandJozef Gaal 8328d5d366 Refine 'onServer' explanation in common.json 2026-11-02 23:17:00 +01:00
Jozef GaalandJozef Gaal 7e6a942d16 Fix Slovak translations in common.json 2026-11-02 23:17:00 +01:00
Jozef GaalandJozef Gaal 03928c4277 Refine Slovak translations in common.json 2026-11-02 23:16:59 +01:00
MacRimiandGitHub 8aefd70e52 Merge pull request #578 from jcastro/feature/oci-paperclip
Add Paperclip to the OCI catalog
2026-11-02 23:02:03 +01:00
MacRimiandGitHub 6bee13114c Merge pull request #579 from f3rs3n/fix/log2ram-journal-transition
fix(log2ram): keep journal protection enabled until disk migration
2026-11-02 23:00:50 +01:00
martino 6a1513a014 fix(log2ram): keep journal protection enabled until disk migration 2026-11-02 22:32:28 +01:00
Jonatan Castro fb083e92cb feat(oci): add Paperclip application recipe 2026-11-02 20:37:29 +01:00
github-actions[bot] 781d2500ad Update AppImage beta build (2026-11-02 18:39:06) 2026-11-02 18:39:06 +00:00
MacRimi 98931fe4a7 New version 1.2.8 2026-11-02 19:22:47 +01:00
MacRimi 46337f162f New version 1.2.8 2026-11-02 19:20:30 +01:00
MacRimiandGitHub 064c2a6c39 Merge pull request #557 from MaEscalanteHe/feature/web-refresh
fix(web): dependency cleanup, HTML entity rendering and sidebar fixes
2026-11-02 19:10:00 +01:00
MacRimi 3feb5ca5cb fix(web): wrap inline code and scroll tables on narrow screens 2026-11-02 19:01:03 +01:00
MacRimi b0215d4bbb fix: OCI image data paths, LAN endpoints for OpenAI-compatible AI and lab-validated apps 2026-11-02 18:42:32 +01:00
MacRimi 9f3fffd249 fix: one source for the disk type and a config volume for SearXNG 2026-11-02 17:28:31 +01:00
Manuel Escalante 511211f10b fix(web): keep ICU quotes out of messages inserted as raw HTML 2026-11-02 10:32:41 +01:00
github-actions[bot] 41081121f8 Update AppImage beta build (2026-11-02 07:28:12) 2026-11-02 07:28:12 +00:00
MacRimi e83794b7e2 fix(web): send only the client translations to each page 2026-11-02 08:25:28 +01:00
MacRimi 1f56d6b1ce Restore the AppImage checksum for the merge 2026-11-02 08:06:15 +01:00
MacRimi 7df9fdf381 New version 1.2.7 2026-11-02 08:00:57 +01:00
MacRimi 9084df7ebd docs: set the 1.2.8 release date 2026-11-02 07:58:18 +01:00
MacRimiandGitHub 0ae3fd19f4 Merge pull request #572 from f3rs3n/fix/italian-1.2.8-review
fix(i18n): review Italian translations for 1.2.8
2026-11-02 07:51:53 +01:00
MacRimi 8d8ae53a75 fix: stop the NVIDIA install under Secure Boot and keep the 2FA field visible to Bitwarden 2026-11-02 07:47:35 +01:00
martino fa8f021ee9 fix(i18n): review Italian translations for 1.2.8 2026-11-02 01:09:21 +01:00
MacRimi d6f6b4c914 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-01 17:56:15 +01:00
MacRimi 28b23c8ffd i18n: use the Slovak term for application in two terminal strings 2026-11-01 17:56:11 +01:00
MacRimiandGitHub 9976183a54 Merge pull request #569 from Vaso73/slovak-translation-review-1.2.8
i18n: review Slovak translations for 1.2.8
2026-11-01 17:53:32 +01:00
VAIO73 f2328624ab i18n: finish consistent Slovak container lifecycle labels 2026-11-01 15:14:27 +00:00
VAIO73 93e2161355 i18n: explain moving Docker image tags in Slovak 2026-11-01 15:14:27 +00:00
VAIO73 fa58d2f7fe i18n: use consistent Slovak ZFS pool notification terminology 2026-11-01 15:14:27 +00:00
VAIO73 0251dd3c7a i18n: clarify Slovak index documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 253e71a85f i18n: clarify Slovak hardware documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 d4cc80cb4c i18n: clarify Slovak architecture documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 3c9dc45d68 i18n: clarify Slovak custom-image documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 b9ee0514c6 i18n: clarify Slovak monitor documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 537bb5a815 i18n: clarify Slovak storage-network documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 2e227a87c5 i18n: clarify Slovak lifecycle documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 6fe5c8e25c i18n: clarify Slovak multi-container application documentation 2026-11-01 15:14:27 +00:00
VAIO73 7fe03d96f5 i18n: correct Slovak backup audit guidance 2026-11-01 15:14:27 +00:00
VAIO73 2c59356e66 i18n: clarify Slovak wording for all application containers 2026-11-01 15:14:27 +00:00
VAIO73 c68d57feb5 i18n: preserve the full pct create command in Slovak release notes 2026-11-01 15:14:27 +00:00
VAIO73 5395dd488a i18n: preserve the executable example in the Slovak app editor 2026-11-01 15:14:27 +00:00
VAIO73 3048ed8e5b i18n: translate remaining Slovak documentation navigation labels 2026-11-01 15:14:27 +00:00
VAIO73 468f57ebed i18n: translate the OCI catalog contribution guide into Slovak 2026-11-01 15:14:27 +00:00
VAIO73 e42fca75ec i18n: add Slovak VLAN networking documentation 2026-11-01 15:14:27 +00:00
VAIO73 1a18432cee i18n: clarify Slovak custom Compose endpoint documentation 2026-11-01 15:14:27 +00:00
VAIO73 b2e84fde08 i18n: correct Slovak scheduled OCI update documentation 2026-11-01 15:14:27 +00:00
VAIO73 cf7269c123 i18n: align Slovak OCI lifecycle docs with preserved Proxmox settings 2026-11-01 15:14:27 +00:00
VAIO73 ef1e6932b4 i18n: review Slovak release notes for version 1.2.8 2026-11-01 15:14:27 +00:00
VAIO73 aadd90810f i18n: translate Slovak hardware storage controller label 2026-11-01 15:14:27 +00:00
VAIO73 fc2a5ff4fe i18n: clarify Slovak storage display and disk exclusion settings 2026-11-01 15:14:27 +00:00
VAIO73 ff78912764 i18n: review Slovak storage controller and ZFS messages 2026-11-01 15:14:27 +00:00
github-actions[bot] 256df1ef3e Update AppImage beta build (2026-11-01 14:27:37) 2026-11-01 14:27:37 +00:00
MacRimi 0333d95479 fix(monitor): do not warn about volume groups without active volumes 2026-11-01 15:13:18 +01:00
Manuel Escalante 418afe385b docs(web): translate AI explanations of backup errors 2026-11-01 14:38:05 +01:00
Manuel Escalante 9ed09cc28e docs(web): document AI explanations of backup errors 2026-11-01 14:36:54 +01:00
github-actions[bot] e3113df0a2 Update AppImage beta build (2026-11-01 13:07:50) 2026-11-01 13:07:50 +00:00
Manuel Escalante 4261019549 docs(web): translate settings-intro drift fixes 2026-11-01 14:05:13 +01:00
Manuel Escalante 5dc98e9daf docs(web): translate monitor drift fixes 2026-11-01 14:05:13 +01:00
Manuel Escalante 488044e0b9 docs(web): fix remaining Settings and Introduction drift from docs audit 2026-11-01 14:05:13 +01:00
Manuel Escalante 7320fc6ff6 docs(web): fix Monitor drift found in docs audit 2026-11-01 14:05:13 +01:00
Manuel Escalante 398ca55247 docs(web): fix Monitor API paths and token and SSL location 2026-11-01 14:05:13 +01:00
Manuel Escalante 4f415c98cf docs(web): fix Monitor install and Change Language drift in Settings 2026-11-01 14:05:12 +01:00
MacRimi 24d069e1dd Show the optional ZFS feature upgrade notice in blue 2026-11-01 14:04:10 +01:00
github-actions[bot] 3a000458b2 Update AppImage beta build (2026-11-01 12:45:30) 2026-11-01 12:45:30 +00:00
MacRimi d5b7d386ea fix(notifications): preserve VM and CT identities in AI diagnoses 2026-11-01 13:35:24 +01:00
MacRimi 624ff628bd fix(notifications): restore AI suggestions and explain backup failures 2026-11-01 13:20:13 +01:00
MacRimi 5ea0645e31 fix(oci): confirm the release of a private network by its result 2026-11-01 11:39:56 +01:00
MacRimi ac2bd28d25 ProxMenux 1.2.8: release notes, changelog, image and version 2026-11-01 10:55:07 +01:00
github-actions[bot] 2327f96479 Update AppImage beta build (2026-11-01 00:50:01) 2026-11-01 00:50:01 +00:00
MacRimi ead77f0d1f fix(monitor): stop intel_gpu_top leaving a process on every GPU reading 2026-11-01 01:38:52 +01:00
MacRimi 52a9034676 fix(monitor): remove the idle disk note from Disk exclusions 2026-11-01 01:30:59 +01:00
MacRimi e8e9e07e6b feat(oci): add Scanopy and force the stop of a container that ignores it 2026-11-01 01:25:04 +01:00
MacRimi cb97fc4c98 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-31 19:45:03 +01:00
MacRimi 75fd803443 fix(app-tracking): support Helper-Scripts core repository split 2026-10-31 19:44:45 +01:00
github-actions[bot] c0a0cdca34 Update AppImage beta build (2026-10-31 18:39:13) 2026-10-31 18:39:13 +00:00
MacRimi d75d44c601 fix(monitor): do not report a service reloading or restarting as failed 2026-10-31 18:49:34 +01:00
MacRimi 276a116696 feat(oci): VLAN tag and updates that keep Proxmox changes 2026-10-31 18:34:14 +01:00
ProxMenuxBot b1010a194a chore(lang): auto-rebuild translation cache
Source: 0faa781
Triggered by: push
2026-10-31 09:40:28 +00:00
MacRimiandGitHub 0faa781dd1 Merge pull request #566 from jcastro/fix/ollama-api-endpoint
fix(oci): correct Ollama API endpoint metadata
2026-10-31 10:39:21 +01:00
ProxMenuxBot 60b66c4668 chore(lang): auto-rebuild translation cache
Source: 393e609
Triggered by: push
2026-10-31 09:32:48 +00:00
MacRimi 393e6095fa fix(backup): repair host backup schedules systemd rejects 2026-10-31 10:31:47 +01:00
ProxMenuxBot 380a9b653c chore(lang): auto-rebuild translation cache
Source: 1920dc2
Triggered by: push
2026-10-31 09:21:18 +00:00
MacRimi 1920dc2937 fix(backup): check the schedule of a host backup job before saving it 2026-10-31 10:19:59 +01:00
Jonatan bfdcd4a42c i18n: translate Ollama catalog metadata 2026-10-31 10:07:52 +01:00
Jonatan 139a4b4089 fix(oci): label Ollama API endpoint accurately 2026-10-31 10:07:51 +01:00
ProxMenuxBot 14e2718275 chore(lang): auto-rebuild translation cache
Source: 6befea0
Triggered by: push
2026-10-31 08:54:29 +00:00
MacRimiandGitHub 6befea0757 Merge pull request #563 from jcastro/feature/oci-authentik
Add Authentik to OCI catalog
2026-10-31 09:53:15 +01:00
MacRimi ed2a696c01 fix(oci): point the Authentik summary to the first-run setup
akadmin has no password until it is created in the setup wizard, which
opens from the root address. The summary now says so instead of reporting
a password that could not be retrieved.
2026-10-31 09:45:27 +01:00
Jonatan 6968409ae5 fix(oci): give Authentik worker outbound access 2026-10-31 09:00:46 +01:00
ProxMenuxBot a6ada401b5 chore(lang): auto-rebuild translation cache
Source: 98d8e78
Triggered by: push
2026-10-30 23:43:27 +00:00
MacRimi 98d8e78dcb fix(i18n): add the Slovak label of the Vulkan profile 2026-10-31 00:42:21 +01:00
MacRimi f9fe4d2793 fix(oci): run Ollama and llama.cpp on Intel and AMD integrated GPUs 2026-10-31 00:25:21 +01:00
ProxMenuxBot 81cc746e19 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: aa4adc3
Triggered by: push
2026-10-30 21:59:48 +00:00
MacRimi aa4adc34a9 fix(monitor): place the Secure Gateway template on an active storage 2026-10-30 22:58:12 +01:00
ProxMenuxBot dc823bc21b chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 72908e5
Triggered by: push
2026-10-30 20:50:19 +00:00
ProxMenuxBot 297a41d58a chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 668b557
Triggered by: push
2026-10-30 20:47:44 +00:00
MacRimiandGitHub 72908e55aa Merge pull request #562 from DrOwl/feature/disk-controller-mapping
Map storage devices to their PCI storage controller
2026-10-30 21:39:07 +01:00
MacRimiandGitHub 668b557fd5 Merge pull request #561 from DrOwl/feature/zfs-pool-details
Show ZFS pool layout, scrub status and device health in Storage view
2026-10-30 21:38:12 +01:00
Jonatan f5ce0438f9 test(oci): run the full engine suite in CI 2026-10-30 19:32:24 +01:00
Jonatan 21616fe53d fix(i18n): translate Authentik catalog strings to Slovak 2026-10-30 19:31:48 +01:00
Jonatan 8e6e9d5cab test(oci): run Authentik recipe checks in CI 2026-10-30 19:30:00 +01:00
Jonatan 3b15e14625 feat(oci): add Authentik catalog profile 2026-10-30 19:24:01 +01:00
MacRimi 89531bbab8 fix(monitor): record kernel I/O errors on the right disk 2026-10-30 18:51:55 +01:00
MacRimi c13d3de023 docs(web): use "backups" in the Spanish sidebar 2026-10-30 18:07:51 +01:00
MacRimiandGitHub dd9c21ed75 Merge pull request #558 from jcastro/docs/oci-catalog-contributor-guide
docs(oci): add reusable app catalog guide
2026-10-30 17:56:50 +01:00
MacRimi aaffe4e08a docs(oci): align the catalog guide with the project flow
Suggest the app in discussion #440 first, call the file an oci-compose,
use the names the interface shows, run the engine tests as an
unprivileged user, and add the page to the docs sidebar.
2026-10-30 17:52:32 +01:00
MacRimiandGitHub d3fedeb201 Merge pull request #555 from jcastro/feature/oci-teslamate
feat(oci): add TeslaMate stack
2026-10-30 17:35:30 +01:00
MacRimi bd42df3ead fix(monitor): keep the LVM filter when the scan names its devices 2026-10-30 17:32:25 +01:00
MacRimi cf7b8363da fix(monitor): serve the bundled PWA manifest and service worker 2026-10-30 16:59:02 +01:00
Jonatan d3752f0417 docs(oci): add reusable app catalog guide 2026-10-30 10:43:32 +01:00
Manuel Escalante 64fa4f8115 fix(web): restore Change Language entry in docs sidebar 2026-10-29 19:54:41 +01:00
Jonatan 88ba698758 fix(oci): use readable names for stack LXCs 2026-10-29 18:18:57 +01:00
DrOwlandClaude Opus 5.5 72491b2445 Show ZFS pool layout, scrub status and device health in Storage view
/api/storage now runs a single `zpool status -j --json-int -P` (OpenZFS
2.3+) for all pools and returns, per pool:
- top-level vdevs with type, raidz/draid parity, class (data, special,
  dedup, log, cache, spare), state and read/write/checksum errors
- member devices with path, resolved base disk, state, error counters,
  slow I/Os, and the spare-N/replacing-N they sit under while swapping
- scrub/resilver status (progress only while running), the pool's
  status/action text and data-error count
- fragmentation, capacity and dedup ratio from `zpool list`

This replaces the per-pool `zpool status` text loop whose result
(zfs_disks) was never used. Member paths (by-id, by-partlabel, wwn) are
resolved to base disks via sysfs rather than an lsblk call per device.
On ZFS without `-j`, pools report details_available: false and the UI
falls back to the existing basic card.

The ZFS pool card shows a layout summary (e.g. "RAIDZ2 | 5 disks |
Scrub finished ...") and opens a details dialog with the vdev tree,
per-device error counts, scrub/resilver progress, and links from each
member disk to its disk details dialog.

Tested against `zpool status -j` output from PVE 9 / OpenZFS 2.4.4:
mirror, raidz2, striped mirrors, single-disk pool, and a file-backed
degraded raidz1 with log, cache and spare devices.

Tests cover the `zpool status -j` -> Storage view shape against real
output from PVE 9 / OpenZFS 2.4.4 hosts (serials and WWNs replaced):
mirror, raidz2, single-disk pool, slow I/Os, and a degraded raidz1 with
log, cache and spare devices; plus a synthetic resilver with a spare
swapping in. Member path -> base disk resolution is tested against a
stubbed sysfs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:55:02 +00:00
Jonatan 8eef466949 test(oci): record TeslaMate browser and lifecycle validation 2026-10-29 12:47:14 +01:00
DrOwlandClaude Opus 5.5 30034e7aa3 Map storage devices to their PCI storage controller
Each disk in /api/hardware now carries a controller_slot, resolved from
the disk's sysfs device path: the deepest PCI address in that path is the
controller (bridges sit above it), normalised to lspci's slot format so it
matches pci_devices[].slot directly. NVMe falls back to the controller's
own sysfs link when the namespace resolves to a subsystem with no PCI
parent; zvols and other virtual devices get None.

Hardware page:
- The disk dialog shows its storage controller (click through to the
  controller), and the PCI device dialog lists the disks attached to it.
- Storage controllers in the PCI Devices list get a badge with the
  number of attached disks.

Storage view:
- /api/storage returns controller_slot on each physical disk plus a
  controllers map (slot -> class, vendor, device, subsystem name,
  driver), built from the cached `lspci -vmm` output so the Storage view
  doesn't need the heavier /api/hardware enumeration.
- "Physical Disks & SMART Status" can group disks under their storage
  controller, with a header showing the controller name, slot, driver
  and disk count. Groups follow PCI slot order; disks with no resolvable
  controller are listed last. USB disks keep their own section.
- The disk details dialog shows the storage controller on the Overview
  tab.
- Grouping is opt-in via a new Storage Display card in Settings, stored
  per browser (localStorage) and synced across open views, following the
  existing nav-tab-order preference pattern. Off by default.

Also:
- Treat "IDE interface" as a storage controller, so SATA controllers left
  in BIOS IDE mode (e.g. Intel 7 Series, ata_piix) appear in pci_devices
  and disks behind them can be mapped.
- lspci prints "Device xxxx" when it has no subsystem name; that
  placeholder is ignored so the chip name is shown instead.
- Add the missing sdevice field to the PCIDevice type.

Tests cover the sysfs path -> controller slot rule against real paths
from two PVE 9 hosts (SAS HBA, AHCI SATA, SATA in BIOS IDE mode, USB
behind a hub, NVMe behind chipset bridges), the non-zero PCI domain case
(Intel VMD), the NVMe multipath fallback, and get_storage_controllers'
lspci parsing including the "Device xxxx" placeholder and driver lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:47:00 +00:00
Manuel Escalante c26eb49e2c fix(web): render HTML entities in translated text 2026-10-29 09:41:32 +01:00
Manuel Escalante db12972253 fix(web): pass em tag to host-local-disk format steps 2026-10-29 09:41:32 +01:00
Manuel Escalante fac7ac5021 chore(web): drop unused dependencies and apply minor updates and audit fixes 2026-10-29 09:41:32 +01:00
Jonatan 512b268815 feat(oci): add TeslaMate native stack profile 2026-10-29 01:08:16 +01:00
Jonatan e52460e991 fix(oci): validate managed routes during transaction recovery 2026-10-29 00:51:58 +01:00
ProxMenuxBot 5db3ae9c92 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 581eed6
Triggered by: push
2026-10-28 22:32:09 +00:00
ProxMenuxBot 79bc8d33fd chore(lang): auto-rebuild translation cache
Source: 581eed6
Triggered by: push
2026-10-28 22:26:01 +00:00
MacRimiandGitHub 581eed61e5 Merge pull request #551 from Vaso73/fix/host-update-recovery
Keep Proxmox updates running across sign-out and show pending reboots
2026-10-28 23:24:38 +01:00
MacRimiandGitHub d27dc834db Merge pull request #550 from jcastro/feature/oci-custom-compose-stacks
feat(oci): import multi-service Compose files from Custom image
2026-10-28 23:05:36 +01:00
MacRimi 2ab83c0b88 fix(oci): clarify the summary of imported Compose stacks
Each registry line names its image, the variables of a stack are listed
without announcing questions that are not asked, and the name given to an
imported stack names its containers.
2026-10-28 22:57:15 +01:00
VAIO73 bdf9075639 fix: distinguish the waiting unattended upgrades shutdown helper 2026-10-28 17:30:44 +00:00
VAIO73 65b10cea09 ui: compact the current ProxMenux version status 2026-10-28 17:30:44 +00:00
VAIO73 9147ec2f52 fix: keep reboot actions blocked during health recovery 2026-10-28 17:30:44 +00:00
VAIO73 ba3fb1197f fix: read completed update results without a live terminal 2026-10-28 17:30:44 +00:00
VAIO73 889409de8d fix: preserve dated health results while update cache refreshes 2026-10-28 17:30:44 +00:00
VAIO73 43973e1d06 fix: clarify system update action and package plan 2026-10-28 17:30:44 +00:00
VAIO73 e1be5c22c8 fix: include host update controls in AppImage assembly 2026-10-28 17:30:44 +00:00
VAIO73 79cdf814ed fix: keep restart controls disabled while request is pending 2026-10-28 17:30:44 +00:00
VAIO73 707a45e639 fix: guard host reboot with native package locks 2026-10-28 17:30:44 +00:00
VAIO73 52cd21dcb1 feat: add guarded host restart and read-only ProxMenux version status 2026-10-28 17:30:44 +00:00
VAIO73 503a946d85 fix: retain translated reboot summary in cached health responses 2026-10-28 17:30:44 +00:00
VAIO73 ac9565fa19 fix: refresh reboot evidence after external host updates 2026-10-28 17:30:44 +00:00
VAIO73 f40e0a53e1 Preserve host updates across sign-out and report pending kernel activation 2026-10-28 17:30:44 +00:00
MacRimi 2d4b0cb746 ci(oci): fetch only the commits the validation checks need 2026-10-28 18:12:17 +01:00
MacRimiandGitHub 616ba129b2 Merge pull request #545 from jcastro/feature/zigbee2mqtt-oci-20261027
Add Zigbee2MQTT to the OCI catalog
2026-10-28 18:08:12 +01:00
MacRimi 6a91487d21 Merge branch 'develop' into feature/zigbee2mqtt-oci-20261027 2026-10-28 17:49:43 +01:00
MacRimi 8ee3b29991 fix(update): confirm with Yes focused and keep the package list aligned 2026-10-28 17:24:14 +01:00
github-actions[bot] 52d44a7435 Update AppImage beta build (2026-10-28 10:51:01) 2026-10-28 10:51:01 +00:00
Jonatan 5e89b2bdf5 fix(i18n): translate managed stack routing errors 2026-10-28 11:43:37 +01:00
Jonatan a259b945eb test(oci): isolate cleanup and recovery fixtures from the host 2026-10-28 11:43:36 +01:00
Jonatan bf8c495d3b fix(oci): validate the installation identity before stack backups 2026-10-28 11:43:36 +01:00
Jonatan 05790b494e fix(oci): preserve private access to stack frontends across lifecycle operations 2026-10-28 11:26:59 +01:00
Jonatan a7786a6874 fix(oci): bind stack public URLs to their frontend before startup 2026-10-28 10:56:36 +01:00
Jonatan 3d1c542a05 fix(oci): normalize pinned references for registry inspection 2026-10-28 10:10:57 +01:00
Jonatan e587aa7d79 fix(oci): carry verified stack metadata into native backups 2026-10-28 09:14:11 +01:00
Jonatan 6a30d92ba7 fix(oci): verify web readiness and persist private hook contracts 2026-10-28 08:52:57 +01:00
Jonatan 3a0a7eaf14 feat(oci): support custom Compose workers and migration jobs 2026-10-28 08:41:41 +01:00
Jonatan b383fb9311 fix(oci): normalize read-only time mounts in custom stacks 2026-10-28 06:41:20 +01:00
Jonatan 68ae23df82 fix(oci): allow IPv6 entrypoints during native updates 2026-10-28 06:41:20 +01:00
Jonatan f23f1912c2 fix(oci): reserve down and pending private network definitions 2026-10-28 06:41:20 +01:00
Jonatan 692c7f7256 feat(oci): support custom multi-service Compose applications 2026-10-28 06:28:18 +01:00
Jonatan a079f3fc90 feat(oci): add optional Zigbee serial adapter 2026-10-27 23:21:32 +01:00
ProxMenuxBot cdb1bd0cbf chore(lang): auto-rebuild translation cache
Source: 87c4883
Triggered by: push
2026-10-27 21:31:10 +00:00
MacRimiandGitHub 87c48830c5 Merge pull request #547 from jcastro/feature/netalertx-oci-20261027
Add NetAlertX to OCI catalog
2026-10-27 22:30:11 +01:00
MacRimi ecc8017f71 Merge develop into feature/netalertx 2026-10-27 22:22:57 +01:00
MacRimi fc8c23fd66 feat(monitor): VM disk thresholds in Settings 2026-10-27 22:22:05 +01:00
MacRimiandGitHub 5adafeaa96 Merge pull request #546 from jcastro/feature/scrypted-oci-20261027
Add Scrypted to the OCI app catalog
2026-10-27 22:18:41 +01:00
Jonatan aeb55236c1 Add NetAlertX to OCI catalog 2026-10-27 22:01:12 +01:00
Jonatan f623d29e12 Add Scrypted OCI application profile 2026-10-27 21:36:38 +01:00
Jonatan 9ab744e784 feat(oci): add Zigbee2MQTT 2026-10-27 20:49:25 +01:00
MacRimiandGitHub 9fef8d89a7 Merge pull request #541 from jcastro/feature/proxmenux-oci-apps-20261027
Add Umami OCI application profile
2026-10-27 20:04:41 +01:00
MacRimi 85857acaf9 Merge develop into feature/proxmenux-oci-apps-20261027 2026-10-27 19:58:28 +01:00
ProxMenuxBot f5c98fb700 chore(lang): auto-rebuild translation cache
Source: f79c35b
Triggered by: push
2026-10-27 18:54:38 +00:00
MacRimiandGitHub f79c35b038 Merge pull request #538 from jcastro/feature/proxmenux-oci-next-10
Add ten OCI application profiles
2026-10-27 19:53:44 +01:00
MacRimiandGitHub 6173c012e6 Merge pull request #539 from f3rs3n/fix/overview-chart-axis-clipping
fix(monitor): keep overview Y-axis titles inside charts
2026-10-27 19:53:01 +01:00
MacRimi 4cefc0484b feat(post-install): rebuild Log2RAM with the journal and task logs kept on disk 2026-10-27 19:48:26 +01:00
MacRimi ac9b8c2705 Update remove-banner-pve-v3.sh 2026-10-27 18:14:40 +01:00
MacRimi 085bb084eb fix(monitor): show quiet rotational disks by their identity after a restart 2026-10-27 17:20:44 +01:00
Jonatan 8f8a80785a Add Umami OCI application profile 2026-10-27 11:32:59 +01:00
martino e6d39f6015 fix(monitor): keep overview Y-axis titles inside charts 2026-10-27 02:48:25 +01:00
Jonatan ec5fbd668f Add ten OCI application profiles 2026-10-27 01:50:12 +01:00
MacRimi 688b7f54bc Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-27 00:16:17 +01:00
MacRimi c676d883d9 ci: merge the feature test workflows into one 2026-10-27 00:16:02 +01:00
github-actions[bot] 6feb11ffb9 Update AppImage beta build (2026-10-26 23:09:48) 2026-10-26 23:09:48 +00:00
MacRimi 2b1ae0be72 docs: remove missing Audit & Report images and sample PDF links 2026-10-26 23:45:25 +01:00
MacRimi 28d9ff1c9e New version 1.2.7 2026-10-26 23:37:27 +01:00
MacRimi 6dc2c1f13c Merge remote-tracking branch 'origin/main' into develop 2026-10-26 23:18:22 +01:00
MacRimi a48bccab38 New version 1.2.7 2026-10-26 23:10:13 +01:00
MacRimi 96a388d1e7 New version 1.2.7 2026-10-26 23:07:32 +01:00
MacRimi 314178e0ac docs: add MaEscalanteHe to the contributors page 2026-10-26 23:01:12 +01:00
MacRimi cca3516e65 fix(oci): update and modify apps that mount a host system path (#531) 2026-10-26 22:53:09 +01:00
MacRimiandGitHub 05cf4a40c3 Merge pull request #535 from f3rs3n/fix/italian-release-delta
fix(i18n): refine final Italian OCI and update messages
2026-10-26 22:36:24 +01:00
MacRimiandGitHub e5b5a99c52 Merge pull request #534 from Vaso73/i18n/sk-pre127-review
i18n: review Slovak strings before 1.2.7
2026-10-26 22:36:09 +01:00
MacRimi f6d16fd054 docs: release notes for the OpenAI model update 2026-10-26 22:33:51 +01:00
martino 01584b974a fix(i18n): refine final Italian OCI and update messages 2026-10-26 21:48:59 +01:00
VAIO73 106454d47b docs(i18n): clarify Slovak AI model recommendations 2026-10-26 19:03:00 +00:00
VAIO73 80456679f1 i18n: polish the Slovak OCI release note 2026-10-26 19:03:00 +00:00
VAIO73 575e759718 i18n: clarify Slovak OCI catalog and runtime messages 2026-10-26 19:02:59 +00:00
MacRimiandGitHub 617d85e427 Merge pull request #532 from MaEscalanteHe/feature/update-openai-models
fix: update OpenAI models and send supported reasoning parameters
2026-10-26 19:37:01 +01:00
ProxMenuxBot ede91f30b8 Update helpers_cache.json 2026-10-26 18:08:39 +00:00
Manuel Escalante e9b8fd28db test: cover chat contract for classic OpenAI models 2026-10-26 19:02:32 +01:00
ProxMenuxBot 9b712982e1 Update app tracking hints 2026-10-26 18:02:11 +00:00
MacRimi 85e5c5f29b Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-26 18:47:12 +01:00
MacRimi 7b1736fe3e fix: name the guest in VM/CT health alerts, refresh AI models, propose the recipe's volume choice
- A health problem of a VM or CT names the guest in its title and in the
  Quiet Hours summary; a recovery shows its category by name in the
  reader's language.
- AI model lists reviewed against each provider: Gemini adds 3.6/3.7/3.8
  Flash, Groq adds gpt-oss-120b, and Groq/OpenRouter models no longer
  served are removed.
- OCI installer proposes the volume choice the recipe declares, so a path
  meant for a host directory is proposed as one; Duplicati /source is
  proposed on a host directory.
2026-10-26 18:47:09 +01:00
Manuel Escalante a4148c6616 docs: update OpenAI model examples 2026-10-26 18:35:47 +01:00
Manuel Escalante acbe53ad4a chore: update verified OpenAI models to current lineup 2026-10-26 18:35:47 +01:00
Manuel Escalante 542ff12890 fix: send supported reasoning parameters to gpt-5.1 and gpt-6 models 2026-10-26 18:35:47 +01:00
MacRimiandGitHub 55e830904d Merge pull request #528 from jcastro/feature/oci-input-review
Support owner-aware OCI tmpfs mounts and enable Input
2026-10-26 18:17:48 +01:00
MacRimi a640993b94 Generate the Laravel APP_KEY of Input during the installation
Laravel accepts a 32-character key, which token-hex with 16 bytes provides, so the installation no longer asks the user to create one with openssl.
2026-10-26 18:15:01 +01:00
MacRimi b6500c147e Monitor: forget the App tab of a destroyed guest 2026-10-26 18:02:30 +01:00
Jonatan 92c6a000b5 Translate OCI tmpfs validation messages 2026-10-26 17:51:06 +01:00
Jonatan f6f811f5ea Refresh OCI validation catalog count 2026-10-26 17:46:45 +01:00
Jonatan 0d063d519c Merge develop into OCI input review 2026-10-26 17:44:52 +01:00
MacRimi fc7c3181bc OCI: retry registry lookups and say why one failed 2026-10-26 17:40:33 +01:00
Jonatan a1f90ddaf2 Add owner-aware OCI tmpfs mounts 2026-10-26 17:36:21 +01:00
MacRimi be6ef0069b 1.2.7: IT-Tools installable, catalog categories and release notes
- IT-Tools has the template the menu installs from, so it is offered
  like the rest of the catalog.
- Every curated application is listed in categories.json, and OpenTask's
  template matches its recipe.
- The texts of the LXC update jobs read correctly in Spanish, German,
  French, Italian, Portuguese and Swedish.
- The release notes count 514 applications and credit PRs #526, #527,
  #529 and #530.
2026-10-26 17:21:24 +01:00
ProxMenuxBot ae809142f6 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ba6f691
Triggered by: push
2026-10-26 15:58:38 +00:00
MacRimiandGitHub 3e1729a262 Merge pull request #530 from MaEscalanteHe/feature/update-anthropic-models
fix: update Anthropic models and handle thinking/refusal responses
2026-10-26 16:57:18 +01:00
MacRimiandGitHub 88b942a543 Merge pull request #529 from jcastro/fix/oci-watchdog-instance-identity
Prevent OCI watchdog from restarting recycled VMIDs
2026-10-26 16:56:37 +01:00
MacRimiandGitHub ba6f691f90 Merge pull request #527 from Vaso73/fix/lxc-update-session-resilience
Keep LXC updates running when the browser session expires
2026-10-26 16:56:12 +01:00
MacRimiandGitHub 5e6ed0a19b Merge pull request #526 from jcastro/feature/oci-next-50-apps
Add 50 validated OCI application profiles
2026-10-26 16:55:41 +01:00
ProxMenuxBot d1c7fa0158 Update helpers_cache.json 2026-10-26 13:31:10 +00:00
ProxMenuxBot 5596cc9149 Update app tracking hints 2026-10-26 12:50:03 +00:00
Manuel Escalante c316e901cd docs: update Anthropic model examples in pt, sk and sv 2026-10-26 12:41:23 +01:00
Manuel Escalante 1e0c6e70bf docs: update Anthropic model examples in AI assistant page 2026-10-26 12:07:18 +01:00
Manuel Escalante 0313e58bd8 chore: update verified Anthropic models to the 5.x family 2026-10-26 12:07:18 +01:00
Manuel Escalante f2ef3bebbd fix: read text block and handle refusals in Anthropic provider 2026-10-26 12:07:18 +01:00
Jonatan Castro d403f25fbb Update watchdog localization fixtures for installation identity 2026-10-26 12:07:03 +01:00
Jonatan Castro ff11a6d462 Fix OCI watchdog identity checks for recycled VMIDs 2026-10-26 12:00:17 +01:00
Jonatan e82d95f1cf Document blocked Input OCI profile 2026-10-26 11:17:47 +01:00
Jonatan 4e00fcd746 Update OCI validation catalog count 2026-10-26 09:25:24 +01:00
Jonatan c5458f34d9 Translate A25 catalog text in seven languages 2026-10-26 09:15:28 +01:00
github-actions[bot] 85650ca0e8 Update AppImage beta build (2026-10-26 08:07:43) 2026-10-26 08:07:43 +00:00
Jonatan 25953b03bd Fix rqlite default startup command 2026-10-26 09:06:56 +01:00
Jonatan e99b5f32ee Use rqlite's upstream GHCR mirror 2026-10-26 09:06:56 +01:00
Jonatan 975a3b99cf oci: fix low-port and generated secret profiles 2026-10-26 09:06:56 +01:00
Jonatan a4fb46f861 oci: fix rqlite data directory argument 2026-10-26 09:06:56 +01:00
Jonatan dede474ff4 i18n: translate remaining OCI profile text 2026-10-26 09:06:56 +01:00
Jonatan 2a02154f17 oci: add 25 standalone service profiles 2026-10-26 09:06:09 +01:00
Jonatan ecd0b9c54b Fix OpenTask managed volume ownership 2026-10-26 09:05:06 +01:00
Jonatan 1ff10e7a7f Set Instatic data volume owner 2026-10-26 09:05:06 +01:00
Jonatan 5c90f40ef6 Set Flagr data volume owner 2026-10-26 09:05:06 +01:00
Jonatan ee5729aba2 Set Sofa persistent volume owner 2026-10-26 09:05:06 +01:00
Jonatan a42dd9d28c Align InvoiceShelf session URLs with LXC address 2026-10-26 09:05:06 +01:00
Jonatan 2014105343 Add 25 curated OCI application profiles 2026-10-26 09:05:06 +01:00
VAIO73 744c0165e2 fix: keep update viewers responsive under input backpressure 2026-10-26 07:57:20 +00:00
VAIO73 723f53a1f1 fix: allow update cancellation to restore the original guest state 2026-10-26 07:57:20 +00:00
VAIO73 fd74b3cacc fix: detach closed update viewers and preserve other guest state 2026-10-26 07:57:20 +00:00
VAIO73 11326badb2 test: preserve legacy terminal and update caller coverage 2026-10-26 07:57:20 +00:00
VAIO73 44ab9d506d fix: keep LXC updates running when the browser session ends 2026-10-26 07:57:20 +00:00
MacRimi af29bc9274 Release notes: the catalog lists 464 applications
The release notes, the changelog and What's New in Version 1.2.7 count
the hundred applications of PRs #524 and #525 and credit them, together
with the Italian review of PR #522 and the French field icons of PR #523.
2026-10-26 07:48:58 +01:00
MacRimiandGitHub 9f977ac0be Merge pull request #525 from jcastro/feature/oci-next-50-extra
Add 50 curated OCI app profiles
2026-10-26 07:43:43 +01:00
MacRimi 761385a107 Merge develop into feature/oci-next-50-extra
Resolves the catalog index and translation conflicts with PR #524 by keeping the entries and keys of both, adds the fifty applications to categories.json and renders oci/VALIDATION.md for 464 applications.
2026-10-26 07:41:00 +01:00
MacRimiandGitHub 354966180c Merge pull request #524 from jcastro/feature/oci-next-50
Add 50 curated OCI app profiles
2026-10-26 07:31:32 +01:00
MacRimi fab4aee37a Keep the configuration of Glance, Homer and Prometheus across updates
Glance and Homer write their starter configuration only when it is missing, so an update does not replace the one the user edited. Prometheus keeps /etc/prometheus in a volume of its own, which starts with the configuration of the image and can be a host directory.
2026-10-26 07:28:40 +01:00
MacRimiandGitHub 170293c67a Merge pull request #523 from f3rs3n/fix/monitor-fixtures-localized-icons
fix(monitor): isolate offline tests and preserve localized field icons
2026-10-26 07:26:58 +01:00
MacRimiandGitHub 9340def0e7 Merge pull request #522 from f3rs3n/fix/italian-prerelease-messages
fix(i18n): polish new Italian catalog messages
2026-10-26 07:26:39 +01:00
Jonatan b4bd674a80 Refresh generated OCI validation count 2026-10-26 06:39:53 +01:00
Jonatan 6b62aa2608 Add missing Admin UI translations 2026-10-26 06:38:13 +01:00
Jonatan 541eb6379e Document oCIS URL behind HTTPS proxy 2026-10-26 06:18:35 +01:00
github-actions[bot] 5c6afd1fcb chore: update repository growth [skip ci] 2026-10-26 04:36:49 +00:00
Jonatan 23ac5ba228 Align Kiwix runtime command with official image 2026-10-26 05:27:14 +01:00
Jonatan e255b4cc64 Pass Kiwix ZIM wildcard to image entrypoint 2026-10-26 05:24:48 +01:00
Jonatan e909e1ce67 Fix Kiwix command passthrough 2026-10-26 05:21:49 +01:00
Jonatan 8269d9aaaf Add 50 curated OCI app profiles 2026-10-26 05:04:41 +01:00
ProxMenuxBot aa8a28294d Update helpers_cache.json 2026-10-26 00:15:07 +00:00
ProxMenuxBot 3b983a0a78 Update app tracking hints 2026-10-26 00:02:34 +00:00
Jonatan f2854ebece Refresh OCI validation catalog count 2026-10-26 00:50:18 +01:00
Jonatan 8eeafa1f18 Add 50 curated OCI app profiles 2026-10-26 00:47:35 +01:00
martino 6c2e268c4b fix(monitor): isolate offline tests and preserve localized field icons 2026-10-26 00:35:37 +01:00
martino 12f424911d fix(i18n): polish new Italian catalog messages 2026-10-25 23:41:36 +01:00
MacRimi 1ff63fa955 OCI catalog: keep the Gatus configuration and explain the Pocket ID address
- Gatus keeps config.yaml in a /config volume of its own, which can also
  be a host directory, and the installation writes it only when it is
  missing, so updates and recreations keep the endpoints added to it.
- The address Pocket ID is opened at says what it is for and that
  passkeys need HTTPS.
- The catalog categories are sorted again.
- The release notes count 364 applications and credit PR #521.
2026-10-25 20:49:55 +01:00
MacRimiandGitHub c24d022f40 Merge pull request #521 from jcastro/feature/oci-apps-tested-batch
Add ten curated OCI service profiles
2026-10-25 20:34:49 +01:00
Jonatan 6bb9a1ebb0 Translate OCI catalog additions 2026-10-25 20:28:56 +01:00
Jonatan e649a92f30 Add ten curated OCI applications 2026-10-25 20:22:01 +01:00
MacRimi bab512281c Release notes: the catalog lists 354 applications 2026-10-25 19:26:08 +01:00
MacRimi 3686d613e6 ProxMenux 1.2.7: release notes, changelog and version 2026-10-25 19:22:44 +01:00
MacRimiandGitHub 896817affa Merge pull request #520 from jcastro/feature/oci-curated-apps
Add seven curated OCI applications
2026-10-25 19:19:54 +01:00
MacRimi b2067e18a5 Add the icons of the seven curated OCI applications
Every catalog application carries its icon; these come from the same selfhst/icons set as the rest of the catalog.
2026-10-25 19:11:45 +01:00
ProxMenuxBot d152fc42d3 Update helpers_cache.json 2026-10-25 18:06:00 +00:00
ProxMenuxBot 69518f701f Update app tracking hints 2026-10-25 18:01:08 +00:00
Jonatan 281dfdf638 Translate new OCI catalog descriptions 2026-10-25 18:59:47 +01:00
Jonatan 17fe62062e Refresh OCI validation catalog count 2026-10-25 18:55:30 +01:00
Jonatan 8b1cbbbb0d Add seven curated OCI applications 2026-10-25 18:52:36 +01:00
MacRimi fc41b04e87 Pre-release 1.2.7: OCI fixes, 12 new catalog applications and docs updates
Last changes before version 1.2.7.

OCI manager Apps
- Multi-container applications start while one of their containers is
  being backed up: the start hook waits until vzdump releases it and does
  not start it twice. The DHCP client of a dependency no longer keeps the
  lock of the hook, so later starts do not hang, and the watchdog waits
  while a dependency is locked. Installing or updating ProxMenux replaces
  the published copies of the hook already in place.
- Images built from scratch with a numeric user, such as ddns-updater,
  install: the container is created from a copy of the image that adds
  the /etc/passwd and /etc/group Proxmox VE needs, and the application
  runs with the user the image declares. The downloaded image stays
  unchanged. An image with a named user and no user database stops
  before anything is created, with a message that says why.

Catalog
- Twelve new applications: MDBListarr, CrossWatch, Free Games Claimer
  Remaster, Cloudreve, Poznote and AceStream HTTP Proxy, suggested in
  discussion #440, and the Dockur images of Chrony, Dnsmasq, Tor,
  Stunnel, strfry and Statping. The catalog lists 347 applications.
- Their prompts, labels and notes are translated into the seven
  languages of the menus.

Documentation
- OCI manager: architecture, lifecycle and stacks pages revised, with
  larger diagrams and new screenshots.
- Contributors: the page now thanks those developing, reviewing,
  translating and testing ProxMenux today and those who helped in its
  first steps, with new avatars.

Tests for the stack start hook, the watchdog, the user database check
of OCI images and the Tor installation summary.
2026-10-25 13:17:54 +01:00
github-actions[bot] e233d1856b chore: update repository growth [skip ci] 2026-10-25 04:32:04 +00:00
github-actions[bot] 257c2728fb chore: update repository growth [skip ci] 2026-10-24 04:30:51 +00:00
MacRimi 2f48689087 fix(post-install): explain when an AMD CPU does not need the AMD fixes 2026-10-23 18:24:43 +02:00
MacRimiandGitHub 779dd7fb26 Merge pull request #517 from f3rs3n/fix/optimization-description-wording
fix(post-install): align optimization descriptions with existing behavior
2026-10-23 17:03:08 +01:00
MacRimi 225438209e Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-23 18:00:10 +02:00
MacRimiandGitHub adda62d1ed Merge pull request #516 from f3rs3n/fix/italian-delta-tui-monitor
fix(i18n): refine new Italian TUI and Monitor messages
2026-10-23 16:58:11 +01:00
MacRimiandGitHub 6556d560fa Merge pull request #515 from f3rs3n/fix/bashrc-migration-safety
fix(post-install): preserve Bashrc on reruns and write failures
2026-10-23 16:57:06 +01:00
MacRimi 5ece466d1e Merge commit 'refs/pull/516/head' of https://github.com/MacRimi/ProxMenux into HEAD
# Conflicts:
#	AppImage/messages/it/common.json
2026-10-23 17:50:12 +02:00
MacRimi b879adf54c i18n: update the six corrected descriptions in the other languages
- Translate the new Log2RAM, kexec, AMD, guest agent and backup descriptions into es, de, fr, pt and sv
2026-10-23 17:49:51 +02:00
github-actions[bot] c15834ba63 chore: update repository growth [skip ci] 2026-10-23 04:33:34 +00:00
ProxMenuxBot 39891e5177 Update helpers_cache.json 2026-10-23 00:14:32 +00:00
martino e032d869ff fix(post-install): align optimization descriptions with existing behavior 2026-10-22 23:46:56 +02:00
martino 1f0c72b90b fix(i18n): refine new Italian TUI and Monitor messages 2026-10-22 22:28:20 +02:00
martino 03770129a3 fix(post-install): preserve Bashrc on reruns and write failures 2026-10-22 21:04:15 +02:00
MacRimiandGitHub 6422d6ecd5 Merge pull request #514 from jcastro/fix/oci-zfs-snapshot-mountpoints
fix(oci): preserve image defaults for ZFS snapshot backups
2026-10-22 17:53:14 +01:00
MacRimi d17c017ccf Thank you so much for this, merging it now. 2026-10-22 18:47:32 +02:00
MacRimi 6699a4afbb fix(oci): keep installing when an image mountpoint is not a plain directory
- Log and continue when the helper refuses a mountpoint, as the seed capture does
- Nothing is moved in that case, so the image installs as before
2026-10-22 10:52:35 +02:00
ProxMenuxBot b3319268a7 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 3298b55
Triggered by: push
2026-10-22 08:34:26 +00:00
jcastro 00ebb0b2f9 fix(oci): preserve image defaults before attaching managed volumes 2026-10-22 10:27:00 +02:00
ProxMenuxBot 7aacd08a96 chore(lang): auto-rebuild translation cache
Source: 3298b55
Triggered by: push
2026-10-22 08:23:52 +00:00
MacRimiandGitHub 3298b55cc6 Merge pull request #513 from Vaso73/i18n/slovak-monitor-cli-messages
i18n: complete Slovak Monitor and CLI messages
2026-10-22 09:22:59 +01:00
ProxMenuxBot 396b44eacb Update helpers_cache.json 2026-10-22 06:08:57 +00:00
VAIO73 d9bd8c47de test: remove duplicate catalog regression after merge 2026-10-22 05:05:05 +00:00
VAIO73 dbcc4de6e1 fix(i18n): address Monitor and CLI review feedback 2026-10-22 04:49:16 +00:00
VAIO73 61168b9abd Merge develop and preserve Slovak Monitor and CLI messages 2026-10-22 04:44:42 +00:00
github-actions[bot] ff81c4269c chore: update repository growth [skip ci] 2026-10-22 04:32:40 +00:00
MacRimi c04fd65b4f Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	AppImage/messages/de/common.json
#	AppImage/messages/es/common.json
#	AppImage/messages/fr/common.json
#	AppImage/messages/it/common.json
#	AppImage/messages/pt/common.json
#	AppImage/messages/sv/common.json
2026-10-22 00:09:58 +02:00
MacRimi 203496f14f i18n: complete the other languages for the Slovak work
- Translate the new terminal and Monitor texts into es, de, fr, it, pt and sv
- Replace the machine fills of those new keys with reviewed translations
- Keep the complete terminal catalog check in CI
- Keep Machine learning in English in Spanish
- Fix the German Exit label
- Make the fallback tests use a locale without the keys
2026-10-22 00:09:54 +02:00
ProxMenuxBot b0ffcd2049 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: a1ee7fa
Triggered by: push
2026-10-21 22:04:28 +00:00
ProxMenuxBot c0046cb24f chore(lang): auto-rebuild translation cache
Source: 53e9f08
Triggered by: push
2026-10-21 21:46:15 +00:00
ProxMenuxBot 4ffbaa26d3 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: a5371b0
Triggered by: push
2026-10-21 21:40:53 +00:00
MacRimiandGitHub a1ee7fa744 Merge pull request #512 from Vaso73/i18n/slovak-optimization-source
i18n: localize Slovak optimization source viewer and handle stale responses
2026-10-21 22:36:28 +01:00
MacRimiandGitHub 0d346c6bef Merge pull request #511 from Vaso73/i18n/slovak-app-editor
i18n: complete Slovak VM and LXC application editor messages
2026-10-21 22:34:03 +01:00
MacRimiandGitHub 53e9f08fe7 Merge pull request #510 from Vaso73/i18n/slovak-oci-manager
i18n: complete Slovak OCI Manager messages and cancellation
2026-10-21 22:16:09 +01:00
MacRimi 9864fae62a Merge commit '1e0595aac2b2fc3a1510e2d356ef01120ef918b4' into HEAD
# Conflicts:
#	.github/scripts/run_offline_qualified.py
2026-10-21 23:14:30 +02:00
MacRimi 1e0595aac2 Merge commit 'c471bccdab95' into HEAD
# Conflicts:
#	.github/scripts/run_offline_qualified.py
2026-10-21 23:14:28 +02:00
MacRimi c471bccdab Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/scripts/run_offline_qualified.py
#	lang/sk.json
2026-10-21 23:13:45 +02:00
MacRimiandGitHub b75f2789b3 Merge pull request #509 from Vaso73/i18n/slovak-zram-pbs-cli
i18n: complete Slovak ZRAM and PBS CLI messages
2026-10-21 22:09:24 +01:00
MacRimiandGitHub a5371b05dc Merge pull request #508 from Vaso73/i18n/slovak-monitor-backup
i18n: complete Slovak node, PBS and host backup messages
2026-10-21 22:06:50 +01:00
VAIO73 6b41ff4247 i18n: complete Slovak Monitor and CLI messages 2026-10-21 09:53:29 +00:00
VAIO73 d687a79ad8 test: match translated source titles to real optimization metadata 2026-10-21 08:56:56 +00:00
VAIO73 09f9ee18fc i18n: translate optimization names in source viewer headings 2026-10-21 08:56:56 +00:00
VAIO73 06d06041c7 test: verify source response after unmount and retain narrowed source 2026-10-21 08:56:56 +00:00
VAIO73 0d7c4554bc fix: validate and type source viewer response fields 2026-10-21 08:56:56 +00:00
VAIO73 08d2b22c14 fix: keep source viewer backdrop aligned with viewport 2026-10-21 08:56:56 +00:00
VAIO73 92cde1bac3 style: align source viewer close button attributes 2026-10-21 08:56:56 +00:00
VAIO73 ae37003df8 i18n: localize optimization source viewer and protect pending reads 2026-10-21 08:56:56 +00:00
VAIO73 40f967d87e test: cover editor cancellation and preserve command diagnostics 2026-10-21 08:13:04 +00:00
VAIO73 62b8b4e2ae test: load application error translations in panel fixtures 2026-10-21 08:13:04 +00:00
VAIO73 e9b310743a i18n: complete Slovak application editor and validation messages 2026-10-21 08:13:04 +00:00
VAIO73 ab1b6ce7a1 test: install JSON Schema validator for offline OCI regressions 2026-10-21 07:39:23 +00:00
VAIO73 a191096ee4 test: allow English fallback for untranslated CLI catalogs 2026-10-21 07:11:28 +00:00
VAIO73 c00207bd20 i18n: unify Slovak network bridge wording across the app 2026-10-21 07:06:04 +00:00
VAIO73 bb8f5b5c06 i18n: retain bridge term alongside Slovak network labels 2026-10-21 07:06:04 +00:00
VAIO73 153743b80d i18n: clarify OCI network bridge labels in Slovak 2026-10-21 07:06:04 +00:00
VAIO73 6c05688196 i18n: complete OCI runtime and maintenance Slovak diagnostics 2026-10-21 07:06:04 +00:00
VAIO73 37a6c02111 i18n: complete OCI catalogue and wizard Slovak wording 2026-10-21 07:06:04 +00:00
VAIO73 092a62e2dd i18n: preserve consume directory name in OCI storage question 2026-10-21 07:05:14 +00:00
VAIO73 3b12e68e8c i18n: clarify OCI storage and shared paths in Slovak 2026-10-21 07:05:14 +00:00
VAIO73 37ad54232a i18n: clarify coordinated OCI update and recreation in Slovak 2026-10-21 07:05:14 +00:00
VAIO73 d5f4691be7 i18n: clarify registration prerequisite checks in initial dialog 2026-10-21 07:05:14 +00:00
VAIO73 06fbea8735 i18n: clarify restored OCI registration and cancellation 2026-10-21 07:05:14 +00:00
VAIO73 56844d7f04 i18n: clarify Slovak interrupted OCI recovery and cancellation 2026-10-21 07:05:14 +00:00
VAIO73 96b62c59cc i18n: clarify Slovak OCI application removal dialogs 2026-10-21 07:05:14 +00:00
VAIO73 5ccc1451ce i18n: correct Slovak multi-container OCI editor dialogs and errors 2026-10-21 07:04:41 +00:00
VAIO73 90f080744a fix: treat OCI editor cancellation as a neutral outcome 2026-10-21 07:04:41 +00:00
VAIO73 c7a134b00a i18n: correct Slovak text in the single-container OCI editor 2026-10-21 07:04:41 +00:00
VAIO73 441c34514d fix: clear the OCI confirmation screen after cancellation 2026-10-21 07:04:41 +00:00
VAIO73 7213fb2553 fix: clarify OCI update dialog and distinguish cancellation 2026-10-21 07:04:41 +00:00
VAIO73 43041846eb i18n: complete Slovak custom OCI image and registry dialogs 2026-10-21 07:03:31 +00:00
VAIO73 3970bafbe0 i18n: complete Slovak PBS post-install and update messages 2026-10-21 06:29:48 +00:00
VAIO73 a0379fee0b i18n: complete Slovak ZRAM CLI messages 2026-10-21 06:29:48 +00:00
github-actions[bot] 0e01b9f652 chore: update repository growth [skip ci] 2026-10-21 04:33:23 +00:00
ProxMenuxBot 34fcc64550 Update helpers_cache.json 2026-10-20 12:08:17 +00:00
ProxMenuxBot f8ebe93730 Update app tracking hints 2026-10-20 12:01:23 +00:00
VAIO73 aed77ecefe i18n: localize host backup application errors 2026-10-20 08:45:29 +00:00
VAIO73 1dc9e92f74 i18n(sk): clarify removal of old backups 2026-10-20 08:35:33 +00:00
VAIO73 f9e36f0b0a fix(i18n): align PBS fields and omit duplicate English errors 2026-10-20 08:13:34 +00:00
VAIO73 c991b8280a i18n: complete Slovak PBS dialogs, status and errors 2026-10-20 07:38:20 +00:00
VAIO73 9ffd154a2a i18n: refine Slovak linked nodes and localize validation errors 2026-10-20 06:46:15 +00:00
github-actions[bot] 133929fda6 chore: update repository growth [skip ci] 2026-10-20 04:32:29 +00:00
github-actions[bot] 65f47ec646 Update AppImage beta build (2026-10-19 22:57:37) 2026-10-19 22:57:37 +00:00
MacRimi 0a7a598210 fix: keep PBS logs and maintenance jobs readable
- Draw ZFS ARC only from recorded history, leaving it out on PBS
- Read the maintenance jobs of a local PBS from its API, with their last state
- Show the OK state of PBS jobs in green whatever its case
- Compress rotated logs at once in the Log2RAM and logrotate profiles
- Offer the Log2RAM 1.8 and logrotate 1.2 update to existing installations
2026-10-20 00:53:21 +02:00
github-actions[bot] d8741a8c5d Update AppImage beta build (2026-10-19 22:28:20) 2026-10-19 22:28:20 +00:00
MacRimi 5f1aa4cde7 fix(oci): show the update list at the size of the other menus 2026-10-20 00:24:22 +02:00
MacRimi 58e16c686e fix(oci): show progress between selected updates
- Show which selected update is starting while its container is checked
- Keep the single-application flow unchanged
- Translate the new progress text
2026-10-20 00:19:02 +02:00
MacRimiandGitHub 29b4913047 Merge pull request #503 from Vaso73/feature/local-app-icon-discovery
feat(monitor): discover icons from local LXC applications
2026-10-19 23:27:37 +02:00
MacRimi ad56c02b21 chore(monitor): pin Pillow 12.3.0 and translate the icon lookup
- Use the 12.x line of Pillow for the bundled image decoder
- Translate the local icon lookup texts
2026-10-19 23:20:50 +02:00
MacRimi 5d87106e69 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-19 23:16:21 +02:00
MacRimi 77a6d1c397 feat(monitor): sign in to a PBS with user and password and add a Tasks tab
- Connect a PBS with user and password, creating a read-only API token
- Never store the password; remove the token when the role cannot be granted
- Show the PBS connection form as a dialog with the project contrast and blue actions
- Move recent tasks and maintenance schedules of a PBS to a new Tasks tab
- Keep the Datastores tab for datastores, disks and pools
2026-10-19 23:12:31 +02:00
ProxMenuxBot 1384a5e2cc Update helpers_cache.json 2026-10-19 18:07:13 +00:00
ProxMenuxBot 6066442cc6 Update app tracking hints 2026-10-19 18:01:56 +00:00
VAIO73 d7cc914c35 fix(monitor): isolate local icon fetching and image decoding 2026-10-19 17:44:53 +00:00
VAIO73 361f26bf70 Merge develop into local application icon discovery 2026-10-19 17:30:37 +00:00
MacRimi cb585632c6 fix(monitor): handle a PBS on the same host and complete the PBS views
- Open a PBS installed on this host on the address in use, without probing it
- Never carry or offer localhost addresses between Monitors
- Read the PBS of a PVE+PBS host in Backup Server, with its hostname and version
- Offer the PBS storages without a connection, with address and fingerprint
- Show the disks and pools of a PBS in its Datastores tab
- Hide guest and PVE storage settings on a PBS
- Show a clearer status line for a PBS
2026-10-19 18:18:18 +02:00
MacRimiandGitHub 311952cdf5 Merge pull request #504 from jcastro/feature/oci-update-status
feat(oci): show update availability and update selected applications
2026-10-19 17:53:11 +02:00
MacRimi 423156214a Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-19 17:27:45 +02:00
MacRimi 818ab1308a fix(oci): keep update results when one application fails
- Check that the Monitor answers on its socket, not only that it exists
- Report the applications whose check failed instead of failing the whole request
- Show those applications as not checked, without their older result
- Tell the user in the menu when a check fails
2026-10-19 17:23:58 +02:00
MacRimiandGitHub 95bf21b6de Merge pull request #505 from Vaso73/feature/docker-app-discovery
feat(monitor): discover Docker application links on demand
2026-10-19 17:17:49 +02:00
MacRimi e138f4c800 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-19 17:17:17 +02:00
MacRimiandGitHub 1385ab3a7c Merge pull request #507 from Vaso73/fix/oci-managed-volume-startup
fix(oci): handle empty lost+found directories before startup
2026-10-19 17:13:49 +02:00
MacRimi 91fe0b400a Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-19 17:12:28 +02:00
MacRimi 7bf1e1e34d fix(monitor): keep Docker web links and schemes accurate
- Reuse the Find snapshot for web links only after a complete search
- Skip unassigned or ranged host ports instead of dropping the container
- Prefill the https scheme of published HTTPS ports in the editor
- Run the Docker discovery tests when they change
- Translate the Docker search texts
2026-10-19 17:12:28 +02:00
MacRimi ec934c8c2d Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-19 17:07:02 +02:00
MacRimi 3e3e0916f2 fix(oci): clean format directories of a running container
- Open the container root without O_NOFOLLOW, as /proc/<pid>/root is a link
- Keep the /config preparation of JDownloader in its overlay
2026-10-19 17:06:49 +02:00
MacRimiandGitHub 83ad947b2c Merge pull request #506 from Vaso73/fix/oci-catalog-web-links
fix(oci): align JDownloader and Arr suite web links
2026-10-19 16:46:52 +02:00
VAIO73 1511680a95 fix(oci): reject missing managed mount roots 2026-10-19 12:53:34 +00:00
VAIO73 3e9c9eb25d fix(oci): prepare managed format directories before startup 2026-10-19 12:23:49 +00:00
ProxMenuxBot ff6f023238 Update helpers_cache.json 2026-10-19 12:09:15 +00:00
VAIO73 7e00904dce fix(oci): align JDownloader and Arr suite web links 2026-10-19 11:02:50 +00:00
Jonatan Castro 70d4101243 feat(oci): show update availability and update selected applications 2026-10-19 11:23:45 +02:00
VAIO73 e44db4897b fix(monitor): label Docker published ports precisely 2026-10-19 08:27:47 +00:00
VAIO73 8775fd30c5 fix(monitor): preserve Docker web link ordering 2026-10-19 08:16:16 +00:00
VAIO73 cfb90a094c feat(monitor): discover Docker application endpoints on demand 2026-10-19 08:03:25 +00:00
github-actions[bot] 4332fec8a3 chore: update repository growth [skip ci] 2026-10-19 04:34:56 +00:00
VAIO73 440b6be6ef style(monitor): make local icon lookup button compact 2026-10-18 19:42:30 +00:00
VAIO73 bc51adcd8f fix(monitor): keep active discovery independent of cache refresh 2026-10-18 19:24:25 +00:00
VAIO73 3071095109 fix(monitor): preserve app editor across lifecycle refreshes 2026-10-18 19:21:52 +00:00
VAIO73 0a06a81871 fix: preserve validated embedded raster icons in SVG 2026-10-18 18:57:00 +00:00
VAIO73 6c269bf7a5 fix: preserve runtime address order for local icon lookup 2026-10-18 18:50:54 +00:00
VAIO73 e63ec2ad7b feat: discover validated icons from local LXC applications 2026-10-18 18:41:29 +00:00
github-actions[bot] 29778aa709 Update AppImage beta build (2026-10-18 18:18:45) 2026-10-18 18:18:45 +00:00
MacRimiandGitHub b1dc624e7d Merge pull request #501 from Vaso73/feature/native-discovery-stopped-message
Explain why Find applications cannot scan a stopped LXC
2026-10-18 20:13:17 +02:00
MacRimi e52d194b9a Translate the stopped-container search notice 2026-10-18 20:09:24 +02:00
MacRimi f6cff70298 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 20:09:15 +02:00
MacRimi ab8c4a56ea Apply one Spanish style to the Monitor and the terminal menus
- The Spanish interface addresses the reader as tú, uses the infinitive on buttons and menu options, and uses Spain vocabulary (pulsar, introducir, añadir, the Intro key)
- Backup, snapshot, host, pool and slot stay in English, and Proxmox web interface paths keep their English labels
- Mistranslated texts, missing spaces and gender agreements corrected
- Spanish documentation quotes the updated screen labels
- The translation guide describes the Spanish style
- The command description fixture follows the updated Spanish text
2026-10-18 20:06:06 +02:00
VAIO73 b5a2a3f52b fix: explain why native discovery cannot scan stopped containers 2026-10-18 17:48:48 +00:00
MacRimi c6f919b085 Show linked node addresses as text with an edit button
- Each linked node shows its Monitor address as plain text, marked Automatic when it was detected
- A pencil button opens the address field with Save and Cancel only when it needs changing
- The node cards keep the dialog background so the editable fields stand out
- The Add button keeps white text when disabled
- New texts translated into all languages
2026-10-18 19:45:54 +02:00
MacRimi b720b4a8de Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-18 19:02:31 +02:00
MacRimi a2b96bf6a4 Bring the web documentation in line with develop in every language
- English pages describe backups, the manifest, ROCm GPUs, OCI host directories and storage as develop does, and say user instead of operator
- New paragraph on host directories mounted with everything the host has mounted inside them, and a new update rule for host directories
- The Proxmox Backup Server section of the post-install page is translated into every language
- Spanish and the other six languages follow the updated English
2026-10-18 19:02:17 +02:00
github-actions[bot] bdb4544ede Update AppImage beta build (2026-10-18 16:48:22) 2026-10-18 16:48:22 +00:00
MacRimi 62ddd7599b Carry the linked nodes across Monitor jumps and clarify PBS web links
- A jump between Monitors carries the Monitors the source links, and the destination offers them in one dialog with a checkbox each
- The offer is not repeated in the same browser session after choosing Not now, and disappears once every node is known
- A Proxmox Backup Server without a Monitor is shown as its web interface, with its own icon
- The linked nodes form follows the edit-mode contrast rule, with a blue Add button
- New texts translated into all languages
2026-10-18 18:43:26 +02:00
MacRimiandGitHub 7e32000302 Merge pull request #470 from jcastro/i18n/complete-translations
Complete remaining dashboard and menu translations
2026-10-18 18:22:41 +02:00
MacRimi 241d7a18bf Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 18:20:11 +02:00
MacRimiandGitHub 0000086a93 Merge pull request #484 from jcastro/docs/review-monitor
docs: review Monitor documentation in all seven translations
2026-10-18 18:18:57 +02:00
MacRimi 06968696e3 Update test_lynis_threshold_wording.py 2026-10-18 17:58:56 +02:00
MacRimi 3c814b24b4 Keep application categories in English in Spanish 2026-10-18 17:50:11 +02:00
MacRimi 0149327367 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	AppImage/messages/es/common.json
2026-10-18 17:49:06 +02:00
MacRimi 947b5291e9 Remove the notification menu from the VM and LXC backup dialog; backups follow the notification settings 2026-10-18 17:43:41 +02:00
MacRimi cc7e626785 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 17:16:51 +02:00
MacRimi c1fb72ed0e Describe the Monitor pages as the code behaves 2026-10-18 17:16:51 +02:00
MacRimi 456f7e64f6 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	web/messages/es/docs/monitor/access-auth.json
#	web/messages/es/docs/monitor/api-reference.json
#	web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json
#	web/messages/es/docs/monitor/integrations.json
#	web/messages/es/docs/monitor/notifications.json
#	web/messages/sk/docs/monitor/dashboard/vms-lxcs-updates.json
2026-10-18 16:50:21 +02:00
MacRimiandGitHub 700a4de2ee Merge pull request #482 from jcastro/docs/review-hardware
docs: review GPU and Coral guides in all seven translations
2026-10-18 16:49:39 +02:00
MacRimi d06cf0f6f6 List the IOMMU group members before adding a GPU to a VM and keep the NVENC patch state
- Add GPU to VM lists the devices from other slots that share the IOMMU group of the selected GPU before the summary
- The NVIDIA installer keeps the patched flag of the installed driver while its version is unchanged
- New messages translated into all languages
2026-10-18 16:46:41 +02:00
MacRimi 27a2c35eff Describe the IOMMU group list shown by Add GPU to VM 2026-10-18 16:43:42 +02:00
MacRimi 1f0c78608d Describe the GPU and Coral assistants as the scripts run them 2026-10-18 16:34:35 +02:00
MacRimi 023c512df3 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	web/messages/es/docs/hardware/switch-gpu-mode.json
2026-10-18 16:20:10 +02:00
MacRimiandGitHub f648521a73 Merge pull request #491 from jcastro/docs/review-utils
docs: review utility documentation in all seven translations
2026-10-18 16:14:32 +02:00
MacRimi b80a255f93 Update proxmox-upgrade-pve8-to-pve9-manual-guide.sh 2026-10-18 16:09:49 +02:00
MacRimi fae36bc09d Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 16:05:23 +02:00
MacRimi 53191b9e7a Describe the PVE 8 to 9 automatic mode as the script runs it 2026-10-18 16:05:23 +02:00
MacRimi 2d48b87ac1 Keep guests running during the PVE 8 to 9 upgrade and complete the help menu
- The upgrade no longer restarts pve-manager while preparing the host, so running guests are not stopped
- The upgrade stops before touching any repository when the Proxmox VE 8 update fails or is cancelled
- The unattended mode continues into the upgrade instead of exiting after the mode question
- Without a subscription, the enterprise repository added by pve-manager 9 is left disabled after the upgrade
- grub-efi-amd64 is installed with its output visible in interactive mode
- Help menu: qm unlock and pct unlock entries in the VM/CT section, renumbered items and aligned labels
- Help menu: pvesm scan nfs takes the server address, and pveupgrade is described as the interactive upgrade
- Help menu: an empty choice no longer runs the previous command
- translate() finds catalog entries for texts with leading or trailing spaces and keeps the padding
- Spanish help menu texts use the tú form and the Intro key
- New help and upgrade messages translated into all languages
2026-10-18 15:56:35 +02:00
MacRimiandGitHub c3cb726cbf Merge pull request #481 from jcastro/docs/review-guides
docs: review practical guides in all seven translations
2026-10-18 15:22:33 +02:00
MacRimi aedd3085a9 Describe the utilities as the scripts run them
- The OVA/OVF import creates the VM with LSI and e1000
- The utilities picker lists 25 tools; jq and ncdu come from the analysis group
- A finished UUP Dump ISO is chosen with Install with personal ISO
2026-10-18 15:15:38 +02:00
MacRimi 2b3f844eef Fix two steps of the Kodi and Samba guides
- The Kodi LXC configuration is edited on the host without stopping it, since the next step reboots it
- Debian 13 uses wsdd2 for WS-Discovery
2026-10-18 15:12:27 +02:00
MacRimi 0eb8c5db20 Keep Spanish utility docs aligned with the menu 2026-10-18 15:07:57 +02:00
MacRimi f66eea2eb7 Keep Spanish guides aligned with the menu 2026-10-18 15:07:56 +02:00
MacRimi 5ac7d976ab Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 15:07:07 +02:00
MacRimi 7ef834411d Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 15:07:06 +02:00
MacRimiandGitHub e87a1e8f51 Merge pull request #478 from jcastro/docs/review-create-vm
docs: review VM creation documentation in all seven translations
2026-10-18 14:32:51 +02:00
MacRimiandGitHub f6d43e7f5f Merge pull request #489 from jcastro/docs/review-settings
docs: review settings documentation in all seven translations
2026-10-18 14:32:24 +02:00
MacRimi cfe0f31e14 Remove the whole installation directory on uninstall and name the release channel in Spanish
- Uninstall removes the installation directory again after the change journal writes to it
- Show version information counts jq installed from GitHub as installed
- Spanish calls the release channel «canal de versiones»
2026-10-18 14:30:02 +02:00
MacRimi bde0cf8da6 Describe the VM creation options as the scripts offer them
- ZimaOS offers Host or KVM64 and SCSI or SATA
- The disk image import works on an existing VM and several formats
- Synology loaders and the NAS list match the current menu
2026-10-18 14:28:28 +02:00
MacRimi 9e03f59034 Describe the settings behaviour as the code does
- A failed channel switch shows the installer output above the error
- Seven language catalogs plus built-in English
- The version information sample keeps the real utils.sh path and channel label
2026-10-18 14:27:36 +02:00
MacRimi 2868f7b8ac Keep Spanish VM creation docs aligned with the glossary 2026-10-18 14:24:09 +02:00
MacRimi d8efdb440b Keep Spanish settings docs aligned with the screen and the glossary 2026-10-18 14:24:07 +02:00
MacRimi 98998fcbd9 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 14:23:39 +02:00
MacRimi b85e3a0f49 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 14:23:38 +02:00
MacRimiandGitHub 944df3f93a Merge pull request #480 from jcastro/docs/review-getting-started
docs: review installation and reference pages in all seven translations
2026-10-18 14:20:14 +02:00
MacRimiandGitHub b18ed8c3d6 Merge pull request #483 from jcastro/docs/review-help-info
docs: review command references in all seven translations
2026-10-18 14:19:35 +02:00
MacRimi 95b7950ca3 Fix the help menu NFS scan and align the FAQ with the installer
- Option 19 scans an NFS server for its exports with pvesm scan nfs
- pveupgrade is described as the interactive package upgrade it starts
- The FAQ describes the wget install command and the Post-Install backups of .bashrc and MOTD
- Two Spanish error titles match the interface
2026-10-18 14:17:16 +02:00
MacRimi d07b970f8b Describe the installer, uninstaller and AI languages as the code does
- The installer runs through bash -c with wget, not curl | bash
- The .bashrc and MOTD backups are created by Post-Install
- Thirteen languages belong to the AI language selector
2026-10-18 14:14:37 +02:00
MacRimi 9247e6d887 Describe the disk image formats qm can import 2026-10-18 14:13:26 +02:00
MacRimi 20e355c09f Keep Spanish introduction docs aligned with the menu and the glossary 2026-10-18 14:10:50 +02:00
MacRimi c09f27d489 Keep Spanish command reference docs aligned with the menu and the glossary 2026-10-18 14:10:48 +02:00
MacRimi 3be6dfbfc8 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 14:09:51 +02:00
MacRimi f18238aad7 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 14:09:50 +02:00
MacRimiandGitHub 8cd262b295 Merge pull request #487 from jcastro/docs/review-post-install
docs: review post-install documentation in all seven translations
2026-10-18 14:04:52 +02:00
MacRimiandGitHub 4213878884 Merge pull request #477 from jcastro/docs/review-backup-restore
docs: review backup and restore documentation in all seven translations
2026-10-18 14:04:26 +02:00
MacRimiandGitHub 8cdb519ccb Merge pull request #490 from jcastro/docs/review-storage-share
docs: review storage sharing documentation in all seven translations
2026-10-18 14:03:54 +02:00
MacRimiandGitHub 5ee0506212 Merge pull request #479 from jcastro/docs/review-disk-manager
docs: review disk manager documentation in all seven translations
2026-10-18 14:03:15 +02:00
MacRimi e5bf63b31f Remove the current open-file limits on uninstall and align the Log2RAM check
- Uninstalling removes DefaultLimitNOFILE=1048576 and ulimit -n 1048576 as well as the legacy 256000 lines
- The customizable post-install runs the Log2RAM check every 10 minutes at low priority, as the automated one does
2026-10-18 14:02:33 +02:00
MacRimi 7a6a6f93dc Describe the backup and restore behaviour as the scripts run it
- BORG_RSH, the Borg SSH wizard and attached jobs match the code
- Restore path classes name the right examples and when each path is written
2026-10-18 13:58:15 +02:00
MacRimi d3da2da48c Describe the post-install behaviour as the scripts run it
- The automated run can also ask to confirm the system update
- Persistent NIC names, ZFS ARC reconcile and ARC policy match the code
- Uninstall rewrites journald.conf with systemd defaults
2026-10-18 13:57:26 +02:00
MacRimi 85bea617d6 Keep Spanish post-install docs aligned with the menu and the glossary
- Menu paths use the names of the Spanish menu
- backup, snapshot and usuario follow the glossary
2026-10-18 13:52:33 +02:00
MacRimi f73154a2c7 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:51:44 +02:00
MacRimi 2efd775559 Keep the curated Spanish backup and restore docs
- The reviewed Spanish text is kept where the English source is unchanged
- New and changed texts follow the glossary and the Spanish interface
2026-10-18 13:51:26 +02:00
MacRimi 64095689ac Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	web/messages/es/docs/backup-restore/cross-kernel.json
#	web/messages/es/docs/backup-restore/destinations/pbs.json
2026-10-18 13:49:35 +02:00
MacRimi d4c4dce832 Describe the storage sharing behaviour as the scripts run it
- A local disk is registered in Proxmox only when As Proxmox storage is marked
- Test NFS Connectivity checks ping, port 2049 and the export list
- Proxmox keeps CIFS passwords with mode 0600, protected by access control
2026-10-18 13:47:58 +02:00
MacRimi 163c964314 Describe the disk manager behaviour as the scripts run it
- Wipe all trims the whole disk when discard is supported and is not a secure erase
- Declining the privileged conversion changes nothing, and accepting backs up the configuration first
- The by-id path is used when it exists, with /dev/sdX as fallback
- Full report opens in a scrollable dialog; SAS progress comes from the self-test log
2026-10-18 13:47:58 +02:00
MacRimi 746c5a61df Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:43:53 +02:00
MacRimi 85bf3b8584 Keep Spanish storage sharing docs aligned with the screen and the glossary
- Error messages match the Spanish interface
- Destructive actions use eliminar
2026-10-18 13:43:53 +02:00
MacRimi ab2dd5dc02 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:43:52 +02:00
MacRimi 3756a6de9a Keep Spanish disk manager docs aligned with the screen
- Format modes, dialogs and error messages use the names the Spanish menu shows
2026-10-18 13:43:52 +02:00
MacRimi 36a8e84491 Update common.json 2026-10-18 13:37:18 +02:00
MacRimiandGitHub 94a8e44ca5 Merge pull request #476 from jcastro/docs/review-about
docs: review project information in all seven translations
2026-10-18 13:35:01 +02:00
MacRimiandGitHub 3ee8f8cd58 Merge pull request #486 from jcastro/docs/review-oci-manager
docs: review OCI manager documentation in all seven translations
2026-10-18 13:34:03 +02:00
MacRimiandGitHub dd62e5ce7d Merge pull request #488 from jcastro/docs/review-security
docs: review security documentation in all seven translations
2026-10-18 13:31:07 +02:00
MacRimiandGitHub ee919dad24 Merge pull request #485 from jcastro/docs/review-network
docs: review networking documentation in all seven translations
2026-10-18 13:27:10 +02:00
MacRimi d67eb39a65 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:19:12 +02:00
MacRimi b6bce3cae4 Keep Spanish project information aligned with the screen and the glossary
- The uninstaller is reached from Ajustes, as the Spanish menu shows
- Backups are called backup; GitHub Discussions keeps its name
2026-10-18 13:19:12 +02:00
MacRimi fc5884cdc1 Keep Spanish OCI manager docs aligned with the screen and the glossary
- Backups of containers and host storage are called backup
- The watchdog is named vigilancia, as in the menu and the Monitor
- The Monitor tabs keep their names App and Logs
2026-10-18 13:18:20 +02:00
MacRimi 45fdceaaa4 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	web/messages/es/docs/oci-manager/hardware.json
#	web/messages/es/docs/oci-manager/storage-network.json
2026-10-18 13:17:03 +02:00
MacRimi 3e9ac6750b Keep iptraf-ng mode names as the program shows them in every language 2026-10-18 13:16:10 +02:00
MacRimi 68eb53c2d9 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:15:58 +02:00
MacRimi f6d3ad4337 Keep security docs aligned with the screen
- Lynis output stays as the program prints it, in every language
- The Spanish HTTPS steps use the Monitor's button names
2026-10-18 13:15:58 +02:00
MacRimi 2d2789a4b7 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:13:38 +02:00
MacRimi efaeed3706 Keep Spanish networking docs aligned with the screen and the glossary
- Quoted messages match the Spanish interface, and untranslated ones stay in English
- backup and slot stay in English; iptraf-ng modes keep the program's own names
2026-10-18 13:13:38 +02:00
MacRimi f8cabb4700 Check translation contracts offline, add Swedish to the installer and enable the website languages
- Offline checks for terminal and website catalogs reject translations that change commands, paths, identifiers or placeholders
- Terminal and documentation texts that broke those contracts are corrected in every language
- Swedish can be chosen in the stable and beta installers
- The website routes, language switcher and exported pages cover the eight languages, with the shared website texts in each of them
- Website documentation translations are generated only when requested
- From PR #475 by @jcastro
2026-10-18 12:58:25 +02:00
MacRimi f363ef4395 Let dismissed remote mount warnings clear the health status 2026-10-18 12:38:40 +02:00
ProxMenuxBot e8b8f95c5b chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 1cbcdd6
Triggered by: push
2026-10-18 10:15:05 +00:00
MacRimiandGitHub 1cbcdd69aa Merge pull request #492 from Vaso73/feature/native-lxc-discovery
Add on-demand discovery for native LXC applications
2026-10-18 12:12:57 +02:00
MacRimi 8757bbeb4f Count merged-usr units once and accept guests without ss or netstat
- /lib/systemd/system is skipped when it is the same directory as /usr/lib/systemd/system
- A guest without ss or netstat has no listener evidence instead of an incomplete scan
- Spanish messages for native discovery
2026-10-18 12:05:11 +02:00
MacRimi ccdc21504a Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 11:55:20 +02:00
MacRimi 9861a85ccd Name the overlapping mount paths and fix new OCI review translations 2026-10-18 11:49:48 +02:00
ProxMenuxBot 2aa6e0ba44 chore(lang): auto-rebuild translation cache
Source: e3c9162
Triggered by: push
2026-10-18 09:41:55 +00:00
MacRimi e3c9162982 Carry over OCI variables changed in Proxmox and keep the LXC update badge on old checks 2026-10-18 11:40:17 +02:00
MacRimiandGitHub f2bb006728 Merge pull request #497 from jcastro/perf/oci-native-modify
Apply supported OCI settings in place with recreation fallback
2026-10-18 10:46:53 +02:00
MacRimi bef2f837c2 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-18 10:16:34 +02:00
github-actions[bot] d4e98d16b1 chore: update repository growth [skip ci] 2026-10-18 04:31:05 +00:00
MacRimiandGitHub 41ef07ac55 Merge pull request #498 from jcastro/perf/oci-modify-opening
Speed up OCI menus and Escape navigation
2026-10-17 22:16:24 +02:00
MacRimi 32b71bdd3d test(oci): follow the review inside the Modify menu in the latency test 2026-10-17 21:52:03 +02:00
MacRimi 7efb63c478 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-17 21:51:33 +02:00
MacRimi d8ae19c76e open Manage straight to the list and simplify registry rows 2026-10-17 21:49:00 +02:00
MacRimiandGitHub 750421a12f Merge pull request #495 from jcastro/feature/oci-modify-menu
Add a settings menu to OCI Modify
2026-10-17 21:36:09 +02:00
MacRimi 2c8ebcaa9f fix(oci): count only paths added by hand in the Modify menu
- Leave the application's own volumes out of the Extra paths count
- Add a regression test for the count
2026-10-17 21:21:30 +02:00
MacRimi 7d17220294 fix(lang): use directorios compartidos in the Spanish Modify menu 2026-10-17 21:20:56 +02:00
MacRimi a32a7577cc Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-17 21:20:56 +02:00
MacRimiandGitHub 84200fadb4 Merge pull request #493 from jcastro/perf/oci-manage-record-sync
Speed up OCI Manage by caching unchanged Proxmox config reads
2026-10-17 21:14:58 +02:00
MacRimi 7e458bae2e Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-17 21:00:18 +02:00
MacRimi 1bc38884ce run the runtime HOME regressions in the OCI workflow 2026-10-17 20:59:40 +02:00
MacRimiandGitHub 12ab5ae10a Merge pull request #496 from Vaso73/feature/oci-home-resolution-hardening
fix(oci): harden runtime HOME resolution
2026-10-17 20:55:56 +02:00
ProxMenuxBot 152c3ddfe0 chore(lang): auto-rebuild translation cache
Source: e38c085
Triggered by: push
2026-10-17 18:48:23 +00:00
MacRimi e38c085e5f accept non-HTTP answers from guessed service ports 2026-10-17 20:47:24 +02:00
MacRimiandGitHub 4bce49eb46 Merge pull request #494 from jcastro/fix/oci-readonly-volume-preparation
fix(oci): preserve read-only volumes during recreation
2026-10-17 20:27:33 +02:00
ProxMenuxBot ba9ae51615 Update helpers_cache.json 2026-10-17 18:05:27 +00:00
ProxMenuxBot 4dc19ed4d8 Update app tracking hints 2026-10-17 18:01:15 +00:00
MacRimiandGitHub 26cfc50555 Merge pull request #473 from jcastro/fix/oci-external-tags
Fix OCI management after changing Proxmox tags
2026-10-17 19:49:21 +02:00
MacRimi e938a25da3 fix(oci): keep Proxmox tags editable on native stack members
- Accept tag changes in the native stack check before recreate or update
- Keep identity and every other setting checked as before
- Add a regression test for the native stack check
2026-10-17 19:46:16 +02:00
Jonatan ac42bea70f Preserve Modify fallback and detached disks after native edits 2026-10-17 18:57:27 +02:00
MacRimi e2fa8adcae Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-17 18:55:26 +02:00
Jonatan 99adc343e8 Adapt isolated removal preview tests to verified configuration reader 2026-10-17 18:50:42 +02:00
Jonatan 6496318452 Shorten Escape key delay in OCI dialog menus 2026-10-17 18:48:33 +02:00
Jonatan 1e97f732ac Avoid unnecessary probes while opening OCI menus 2026-10-17 18:48:33 +02:00
Jonatan 8a774d05c7 Merge remote-tracking branch 'origin/develop' into perf/oci-modify-opening 2026-10-17 18:48:24 +02:00
MacRimi 592354b0e0 keep the Proxmox Backup Server name in PBS and node selector texts 2026-10-17 18:28:54 +02:00
ProxMenuxBot 6157f34ced chore(lang): auto-rebuild translation cache
Source: 98ae1f3
Triggered by: push
2026-10-17 16:21:01 +00:00
ProxMenuxBot 74c0321956 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 98ae1f3
Triggered by: push
2026-10-17 16:20:37 +00:00
MacRimi 98ae1f3626 feat(pbs): post-install menu for Proxmox Backup Server
- Open the post-install menu from the PBS menu
- List only the customizable options that apply to PBS
- Offer the PBS no-subscription repository through the native repository API
- Upgrade PBS with the package list shown before applying it
- Remove and restore the subscription notice on the PBS web interface
- Install Figurine on PBS without the Proxmox VE aliases
- Offer the Helper-Scripts PBS post-install and microcode scripts
- Tune firewall bridges after the systemd udev rules in the automated post-install
- Add PBS repository policy tests and syntax checks
- Document the PBS differences on the post-install page
- Use backups in the Spanish PBS update message
2026-10-17 18:13:44 +02:00
Jonatan 14513cc898 Speed up OCI Modify entry by deferring backup probes and reusing verified config 2026-10-17 18:05:26 +02:00
Jonatan b70e968f73 Apply OCI runtime settings to existing containers with recoverable edits 2026-10-17 17:11:27 +02:00
Jonatan d286b650d7 Keep native resource edits independent of OCI recreation checks 2026-10-17 17:11:26 +02:00
Jonatan 784ce13c14 Reuse OCI images and apply resource edits without rebuilding 2026-10-17 17:11:26 +02:00
Jonatan 1c42203166 Add selective settings menu for OCI modifications 2026-10-17 16:25:51 +02:00
Jonatan 2390257515 fix(oci): skip preparation of read-only volumes 2026-10-17 15:53:33 +02:00
Jonatan 3e1ae3c4ee Cache unchanged OCI config reads when opening Manage 2026-10-17 15:49:55 +02:00
Jonatan 178540a94f Use the translated integration heading 2026-10-17 15:15:37 +02:00
Jonatan 9f6296501a docs: review utility documentation in all seven translations 2026-10-17 15:08:54 +02:00
Jonatan a35700f54f docs: review settings documentation in all seven translations 2026-10-17 15:08:54 +02:00
Jonatan 068485d339 docs: review storage sharing documentation in all seven translations 2026-10-17 15:08:54 +02:00
Jonatan a9241d7a6c docs: review security documentation in all seven translations 2026-10-17 15:08:54 +02:00
Jonatan 5210fc415a docs: review GPU and Coral guides in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 2b1ae1620d docs: review practical guides in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 5369c3acce docs: review VM creation documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan f07482f201 docs: review installation and reference pages in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 45756cb45f docs: review command references in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 11b1c895f4 docs: review post-install documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan ea6f6dbf6e docs: review backup and restore documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan de9c29a9e9 docs: review disk manager documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan f36b5e22a8 docs: review project information in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan d15876d940 docs: review OCI manager documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 427c3dc2b9 docs: review networking documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan dace78214a docs: review Monitor documentation in all seven translations 2026-10-17 15:08:53 +02:00
VAIO73 7aec721eb5 fix: invalidate native scans across lifecycle changes and serialize updates 2026-10-17 12:23:12 +00:00
VAIO73 22098042ba fix: exclude Alpine service supervision from native discovery 2026-10-17 11:53:30 +00:00
VAIO73 c393a56b92 fix: preserve native discovery results after delayed cache reads 2026-10-17 11:31:51 +00:00
VAIO73 503302cea3 fix: prefill verified Hestia panel endpoint 2026-10-17 11:27:16 +00:00
VAIO73 20acf0852a fix: find Hestia behind bounded process metadata 2026-10-17 11:27:16 +00:00
VAIO73 1f75147d51 fix: recognize Hestia panel and filter watch helpers 2026-10-17 11:27:16 +00:00
VAIO73 e978bac91a fix: detect native node release layouts 2026-10-17 11:27:16 +00:00
VAIO73 61fae8f6f7 fix: filter native discovery helper services 2026-10-17 11:27:16 +00:00
VAIO73 6d82cf4f15 fix: exclude native system service candidates 2026-10-17 11:27:16 +00:00
VAIO73 bbe811e873 fix: hide native discovery editor immediately on target change 2026-10-17 11:27:16 +00:00
VAIO73 e89f2596b2 feat: discover native LXC applications on demand with evidence 2026-10-17 11:27:16 +00:00
VAIO73 b7b412b45d fix(oci): harden runtime HOME resolution 2026-10-17 11:23:38 +00:00
Jonatan 111593bc38 Fix OCI management after changing Proxmox tags 2026-10-17 12:48:02 +02:00
ProxMenuxBot ce1a3de719 chore(lang): auto-rebuild translation cache
Source: 85f0123
Triggered by: push
2026-10-17 08:36:16 +00:00
MacRimiandGitHub 85f0123c5d Merge pull request #467 from jcastro/feature/pbs-support
Support PBS hosts and remote PBS monitoring
2026-10-17 10:35:28 +02:00
MacRimi 08ae134860 Keep guest services off PBS hosts, refuse only known PBS in PVE scripts and follow the menu conventions
- On a PBS host the Monitor no longer starts the node metrics and VM prewarmers, the LXC update scheduler, the managed installs detector or the app update emitter, which read guests and PVE metrics that do not exist there
- pmx_require_pve refuses only a host known to be PBS, so an unreadable package list leaves the PVE scripts working as before
- The PBS menu numbers its options, shows the tables proxmox-backup-manager prints instead of raw JSON, and says Goodbye on exit
- The PBS update asks to press Enter before returning
- Spanish: Datastores de PBS
2026-10-17 10:01:37 +02:00
MacRimi 69b2dc31d0 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-17 09:47:40 +02:00
github-actions[bot] a42d402e3a chore: update repository growth [skip ci] 2026-10-17 04:30:13 +00:00
ProxMenuxBot c83559643d Update helpers_cache.json 2026-10-17 00:14:09 +00:00
ProxMenuxBot 40c0701ab1 Update app tracking hints 2026-10-17 00:02:37 +00:00
MacRimiandGitHub 227780aba7 Merge pull request #464 from jcastro/fix/lvm-disk-read-policy
Apply disk exclusions and idle policy to automatic LVM scans
2026-10-17 00:05:07 +02:00
MacRimi 628dbe3aa3 Name only physical volumes in a restricted LVM scan and keep its own reads from counting as activity
- An explicit --devices list makes LVM open every device in it, while a plain scan opens only known volumes; the list now names only LVM physical volumes, read from udev by lsblk, so no other disk is touched
- RAID, multipath and encrypted volumes follow the disks they are built on, instead of turning the whole check off
- After each pvs, lvs or vgs run, the disks it read take their counters as the new baseline, so an unused disk can become idle
2026-10-17 00:03:39 +02:00
MacRimi 72b596ff4b Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-16 23:58:03 +02:00
Jonatan c32a9880d9 Merge develop and retain guest retention beside PBS navigation 2026-10-16 23:56:46 +02:00
MacRimiandGitHub f13ce25071 Merge pull request #461 from jcastro/feature/guest-backup-management
Allow selecting and deleting guest backup archives in Monitor
2026-10-16 23:51:46 +02:00
MacRimi a230e8b067 Use backups in the Spanish backup deletion messages 2026-10-16 23:49:08 +02:00
MacRimi 63ae1ba68f Confirm deletions that end within the delay and keep the storage colours
- A deletion that Proxmox completes within --delay prints its task log line, Removed volume '<volid>', instead of JSON; that exact line for the requested volume now counts as deleted
- The backup list keeps the green dot and the colour of each storage
- The deletion request waits up to 300 s, the worst case of 20 archives checked and deleted four at a time
2026-10-16 23:48:49 +02:00
MacRimi 7e97bcb25b Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-16 23:44:39 +02:00
MacRimi 29260a0e41 mend the translations of the storage retention default option 2026-10-16 23:41:01 +02:00
ProxMenuxBot 8d9c0083cd chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ac391cd
Triggered by: push
2026-10-16 21:37:39 +00:00
MacRimiandGitHub ac391cdb35 Merge pull request #462 from jcastro/feature/backup-storage-retention
Expose native storage retention for VM and CT backups
2026-10-16 23:36:09 +02:00
MacRimi f336eb65f0 Let a storage return to the Proxmox default retention and match the job editor labels
- An empty policy removes the storage setting with --delete prune-backups, and a storage without a policy opens with that choice
- Retention fields use the Keep last, Keep daily... labels of the backup job editor
- A retention count of zero is rejected before calling Proxmox
- Spanish uses backups for the retention messages
2026-10-16 23:34:21 +02:00
MacRimi f7220826a6 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
2026-10-16 23:32:05 +02:00
MacRimiandGitHub 1a20638097 Merge pull request #463 from jcastro/feature/apprise-service-guidance
Clarify Slack and Matrix setup and preserve Apprise recovery severity
2026-10-16 23:26:44 +02:00
MacRimi 9a94045c33 Show the external link icon on the Apprise service help links 2026-10-16 23:25:01 +02:00
MacRimi 3c962fef11 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
2026-10-16 23:25:01 +02:00
MacRimi b75d373f31 remove invisible characters from translations 2026-10-16 23:22:50 +02:00
ProxMenuxBot 5661891d83 chore(lang): auto-rebuild translation cache
Source: c9caea0
Triggered by: push
2026-10-16 21:18:59 +00:00
MacRimiandGitHub c9caea0b7a Merge pull request #465 from jcastro/fix/figurine-release-architecture
fix: update Figurine to 2.1.1 and select matching Linux binaries
2026-10-16 23:18:05 +02:00
MacRimiandGitHub 40fdaf0145 Merge pull request #469 from jcastro/fix/i18n-regression-fixture
Fix recurring i18n fixture failures when adding new messages
2026-10-16 23:08:49 +02:00
Jonatan 9ba5a797c4 Merge develop while preserving reviewed translations 2026-10-16 23:06:47 +02:00
MacRimi 4ca430db3a Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
2026-10-16 23:06:07 +02:00
MacRimi 9558bd3609 mend the translations of the stopped scheduled update messages 2026-10-16 23:04:02 +02:00
Jonatan 3a90f218ac Merge develop and preserve generic i18n regression coverage 2026-10-16 23:03:54 +02:00
Jonatan 5197d84328 Merge develop and preserve generic i18n regression coverage 2026-10-16 23:03:50 +02:00
Jonatan 6a9a025199 Complete remaining dashboard and menu translations 2026-10-16 23:02:18 +02:00
Jonatan aba6da22db Complete translations for all supported languages 2026-10-16 23:00:48 +02:00
Jonatan 766cfa0779 Complete translations for all supported languages 2026-10-16 23:00:43 +02:00
Jonatan 8772b286da Complete translations for all supported languages 2026-10-16 23:00:38 +02:00
Jonatan b035373efb Complete translations for all supported languages 2026-10-16 23:00:33 +02:00
Jonatan 8bbb475614 Complete translations for all supported languages 2026-10-16 23:00:22 +02:00
ProxMenuxBot 2898ecbfdb chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 5063c7a
Triggered by: push
2026-10-16 20:57:33 +00:00
MacRimiandGitHub 5063c7a006 Merge pull request #456 from Vaso73/codex/lxc-scheduled-stopped-guard
fix: skip scheduled updates for stopped LXC
2026-10-16 22:56:15 +02:00
MacRimi def0987711 space after semicolons in the registry account messages 2026-10-16 22:42:52 +02:00
ProxMenuxBot b938e14fc5 chore(lang): auto-rebuild translation cache
Source: 248ac5d
Triggered by: push
2026-10-16 20:36:20 +00:00
MacRimi 248ac5dec6 keep registry accounts for the whole cluster and use them everywhere
- Registry accounts are kept in /etc/pve/priv/proxmenux/registry-auth.json, shared by every node, unless REGISTRY_AUTH_FILE says otherwise
- The custom image importer offers to sign in when a registry refuses an image, and reads it again
- Installers, updates, the watchdog and the Monitor update check read private images with the saved accounts
- The host backup includes the accounts, and uninstalling ProxMenux removes them and keeps the OCI records of the cluster
- Spanish written by hand and spacing fixed for the registry account messages in every locale
- Docs: private registries section, Registry accounts in the OCI menu, README storage notes
2026-10-16 22:35:07 +02:00
Jonatan 52632abbd6 Make i18n steady-state tests handle pending translations 2026-10-16 22:16:55 +02:00
Jonatan ada0f1109f Make i18n steady-state tests handle pending translations 2026-10-16 22:16:45 +02:00
Jonatan 87720f14f1 Make i18n steady-state tests handle pending translations 2026-10-16 22:15:49 +02:00
ProxMenuxBot e4c517f258 chore(lang): auto-rebuild translation cache
Source: 31254b1
Triggered by: push
2026-10-16 19:53:59 +00:00
Jonatan 816efa533e Scope menu search coverage to the PVE platform 2026-10-16 21:51:39 +02:00
Jonatan 511bfe52ea feat: support PBS hosts and remote PBS monitoring 2026-10-16 21:46:26 +02:00
MacRimiandGitHub 31254b1c9c Merge pull request #460 from jcastro/feature/oci-private-registries
Add private registry accounts to OCI manager Apps
2026-10-16 21:28:00 +02:00
MacRimi 8840490da0 mend the bot translations of the Synology loader and paste messages 2026-10-16 21:23:31 +02:00
MacRimi af1a648689 feat(vm): add FygoOS to the NAS systems of Create VM
- NAS menu: FygoOS, with the latest installer published on fygonas.com detected and a fallback version
- Menu search finds the NAS menu by the name of each system
- Docs: FygoOS card and per-system notes, including its web port
2026-10-16 21:19:38 +02:00
Jonatan c2ef5a54a6 fix: update Figurine and select architecture-specific binaries 2026-10-16 20:57:14 +02:00
Jonatan 4ffc883c32 Apply disk exclusions and idle policy to automatic LVM scans 2026-10-16 20:45:17 +02:00
Jonatan ee6b3bb232 Clarify Slack and Matrix setup and preserve Apprise recovery severity 2026-10-16 20:39:43 +02:00
Jonatan 113659c0e3 Expose native storage retention for VM and CT backups 2026-10-16 20:32:05 +02:00
VAIO73 9e6978cf10 fix: guard OCI scheduled updates after stop 2026-10-16 18:27:33 +00:00
Jonatan c9cdac58aa Account for pending guest backup translations in i18n fixture 2026-10-16 20:27:27 +02:00
Jonatan d487f33700 Allow selecting and deleting guest backup archives in Monitor 2026-10-16 20:23:09 +02:00
Jonatan 6fc9f9003b Update OCI wording tests for private registry support 2026-10-16 20:03:58 +02:00
Jonatan 7d2d430320 Add private registry accounts to OCI manager Apps 2026-10-16 19:58:40 +02:00
VAIO73 690ace1e57 i18n: shorten stopped scheduled-update reason 2026-10-16 17:21:34 +00:00
VAIO73 d25747646e fix: distinguish current and historical stopped schedule state 2026-10-16 17:21:34 +00:00
VAIO73 413ac5e80e i18n: describe skipped update as historical state 2026-10-16 17:21:34 +00:00
VAIO73 9bc83a13ef fix: address scheduled stopped LXC review feedback 2026-10-16 17:21:34 +00:00
VAIO73 fdbd420961 test: cover scheduled stopped reason in i18n fixture 2026-10-16 17:21:34 +00:00
VAIO73 59ab7784a7 i18n: localize stopped scheduled update reason 2026-10-16 17:21:34 +00:00
VAIO73 c2ad35ca73 fix: skip scheduled updates for stopped LXC 2026-10-16 17:21:34 +00:00
ProxMenuxBot 3000c1cd66 chore(lang): auto-rebuild translation cache
Source: 1b0d6a0
Triggered by: push
2026-10-16 16:38:57 +00:00
MacRimiandGitHub 1b0d6a086e Merge pull request #459 from f3rs3n/fix/synology-loader-downloads
fix(vm): repair Synology loader downloads and extraction
2026-10-16 18:34:14 +02:00
martino db0e609259 fix(vm): repair Synology loader downloads and extraction 2026-10-16 18:28:29 +02:00
ProxMenuxBot d0f8df39db chore(lang): auto-rebuild translation cache
Source: 653dee1
Triggered by: push
2026-10-16 16:28:11 +00:00
MacRimi 653dee192c fix(oci): end a pasted definition with a single Ctrl+D 2026-10-16 18:26:27 +02:00
MacRimi ea3b579089 docs(post-install): document the ZRAM swap profile and fix its translations
- Optional Settings: new step for the ZRAM swap profile, with what it does, how it is used and how to verify it
- Uninstall Optimizations: add the ZRAM swap profile to the kernel and memory group
- Customizable: list the ZRAM swap profile in the Optional category
- lang: rewrite the Spanish ZRAM messages and add the missing space after semicolons and the initial capital in the other locales
2026-10-16 17:50:13 +02:00
ProxMenuxBot 4c8b5b1188 chore(lang): auto-rebuild translation cache
Source: 728baf3
Triggered by: push
2026-10-16 15:34:11 +00:00
MacRimiandGitHub 728baf3714 Merge pull request #450 from phyrexia/feature/zram-memory-profile
feat: add optional ZRAM swap profile and live metrics
2026-10-16 17:28:11 +02:00
MacRimi 4e572b8c21 Merge branch 'develop' into feature/zram-memory-profile 2026-10-16 17:27:04 +02:00
MacRimiandGitHub ebce0b8511 Merge pull request #458 from f3rs3n/fix/vm-iso-downloads
fix(vm): reject failed ISO downloads and repair image sources
2026-10-16 17:14:53 +02:00
MacRimiandGitHub 2cbeacefb2 Merge pull request #457 from f3rs3n/fix/cloud-init-helper-launcher
fix(vm): repair Cloud-Init helper links and download checks
2026-10-16 17:08:52 +02:00
martino b55eca753d test(vm): keep ISO storage isolation in fixtures 2026-10-16 16:28:23 +02:00
Gabriel Gonzalez Pineda 3ad7407e07 fix(post-install): prioritize ZRAM ahead of disk swap 2026-10-16 15:12:14 +01:00
martino f07dbfe10f fix(vm): reject failed ISO downloads and repair image sources 2026-10-16 15:42:58 +02:00
martino 84eb6d2167 fix(vm): repair Cloud-Init helper links and download checks 2026-10-16 14:36:24 +02:00
ProxMenuxBot 36b7bf1bf9 Update helpers_cache.json 2026-10-16 12:08:07 +00:00
github-actions[bot] 7f21cae196 Update AppImage beta build (2026-10-16 06:26:35) 2026-10-16 06:26:35 +00:00
MacRimi 8220edf3b6 keep the current German where the English changed after the review 2026-10-16 07:31:50 +02:00
MacRimiandGitHub ab730977ab Merge pull request #455 from detlefs/develop
Improve german localization of Monitor web UI
2026-10-16 07:27:22 +02:00
Detlef SchneiderandGitHub 2500fb0cfc Merge branch 'develop' into develop 2026-10-16 07:02:38 +02:00
Detlef Schneider d6f814ba52 Fix a lot of uncommon or wrong translations, improve consistency. 2026-10-16 06:52:55 +02:00
github-actions[bot] 307c7cbb83 chore: update repository growth [skip ci] 2026-10-16 04:31:55 +00:00
MacRimi abf05493b9 number the options of the OCI menus instead of showing internal values
- Dialog menus show their options numbered, as the rest of ProxMenux does, and return the value of the chosen one, so each description has the whole width in every language.
- A storage, a bridge, a device node and the main OCI menu keep their tags, and number tags and separators are left as they are.
- Test for the numbering, the default option, the kept tags and a cancelled menu.
2026-10-15 22:55:21 +02:00
github-actions[bot] c7480fc4e7 Update AppImage beta build (2026-10-15 20:43:55) 2026-10-15 20:43:55 +00:00
MacRimi 6890d770bf correct memory and storage in German and keep Technology Preview untranslated 2026-10-15 22:36:17 +02:00
ProxMenuxBot 1e9d97f1aa chore(lang): auto-rebuild translation cache
Source: f5d2074
Triggered by: push
2026-10-15 20:30:38 +00:00
MacRimi f5d2074a9a label OCI manager Apps as a Technology Preview, as Proxmox VE does
- OCI manager Apps carries the Technology Preview label that Proxmox VE gives to application containers from OCI images, in the main menu, the engine windows, the menu search, the message at the end of each installation, the release notes and the documentation.
- The documentation explains that the label refers to the Proxmox VE support it runs on, not to the ProxMenux engine, and stays until Proxmox VE marks it as stable.
- Spanish for the new message; the other languages are left to the translation bot.
2026-10-15 22:28:57 +02:00
ProxMenuxBot bec077dd9e chore(lang): auto-rebuild translation cache
Source: 0035065
Triggered by: push
2026-10-15 18:08:20 +00:00
ProxMenuxBot cf2c0637a7 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 0035065
Triggered by: push
2026-10-15 18:07:24 +00:00
MacRimi 00350655bf add NUT Server to the catalog
- NUT Server (instantlinux/nut-upsd) runs the Network UPS Tools server for a UPS connected to the host by USB: the installation proposes the UPS the host detects, passes it to the container, generates the password of the upsmon user and starts with the host; clients such as PeaNUT connect on port 3493.
- A recipe can ask for the UPS of the host: without one, the installation stops and says so, and the advanced installation lets the node be typed by hand.
- The catalog lists 335 applications.
- Tests for the proposed UPS, a host without one and the recipe.
2026-10-15 20:06:06 +02:00
MacRimi abbb3afc0c return to the installed applications list after each operation 2026-10-15 19:05:19 +02:00
github-actions[bot] 1ca028481d Update AppImage beta build (2026-10-15 16:40:32) 2026-10-15 16:40:32 +00:00
MacRimi 1056cc50c2 capitalise and align the notification history and Ctrl+D translations 2026-10-15 18:35:41 +02:00
ProxMenuxBot 6677743a82 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 89fdfd0
Triggered by: push
2026-10-15 16:33:26 +00:00
MacRimi 89fdfd0952 fix: notification history, Ctrl+D in web terminals, repository and VFIO rule fixes, translation corrections
- Notification settings show the last 20 notifications with the result of each send, the error when one failed and every daily digest run, also on days with nothing to send; the history is read without the flag the API never returned, and the page does not offer to erase it, as Audit & Report reads it.
- The Ctrl menu of the three web terminals sends Ctrl+D, which ends a text pasted in the terminal, also from a phone or a tablet.
- The repository check lists every Proxmox VE or Enterprise Ceph repository whose suite does not match the installed version, with its channel and file, and says where to disable it; repository addresses are never shown.
- Adding an NVIDIA GPU to a VM writes its VFIO udev rule again when it is missing or was edited, and the pre-check counts a missing rule as a pending host change.
- New texts in English and Spanish, modelled in the translation steady-state test, and the notifications docs page describes the history section.
- Corrected the automatic translations of the new LXC update and power control texts, and the start, shutdown, reboot and stop buttons of VMs and LXCs in German, French, Portuguese and Swedish.
- Tests for the history panel, the terminal Ctrl+D, the repository message and the VFIO rule.
2026-10-15 18:30:53 +02:00
ProxMenuxBot 983ed38e3b chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 54d9283
Triggered by: push
2026-10-15 13:51:39 +00:00
MacRimiandGitHub 54d9283eba Merge pull request #449 from Vaso73/fix/vm-control-task-completion
fix: wait for VM power tasks before completing controls
2026-10-15 15:50:01 +02:00
ProxMenuxBot 346c9988a9 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: cd81752
Triggered by: push
2026-10-15 13:47:49 +00:00
MacRimiandGitHub cd817524ff Merge pull request #448 from Vaso73/fix/lxc-package-check-install
fix: separate LXC package checks from installation
2026-10-15 15:44:35 +02:00
VAIO73 8b0ca0d06b fix: accept HA shutdown tasks 2026-10-15 04:33:38 +00:00
github-actions[bot] 84faf0bce4 chore: update repository growth [skip ci] 2026-10-15 04:31:42 +00:00
VAIO73 3254ca6d05 fix: confirm VM control tasks safely 2026-10-15 04:29:26 +00:00
VAIO73 3e806afe8c fix: wait for VM power tasks before completing controls 2026-10-15 04:29:26 +00:00
VAIO73 017b73fffd fix: clear obsolete LXC check failure markers 2026-10-15 04:11:10 +00:00
VAIO73 18236e06bc fix: require running LXC for package checks 2026-10-15 03:49:58 +00:00
VAIO73 664ef981e5 fix: separate LXC package checks from installation 2026-10-15 03:49:58 +00:00
Gabriel Gonzalez Pineda 51ed84f9b1 feat(post-install): add optional zram profile and metrics 2026-10-15 00:28:52 +01:00
MacRimiandGitHub f3add354e6 Merge pull request #447 from Vaso73/fix/lxc-stopped-update-results
fix: preserve update observations for stopped LXC guests
2026-10-14 22:43:37 +02:00
VAIO73 638552e5f8 fix: preserve verified LXC state before a failed start 2026-10-14 17:51:53 +00:00
VAIO73 3489dc0530 fix: preserve update observations for stopped LXC guests 2026-10-14 17:51:53 +00:00
MacRimi ceb783bcf2 follow the node filter in the empty backup list test 2026-10-14 18:12:24 +02:00
ProxMenuxBot 51ce1184da chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: c9479c6
Triggered by: push
2026-10-14 16:07:00 +00:00
MacRimi c9479c6ac1 set apart host backups of other nodes and of former jobs
- Each host backup carries the node it belongs to, read from its source host or its name (`hostcfg-<node>`, a job of this node or its PBS backup ID); both backup lists also return the node name.
- The backup list offers This node / All nodes when some backup belongs to another node, with This node as the default, a count that follows the choice and a note with the backups not shown.
- Backups whose node cannot be read from their name are listed under All nodes.
- A local backup made on this node by a job that no longer exists is marked as Former job, read from its metadata file; backups without it, manual ones and those of other nodes are not marked.
- New texts in English and Spanish, modelled in the translation steady-state test.
- Tests for the node and the former job read from each backup, and for the cluster node membership notifications.
2026-10-14 18:04:42 +02:00
MacRimiandGitHub f2c5ab1636 Merge pull request #444 from fklnc94/fix/cluster-node-notifications
fix(monitor): notify when a cluster node leaves or rejoins
2026-10-14 07:34:31 +02:00
github-actions[bot] 1a8efb96a5 chore: update repository growth [skip ci] 2026-10-14 04:34:12 +00:00
fklnc94 7e04500e0c fix(monitor): notify when a cluster node leaves or rejoins 2026-10-14 01:45:49 +03:00
github-actions[bot] e0e2f10471 Update AppImage beta build (2026-10-13 20:27:57) 2026-10-13 20:27:57 +00:00
MacRimiandGitHub 2167934289 Merge pull request #443 from f3rs3n/fix/health-resolved-recurrence
fix(monitor): reactivate resolved health conditions
2026-10-13 22:17:11 +02:00
martino 3023ac33ec test(monitor): support discovery without TMPDIR 2026-10-13 21:58:09 +02:00
martino 03c8dc9eb6 fix(monitor): reactivate resolved health conditions 2026-10-13 20:08:22 +02:00
ProxMenuxBot 39bb4f8616 Update helpers_cache.json 2026-10-13 18:08:00 +00:00
github-actions[bot] f3d97724c5 Update AppImage beta build (2026-10-13 18:06:56) 2026-10-13 18:06:56 +00:00
MacRimi 00b00b9db7 review the bot texts of the LXC update check in the Monitor 2026-10-13 20:03:21 +02:00
ProxMenuxBot 7a9878e458 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 6efe322
Triggered by: push
2026-10-13 17:55:50 +00:00
MacRimiandGitHub 6efe322c44 Merge pull request #441 from Vaso73/fix/lxc-observed-state
fix: revalidate LXC data after external changes
2026-10-13 19:54:08 +02:00
VAIO73 63f1e631b9 fix: preserve LXC OS hints and allow daily check margin 2026-10-13 17:11:43 +00:00
VAIO73 e83e66caaf fix: revalidate externally changed LXC state 2026-10-13 17:08:42 +00:00
MacRimi ea5831d1bf fix(monitor): an application installed from the OCI menu shows on the Apps page on its own 2026-10-13 16:56:57 +02:00
ProxMenuxBot 3708e9cf79 Update helpers_cache.json 2026-10-13 12:09:54 +00:00
ProxMenuxBot 61041bcbfd Update app tracking hints 2026-10-13 06:03:23 +00:00
github-actions[bot] 118f7c1cf6 chore: update repository growth [skip ci] 2026-10-13 04:41:04 +00:00
ProxMenuxBot 3414a77aa4 Update helpers_cache.json 2026-10-13 00:17:45 +00:00
MacRimi d7f2f13cd9 review the bot texts of the paste option 2026-10-12 23:08:01 +02:00
ProxMenuxBot 631b1cf253 chore(lang): auto-rebuild translation cache
Source: c8e5421
Triggered by: push
2026-10-12 21:05:11 +00:00
MacRimi c8e5421d75 paste an oci-compose in the terminal
- "Install an image that is not in the catalog" also takes an oci-compose pasted in the terminal, next to reading it from a file or downloading it from an address.
- oci/README.md and the documentation page of the option describe the three ways, and the README links to the thread for suggesting applications (#440).
- oci/VALIDATION.md points to discussion #440 instead of the closed #360.
- The oci-compose tests pass on a host set to another language and without python3-jsonschema.
2026-10-12 23:03:49 +02:00
MacRimi aba805d4d9 review the bot texts of the oci-compose option and fix the wording test 2026-10-12 22:38:44 +02:00
ProxMenuxBot 21ae8e94a4 chore(lang): auto-rebuild translation cache
Source: cc12c62
Triggered by: push
2026-10-12 20:32:18 +00:00
MacRimi cc12c625a6 install from a hand-written oci-compose; Log2RAM journal fix
OCI manager Apps
- "Install an image that is not in the catalog" reads an oci-compose from a file of the host or from an address.
- A file written by hand states the image, its ports, variables and volumes, and what the installation asks; the rest is completed when it is read. A complete file, or the record of an installation, is installed as it is, and of a record only the template is used.
- Errors name the field they are in, also on a host without python3-jsonschema.
- oci/README.md describes the engine and the oci-compose block by block, with two examples in oci/examples/.
- The documentation page of the option covers it, in English and Spanish.

Log2RAM (reported by @f3rs3n)
- The sync removes the old active journal file from the disk copy, so a boot no longer restores it and journald deletes its oldest archives first. Log2RAM optimization 1.7; it takes effect from the next reboot.

Translations
- The bot keeps "oci-compose" as written.
- Spanish texts for the new option and for the Log2RAM notice.
2026-10-12 22:29:08 +02:00
ProxMenuxBot 87403a2e48 Update helpers_cache.json 2026-10-12 18:06:40 +00:00
github-actions[bot] 9089f79342 Update AppImage beta build (2026-10-12 16:09:56) 2026-10-12 16:09:56 +00:00
MacRimi 3198209493 fix(monitor): the Changes view lists the customizable post-install under its options 2026-10-12 18:06:18 +02:00
ProxMenuxBot c496f111b6 Update helpers_cache.json 2026-10-12 12:12:39 +00:00
ProxMenuxBot fbafb92f49 Update app tracking hints 2026-10-12 12:03:18 +00:00
MacRimi 03f2a195a6 Update es.json 2026-10-12 13:06:22 +02:00
MacRimi f17f87c94d fix(oci): FileBrowser Quantum opens the folder it is given 2026-10-12 13:00:55 +02:00
github-actions[bot] 4e4b19213b Update AppImage beta build (2026-10-12 10:32:27) 2026-10-12 10:32:27 +00:00
MacRimi e587e27144 fix(monitor): the storage check reads the node name without waiting for the Proxmox API 2026-10-12 12:29:35 +02:00
MacRimi bff791581c fix(gpu): changing the mode of a GPU leaves the other devices of its slot in their VM 2026-10-12 12:12:59 +02:00
github-actions[bot] de7fc4bd49 Update AppImage beta build (2026-10-12 10:05:03) 2026-10-12 10:05:03 +00:00
MacRimi 80af670794 fix(monitor): a permanent dismissal stays permanent, and every host backup run is notified 2026-10-12 12:02:18 +02:00
MacRimi 1ac1f58afa fix(monitor): read the node name without waiting for the Proxmox API 2026-10-12 10:59:26 +02:00
github-actions[bot] 370b45a2d8 chore: update repository growth [skip ci] 2026-10-12 04:45:52 +00:00
MacRimi 5f3729568e fix(post-install): updating optimizations of both flows prints only its steps 2026-10-12 01:15:39 +02:00
MacRimi c3c29c8692 New version 1.2.6.2-beta 2026-10-12 01:05:34 +02:00
github-actions[bot] 9ebc959530 Update AppImage beta build (2026-10-11 23:03:06) 2026-10-11 23:03:06 +00:00
ProxMenuxBot 35d61beaa1 Update helpers_cache.json 2026-10-11 18:07:14 +00:00
ProxMenuxBot 6a7565c858 Update app tracking hints 2026-10-11 18:01:29 +00:00
ProxMenuxBot 7b2d345e83 Update helpers_cache.json 2026-10-11 12:08:28 +00:00
github-actions[bot] b026332032 chore: update repository growth [skip ci] 2026-10-11 04:36:21 +00:00
ProxMenuxBot 7215c551d4 Update helpers_cache.json 2026-10-11 00:18:30 +00:00
ProxMenuxBot bb817ad1f9 Update app tracking hints 2026-10-11 00:02:43 +00:00
ProxMenuxBot dad116c8b5 Update app tracking hints 2026-10-10 18:01:28 +00:00
ProxMenuxBot 34c549b738 Update helpers_cache.json 2026-10-10 12:07:59 +00:00
ProxMenuxBot 2d022a873d Update app tracking hints 2026-10-10 12:01:27 +00:00
github-actions[bot] ee3d8db577 chore: update repository growth [skip ci] 2026-10-10 04:35:18 +00:00
ProxMenuxBot 8169aeadce Update helpers_cache.json 2026-10-10 00:16:48 +00:00
ProxMenuxBot 9ac9556940 Update helpers_cache.json 2026-10-09 12:08:28 +00:00
ProxMenuxBot 8902a2574f Update app tracking hints 2026-10-09 12:01:33 +00:00
github-actions[bot] ded2af3a57 chore: update repository growth [skip ci] 2026-10-09 04:37:58 +00:00
ProxMenuxBot 4ea1bf4a80 Update helpers_cache.json 2026-10-08 12:08:50 +00:00
github-actions[bot] 1bf8dd00c9 chore: update repository growth [skip ci] 2026-10-08 04:36:45 +00:00
ProxMenuxBot 7305713c80 Update helpers_cache.json 2026-10-08 00:15:36 +00:00
ProxMenuxBot a02057fd52 Update helpers_cache.json 2026-10-07 18:07:31 +00:00
ProxMenuxBot 65d55b0f58 Update helpers_cache.json 2026-10-07 12:09:29 +00:00
github-actions[bot] 0d2d639795 chore: update repository growth [skip ci] 2026-10-07 11:07:18 +00:00
ProxMenuxBot bc913ff5eb Update helpers_cache.json 2026-10-07 06:09:12 +00:00
ProxMenuxBot 113c31ab09 Update helpers_cache.json 2026-10-06 22:04:22 +00:00
ProxMenuxBot 8094d69fb4 Update helpers_cache.json 2026-10-06 12:31:03 +00:00
ProxMenuxBot f3a3332327 Update app tracking hints 2026-10-06 12:17:02 +00:00
github-actions[bot] 5734947e17 chore: update repository growth [skip ci] 2026-10-06 11:19:31 +00:00
ProxMenuxBot ae22df1f9d Update helpers_cache.json 2026-10-05 13:11:02 +00:00
github-actions[bot] e82216eb5b chore: update repository growth [skip ci] 2026-10-05 11:34:22 +00:00
ProxMenuxBot 346d9b15f6 Update helpers_cache.json 2026-10-04 11:35:51 +00:00
github-actions[bot] b9e5b03d6b chore: update repository growth [skip ci] 2026-10-04 10:36:59 +00:00
ProxMenuxBot 3fc99a35b6 Update helpers_cache.json 2026-10-04 03:37:30 +00:00
ProxMenuxBot 071d6081f8 Update helpers_cache.json 2026-10-03 20:25:04 +00:00
ProxMenuxBot af10486436 Update helpers_cache.json 2026-10-03 15:30:08 +00:00
ProxMenuxBot db1ab725d6 Update app tracking hints 2026-10-03 15:17:14 +00:00
github-actions[bot] 85b2d2114a chore: update repository growth [skip ci] 2026-10-03 09:54:30 +00:00
ProxMenuxBot 0cd2efdd4e Update app tracking hints 2026-10-02 11:26:18 +00:00
github-actions[bot] f40910125c chore: update repository growth [skip ci] 2026-10-02 10:32:51 +00:00
ProxMenuxBot 98da85bf00 Update helpers_cache.json 2026-10-01 22:11:10 +00:00
github-actions[bot] ddb0b5a400 chore: update repository growth [skip ci] 2026-10-01 10:59:42 +00:00
ProxMenuxBot f641bab0e0 Update helpers_cache.json 2026-09-30 21:43:02 +00:00
ProxMenuxBot cbd4aff7a4 Update app tracking hints 2026-09-30 21:34:08 +00:00
github-actions[bot] 8efccc3d03 chore: update repository growth [skip ci] 2026-09-30 10:31:59 +00:00
ProxMenuxBot 5b789d2433 Update helpers_cache.json 2026-09-29 17:22:53 +00:00
ProxMenuxBot bfe7794d98 Update helpers_cache.json 2026-09-29 11:53:13 +00:00
ProxMenuxBot ff51c2239e Update app tracking hints 2026-09-29 11:39:08 +00:00
github-actions[bot] f7239889ca chore: update repository growth [skip ci] 2026-09-29 10:44:01 +00:00
github-actions[bot] 7a3f713670 chore: update repository growth [skip ci] 2026-09-28 10:58:51 +00:00
ProxMenuxBot fd8dd11643 Update helpers_cache.json 2026-09-27 20:39:23 +00:00
ProxMenuxBot dd18ba6cd6 Update helpers_cache.json 2026-09-27 11:07:02 +00:00
github-actions[bot] 8cf19ad79b chore: update repository growth [skip ci] 2026-09-27 10:02:30 +00:00
github-actions[bot] 7899ffb43d chore: update repository growth [skip ci] 2026-09-26 09:23:21 +00:00
ProxMenuxBot 3cff8caa84 Update helpers_cache.json 2026-09-25 20:56:07 +00:00
ProxMenuxBot b23eaf1713 Update app tracking hints 2026-09-25 20:47:49 +00:00
ProxMenuxBot b2294a6c59 Update helpers_cache.json 2026-09-25 10:54:10 +00:00
github-actions[bot] 3df04b8428 chore: update repository growth [skip ci] 2026-09-25 09:38:45 +00:00
github-actions[bot] 8a8deed134 chore: update repository growth [skip ci] 2026-09-24 09:20:46 +00:00
ProxMenuxBot cb3ff72699 Update helpers_cache.json 2026-09-23 15:51:52 +00:00
ProxMenuxBot 7c64f7427a Update helpers_cache.json 2026-09-23 10:32:45 +00:00
github-actions[bot] f1c15a1cbe chore: update repository growth [skip ci] 2026-09-23 09:20:53 +00:00
ProxMenuxBot a26876cff9 Update helpers_cache.json 2026-09-22 16:01:37 +00:00
github-actions[bot] 0fd051a498 chore: update repository growth [skip ci] 2026-09-22 09:20:22 +00:00
ProxMenuxBot 4eccbd9269 Update helpers_cache.json 2026-09-21 17:33:23 +00:00
ProxMenuxBot c5752fe582 Update app tracking hints 2026-09-21 17:15:15 +00:00
github-actions[bot] c8f7a4fe3e chore: update repository growth [skip ci] 2026-09-21 09:59:45 +00:00
ProxMenuxBot 7198c19da8 Update helpers_cache.json 2026-09-20 15:10:49 +00:00
ProxMenuxBot acc426e723 Update app tracking hints 2026-09-20 14:57:30 +00:00
github-actions[bot] 662793d7c2 chore: update repository growth [skip ci] 2026-09-20 09:19:16 +00:00
ProxMenuxBot e704210685 Update helpers_cache.json 2026-09-20 02:41:12 +00:00
ProxMenuxBot 33c8ff5e7e Update app tracking hints 2026-09-20 01:51:00 +00:00
ProxMenuxBot dc26a872fd Update helpers_cache.json 2026-09-19 10:02:43 +00:00
github-actions[bot] af41036c18 chore: update repository growth [skip ci] 2026-09-19 08:50:04 +00:00
ProxMenuxBot ef6d024a4a Update helpers_cache.json 2026-09-18 10:19:34 +00:00
github-actions[bot] 15e819385f chore: update repository growth [skip ci] 2026-09-18 09:02:34 +00:00
ProxMenuxBot 50b4dc33fd Update helpers_cache.json 2026-09-17 15:57:48 +00:00
ProxMenuxBot 26bc278b34 Update helpers_cache.json 2026-09-17 10:44:02 +00:00
github-actions[bot] 7ba90791ea chore: update repository growth [skip ci] 2026-09-17 09:28:58 +00:00
ProxMenuxBot c28b22fb59 Update helpers_cache.json 2026-09-16 20:42:56 +00:00
github-actions[bot] 7640762462 chore: update repository growth [skip ci] 2026-09-16 09:20:11 +00:00
ProxMenuxBot 891c68ba42 Update helpers_cache.json 2026-09-15 15:59:01 +00:00
ProxMenuxBot 7aa9840414 Update helpers_cache.json 2026-09-15 10:45:54 +00:00
ProxMenuxBot 09e4b81d65 Update app tracking hints 2026-09-15 10:32:35 +00:00
github-actions[bot] 80c1cf4475 chore: update repository growth [skip ci] 2026-09-15 09:28:56 +00:00
ProxMenuxBot 26d30481b5 Update helpers_cache.json 2026-09-14 17:20:00 +00:00
ProxMenuxBot 4d32422fef Update app tracking hints 2026-09-14 17:06:00 +00:00
ProxMenuxBot 43a655a57a Update helpers_cache.json 2026-09-14 11:21:56 +00:00
github-actions[bot] 93900b872e chore: update repository growth [skip ci] 2026-09-14 09:55:41 +00:00
ProxMenuxBot ac4be7031f Update helpers_cache.json 2026-09-13 20:03:16 +00:00
ProxMenuxBot 102ac40226 Update app tracking hints 2026-09-13 15:08:29 +00:00
ProxMenuxBot 7f90154410 Update helpers_cache.json 2026-09-13 10:51:49 +00:00
github-actions[bot] 013def197a chore: update repository growth [skip ci] 2026-09-13 09:36:39 +00:00
ProxMenuxBot baaff2725b Update helpers_cache.json 2026-09-13 02:24:38 +00:00
ProxMenuxBot 8e7588f372 Update helpers_cache.json 2026-09-12 14:44:02 +00:00
ProxMenuxBot 8507097a9a Update app tracking hints 2026-09-12 14:27:28 +00:00
ProxMenuxBot ed5f0e8393 Update app tracking hints 2026-09-12 09:40:05 +00:00
github-actions[bot] 49efa18f97 chore: update repository growth [skip ci] 2026-09-12 08:39:58 +00:00
ProxMenuxBot a4537be773 Update helpers_cache.json 2026-09-12 02:26:12 +00:00
ProxMenuxBot 8f62859971 Update app tracking hints 2026-09-12 01:43:51 +00:00
ProxMenuxBot fb1517807c Update helpers_cache.json 2026-09-11 10:20:22 +00:00
ProxMenuxBot 685b37bc43 Update app tracking hints 2026-09-11 10:04:12 +00:00
github-actions[bot] 5867d467f3 chore: update repository growth [skip ci] 2026-09-11 08:56:57 +00:00
ProxMenuxBot dd2a9f611d Update helpers_cache.json 2026-09-10 15:35:46 +00:00
ProxMenuxBot 30e6e64d4f Update app tracking hints 2026-09-10 15:17:31 +00:00
ProxMenuxBot f9373f8777 Update helpers_cache.json 2026-09-10 10:19:59 +00:00
github-actions[bot] cad825ae7e chore: update repository growth [skip ci] 2026-09-10 08:58:43 +00:00
ProxMenuxBot 5b4366fa82 Update helpers_cache.json 2026-09-09 15:40:19 +00:00
ProxMenuxBot 1df63be887 Update app tracking hints 2026-09-09 15:23:31 +00:00
github-actions[bot] b599d1cf26 chore: update repository growth [skip ci] 2026-09-09 08:59:40 +00:00
ProxMenuxBot 1919e62646 Update helpers_cache.json 2026-09-08 15:45:06 +00:00
ProxMenuxBot 38d445a379 Update helpers_cache.json 2026-09-08 10:21:41 +00:00
ProxMenuxBot 150494bce1 Update app tracking hints 2026-09-08 10:09:14 +00:00
github-actions[bot] 2424545532 chore: update repository growth [skip ci] 2026-09-08 08:54:07 +00:00
ProxMenuxBot e9c5a12702 Update helpers_cache.json 2026-09-07 20:57:05 +00:00
ProxMenuxBot 3dcf6cd661 Update helpers_cache.json 2026-09-07 16:57:37 +00:00
ProxMenuxBot d91599d354 Update app tracking hints 2026-09-07 16:42:34 +00:00
ProxMenuxBot 97bf9e24e5 Update helpers_cache.json 2026-09-07 11:11:20 +00:00
github-actions[bot] 9a1642b53c chore: update repository growth [skip ci] 2026-09-07 09:25:01 +00:00
github-actions[bot] c90e365d1f chore: update repository growth [skip ci] 2026-09-06 08:42:07 +00:00
github-actions[bot] 9ae3a54e8b chore: update repository growth [skip ci] 2026-09-05 08:23:17 +00:00
ProxMenuxBot cc66939095 Update app tracking hints 2026-09-04 15:13:17 +00:00
ProxMenuxBot 56da2ac880 Update helpers_cache.json 2026-09-04 10:15:52 +00:00
github-actions[bot] 2630e4f016 chore: update repository growth [skip ci] 2026-09-04 08:52:39 +00:00
ProxMenuxBot 966e9799f2 Update helpers_cache.json 2026-09-03 15:33:25 +00:00
ProxMenuxBot ae3b93a3c1 Update helpers_cache.json 2026-09-03 10:28:05 +00:00
github-actions[bot] 459ea9c6db chore: update repository growth [skip ci] 2026-09-03 08:58:03 +00:00
github-actions[bot] 636983b697 Update AppImage release build (2026-09-02 20:28:55) 2026-09-02 20:28:55 +00:00
MacRimiandGitHub 986301d152 Merge pull request #326 from MacRimi/develop
New version 1.2.6
2026-09-02 22:24:48 +02:00
ProxMenuxBot 5b41cbfbe8 Update helpers_cache.json 2026-09-02 15:43:22 +00:00
ProxMenuxBot 17a72753bb Update app tracking hints 2026-09-02 15:25:16 +00:00
MacRimiandGitHub 333d03a51e Merge pull request #323 from MacRimi/develop
chore(ai-models): refresh catalog
2026-09-02 13:43:09 +02:00
MacRimiandGitHub f59323455b narrow _exclude to technical incapacity only
narrow _exclude to technical incapacity only
2026-09-02 11:34:39 +02:00
MacRimiandGitHub ac30502b67 AI models catalog — _exclude list + preserve recommended
AI models catalog — _exclude list + preserve recommended
2026-09-02 11:07:56 +02:00
2179 changed files with 461618 additions and 16112 deletions
+593 -3
View File
@@ -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
+77 -6
View File
@@ -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()),
+2 -2
View File
@@ -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",
"",
+46 -2
View File
@@ -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/&lt;upid&gt;", "/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 &lt;host&gt;."
protected, mapping = MODULE.protect_text(source)
for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "&lt;", "&gt;"):
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()
+311
View File
@@ -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),
+29 -2
View File
@@ -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)
+26 -19
View File
@@ -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})
+3 -8
View File
@@ -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'
+6 -1
View File
@@ -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"
+389
View File
@@ -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
+21 -4
View File
@@ -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
+27 -2
View File
@@ -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 }}
+1
View File
@@ -54,6 +54,7 @@ Thumbs.db
# Cache
.cache
/oci/.catalog-cache/
/web/.cache
# IDE/editor files
+1 -1
View File
@@ -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>
}
+5 -4
View File
@@ -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 -4
View File
@@ -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>
)
}
-12
View File
@@ -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>
+135
View File
@@ -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>
</>
}
+79 -3
View File
@@ -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>
+145 -27
View File
@@ -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
+19 -1
View File
@@ -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">
+124 -50
View File
@@ -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>
)
+67
View File
@@ -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>
}
+177
View File
@@ -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>
}
+18 -15
View File
@@ -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>
+296 -36
View File
@@ -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 &lt;app&gt;</code> or{" "}
{t("vmLxc.appEditor.binaryHelpBefore")}{" "}
<code className="text-foreground/70">which &lt;app&gt;</code>{" "}{t("vmLxc.appEditor.binaryHelpOr")}{" "}
<code className="text-foreground/70">systemctl show &lt;service&gt; -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>
+48
View File
@@ -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>
}
+412
View File
@@ -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"
+2 -2
View File
@@ -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"
+112 -28
View File
@@ -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>
</>
)}
+115
View File
@@ -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>
}
+337
View File
@@ -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>
}
+99 -32
View File
@@ -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
+80 -32
View File
@@ -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.",
},
]
+170 -25
View File
@@ -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>
+9 -4
View File
@@ -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)}
+70 -29
View File
@@ -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>
)
}
+496 -56
View File
@@ -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) ? `
+120 -11
View File
@@ -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">
+4
View File
@@ -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