Compare commits

...
685 Commits
Author SHA1 Message Date
github-actions[bot] f14ad4d7b1 Update AppImage release build (2026-10-26 22:58:19) 2026-10-26 22:58:19 +00:00
MacRimiandGitHub 1285132326 New version 1.2.7
New version 1.2.7
2026-10-26 23:49:36 +01: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
github-actions[bot] a98f7d859b Update AppImage release build (2026-10-26 22:31:49) 2026-10-26 22:31:49 +00:00
ProxMenuxBot 860fac9271 Update app tracking hints 2026-10-26 22:23:18 +00:00
MacRimiandGitHub e904f9d37f New version 1.2.7
New version 1.2.7
2026-10-26 23:22:43 +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
1931 changed files with 393501 additions and 11807 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",
@@ -102,6 +114,9 @@ PROTECTED_TECHNICAL_TERMS = (
# Open Container Initiative. Read as a word it becomes "BEC" in French,
# which named nothing, across thirty-one strings of the OCI section.
"OCI manager Apps",
# The file an application is installed from. Its name is the one of the
# file on disk; translated, it names something the menu does not offer.
"oci-compose",
"OCI",
"beta",
# Debian release names. "Bookworm" is a bird and a reader of books, and
+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 -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",
"",
+30 -1
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,19 @@ import os
from pathlib import Path
import subprocess
import sys
import tempfile
PYTHON_FILES = (
"tests/test_lxc_update_jobs.py",
"tests/test_post_install_i18n.py",
"tests/test_remaining_cli_i18n.py",
"AppImage/scripts/tests/test_pbs_i18n.py",
"AppImage/scripts/tests/test_monitor_links_i18n.py",
"AppImage/scripts/tests/test_monitor_links.py",
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
"AppImage/scripts/tests/test_local_app_icons.py",
"AppImage/scripts/tests/test_docker_app_discovery.py",
"AppImage/scripts/tests/test_native_app_discovery.py",
"tests/test_audit_presentation.py",
"tests/test_audit_safety_wording.py",
"tests/test_audit_policy.py",
@@ -20,8 +31,22 @@ PYTHON_FILES = (
"tests/storage/test_nvme_status_message.py",
"tests/test_fastfetch_config_generation.py",
"tests/test_notification_active_payloads.py",
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
)
NODE_FILES = (
"tests/test_lxc_update_jobs.cjs",
"tests/test_api_import_boundaries.cjs",
"tests/test_api_errors.cjs",
"tests/test_optimization_labels.cjs",
"tests/test_optimization_source.cjs",
"tests/lxc_updates/test_app_editor_errors.cjs",
"tests/test_oci_update_cancellation.cjs",
"tests/test_host_backup_messages.cjs",
"tests/test_pbs_i18n.cjs",
"tests/test_monitor_links_i18n.cjs",
"tests/lxc_updates/test_local_app_icons_ui.cjs",
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
"tests/lxc_updates/test_docker_delegated_ui.cjs",
"tests/test_audit_diagnostic_document.cjs",
"tests/test_audit_policy.cjs",
@@ -69,6 +94,10 @@ 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)
@@ -30,7 +30,7 @@
"Apagar una máquina virtual de forma segura",
"Mostrar estado de VM",
"Mostrar configuración de VM",
"Crear una instantánea de VM",
"Crear un snapshot de VM",
"Listar todos los contenedores LXC",
"Iniciar un contenedor LXC",
"Detener un contenedor LXC",
@@ -126,7 +126,7 @@
"Dateieigentümer ändern",
"Tar.gz-Archiv extrahieren",
"Tar.gz-Archiv erstellen",
"Festplattennutzung anzeigen",
"Laufwerksnutzung anzeigen",
"Verzeichnisgrößen anzeigen",
"Speichernutzung anzeigen",
"Laufende Prozesse anzeigen",
@@ -0,0 +1,349 @@
"""Run only the Bashrc functions, with all root files in a private fixture."""
import json
import os
from pathlib import Path
import re
import shlex
import stat
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
FLOWS = ("auto_post_install.sh", "customizable_post_install.sh")
BEGIN = b"# BEGIN PMX_CORE_BASHRC\n"
END = b"# END PMX_CORE_BASHRC\n"
# The unmarked block shipped in v1.1.3 (fc974995), without its leading blank line.
LEGACY = rb'''# ProxMenux customizations
export HISTTIMEFORMAT="%d/%m/%y %T "
export PS1="\[\e[31m\][\[\e[m\]\[\e[38;5;172m\]\u\[\e[m\]@\[\e[38;5;153m\]\h\[\e[m\] \[\e[38;5;214m\]\W\[\e[m\]\[\e[31m\]]\[\e[m\]\\$ "
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -alF'
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias fgrep='fgrep --color=auto'
alias egrep='egrep --color=auto'
source /etc/profile.d/bash_completion.sh
'''
def extract(source, name):
match = re.search(rf"(?ms)^{name}\(\) \{{\n.*?^\}}$", source)
if match is None:
raise AssertionError(f"missing function: {name}")
return match.group()
class BashrcMigrationTests(unittest.TestCase):
def run_flow(self, flow, original, times=1, injection="", style=""):
with tempfile.TemporaryDirectory(prefix="bashrc-migration-") as temporary:
work = Path(temporary)
bashrc = work / ".bashrc"
bashrc.write_bytes(original)
bashrc.chmod(0o640)
before = bashrc.stat()
(work / "bin").mkdir()
# The embedded Python must use the interpreter running this test.
(work / "bin/python3").symlink_to(sys.executable)
source = (ROOT / "scripts/post_install" / flow).read_text()
functions = "\n".join(extract(source, name) for name in
("_migrate_proxmenux_bashrc", "customize_bashrc"))
# Relocate only the fixture copy; never source the administrative script.
functions = functions.replace("/root/", str(work) + "/")
prelude = f'source {shlex.quote(str(ROOT / "scripts/global/pmx_journal.sh"))}\n'
prelude += r'''
translate() { printf '%s' "$1"; }
msg_info() { :; }
msg_info2() { :; }
msg_warn() { :; }
msg_error() { printf '%s\n' "$*" >&2; }
msg_ok() { printf 'success\n' >> "$FIXTURE_ROOT/messages"; }
register_tool() { printf '%s|%s|%s\n' "$@" >> "$FIXTURE_ROOT/registry"; }
'''
calls = "\n".join(
f'customize_bashrc; rc=$?; printf "%s\\n" "$rc" >> "$FIXTURE_ROOT/status"; '
f'cp "$FIXTURE_ROOT/.bashrc" "$FIXTURE_ROOT/pass-{i}"'
for i in range(times))
env = os.environ.copy()
env.update(HOME=str(work), FIXTURE_ROOT=str(work), TMPDIR=str(work),
PMX_JOURNAL_ROOT=str(work / "journal"), PMX_BASHRC_PATH_STYLE=style,
PATH=str(work / "bin") + os.pathsep + env.get("PATH", ""))
result = subprocess.run(
["bash", "--noprofile", "--norc", "-c",
prelude + functions + "\n" + injection + "\n" + calls],
cwd=work, env=env, stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
after = bashrc.stat()
def read(name):
path = work / name
return path.read_bytes() if path.exists() else b""
return {
"passes": [read(f"pass-{i}") for i in range(times)],
"status": read("status").splitlines(), "registry": read("registry"),
"messages": read("messages"), "backup": read(".bashrc.bak"),
"profile": read(".bash_profile"), "stderr": result.stderr,
"detected_style": read("style"),
"before": (stat.S_IMODE(before.st_mode), before.st_uid, before.st_gid),
"after": (stat.S_IMODE(after.st_mode), after.st_uid, after.st_gid),
"inode_before": before.st_ino, "inode_after": after.st_ino,
"leftovers": [p.name for p in work.iterdir() if "proxmenux" in p.name],
"journal": [json.loads(p.read_text()) for p in
sorted((work / "journal/spool").glob("*.json"))],
}
def test_inspect_detects_style_without_rewriting_the_original(self):
for flow in FLOWS:
for block in (LEGACY, LEGACY.replace(rb"\W", rb"\w")):
with self.subTest(flow=flow, full=rb"\w" in block):
injection = r'''customize_bashrc() {
_migrate_proxmenux_bashrc "$FIXTURE_ROOT/.bashrc" inspect > "$FIXTURE_ROOT/style"
}'''
run = self.run_flow(flow, block, injection=injection)
self.assertEqual(run["status"], [b"0"], run["stderr"])
self.assertEqual(run["passes"], [block])
self.assertEqual(run["detected_style"], b"full\n" if rb"\w" in block else b"short\n")
self.assertEqual(run["inode_before"], run["inode_after"])
self.assertEqual(run["backup"], b"")
self.assertEqual(run["registry"], b"")
self.assertEqual(run["journal"], [])
def test_historical_blocks_migrate_once_with_prompt_backup_and_journal(self):
for flow in FLOWS:
for full in (False, True):
legacy = LEGACY.replace(rb"\W", rb"\w") if full else LEGACY
blocks = (legacy, b"${marker_begin}\n" + legacy + b"${marker_end}\n",
BEGIN + legacy + END, legacy + BEGIN + legacy + END)
for block in blocks:
with self.subTest(flow=flow, full=full, block=block[:40]):
original = b"# user prefix\n" + block + b"# user suffix\n"
run = self.run_flow(flow, original, times=2)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
first, second = run["passes"]
self.assertEqual(first, second)
self.assertTrue(first.startswith(b"# user prefix\n# user suffix\n"))
self.assertEqual(first.count(BEGIN), 1)
self.assertEqual(first.count(END), 1)
self.assertEqual(first.count(b"alias ll="), 1)
self.assertNotIn(b"${marker_", first)
self.assertIn(rb"\w" if full else rb"\W", first)
self.assertEqual(run["backup"], original)
self.assertEqual(len(run["profile"].splitlines()), 1)
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 2)
self.assertEqual(run["before"], run["after"])
self.assertEqual(run["leftovers"], [])
entries = [e for e in run["journal"] if e["target"].endswith("/.bashrc")]
self.assertEqual(len(entries), 1)
entry = entries[0]
self.assertEqual(entry["operation"], "write_file")
self.assertEqual(entry["exactness"], "exact")
self.assertEqual(entry["capture"], "present")
self.assertNotEqual(entry["before"], entry["after"])
self.assertTrue(all("proxmenux." not in e["target"] for e in run["journal"]))
syntax = subprocess.run(["bash", "-n"], input=first, capture_output=True)
self.assertEqual(syntax.returncode, 0, syntax.stderr)
def test_incomplete_and_oversized_ranges_are_preserved_on_rerun(self):
fragments = (LEGACY.rsplit(b"source", 1)[0], END,
b"${marker_begin}\n# user\n", b"${marker_end}\n",
BEGIN + b"# user\n" * 33 + END,
b"${marker_begin}\n" + b"# user\n" * 33 + b"${marker_end}\n",
BEGIN + b"# user\n${marker_begin}\n# inner\n${marker_end}\n# suffix\n")
for flow in FLOWS:
for fragment in fragments:
with self.subTest(flow=flow, fragment=fragment[:40]):
run = self.run_flow(flow, fragment, times=2)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
# Complete inner ranges can migrate, but their outer prefix cannot.
preserved = BEGIN + b"# user\n# suffix\n" if b"# inner" in fragment else fragment
self.assertTrue(run["passes"][0].startswith(preserved))
self.assertEqual(run["passes"][0], run["passes"][1])
def test_nested_ranges_with_outer_end_are_preserved_on_repeated_runs(self):
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
for flow in FLOWS:
for outer_begin, outer_end in markers:
for inner_begin, inner_end in markers:
with self.subTest(flow=flow, outer=outer_begin, inner=inner_begin):
original = (outer_begin + b"# OUTER_USER_PREFIX\n"
b"user_function() { :; }\n" + inner_begin
+ b"# old inner block\n" + inner_end
+ b"# OUTER_USER_SUFFIX\n" + outer_end)
run = self.run_flow(flow, original, times=3)
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
for content in run["passes"]:
self.assertTrue(content.startswith(original), content)
self.assertEqual(content.count(BEGIN), original.count(BEGIN) + 1)
self.assertEqual(content.count(END), original.count(END) + 1)
self.assertEqual(content.count(b"# ProxMenux core customizations\n"), 1)
self.assertEqual(run["passes"], [run["passes"][0]] * 3)
self.assertEqual(run["backup"], original)
def test_crossed_marker_families_preserve_every_byte_on_repeated_runs(self):
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
for flow in FLOWS:
canonical = self.run_flow(flow, b"")["passes"][0]
for outer, inner in (markers, markers[::-1]):
for newline in (b"\n", b"\r\n"):
with self.subTest(flow=flow, outer=outer[0], newline=newline):
original = (outer[0] + b"user_prefix() { :; } # \xff\n"
+ inner[0] + outer[1]
+ b"user_suffix() { :; }\n" + inner[1])
original = original.replace(b"\n", newline)
run = self.run_flow(flow, original, times=3)
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
self.assertEqual(run["passes"], [original + canonical] * 3)
self.assertEqual(run["backup"], original)
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 3)
def test_marker_ambiguity_matrix_preserves_spans_without_capturing_separate_blocks(self):
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
prefix = b"user_prefix() { :; }\n"
suffix = b"user_suffix() { :; }\n"
for flow in FLOWS:
canonical = self.run_flow(flow, b"")["passes"][0]
for (begin, end), (foreign_begin, foreign_end) in (markers, markers[::-1]):
# Each malformed complete span is ambiguous, even if the foreign
# token has no partner. Never expose its user bytes on a rerun.
ambiguous = {
"unmatched-inner-begin": begin + prefix + foreign_begin + suffix + end,
"unmatched-foreign-end": begin + prefix + foreign_end + suffix + end,
"inner-begin-extra-end": begin + prefix + foreign_begin + end + suffix + end,
"nested-with-stray-end": (begin + prefix + foreign_begin + end
+ foreign_end + suffix + end),
"overlap-chain": (begin + prefix + foreign_begin + end + begin
+ foreign_end + suffix + end),
"ambiguous-legacy-signature": (begin + prefix + foreign_end
+ LEGACY + suffix + end),
"oversized-ambiguous-span": (begin + prefix + foreign_end
+ b"# user\n" * 33 + suffix + end),
}
cases = [(name, fragment, fragment) for name, fragment in ambiguous.items()]
# An unclosed old BEGIN is not an enclosing range. Its prefix
# must survive, but independent complete blocks must still migrate.
cases.extend([
("orphan-before-same-family", begin + prefix + begin + b"old\n" + end + suffix,
begin + prefix + suffix),
("orphan-before-other-family", begin + prefix + foreign_begin + b"old\n"
+ foreign_end + suffix, begin + prefix + suffix),
("missing-outer-end", begin + prefix + foreign_begin + b"old\n"
+ foreign_end + suffix + foreign_end, begin + prefix + suffix + foreign_end),
("separated-valid-blocks", foreign_end + prefix + begin + b"old\n" + end
+ suffix + foreign_begin + b"old\n" + foreign_end + foreign_begin,
foreign_end + prefix + suffix + foreign_begin),
("limit-32", begin + b"# old\n" * 31 + end + suffix, suffix),
("over-limit-33", begin + b"# user\n" * 32 + end + suffix,
begin + b"# user\n" * 32 + end + suffix),
])
for name, original, preserved in cases:
with self.subTest(flow=flow, family=begin, case=name):
run = self.run_flow(flow, original, times=3)
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
self.assertEqual(run["passes"], [preserved + canonical] * 3)
self.assertEqual(run["backup"], original)
self.assertEqual(run["before"], run["after"])
self.assertEqual(run["leftovers"], [])
def test_path_override_and_empty_install_remain_idempotent(self):
for flow in FLOWS:
for style in ("short", "full"):
with self.subTest(flow=flow, style=style):
run = self.run_flow(flow, b"", times=2, style=style)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
self.assertEqual(run["passes"][0], run["passes"][1])
self.assertIn(rb"\w" if style == "full" else rb"\W", run["passes"][0])
def test_preparation_and_commit_failures_leave_bashrc_untouched(self):
failures = {
"inspect": 'python3() { [[ "$3" == inspect ]] && return 71; command python3 "$@"; }',
"migrate": 'python3() { [[ "$3" == migrate ]] && return 71; command python3 "$@"; }',
"replace": '''python3() {
command python3 -c 'import os, sys
sys.argv = sys.argv[1:]
def fail(*args):
raise OSError("injected replace failure")
os.replace = fail
exec(sys.stdin.read())' "$@"
}''',
"backup": 'cp() { [[ "${@: -1}" == *.bak ]] && return 71; command cp "$@"; }',
"mktemp": 'mktemp() { return 71; }',
"copy": 'cp() { if [[ "$1" == -p ]]; then printf partial > "$3"; return 71; fi; command cp "$@"; }',
"profile": 'pmx_append_file() { return 71; }',
"commit": 'mv() { [[ "${@: -1}" == "$FIXTURE_ROOT/.bashrc" ]] && return 71; command mv "$@"; }',
}
original = b"# user\n" + LEGACY
for flow in FLOWS:
for failure, injection in failures.items():
with self.subTest(flow=flow, failure=failure):
run = self.run_flow(flow, original, injection=injection)
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
self.assertEqual(run["passes"], [original])
self.assertEqual(run["before"], run["after"])
self.assertEqual(run["inode_before"], run["inode_after"])
self.assertEqual(run["registry"], b"")
self.assertEqual(run["messages"], b"")
self.assertEqual(run["leftovers"], [])
self.assertFalse(any(e["target"].endswith("/.bashrc") for e in run["journal"]))
with self.subTest(flow=flow, failure="invalid-style"):
run = self.run_flow(flow, original, style="invalid")
self.assertNotEqual(run["status"], [b"0"])
self.assertEqual(run["passes"], [original])
self.assertEqual(run["registry"], b"")
def test_migration_preserves_unrelated_non_utf8_bytes_and_line_endings(self):
prefix = b"# user bytes: \xff\r\n"
suffix = b"user_function() { :; }\r\n"
original = prefix + BEGIN + b"old block\n" + END + suffix
for flow in FLOWS:
with self.subTest(flow=flow):
run = self.run_flow(flow, original, times=2)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
self.assertTrue(run["passes"][0].startswith(prefix + suffix))
self.assertEqual(run["passes"][0], run["passes"][1])
self.assertEqual(run["before"], run["after"])
def test_failed_block_write_preserves_original_without_success(self):
original = b"# user content\n" + BEGIN + b"old managed block\n" + END
# Fail the real stdin writer after a partial canonical-block write.
# The override is confined to this shell fixture, not production.
injection = r'''
cat() {
if [[ $# == 0 ]]; then
local first
IFS= read -r first
if [[ "$first" == '# BEGIN PMX_CORE_BASHRC' ]]; then
printf '# partial block'
return 71
fi
printf '%s\n' "$first"
fi
command cat "$@"
}
'''
for flow in FLOWS:
with self.subTest(flow=flow):
run = self.run_flow(flow, original, injection=injection)
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
self.assertEqual(run["passes"], [original])
self.assertEqual(run["before"], run["after"])
self.assertEqual(run["inode_before"], run["inode_after"])
self.assertEqual(run["registry"], b"")
self.assertEqual(run["messages"], b"")
self.assertEqual(run["leftovers"], [])
def test_orphan_begin_cannot_consume_user_content_on_rerun(self):
original = BEGIN + b"user_function() { :; }\n# user suffix\n"
for flow in FLOWS:
with self.subTest(flow=flow):
run = self.run_flow(flow, original, times=2)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
for content in run["passes"]:
self.assertTrue(content.startswith(original), content)
self.assertEqual(run["passes"][0], run["passes"][1])
if __name__ == "__main__":
unittest.main()
@@ -60,6 +60,124 @@ class WebDocsI18nTests(unittest.TestCase):
target = {"title": "Actualizaciones", "url": "https://example.com"}
self.assertEqual(MODULE.pending_leaves(source, target), [])
def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self):
for key, value in (("tone", "amber"), ("variant", "source"),
("accent", "border-blue-300 bg-blue-50"),
("iconBg", "bg-blue-100 text-blue-700"),
("color", "green")):
with self.subTest(key=key):
self.assertTrue(MODULE.should_copy(value, ("steps", 0, key)))
self.assertFalse(MODULE.should_copy(value, ("label",)))
self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value})
self.assertFalse(MODULE.should_copy("Translation", ("type",)))
self.assertFalse(MODULE.should_copy("English", ("lang",)))
for value in ("loader", "dedicated"):
self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow")))
self.assertFalse(MODULE.should_copy(value, ("label",)))
def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self):
for source in ("<code>apt update</code>",
"<code>net0</code> / <code>net1</code>.",
"/api/task-log/&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()
@@ -0,0 +1,105 @@
"""Log2RAM syncs only the archived journal files, so the active file it found
on disk when it was installed stays there and is restored at every boot.
journald then continues an old sequence and deletes its newest archives first.
The post-install makes the sync remove that file from the disk copy."""
from pathlib import Path
import re
import shutil
import stat
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SHARED = ROOT / "scripts/global/utils-install-functions.sh"
FLOWS = {"auto_post_install.sh": "install_log2ram_auto", "customizable_post_install.sh": "configure_log2ram"}
# The sync of Log2RAM in its journald-aware mode, as its project ships it.
JOURNALD_AWARE = '''#!/usr/bin/env bash
sync_to_disk() {
is_safe
local optional_params=()
if journald_logrotate; then
optional_params+=("--include=journal/*/*@*.journal")
optional_params+=("--exclude=journal/*/*")
fi
rsync -aXv --no-acls --delete-after "${optional_params[@]}" "${RAM_LOG}"/ "${HDD_LOG}"/
}
'''
NOT_AWARE = '''#!/usr/bin/env bash
sync_to_disk() {
rsync -aXv --delete-after "${RAM_LOG}"/ "${HDD_LOG}"/
}
'''
def gnu_sed():
return shutil.which("bash") and subprocess.run(["sed", "--version"], capture_output=True).returncode == 0
@unittest.skipUnless(gnu_sed(), "bash and GNU sed are needed")
class Log2ramJournalSyncTests(unittest.TestCase):
def correct(self, folder, content, times=1):
script = Path(folder) / "log2ram"
script.write_text(content)
script.chmod(0o755)
calls = "; ".join(['pmx_log2ram_drop_stale_journal "$1"; echo "rc=$?"'] * times)
# As the change journal does, the new content is read whole before it is written.
write = 'pmx_write_file() { local content; content="$(cat)"; printf "%s\\n" "$content" > "$1"; }'
run = subprocess.run(["bash", "-c", f'source "{SHARED}"; {write}; {calls}',
"bash", str(script)], capture_output=True, text=True, check=False)
return script, run.stdout.split()
def test_the_sync_removes_what_it_does_not_copy(self):
with tempfile.TemporaryDirectory() as folder:
script, results = self.correct(folder, JOURNALD_AWARE)
lines = script.read_text().splitlines()
position = lines.index(' optional_params+=("--exclude=journal/*/*")')
self.assertEqual(lines[position + 1], ' optional_params+=("--delete-excluded")')
self.assertEqual(lines[position + 2], " fi")
self.assertEqual(results, ["rc=0"])
self.assertEqual(subprocess.run(["bash", "-n", str(script)]).returncode, 0)
self.assertTrue(script.stat().st_mode & stat.S_IXUSR)
self.assertEqual(Path(str(script) + ".proxmenux.bak").read_text(), JOURNALD_AWARE)
def test_it_is_applied_once(self):
with tempfile.TemporaryDirectory() as folder:
script, results = self.correct(folder, JOURNALD_AWARE, times=2)
self.assertEqual(results, ["rc=0", "rc=1"])
self.assertEqual(script.read_text().count("--delete-excluded"), 1)
def test_a_sync_that_copies_every_file_is_left_as_it_is(self):
with tempfile.TemporaryDirectory() as folder:
script, results = self.correct(folder, NOT_AWARE)
self.assertEqual(results, ["rc=1"])
self.assertEqual(script.read_text(), NOT_AWARE)
self.assertFalse(Path(str(script) + ".proxmenux.bak").exists())
def test_both_flows_apply_it_to_new_and_existing_installations(self):
for name, function in FLOWS.items():
with self.subTest(flow=name):
text = (ROOT / "scripts/post_install" / name).read_text()
self.assertEqual(text.count("pmx_log2ram_drop_stale_journal"), 2)
# An existing installation is told that it needs a reboot.
self.assertIn('if pmx_log2ram_drop_stale_journal "$log2ram_bin"; then\n NECESSARY_REBOOT=1', text)
# The version is what offers the update to existing installations.
version = re.search(rf'{function}\(\) {{\n local FUNC_VERSION="([0-9.]+)"', text)
self.assertIsNotNone(version)
self.assertGreaterEqual(tuple(map(int, version.group(1).split("."))), (1, 7))
def test_rotated_logs_are_compressed_at_once_on_a_tmpfs_var_log(self):
# A rotated log kept uncompressed filled the 128M Log2RAM of a PBS.
for name, function in FLOWS.items():
with self.subTest(flow=name):
text = (ROOT / "scripts/post_install" / name).read_text()
self.assertNotIn("delaycompress", text)
version = re.search(rf'{function}\(\) {{\n local FUNC_VERSION="([0-9.]+)"', text)
self.assertGreaterEqual(tuple(map(int, version.group(1).split("."))), (1, 8))
if __name__ == "__main__":
unittest.main()
@@ -110,7 +110,7 @@ class LynisThresholdContracts(unittest.TestCase):
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
# The method is bound to an inert class, not the production constructor.
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
MEMORY_WARNING=80, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
MEMORY_WARNING=80, MEMORY_CRITICAL=90, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
memory = SimpleNamespace(percent=50, available=10, total=100)
swap = SimpleNamespace(percent=90, used=90, total=100)
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
+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,46 @@
"""OCI manager Apps opens only on Proxmox VE 9.1 or later, the first release
that runs OCI images as LXC containers."""
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
LAUNCHER = ROOT / 'scripts/oci/oci_manager_apps.sh'
class OciPveVersionGateTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
text = LAUNCHER.read_text(encoding='utf-8')
cls.function = re.search(r'^pve_supports_oci\(\) \{\n.*?^\}\n', text, re.M | re.S).group(0)
cls.text = text
def supported(self, version):
with tempfile.TemporaryDirectory() as folder:
fake = Path(folder, 'pveversion')
# Without Proxmox the command is missing: a stand-in that fails hides the host's own.
fake.write_text('#!/bin/sh\nexit 127\n' if version is None else
f'#!/bin/sh\necho "pve-manager/{version}/b9984c6d90a4bd80 (running kernel: 6.17)"\n')
fake.chmod(0o755)
result = subprocess.run(['bash', '-c', f'PATH={folder}:/usr/bin:/bin\n{self.function}pve_supports_oci'],
capture_output=True, text=True)
return result.returncode == 0
def test_versions_from_9_1_open_it(self):
for version in ('9.1.0', '9.1.4', '9.2.2', '10.0.1'):
with self.subTest(version=version):
self.assertTrue(self.supported(version))
def test_older_versions_and_hosts_without_proxmox_do_not(self):
for version in ('8.4.1', '9.0.11', None):
with self.subTest(version=version):
self.assertFalse(self.supported(version))
def test_it_is_checked_before_anything_is_installed(self):
self.assertLess(self.text.index('if ! pve_supports_oci; then'), self.text.index('ensure_oci_dependencies || exit 1'))
if __name__ == '__main__':
unittest.main()
@@ -5,6 +5,7 @@ import json
from pathlib import Path
import re
import runpy
import subprocess
import sys
import tempfile
from types import ModuleType, SimpleNamespace
@@ -52,7 +53,8 @@ class InventoryMessages(unittest.TestCase):
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
'translate': translate, 're': re}
'translate': translate, 're': re, 'subprocess': subprocess,
'_selected_configuration': remover.guest_config}
summary = extracted(MENU, '_removal_summary', scope)
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
@@ -114,7 +116,7 @@ class RecoveryMessages(unittest.TestCase):
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: self.primary
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
'_remove': lambda *args: self.fail('remove must not run'),
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
@@ -0,0 +1,246 @@
"""Pending recovery through real Menu/CLI/Dialog paths; all host effects forbidden."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
import oci_instances as instances
import oci_instance_transaction as transaction
import oci_native_modify as native_modify
import oci_stack_transaction as stack_transaction
import oci_ui
ACTUAL_RECOVER = transaction.recover
class PendingRecoveryFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.record.update(pending_transaction='/fiction-only/transaction.json')
self.row['pending'] = True
self.ui.choose.return_value = 'recover'
self.original = copy.deepcopy(self.record)
self.writes = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
self.processes = [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
self.recovery = self.guard(transaction, 'recover')
self.native_recovery = self.guard(native_modify, 'recover')
self.stack_recovery = self.guard(stack_transaction, 'execute')
def stack(self):
self.row['stack'] = True
self.record['pending_stack_transaction'] = '/fiction-only/stack.json'
self.original = copy.deepcopy(self.record)
def no_effects(self):
super().no_effects()
for mock in [*self.writes, *self.processes, self.recovery, self.native_recovery, self.stack_recovery]:
mock.assert_not_called()
class PendingRecoveryTests(PendingRecoveryFixture):
def test_single_no_is_distinct_falsy_cancellation(self):
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
self.assertFalse(menu.CANCELLED)
self.no_effects()
def test_single_menu_cancel_is_distinct_cancellation(self):
self.ui.choose.return_value = None
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
self.ui.review.assert_not_called()
self.no_effects()
def test_real_cli_single_no_and_cancel_return_six_without_duplicate_output(self):
for choice in ('recover', None):
with self.subTest(choice=choice), patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
self.ui.choose.return_value = choice
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
self.assertEqual(output.getvalue(), '')
self.no_effects()
def test_real_cli_stack_no_returns_six_before_reading_the_journal(self):
self.stack()
with patch.object(cli, 'Catalog'), patch.object(Path, 'read_text', side_effect=AssertionError('Journal read after No')) as read:
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
read.assert_not_called()
self.no_effects()
def test_actual_dialog_no_and_escape_cancel_single_and_stack(self):
dialog = native_ui.DialogUI()
for stacked in (False, True):
if stacked:
self.stack()
for code in (1, 255):
with self.subTest(stack=stacked, code=code):
answers = ([] if stacked else [subprocess.CompletedProcess('dialog', 0, stderr='2')])
answers.append(subprocess.CompletedProcess('dialog', code, stderr=''))
with patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', side_effect=answers):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
self.no_effects()
def test_actual_dialog_cancel_and_escape_from_single_operation_menu(self):
dialog = native_ui.DialogUI()
for code in (1, 255):
with self.subTest(code=code), patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
self.no_effects()
def test_native_menu_returns_to_its_list_after_pending_single_or_stack_no(self):
for stacked in (False, True):
if stacked:
self.stack()
self.ui.choose.reset_mock()
self.ui.choose.side_effect = ['110', None] if stacked else ['110', 'recover', None]
with self.subTest(stack=stacked), patch.object(menu, '_clean_orphans'), \
patch.object(menu, 'offer_recovery'), patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT/'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 2 if stacked else 3)
self.no_effects()
def test_confirmed_single_recovery_and_status_preserve_exact_commands_and_results(self):
self.ui.review.return_value = True
for action in ('recover', 'status'):
for success, code in ((True, 0), (False, 1)):
self.lifecycle.reset_mock()
self.lifecycle.side_effect = None
self.lifecycle.return_value = success
self.ui.choose.return_value = action
with self.subTest(action=action, result=success):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
self.lifecycle.assert_called_once_with(
[sys.executable, str(ROOT/'oci/remote/oci_instance_transaction.py'), action, '110'],
'Recover OCI' if action == 'recover' else 'OCI management')
self.write.assert_not_called(); self.backup.assert_not_called()
self.assertEqual(self.record, self.original)
def test_confirmed_stack_recovery_preserves_command_and_failure_is_not_cancel(self):
self.stack()
self.ui.review.return_value = True
for success, code in ((True, 0), (False, 1)):
self.lifecycle.reset_mock()
self.lifecycle.side_effect = None
self.lifecycle.return_value = success
with self.subTest(result=success), patch.object(Path, 'read_text', return_value=json.dumps({'plan': {'members': [{'vmid': 110}, {'vmid': 111}]}})):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
self.lifecycle.assert_called_once_with(
[sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--recover', '--acknowledge-external-data'],
'Recover OCI stack')
self.write.assert_not_called(); self.backup.assert_not_called()
self.assertEqual(self.record, self.original)
def test_identity_failure_stays_failure_and_does_not_offer_recovery(self):
with patch.object(menu, 'check_selected', return_value={**self.row, 'reason': 'different-installation'}):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 1)
self.ui.choose.assert_not_called(); self.ui.review.assert_not_called()
self.no_effects()
def test_unattended_does_not_turn_pending_menu_into_an_automatic_restore(self):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update', unattended=True), 1)
self.no_effects()
def test_registration_cli_retains_its_separate_entry_point(self):
with patch.object(cli, 'Catalog'), patch.object(menu, 'direct_recovery', return_value=0) as register, \
patch.object(menu, 'direct_management', side_effect=AssertionError('Registration routed to rollback')):
self.assertEqual(cli.main(['recover']), 0)
register.assert_called_once_with(ROOT/'oci')
self.no_effects()
class RecoverySlovakMeaning(PendingRecoveryFixture):
def test_actual_recovery_dialogs_preserve_host_data_and_bounded_recovery_meaning(self):
cache = json.loads((ROOT/'lang/sk.json').read_text())
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
menu.manage_instance(ROOT/'oci', self.ui, self.row)
text = self.ui.review.call_args.args[0]
self.assertIn('Zdieľané hostiteľské adresáre sa nevrátia späť', text)
self.assertIn('po úspešnom vyčistení', text)
self.assertEqual(self.ui.choose.call_args.args[0], 'Prerušená operácia')
self.assertEqual(dict(self.ui.choose.call_args.args[1])['status'], 'Zobraziť stav')
self.stack()
menu.manage_instance(ROOT/'oci', self.ui, self.row)
self.assertEqual(self.ui.review.call_args.args[1], 'Obnoviť OCI aplikáciu s viacerými kontajnermi')
self.assertIn('môžu zlyhať', self.ui.review.call_args.args[0])
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Obnoviť alebo dokončiť operáciu?')
self.no_effects()
class RecoveryGuardMessages(PendingRecoveryFixture):
def setUp(self):
super().setUp()
cache = json.loads((ROOT/'lang/sk.json').read_text())
for name, value in (('_language', 'sk'), ('_cache', cache)):
p = patch.object(oci_ui, name, value)
p.start(); self.addCleanup(p.stop)
self.record.update(installation_id='fiction-installation', transaction_id='fiction-tx')
self.original = copy.deepcopy(self.record)
self.journal = Path(self.record['pending_transaction'])
self.state = {'id': 'fiction-tx', 'vmid': 110, 'phase': 'backup-ready',
'record': {'installation_id': 'fiction-installation'}, 'before_config': 'before',
'backup': None, 'backup_sha256': 'saved-sha'}
def rejected(self, text, *, node='fiction-host', changed_backup=False):
calls = []
def readonly(*args):
calls.append(args)
if args[0] == 'pvesh':
return json.dumps([{'type': 'lxc', 'vmid': 110, 'node': node}]).encode()
if args == ('pct', 'config', '110'):
return b'changed'
raise AssertionError('Unexpected recovery effect: ' + repr(args))
with patch.object(Path, 'read_text', return_value=json.dumps(self.state)), \
patch.object(Path, 'exists', return_value=True), patch.object(Path, 'is_file', return_value=True), \
patch.object(transaction.socket, 'gethostname', return_value='fiction-host'), \
patch.object(transaction.host_mounts, 'verify_sources'), patch.object(transaction.gpu_devices, 'verify'), \
patch.object(transaction, 'owned', return_value=b'fiction config'), \
patch.object(transaction, 'filehash', return_value='changed-sha' if changed_backup else 'saved-sha'), \
patch.object(transaction, 'run', side_effect=readonly), redirect_stdout(io.StringIO()):
# Only reads before the rejecting guard are supplied. Any next
# command or attempt to persist state fails this fixture.
with self.assertRaisesRegex(ValueError, text):
ACTUAL_RECOVER(ROOT/'oci', self.journal)
self.no_effects()
return calls
def test_foreign_transaction_is_rejected_before_probing_the_container(self):
self.state['record']['installation_id'] = 'different-installation'
self.assertEqual(self.rejected('Záznam nepatrí k tejto operácii'), [])
def test_missing_verified_backup_cannot_touch_a_changed_container(self):
self.assertEqual(len(self.rejected('Chýba overená záloha; zmenený kontajner zostáva nedotknutý')), 2)
def test_changed_backup_is_rejected_before_decompression_or_restore(self):
self.state['backup'] = '/fiction-only/backup.zst'
self.assertEqual(len(self.rejected('Záloha bola zmenená; obnova zablokovaná', changed_backup=True)), 2)
def test_guest_on_another_node_is_never_overwritten(self):
self.assertEqual(len(self.rejected('kontajner je na inom uzle; nie je prepísaný', node='another-node')), 1)
def test_incomplete_stack_recovery_state_fails_before_any_adapter_call(self):
adapter = SimpleNamespace(validate=self.recovery, stop=self.native_recovery,
restore=self.stack_recovery)
state = {'phase': 'recovery-failed', 'plan': {'start_order': [110, 111]},
'running': {'110': True}, 'stop_intent': True, 'replacement_intent': False}
before = copy.deepcopy(state)
with self.assertRaisesRegex(ValueError, 'Záznam operácie obsahuje neúplný stav obnovy'):
stack_transaction.recover_state(self.journal, state, adapter)
self.assertEqual(state, before)
self.no_effects()
def test_critical_safety_and_untouched_result_have_the_correct_meaning(self):
cache = json.loads((ROOT/'lang/sk.json').read_text())
self.assertEqual(cache['A host mount is not part of the journal; recovery blocked'],
'Pripojenie adresára hostiteľa nie je uvedené v zázname operácie; obnova je zablokovaná')
self.assertEqual(cache['Recovery completed. The container had not been modified yet.'],
'Obnova dokončená. Kontajner ešte nebol zmenený.')
self.assertEqual(cache['A backup was modified'], 'Záloha bola zmenená')
self.assertEqual(cache['Recovering the previous installation'], 'Obnovovanie predchádzajúcej inštalácie')
self.assertEqual(cache['Recover the previous installation'], 'Obnoviť predchádzajúcu inštaláciu')
self.no_effects()
@@ -0,0 +1,234 @@
"""Restored-app registration through real Menu/CLI/Dialog; no host effects."""
from contextlib import nullcontext, redirect_stdout
import copy
import io
import json
from pathlib import Path
import string
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
import oci_restore_recovery as restored
import oci_ui
class RegistrationFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.found = [{'vmid':110, 'hostname':'fiction-primary'}]
self.original_found = copy.deepcopy(self.found)
self.ui.confirm.return_value = False
item = patch.object(menu, 'restored_applications', side_effect=lambda project:self.found)
self.pending = item.start();self.addCleanup(item.stop)
self.firewall = self.guard(menu, '_restored_firewall_rules')
self.backend = [self.guard(restored, name) for name in ('recover','register','run')]
self.files = [self.guard(Path, name) for name in ('write_text','write_bytes','unlink')]
self.processes = [self.guard(subprocess, name) for name in ('run','Popen','check_output')]
def members(self):
self.found.append({'vmid':111,'hostname':'fiction-dependent'})
self.original_found = copy.deepcopy(self.found)
def no_effects(self):
super().no_effects()
self.firewall.assert_not_called()
self.safe_backend()
def safe_backend(self):
for item in [*self.backend,*self.files,*self.processes]:item.assert_not_called()
self.assertEqual(self.found,self.original_found)
def accepted(self, answers, completed=True):
self.ui.confirm.side_effect=answers
self.firewall.side_effect=None
self.firewall.return_value=[{'vmid':row['vmid'],'port':61208 if row['vmid']==110 else 19999,
'source':'192.0.2.0/24'} for row in self.found]
self.lifecycle.side_effect=None;self.lifecycle.return_value=completed
self.pending.side_effect=lambda project:[] if completed and self.lifecycle.called else self.found
class RegistrationCancellationTests(RegistrationFixture):
def test_offer_no_is_explicit_falsy_cancellation(self):
self.assertIs(menu.offer_recovery(ROOT/'oci',self.ui),menu.CANCELLED)
self.assertFalse(menu.CANCELLED);self.no_effects()
def test_direct_single_no_finishes_before_a_third_pending_read(self):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
self.assertEqual(self.pending.call_count,2)
self.no_effects()
def test_real_cli_single_no_returns_six_without_output(self):
with patch.object(cli,'Catalog'),redirect_stdout(io.StringIO()) as output:
self.assertEqual(cli.main(['recover']),6)
self.assertEqual(output.getvalue(),'');self.no_effects()
def test_real_cli_multi_container_no_is_not_a_guest_action(self):
self.members()
with patch.object(cli,'Catalog'):
self.assertEqual(cli.main(['recover']),6)
text=self.ui.confirm.call_args.args[0]
self.assertIn('CT 110',text);self.assertIn('CT 111',text)
self.no_effects()
def test_actual_dialog_initial_no_and_escape_for_single_and_multi(self):
dialog=native_ui.DialogUI()
for multi in (False,True):
if multi:self.members()
for code in (1,255):
with self.subTest(multi=multi,code=code),patch.object(native_ui,'interactive_ui',return_value=dialog), \
patch.object(dialog,'_run',return_value=subprocess.CompletedProcess('dialog',code,stderr='')):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
self.no_effects()
def test_native_menu_continues_to_list_after_registration_no(self):
self.ui.choose.return_value=None
with patch.object(menu,'_clean_orphans'),patch.object(menu,'_records_unread',return_value=[]):
menu.interactive_management(ROOT/'oci',self.ui)
self.ui.choose.assert_called_once();self.no_effects()
def test_optional_firewall_and_start_no_registers_without_flags(self):
self.members();self.accepted([True,False,False,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0],
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
self.assertEqual(self.ui.confirm.call_count,4);self.safe_backend()
def test_single_optional_no_preserves_registration_without_start(self):
self.accepted([True,False,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0],
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
self.assertEqual(self.ui.confirm.call_count,3);self.safe_backend()
def test_only_one_confirmed_firewall_is_forwarded_and_no_start(self):
self.members();self.accepted([True,False,True,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0][-2:],['--host-firewall','111'])
self.assertNotIn('--start',self.lifecycle.call_args.args[0]);self.safe_backend()
def test_explicit_start_yes_retains_flag_but_never_starts_any_guest_here(self):
self.accepted([True,False,True])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0][-1],'--start');self.safe_backend()
def test_failed_registration_stays_failure_with_pending_records(self):
self.accepted([True,False,False],completed=False)
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
self.safe_backend()
def test_empty_list_is_success_without_confirmation_or_backend(self):
self.found.clear();self.original_found=[]
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.ui.confirm.assert_not_called();self.ui.message.assert_called_once();self.no_effects()
def test_root_identity_failure_never_probes_restored_apps(self):
with patch.object(menu.os,'geteuid',return_value=1000):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
self.pending.assert_not_called();self.ui.confirm.assert_not_called();self.no_effects()
def test_empty_offer_does_not_cancel_the_native_menu(self):
self.found.clear();self.original_found=[]
self.assertIsNone(menu.offer_recovery(ROOT/'oci',self.ui))
self.ui.confirm.assert_not_called();self.no_effects()
def test_empty_registration_state_is_rendered_in_slovak_without_recovery(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
with patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
self.found.clear();self.original_found=[]
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.ui.message.call_args.args[0],
'Žiadna OCI aplikácia obnovená zo zálohy nečaká na opätovnú registráciu.')
self.no_effects()
def test_initial_single_and_multi_dialog_describes_registration_precheck(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
for multi in (False,True):
if multi:self.members()
with self.subTest(multi=multi),patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
text=self.ui.confirm.call_args.args[0]
self.assertIn('Pred opätovnou registráciou',text)
self.assertNotIn('Obnova najprv',text)
self.assertIn('aplikácie sa potom nespúšťajú automaticky',text)
self.no_effects()
def test_real_backend_blocker_stays_error_and_uses_registration_wording(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
plan={'title':'Fictional restored application','restored':[{'vmid':110}],
'blockers':['Fictional missing resource'],'notes':[]}
with patch.object(menu.sys,'argv',['fixture','recover','--root','/fiction-only']), \
patch.object(restored.instances,'locked',return_value=nullcontext()), \
patch.object(restored,'prepare',return_value=[plan]), \
patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
patch.object(restored,'msg_error') as error,patch.object(restored,'msg_info2'):
self.assertEqual(restored.main(),1)
error.assert_called_once_with('Túto aplikáciu zatiaľ nemožno znova zaregistrovať:')
self.no_effects()
def test_real_host_resource_check_reports_missing_data_without_creating_anything(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
plan={'restored':[{'vmid':110,'config':'mp0: /fiction-only/data,mp=/data\n',
'copy':{'record':{'deployment':{},'observed':{}}}}], 'blockers':[],'notes':[]}
with patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
patch.object(restored.os.path,'isdir',return_value=False), \
patch.object(restored,'firewall_rule',return_value=None):
restored.check_host_resources(plan)
self.assertEqual(len(plan['blockers']),1)
self.assertIn('/fiction-only/data',plan['blockers'][0])
self.assertIn('registráciu zopakujte',plan['blockers'][0])
self.no_effects()
class RegistrationContractTests(unittest.TestCase):
def test_real_monitor_shell_recover_keeps_global_cli_contract_and_exit_six(self):
with tempfile.TemporaryDirectory() as folder:
base=Path(folder);(base/'scripts/oci').mkdir(parents=True)
(base/'utils.sh').write_text('load_language() { :; }\ninitialize_cache() { :; }\n')
(base/'scripts/oci/oci_manager_apps.sh').write_text('printf "%s\\n" "$@"\nexit 6\n')
script=(ROOT/'scripts/oci/manage_instance.sh').read_text()
script=script.replace('/usr/local/share/proxmenux',folder)
import os
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,
env={**os.environ,'ACTION':'recover','VMID':'not-a-vmid'})
self.assertEqual(result.returncode,6);self.assertEqual(result.stdout,'recover\n')
self.assertEqual(result.stderr,'')
def test_curated_slovak_registration_words_and_format_fields(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
expected={
'Recover restored OCI applications':'Registrácia',
'No restored OCI application is waiting to be recovered.':'registráciu',
'Recover them now?':'zaregistrovať',
'This application cannot be recovered yet:':'zaregistrovať',
'The application could not be recovered:':'zaregistrovať',
'Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.':'vyberte Nie',
'it is locked or its disk could not be mounted':'uzamknutý',
'The local storage does not accept snippets':'local',
'it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.':'NVIDIA Container Toolkit',
'the NVIDIA hook of the container is not available:':'spúšťací skript',
'its Rclone mount is published again on this host at':'v ceste',
'Choose another bridge for the container in Proxmox (Network) and run the recovery again.':'registráciu',
'Mount or create it with its data and run the recovery again.':'registráciu',
'Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.':'registráciu',
}
fields=lambda text:sorted(f for _,f,_,_ in string.Formatter().parse(text) if f)
for key,word in expected.items():
with self.subTest(key=key):
self.assertIn(word,catalog[key]);self.assertEqual(fields(key),fields(catalog[key]))
def test_registration_web_text_is_distinct_from_previous_installation_recovery(self):
for locale in ('en','sk'):
data=json.loads((ROOT/'AppImage/messages'/locale/'common.json').read_text())['vmLxc']
with self.subTest(locale=locale):
self.assertEqual(data['ociUpdates']['registerRestored'],
'Znova zaregistrovať' if locale=='sk' else 'Register again')
self.assertEqual(data['ociUpdates']['recover'],'Obnoviť' if locale=='sk' else 'Recover')
self.assertIn('zaregistrovať' if locale=='sk' else 'Register again',data['updates']['ociRestoredBody'])
self.assertIn('Zaregistrovať' if locale=='sk' else 'Register',data['ociUpdates']['terminalTitleRecover'])
if __name__=='__main__':unittest.main()
@@ -56,7 +56,8 @@ class RemovalWordings(unittest.TestCase):
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {}
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': translated or (lambda value: value), 're': re}
'translate': translated or (lambda value: value), 're': re,
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
@@ -87,7 +88,8 @@ class RemovalWordings(unittest.TestCase):
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': lambda value: value, 're': re}
'translate': lambda value: value, 're': re,
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
self.assertIn(WHOLE, preview)
@@ -0,0 +1,235 @@
"""Actual removal UI/guards in SK and EN; real host effects are forbidden."""
from contextlib import nullcontext
import copy
import json
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import i18n, management
from proxmenux_oci.ui import UserCancelled
import oci_instances as instances
import oci_remove as removal
import oci_ui
class RemovalSlovakTests(unittest.TestCase):
def setUp(self):
cache = json.loads((ROOT / 'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in (('_language', 'sk'), ('_cache', cache)):
p = patch.object(module, name, value)
p.start(); self.addCleanup(p.stop)
self.records = {}
self.make_records(2)
self.read = self.start(instances, 'read', side_effect=lambda root, vmid: self.records[vmid])
self.start(management, '_selected_configuration', side_effect=self.config)
self.bridge = self.start(removal, 'bridge_in_use', return_value=False)
self.ui = Mock()
self.ui.review.return_value = False
self.forbidden = []
for module, name in ((subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink'),
(removal.shutil, 'rmtree'), (instances, 'write'), (instances, 'command'),
(management, '_run_lifecycle'), (removal, 'run'),
(removal, 'remove_host_state'), (removal.oci_console, 'remove_log'),
(removal, 'release_bridge'), (removal, 'remove_owned_host_firewall'),
(removal, 'release_shared_host_files'), (removal.work_backup, 'forget'),
(removal.image_cache, 'prune')):
self.forbidden.append(self.start(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
def start(self, module, name, **kwargs):
p = patch.object(module, name, **kwargs)
result = p.start(); self.addCleanup(p.stop)
return result
def make_records(self, count):
self.records.clear()
for offset in range(count):
vmid = 999990 + offset
self.records[vmid] = {'vmid': vmid, 'installation_id': 'fixture-' + str(vmid),
'template': {'catalog_ui': {'title': 'Fixture App'}},
'deployment': {'mounts': [{'type': 'host-bind', 'source': '/fixture/saved',
'container_path': '/saved'}]}}
if offset:
self.records[vmid]['stack_member'] = {'primary_vmid': 999990}
if count > 1:
self.records[999990]['stack'] = {'members': [{'vmid': vmid} for vmid in self.records],
'deployment': {'network': {'private_bridge': 'vmbr999', 'private_subnet': '10.77.99.0/24'}}}
self.records[999990]['deployment']['host_firewall'] = {'port': 8080}
self.original = copy.deepcopy(self.records)
def config(self, vmid):
return (f'hostname: fixture-{vmid}\nrootfs: fixture:vm-{vmid}-disk-0,size=8G\n'
f'mp0: fixture:vm-{vmid}-disk-1,mp=/data,size=16G\n'
'mp1: /fixture/shared,mp=/shared\n').encode()
def tearDown(self):
self.assertEqual(self.records, self.original)
for effect in self.forbidden:
effect.assert_not_called()
def test_single_summary_separates_deleted_disks_and_retained_host_paths(self):
self.make_records(1)
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Kontajnery určené na odstránenie:', text)
self.assertIn('Údaje kontajnera určené na vymazanie:', text)
self.assertIn('/data (16G)', text)
kept = text.split('Cesty hostiteľa nájdené')[1]
self.assertIn('/fixture/shared', kept); self.assertIn('/fixture/saved', kept)
self.assertNotIn('/fixture/shared', text.split('Cesty hostiteľa nájdené')[0])
self.assertNotIn('súkromnej siete:', text)
def test_primary_summary_counts_are_grammatical_for_two_three_and_five(self):
for count in (2, 3, 5):
with self.subTest(count=count):
self.make_records(count)
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn(f'Počet kontajnerov aplikácie Fixture App: {count}.', text)
self.assertIn('Všetky sú určené na odstránenie.', text)
for vmid in self.records:
self.assertIn(f'CT {vmid}', text)
def test_member_summary_warns_that_entire_application_is_selected(self):
text = management._removal_summary(ROOT / 'oci', 999991)
self.assertIn('CT 999991 patrí do aplikácie Fixture App (počet kontajnerov: 2).', text)
self.assertIn('odstránenie celej aplikácie', text)
self.assertNotIn('nádob', text)
def test_free_bridge_is_only_targeted_and_firewall_removal_is_conditional(self):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Súkromná sieť určená na uvoľnenie, ak ju nepoužíva iná VM alebo iný CT: vmbr999', text)
self.assertIn('Môže sa odstrániť aj zodpovedajúce spravované pravidlo firewallu hostiteľa.', text)
def test_shared_bridge_is_kept_and_not_reported_as_released(self):
self.bridge.return_value = True
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Súkromná sieť zachovaná', text)
self.assertNotIn('určená na uvoľnenie', text)
def test_missing_config_still_discloses_saved_host_paths_without_deleting(self):
with patch.object(management, '_selected_configuration', side_effect=OSError('fixture missing')):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('/fixture/saved', text)
self.assertIn('rootfs -', text)
def test_no_from_real_remove_review_never_calls_lifecycle(self):
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999991))
review = self.ui.review.call_args
self.assertFalse(review.kwargs['default'])
self.assertEqual(review.kwargs['question'],
'Odstrániť aplikáciu? Jej kontajnerové disky sa vymažú a obnoviť ich možno iba zo zálohy.')
def test_cancelled_real_remove_review_never_calls_lifecycle(self):
self.ui.review.side_effect = UserCancelled('fixture cancelled')
with self.assertRaises(UserCancelled):
management._remove(ROOT / 'oci', self.ui, 999990)
def test_preparation_error_does_not_show_confirmation_or_call_lifecycle(self):
with patch.object(removal, 'members_of', side_effect=ValueError('fixture record invalid')):
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999990))
self.ui.review.assert_not_called()
self.assertIn('Odstránenie nebolo možné pripraviť:', self.ui.message.call_args.args[0])
def test_pending_operation_error_precedes_configuration_probe_and_all_mutations(self):
self.make_records(1)
self.records[999990]['pending_transaction'] = {'fixture': True}
self.original = copy.deepcopy(self.records)
with patch.object(removal, 'guest_config') as config:
with self.assertRaisesRegex(ValueError, 'Operácia tejto inštalácie sa ešte nedokončila'):
removal.remove(Path('/fixture/oci'), 999990)
config.assert_not_called()
def test_container_on_other_node_errors_before_host_cleanup(self):
with patch.object(removal, 'guest_config', return_value=None), \
patch.object(removal, 'guest_node', return_value='fixture-other-node'):
with self.assertRaisesRegex(ValueError, 'Kontajner beží na inom uzle klastra'):
removal.remove(Path('/fixture/oci'), 999990)
def run_main(self, result=False, busy=False):
with patch.object(sys, 'argv', ['oci_remove.py', '999990']), \
patch.object(removal.os, 'geteuid', return_value=0), \
patch.object(instances, 'locked', side_effect=BlockingIOError if busy else None,
return_value=nullcontext()), \
patch.object(removal, 'remove', return_value=result) as mocked_remove, \
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_error') as error:
code = removal.main()
return code, mocked_remove, ok, error
def test_busy_registry_error_does_not_enter_removal(self):
code, remove, ok, error = self.run_main(busy=True)
self.assertEqual(code, 1); remove.assert_not_called(); ok.assert_not_called()
self.assertEqual(error.call_args.args[0], 'Register inštancií používa iná operácia OCI')
def test_partial_completion_is_a_warning_to_review_not_unqualified_success(self):
code, remove, ok, error = self.run_main(result=True)
self.assertEqual(code, 0); error.assert_not_called()
self.assertEqual(ok.call_args.args[0],
'Príkaz na odstránenie sa dokončil; skontrolujte všetky upozornenia vyššie.')
self.assertNotEqual(ok.call_args.args[0], i18n.translate('The application was removed'))
def test_clean_completion_keeps_existing_success_contract(self):
code, remove, ok, error = self.run_main()
self.assertEqual(code, 0); error.assert_not_called()
self.assertEqual(ok.call_args.args[0], 'Aplikácia bola odstránená')
def test_english_primary_and_member_summaries_keep_identical_sentences(self):
with patch.object(i18n, '_language', 'en'):
primary = management._removal_summary(ROOT / 'oci', 999990)
member = management._removal_summary(ROOT / 'oci', 999991)
self.assertTrue(primary.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
self.assertTrue(member.startswith('CT 999991 is one of the 2 containers of Fixture App. It cannot be removed on its own,'))
def test_missing_translation_falls_back_to_the_same_english_summary(self):
with patch.object(i18n, '_cache', {}):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertTrue(text.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
def test_yes_only_builds_the_existing_command_when_lifecycle_is_inert(self):
self.ui.review.return_value = True
with patch.object(management, '_run_lifecycle', return_value=True) as inert:
self.assertTrue(management._remove(ROOT / 'oci', self.ui, 999990))
self.assertEqual(inert.call_args.args[0],
[sys.executable, str(ROOT / 'oci/remote/oci_remove.py'), '999990'])
def test_single_and_stack_menu_routes_use_the_translated_label_and_real_no_review(self):
for count in (1, 2):
with self.subTest(count=count):
self.make_records(count)
self.ui.reset_mock()
self.ui.choose.return_value = 'remove'
row = {'vmid': 999990, 'stack': count > 1, 'pending': False,
'status': 'installed', 'reason': 'matched'}
with patch.object(management, 'carry_records', return_value={}), \
patch.object(management, 'check_selected', return_value=row):
self.assertFalse(management.manage_instance(ROOT / 'oci', self.ui, row))
options = self.ui.choose.call_args.args[1]
self.assertIn(('remove', 'Odstrániť: zmazať aplikáciu a jej kontajnery'), options)
self.ui.review.assert_called_once()
self.assertFalse(self.ui.review.call_args.kwargs['default'])
def test_changed_identity_warning_does_not_claim_the_other_container_was_deleted(self):
self.make_records(1)
# Exercise the real backend branch; record cleanup is inert as well.
with patch.object(removal, 'guest_config', return_value=b'fixture'), \
patch.object(instances, 'identity', return_value='fixture-other-identity'), \
patch.object(removal, 'remove_owned_host_firewall', return_value=True), \
patch.object(removal, 'release_shared_host_files'), \
patch.object(removal.work_backup, 'forget'), \
patch.object(removal.image_cache, 'prune', return_value=[]), \
patch.object(Path, 'exists', return_value=False), \
patch.object(Path, 'is_dir', return_value=False), \
patch.object(removal, 'msg_warn') as warning, \
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_info'):
self.assertTrue(removal.remove(Path('/fixture/oci'), 999990))
self.assertEqual(warning.call_args.args[0],
'VMID teraz patrí inému kontajneru; ten zostane nedotknutý: CT 999990')
self.assertNotIn('Odstránený kontajner:', str(ok.mock_calls))
if __name__ == '__main__':
unittest.main()
@@ -7,7 +7,7 @@ from pathlib import Path
import subprocess
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
@@ -42,12 +42,56 @@ class SelectionSetupWording(TestCase):
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
_clean_orphans=lambda project: None,
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
carry_records=lambda project, **options: {},
carry_records=lambda project, **options: {}, _records_unread=lambda project: False,
translate=lambda s: s)
fn(Path('/fixture'), ui)
ui.message.assert_called_once_with(EMPTY, 'OCI management')
ui.choose.assert_not_called()
def _menu(self, inventories, selections, unread=True):
rows = iter(inventories)
ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections))
refresh = Mock(return_value={})
manage = Mock(return_value=True)
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows),
_clean_orphans=lambda project: None,
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
carry_records=refresh, _records_unread=lambda project: unread,
manage_instance=manage, menu_size=lambda: 10,
STATUS_LABELS={}, translate=lambda s: s)
fn(Path('/fixture'), ui)
return ui, refresh, manage
def test_the_list_comes_back_after_an_operation_without_refreshing_every_copy(self):
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
ui, refresh, manage = self._menu([[row], [row]], ['101', None])
self.assertEqual(ui.choose.call_count, 2)
manage.assert_called_once()
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True,
heading='Manage installed OCI applications')
def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self):
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
_, refresh, _ = self._menu([[row]], [None], unread=False)
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False,
heading='Manage installed OCI applications')
def test_removing_the_last_application_returns_without_a_message(self):
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
ui, _, manage = self._menu([[row], []], ['101'])
manage.assert_called_once()
ui.message.assert_not_called()
def test_the_watchdog_refreshes_the_copy_of_the_containers_it_changed(self):
refresh = Mock(return_value={})
watchdog = SimpleNamespace(set_watchdog=Mock(side_effect=[[101, 102]]))
fn = extracted('set_watchdog', sys=SimpleNamespace(path=[]), carry_records=refresh,
instances=SimpleNamespace(ROOT=Path('/fixture')))
with patch.dict('sys.modules', {'oci_watchdog': watchdog}):
self.assertTrue(fn(Path('/fixture'), [102], True))
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, vmids=[101, 102])
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
class Entry:
name = '101'
@@ -0,0 +1,239 @@
"""Exercise the multi-container editor in Slovak without applying a change."""
from contextlib import redirect_stderr
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import host, i18n, installer, management, stack_recreation as editor
import oci_immich_recognition as recognition
import oci_instances as instances
import oci_stack_modify as modify
import oci_ui
CONFIG = {
'rootfs': 'fixture:vm-999991-disk-0,size=8G',
'mp0': 'fixture:vm-999991-disk-1,mp=/opt/recipes/mediafiles,size=8G',
'mp1': 'fixture:vm-999991-disk-2,mp=/extra,size=4G',
'mp2': '/fixture/shared,mp=/shared',
'dev0': 'path=/dev/ttyACM7,mode=0660',
}
class StackEditorSlovakTests(unittest.TestCase):
def setUp(self):
self.translations = json.loads((ROOT / 'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in (('_language', 'sk'), ('_cache', self.translations)):
item = patch.object(module, name, value)
item.start()
self.addCleanup(item.stop)
self.member = {'vmid': 999991, 'stack_member': {'name': 'application'},
'deployment': {'replay_profile': {'adapter': 'install_tandoor_stack.sh',
'role': 'application'}}}
self.primary = {'vmid': 999990, 'stack': {'members': [self.member,
{'vmid': 999992, 'deployment': {'replay_profile': {'role': 'database'}}}]}}
self.original = copy.deepcopy(self.primary)
self.ui = Mock()
self.ui.choose.return_value = None
self.ui.confirm.return_value = False
self.ui.review.return_value = False
self.ui.checklist.side_effect = lambda text, options, defaults: list(defaults)
config = patch.object(editor, '_config', return_value=CONFIG)
config.start()
self.addCleanup(config.stop)
self.forbidden = []
for target, name in ((subprocess, 'run'), (subprocess, 'Popen'),
(subprocess, 'check_output'), (Path, 'write_text'),
(Path, 'write_bytes'), (instances, 'write'),
(instances, 'command'), (modify, 'apply'),
(recognition, 'apply'), (recognition, 'configure'),
(management, '_run_lifecycle')):
item = patch.object(target, name, side_effect=AssertionError('Forbidden effect: ' + name))
self.forbidden.append(item.start())
self.addCleanup(item.stop)
self.lifecycle = Mock(side_effect=AssertionError('Editor called lifecycle'))
self.forbidden.append(self.lifecycle)
def tearDown(self):
self.assertEqual(self.primary, self.original)
for callback in self.forbidden:
callback.assert_not_called()
def shown(self):
return '\n'.join(str(call) for call in self.ui.mock_calls)
def immich(self, acceleration='cpu'):
self.member['deployment']['replay_profile'] = {
'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}
self.member['deployment']['machine_learning'] = {'acceleration': acceleration}
self.original = copy.deepcopy(self.primary)
return self.member
def changes(self, **values):
return {'add_mounts': [], 'remove_mounts': [], 'add_devices': [],
'remove_devices': [], **values}
def test_editor_only_offers_extra_paths_and_leaves_database_out(self):
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
shown = self.shown()
self.assertIn('odznačené sa odstránia po potvrdení', shown)
self.assertIn('odznačené sa odpoja po potvrdení', shown)
options = self.ui.checklist.call_args_list[0].args[1]
self.assertEqual([path for path, label in options], ['/extra', '/shared'])
self.assertNotIn('zásobník', shown)
def test_declining_volume_deletion_keeps_it_and_only_detaches_host_path_in_draft(self):
self.ui.checklist.side_effect = [[], ['/dev/ttyACM7']]
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
warning = self.ui.confirm.call_args_list[0].args[0]
self.assertIn('trvalé úložisko aj všetky údaje v ňom', warning)
self.assertIn('/extra', warning)
summary = self.ui.review.call_args.args[0]
self.assertIn('- /shared', summary)
self.assertNotIn('- /extra', summary)
self.assertIn('Spustený kontajner sa reštartuje', summary)
self.assertIn('zastavený zostane zastavený', summary)
self.assertIn('Databáza ani ostatné kontajnery sa nemenia', summary)
def test_declined_review_describes_confirmed_volume_deletion_without_performing_it(self):
self.ui.checklist.side_effect = [[], []]
self.ui.confirm.side_effect = [True, False, False]
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('- /extra', self.ui.review.call_args.args[0])
self.assertIn('vymažú aj údaje', self.ui.review.call_args.args[0])
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Použiť tieto zmeny?')
def test_member_selection_cancels_before_any_configuration_read(self):
other = copy.deepcopy(self.member)
other['vmid'] = 999993
self.primary['stack']['members'].append(other)
self.original = copy.deepcopy(self.primary)
with patch.object(editor, '_config') as read:
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
read.assert_not_called()
self.assertEqual(self.ui.choose.call_args.args[0], 'Kontajner, ktorý chcete upraviť')
def test_missing_member_error_uses_application_language(self):
self.primary['stack']['members'] = []
self.original = copy.deepcopy(self.primary)
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('Pre túto aplikáciu nie je uložený zoznam kontajnerov.', self.shown())
self.assertIn('Správa OCI aplikácie s viacerými kontajnermi', self.shown())
def test_immich_action_menu_identifies_recognition_instead_of_video_transcoding(self):
self.immich()
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('Vybrať CPU alebo GPU pre rozpoznávanie', self.shown())
self.assertIn('Upraviť pripojené cesty a zariadenia', self.shown())
def test_cpu_only_host_explains_why_no_gpu_choice_is_offered(self):
member = self.immich()
with patch.object(host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': []}):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
self.assertIn('nenašla použiteľná grafická karta', self.shown())
self.assertIn('Rozpoznávanie zostáva na CPU.', self.shown())
self.ui.review.assert_not_called()
def test_gpu_review_explains_whole_application_update_and_kept_data_before_no(self):
member = self.immich()
self.ui.choose.return_value = 'openvino'
found = {'intel': ['/dev/dri/renderD128'], 'amd': [], 'nvidia': []}
with patch.object(host, 'gpus', return_value=found):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
choices = self.ui.choose.call_args.args[1]
self.assertEqual(choices, [('cpu', 'CPU'), ('openvino', 'Intel GPU')])
text = self.ui.review.call_args.args[0]
for fragment in ('CPU → Intel GPU', 'pamäť modelov, knižnica a databáza sa zachovajú',
'Celá aplikácia sa zastaví a aktualizuje', 'ProxMenux sa pokúsi obnoviť',
'ponuky správy OCI'):
self.assertIn(fragment, text)
self.assertEqual(self.ui.review.call_args.kwargs['question'],
'Použiť zvolený CPU/GPU pre rozpoznávanie?')
def test_unchanged_recognition_does_not_open_confirmation_or_apply(self):
member = self.immich()
self.ui.choose.return_value = 'cpu'
with patch.object(editor, 'recognition_choices', return_value=[('cpu', 'CPU', {}),
('cuda', 'NVIDIA GPU', {})]):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
self.ui.review.assert_not_called()
self.assertIn('Nič sa nezmenilo.', self.shown())
def test_experimental_rocm_can_be_declined_before_final_review(self):
member = self.immich()
self.ui.choose.return_value = 'rocm'
choices = [('cpu', 'CPU', {}), ('rocm', 'AMD GPU', {'experimental': True})]
with patch.object(editor, 'recognition_choices', return_value=choices), \
patch.object(host, 'amd_gpu_name', return_value='Fixture AMD'):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
text = self.ui.confirm.call_args.args[0]
self.assertIn('ROCm túto grafickú kartu oficiálne nepodporuje.', text)
self.assertIn('GPU prestať reagovať', text)
self.assertIn('bez preinštalovania', text)
self.assertIn('Použiť napriek tomu GPU s ROCm?', text)
self.assertFalse(self.ui.confirm.call_args.args[1])
self.ui.review.assert_not_called()
def test_backend_invalid_volume_size_preserves_path_and_fails_before_mutation(self):
mount = {'type': 'managed-volume', 'container_path': '/new-data',
'size_gb': 0, 'source': 'fixture'}
with patch.object(modify, 'entries', return_value={}):
with self.assertRaisesRegex(ValueError, 'Neplatná veľkosť trvalého úložiska: /new-data'):
modify.validate(999991, self.changes(add_mounts=[mount]))
def test_backend_unsupported_device_explains_separate_profile_before_mutation(self):
device = {'kind': 'character-device', 'host_path': '/dev/sda'}
with patch.object(modify, 'entries', return_value={}):
with self.assertRaisesRegex(ValueError, 'NVIDIA a celé stromy zariadení vyžadujú iný profil: /dev/sda'):
modify.validate(999991, self.changes(add_devices=[device]))
def test_root_error_is_localized_without_starting_the_operation(self):
with patch.object(modify.os, 'geteuid', return_value=1000), \
patch.object(sys, 'argv', ['fixture', '999991', '--changes', '/not-read.json']), \
redirect_stderr(io.StringIO()) as output:
with self.assertRaises(SystemExit) as result:
modify.main()
self.assertEqual(result.exception.code, 2)
self.assertIn('Vyžadujú sa oprávnenia používateľa root', output.getvalue())
self.assertNotIn('required', output.getvalue())
def test_invalid_recognition_profile_is_localized_without_any_gpu_probe(self):
with self.assertRaisesRegex(ValueError, 'Profil GPU pre Immich nebol overený'):
recognition.validate('unknown', None, None)
def test_recognition_errors_distinguish_unchanged_from_incomplete_operation(self):
incomplete = RuntimeError('fixture diagnostic')
incomplete.recognition_kept = False
for error, expected in ((ValueError('fixture diagnostic'), 'Spôsob rozpoznávania v Immichu sa nezmenil:'),
(incomplete, 'Zmenu CPU/GPU pre rozpoznávanie v Immichu sa nepodarilo dokončiť:')):
with self.subTest(error=type(error).__name__), \
patch.object(recognition.os, 'geteuid', return_value=0), \
patch.object(sys, 'argv', ['fixture', '999990', '--acceleration', 'cpu']), \
patch.object(recognition, 'change', side_effect=error), \
patch.object(recognition, 'msg_error') as message:
self.assertEqual(recognition.main(), 1)
self.assertEqual(message.call_args.args[0], expected + ' fixture diagnostic')
def test_english_and_missing_translation_fallback_preserve_source_text(self):
changes = self.changes(remove_mounts=['/extra'])
for language, cache in (('en', self.translations), ('sk', {})):
with self.subTest(language=language, empty_cache=not cache), \
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
text = editor.summary(self.member, changes)
self.assertIn('A running container is restarted', text)
self.assertIn('- /extra', text)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,196 @@
"""Coordinated update/recreate contracts; no host lifecycle is permitted."""
import copy
import json
from pathlib import Path
import subprocess
import sys
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
import oci_instances as instances
import oci_stack_plan as stack_plan
import oci_stack_transaction as transaction
import oci_ui
class StackFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.row['stack'] = True
self.record.update(installation_id='fiction-main', stack={
'id': 'fiction-main', 'members': [
{'vmid': vmid, 'installation_id': identity, 'status': 'installed',
'stack_member': {'stack_id': 'fiction-main', 'primary_vmid': 110}}
for vmid, identity in [(110, 'fiction-main'), (111, 'fiction-dependent')]],
'deployment': {'services': [{'vmid': 111}, {'vmid': 110}]}})
self.original = copy.deepcopy(self.record)
self.extra_effects = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
self.extra_effects += [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
self.extra_effects += [self.guard(transaction, 'execute'), self.guard(transaction, 'save'),
self.guard(menu.images, 'offer_removal')]
def no_effects(self):
super().no_effects()
for effect in self.extra_effects:
effect.assert_not_called()
class StackCancellationTests(StackFixture):
def test_update_and_recreate_no_are_falsy_distinct_cancellation(self):
for action in ('update', 'recreate'):
with self.subTest(action=action):
result = menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
self.assertIs(result, menu.CANCELLED)
self.assertFalse(result)
self.no_effects()
def test_real_cli_cancel_returns_six_for_both_actions(self):
for action in ('update', 'recreate'):
with self.subTest(action=action), patch.object(cli, 'Catalog'):
self.assertEqual(cli.main(['manage', '110', '--action', action]), 6)
self.no_effects()
def test_actual_dialog_no_and_escape_return_six_before_any_effect(self):
dialog = native_ui.DialogUI()
for action in ('update', 'recreate'):
for code in (1, 255):
with self.subTest(action=action, code=code), \
patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 6)
self.no_effects()
def test_confirmation_defaults_and_member_count_are_preserved(self):
for action, default in [('update', True), ('recreate', False)]:
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
args, kwargs = self.ui.review.call_args
self.assertIn('2 containers', args[0])
self.assertIn('main CT: 110', args[0])
self.assertEqual(kwargs['default'], default)
self.no_effects()
def test_native_menu_returns_to_the_application_list_after_no(self):
for action in ('update', 'recreate'):
with self.subTest(action=action):
self.ui.choose.reset_mock()
self.ui.choose.side_effect = ['110', action, None]
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT/'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 3)
self.no_effects()
def test_batch_cancel_never_checks_the_next_application(self):
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 112, 'title': 'Second'}]
self.ui.checklist.return_value = ['all']
self.ui.review.side_effect = [True, False]
with patch.object(updates, 'UpdateCheck') as checker, \
patch.object(menu, 'saved_inventory', return_value=[self.row, {**self.row, 'vmid': 112}]), \
patch.object(menu, 'check_selected', side_effect=lambda project, row: row) as selected:
checker.return_value.status.return_value = {'available': available}
self.assertFalse(updates.select_updates(ROOT/'oci', self.ui, available))
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
self.no_effects()
def test_rejected_backup_remains_failure_one_without_dispatch(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = False
for action in ('update', 'recreate'):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 1)
self.lifecycle.assert_not_called()
self.assertEqual(self.record, self.original)
def test_positive_confirmation_preserves_success_failure_and_exact_commands(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
for action in ('update', 'recreate'):
for result in (False, True):
self.lifecycle.reset_mock()
self.lifecycle.return_value = result
with patch.object(menu.images, 'offer_removal') as offer:
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action,
lifecycle_args=('--keep-backup',)), 0 if result else 1)
expected = [sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--keep-backup']
if action == 'recreate':
expected += ['--operation', 'recreate']
self.assertEqual(self.lifecycle.call_args.args[0], expected+['--acknowledge-external-data'])
self.assertEqual(offer.call_count, int(result))
self.assertEqual(self.record, self.original)
self.write.assert_not_called()
self.command.assert_not_called()
def test_acknowledgement_is_not_duplicated(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = False
menu.direct_management(ROOT/'oci', 110, 'recreate', lifecycle_args=('--acknowledge-external-data',))
self.assertEqual(self.lifecycle.call_args.args[0].count('--acknowledge-external-data'), 1)
class StackSlovakTests(StackFixture):
def setUp(self):
super().setUp()
self.catalog = json.loads((ROOT/'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in [('_language', 'sk'), ('_cache', self.catalog)]:
item = patch.object(module, name, value)
item.start()
self.addCleanup(item.stop)
def test_questions_and_titles_name_the_whole_application(self):
for action, question in [('update', 'Aktualizovať celú aplikáciu?'),
('recreate', 'Znovu vytvoriť všetky kontajnery aplikácie?')]:
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
self.assertEqual(self.ui.review.call_args.kwargs['question'], question)
self.assertIn('aplikáciu s viacerými kontajnermi', self.ui.review.call_args.args[1])
self.no_effects()
def test_recreation_dialog_explains_saved_images_and_restoring_backups(self):
menu.manage_instance(ROOT/'oci', self.ui, self.row, action='recreate')
text = self.ui.review.call_args.args[0]
self.assertIn('uložených identifikátorov obrazov', text)
self.assertIn('2', text)
self.assertIn('110', text)
self.assertIn('zálohy sa obnovia', text)
self.assertNotIn('Stack', text)
self.no_effects()
def test_shared_missing_primary_error_in_plan_and_publish_before_any_effect(self):
primary = copy.deepcopy(self.record)
primary['vmid'] = 112
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
stack_plan.build(primary, {}, {}, 'update')
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
instances.publish_stack(Path('/fiction-only'), 110, {}, {}, [])
self.no_effects()
def test_missing_member_error_keeps_volume_verification_requirement(self):
records = {row['vmid']: row for row in self.record['stack']['members']}
with self.assertRaisesRegex(ValueError, 'znova ich vytvorte až po overení ich dátových zväzkov'):
stack_plan.build(self.record, records, {110: 'fiction-main'}, 'update')
self.no_effects()
def test_pending_and_conflicting_member_errors_keep_the_identifier(self):
records = {row['vmid']: copy.deepcopy(row) for row in self.record['stack']['members']}
inventory = {key: row['installation_id'] for key, row in records.items()}
records[111]['status'] = 'pending'
with self.assertRaisesRegex(ValueError, '^Nedokončená operácia existuje pre CT 111$'):
stack_plan.build(self.record, records, inventory, 'update')
records[111]['status'] = 'installed'
inventory[111] = 'different-installation'
with self.assertRaisesRegex(ValueError, '^Iná inštancia už používa VMID 111$'):
stack_plan.build(self.record, records, inventory, 'update')
self.no_effects()
def test_plan_failure_is_read_only_and_distinguishes_invalid_identity(self):
primary = copy.deepcopy(self.record)
primary['stack']['id'] = 'different-stack'
with self.assertRaisesRegex(ValueError, '^Nekonzistentná identita aplikácie s viacerými kontajnermi$'):
stack_plan.build(primary, {}, {}, 'recreate')
self.no_effects()
@@ -0,0 +1,210 @@
"""Slovak storage questions through real builders and UI; installation forbidden."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
ROOT=Path(__file__).resolve().parents[3]
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
from proxmenux_oci import cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
from proxmenux_oci.catalog import Catalog
class Recorder:
def __init__(self, answers=None):
self.answers=answers or {}
self.asked=[]
self.messages=[]
def _answer(self,text,default):
self.asked.append(text)
return self.answers.get(text,default)
def ask(self,text,default=None,**kwargs):return self._answer(text,default if default is not None else '')
def password(self,text,**kwargs):return self._answer(text,'fictional-value-for-tests-000000')
def choose(self,text,options,default=None,**kwargs):return self._answer(text,default)
def confirm(self,text,default=False,**kwargs):return self._answer(text,default)
def checklist(self,text,options,defaults=None,**kwargs):return self._answer(text,defaults or [])
def review(self,*args,**kwargs):return False
def info(self,text):pass
def message(self,text,*args):self.messages.append(text)
class StorageFixture(unittest.TestCase):
def setUp(self):
self.catalog=Catalog(ROOT/'oci')
self.sk=json.loads((ROOT/'lang/sk.json').read_text())
self.forbidden=[]
self.patch(i18n,'_language','sk')
self.patch(i18n,'_cache',self.sk)
self.patch(installer.host,'storages',side_effect=lambda content: [
{'storage':'local-lvm','type':'lvmthin','avail':100*2**30},
{'storage':'fiction-storage','type':'dir','avail':200*2**30}] if content=='rootdir' else [
{'storage':'local','type':'dir','avail':100*2**30}])
self.patch(installer.host,'bridges',return_value=[{'iface':'vmbr0','cidr':'192.0.2.10/24'}])
self.patch(installer.host,'timezone',return_value='Europe/Bratislava')
self.patch(installer.host,'gpus',return_value={'intel':[],'amd':[],'nvidia':False})
self.patch(installer.access,'ask_addresses',side_effect=lambda ui,bridge,names,*a:({name:'dhcp' for name in names},None))
self.patch(installer.access,'ask_ipv4',return_value=('dhcp',None))
for module,name in [(installer,'run_remote_install'),(cli,'run_remote_install'),
(cli.images,'offer_removal'),(subprocess,'run'),(subprocess,'Popen'),(subprocess,'check_output'),
(Path,'write_text'),(Path,'write_bytes'),(Path,'unlink')]:
self.forbidden.append(self.patch(module,name,side_effect=AssertionError('Forbidden effect: '+name)))
self.addCleanup(self.no_effects)
def patch(self,module,name,*args,**kwargs):
item=patch.object(module,name,*args,**kwargs);value=item.start();self.addCleanup(item.stop);return value
def no_effects(self):
for effect in self.forbidden:effect.assert_not_called()
def recorder(self,answers=None):
return Recorder({i18n.translate(key):value for key,value in (answers or {}).items()})
def build(self,app,answers=None,mode=installer.ADVANCED_MODE):
ui=self.recorder(answers)
plan=installer.build_deployment(self.catalog.compose(app),ui,mode)
return ui,plan
class StorageWordingTests(StorageFixture):
def test_tandoor_files_question_and_minimum_error_keep_the_limit(self):
ui,plan=self.build('tandoor',{'Files volume size in GB':'9'})
self.assertIn('Veľkosť dátového zväzku pre súbory v GB',ui.asked)
self.assertEqual(plan['media']['size_gb'],9)
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok súborov Tandoor musí mať najmenej 2 GB'):
self.build('tandoor',{'Files volume size in GB':'1'})
def test_paperless_storage_documents_and_consume_export_names(self):
ui,plan=self.build('paperless-ngx',{'Documents volume size in GB':'30',
'Where to store the consume and export folders':'managed-volume','Size of each consume/export volume in GB':'3'})
for text in ['Úložisko pre údaje a dokumenty aplikácie Paperless',
'Kam uložiť priečinky consume a export',
'Veľkosť dátového zväzku pre dokumenty v GB',
'Veľkosť každého dátového zväzku pre consume a export v GB']:
self.assertIn(text,ui.asked)
self.assertEqual((plan['media_size_gb'],plan['transfer']['size_gb'],plan['transfer']['backup']),(30,3,True))
def test_paperless_volume_limit_and_transfer_limit_errors(self):
with self.assertRaisesRegex(installer.InstallError,'Trvalé dátové zväzky aplikácie Paperless musia mať najmenej 8 GB'):
self.build('paperless-ngx',{'Documents volume size in GB':'7'})
with self.assertRaisesRegex(installer.InstallError,'Dátové zväzky pre consume a export musia mať najmenej 1 GB'):
self.build('paperless-ngx',{'Where to store the consume and export folders':'managed-volume',
'Size of each consume/export volume in GB':'0'})
def test_postgresql_question_and_error_are_shared_by_immich_and_nextcloud(self):
for app in ('immich','nextcloud-stack'):
with self.subTest(app=app):
ui,plan=self.build(app,{'PostgreSQL volume size in GB':'12'})
self.assertIn('Veľkosť dátového zväzku PostgreSQL v GB',ui.asked)
self.assertEqual(plan['database_size_gb'],12)
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok PostgreSQL musí mať najmenej 8 GB'):
self.build(app,{'PostgreSQL volume size in GB':'7'})
def test_host_directory_remains_outside_the_container_backup(self):
ui,plan=self.build('nextcloud-stack',{'Where to store the Nextcloud files, configuration and data':'host-bind',
'Shared host directory':'/fiction-only/existing-data'})
self.assertIn('Zdieľaný adresár hostiteľa',ui.asked)
self.assertEqual((plan['application']['mode'],plan['application']['host_path'],plan['application']['backup']),
('host-bind','/fiction-only/existing-data',False))
def test_default_and_advanced_builders_preserve_language_independent_plans(self):
for app in ('jellyfin','immich','nextcloud-stack','paperless-ngx','tandoor'):
for mode in (installer.DEFAULT_MODE,installer.ADVANCED_MODE):
with self.subTest(app=app,mode=mode):
_ui,slovak=self.build(app,mode=mode)
with patch.object(i18n,'_language','en'):
_ui,english=self.build(app,mode=mode)
self.assertEqual(slovak,english)
def test_multi_path_cancel_and_invalid_selection_preserve_all_members(self):
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
{'name':'database','deployment':{'mounts':[]}}]
original=copy.deepcopy(services)
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':[]})
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertIn('Pridať ďalšie cesty do aplikácie s viacerými kontajnermi',ui.asked)
self.assertEqual(services,original)
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main'],
'Data location':None})
with self.assertRaises(native_ui.UserCancelled):custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertEqual(services,original)
def test_shared_selection_error_in_device_flow_also_preserves_members(self):
services=[{'name':'main','main':True,'deployment':{}}];original=copy.deepcopy(services)
ui=self.recorder()
with patch.object(extra_devices,'ask_extra_devices',return_value=[{'kind':'usb','host_path':'/fiction-only/device'}]), \
patch.object(ui,'checklist',return_value=[]):
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
extra_devices.ask_stack_extra_devices(ui,services)
self.assertEqual(services,original)
def test_selected_path_is_added_only_to_selected_member_with_backup(self):
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
{'name':'database','deployment':{'mounts':[]}}]
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main']})
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertEqual(services[1]['deployment']['mounts'],[])
mount=services[0]['deployment']['mounts'][0]
self.assertEqual((mount['source'],mount['container_path'],mount['size_gb'],mount['backup']),
('fiction-storage','/media-extra',8,True))
self.assertIn('Pridať ďalšiu cestu do aplikácie s viacerými kontajnermi',ui.asked)
def test_rclone_shared_directory_question_is_localized_without_creating_it(self):
ui=self.recorder({'Shared directory for copy/sync operations':'/fiction-only/rclone'})
with patch.object(ui,'confirm',return_value=True):
plan=installer.build_deployment(self.catalog.compose('rclone'),ui,installer.ADVANCED_MODE)
self.assertIn('Zdieľaný adresár na kopírovanie a synchronizáciu',ui.asked)
self.assertTrue(any(m.get('source')=='/fiction-only/rclone' and not m['backup'] for m in plan['mounts']))
class InputAndCancellationTests(StorageFixture):
def test_dialog_required_value_and_password_use_slovak_then_retry(self):
dialog=native_ui.DialogUI()
empty=subprocess.CompletedProcess('dialog',0,stderr='')
value=subprocess.CompletedProcess('dialog',0,stderr='fiction-value')
with patch.object(dialog,'_run',side_effect=[empty,value]),patch.object(dialog,'message') as message:
self.assertEqual(dialog.ask('fiction-field'),'fiction-value')
message.assert_called_once_with('Táto hodnota je povinná.')
with patch.object(dialog,'_run',side_effect=[empty,value,value]),patch.object(dialog,'message') as message:
self.assertEqual(dialog.password('fiction-field'),'fiction-value')
message.assert_called_once_with('Táto hodnota je povinná.')
def test_terminal_required_value_and_password_are_also_localized(self):
terminal=native_ui.TerminalUI()
with patch('builtins.input',side_effect=['','fiction-value']),redirect_stdout(io.StringIO()) as output:
self.assertEqual(terminal.ask('fiction-field'),'fiction-value')
self.assertIn('Táto hodnota je povinná.',output.getvalue())
with patch.object(native_ui.getpass,'getpass',side_effect=['','fiction-value','fiction-value']),redirect_stdout(io.StringIO()) as output:
self.assertEqual(terminal.password('fiction-field'),'fiction-value')
self.assertIn('Táto hodnota je povinná.',output.getvalue())
def test_real_install_flow_storage_cancel_and_final_no_never_dispatch(self):
template=self.catalog.compose('tandoor');original=copy.deepcopy(template)
dialog=native_ui.DialogUI()
for code in (1,255):
replies=[subprocess.CompletedProcess('dialog',0,stderr=value) for value in ('','fiction-stack','2','2048')]
replies.append(subprocess.CompletedProcess('dialog',code,stderr=''))
with self.subTest(code=code),patch.object(dialog,'_run',side_effect=replies) as run:
with self.assertRaises(native_ui.UserCancelled):cli.install_template(dialog,template,'tandoor',installer.ADVANCED_MODE)
self.assertEqual(run.call_count,5)
self.assertEqual(template,original)
self.assertIsNone(cli.install_template(self.recorder(),template,'tandoor',installer.ADVANCED_MODE))
self.assertEqual(template,original)
def test_numeric_retry_keeps_previous_answers_and_already_correct_slovak(self):
ui=Recorder();wizard=native_ui.BacktrackUI(ui)
wizard.answers=[('ask','previous'),('ask','abc')];wizard.cursor=2
self.assertTrue(wizard.retry_last(ValueError("invalid literal for int() with base 10: 'abc'")))
self.assertEqual(ui.messages,["Hodnota musí byť číslo: 'abc'\n\nZadajte hodnotu znova."])
self.assertEqual((wizard.answers,wizard.cursor),([('ask','previous')],0));wizard.close()
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
ui=Mock();ui.choose.side_effect=['custom',None]
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
patch.object(updates,'UpdateCheck') as checker:
check=checker.return_value.__enter__.return_value
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
self.assertEqual(cli.interactive(self.catalog),0)
ui.message.assert_called_once_with('Operáciu sa nepodarilo dokončiť:\n\nfiction-detail')
@@ -0,0 +1,168 @@
"""Real native OCI menu/CLI paths with all external effects replaced."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
import oci_instances as instances
import oci_instance_reconcile as reconcile
class CancellationFixture(unittest.TestCase):
def setUp(self):
self.record = {'status': 'installed', 'vmid': 110, 'deployment': {}, 'template': {}}
self.original = copy.deepcopy(self.record)
self.row = dict(vmid=110, reason='matched', stack=False, pending=False, status='installed',
title='Fixture application', hostname='fixture', image='fixture:latest')
self.ui = Mock()
self.ui.unattended = False
self.ui.review.return_value = False
for module, name, options in [
(i18n, '_language', dict(new='en')), (i18n, '_cache', dict(new={})),
(menu.os, 'geteuid', dict(return_value=0)), (menu.shutil, 'which', dict(return_value='/fixture/pct')),
(native_ui, 'interactive_ui', dict(return_value=self.ui)),
(menu, 'recover_automatically', dict(return_value=None)),
(menu, 'carry_records', dict(return_value={})),
(menu, 'check_selected', dict(side_effect=lambda project, row: row)),
(menu, 'saved_inventory', dict(return_value=[self.row])),
(menu, '_selected_configuration', dict(return_value=b'fixture configuration')),
(instances, 'read', dict(return_value=self.record)),
(reconcile, 'propose', dict(return_value=None)),
]:
item = patch.object(module, name, **options)
item.start()
self.addCleanup(item.stop)
self.lifecycle = self.guard(menu, '_run_lifecycle')
self.backup = self.guard(menu, '_work_backup_ready')
self.write = self.guard(instances, 'write')
self.command = self.guard(instances, 'command')
self.commit = self.guard(reconcile, 'commit')
def guard(self, module, name):
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
mock = item.start()
self.addCleanup(item.stop)
return mock
def no_effects(self):
for mock in [self.lifecycle, self.backup, self.write, self.command, self.commit]:
mock.assert_not_called()
self.assertEqual(self.record, self.original)
class UpdateCancellationTests(CancellationFixture):
def test_no_is_a_distinct_falsy_result_before_backup_or_lifecycle(self):
result = menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
self.assertIs(result, menu.CANCELLED)
self.assertFalse(result)
self.no_effects()
def test_real_cli_no_returns_cancel_code_instead_of_failure_or_success(self):
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
result = cli.main(['manage', '110', '--action', 'update'])
self.assertEqual(result, 6)
self.assertEqual(output.getvalue(), '') # The terminal renders the localized outcome once.
self.no_effects()
def test_no_in_stack_confirmation_keeps_every_member_unchanged(self):
self.row['stack'] = True
self.record['stack'] = {'members': [{'vmid': 110}, {'vmid': 111}]}
self.original = copy.deepcopy(self.record)
with redirect_stdout(io.StringIO()):
result = menu.direct_management(ROOT / 'oci', 110, 'update')
self.assertEqual(result, 6)
self.no_effects()
def test_native_menu_returns_to_its_list_after_no(self):
self.ui.choose.side_effect = ['110', 'update', None]
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT / 'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 3)
self.no_effects()
def test_batch_no_does_not_start_the_next_application(self):
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 111, 'title': 'Second'}]
rows = [self.row, {**self.row, 'vmid': 111}]
self.ui.checklist.return_value = ['all']
self.ui.review.side_effect = [True, False] # Batch review, first application's confirmation.
with patch.object(updates, 'UpdateCheck') as checker, patch.object(menu, 'saved_inventory', return_value=rows), \
patch.object(menu, 'check_selected', wraps=lambda project, row: row) as selected:
checker.return_value.status.return_value = {'available': available}
self.assertFalse(updates.select_updates(ROOT / 'oci', self.ui, available))
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
self.no_effects()
def test_accepted_update_failure_keeps_failure_code_one(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = False
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
self.lifecycle.assert_not_called()
self.write.assert_not_called()
def test_accepted_update_preserves_success_and_existing_lifecycle_command(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = True
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 0)
self.lifecycle.assert_called_once()
command = self.lifecycle.call_args.args[0]
self.assertEqual(command[:3], [sys.executable, str(ROOT / 'oci/remote/oci_update_current.py'), '110'])
self.assertIn('--acknowledge-external-data', command)
self.write.assert_not_called()
def test_actual_lifecycle_failure_remains_failure_after_positive_confirmation(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = False
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
self.lifecycle.assert_called_once()
self.write.assert_not_called()
def test_unattended_positive_default_still_reaches_existing_update(self):
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = True
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 0)
self.lifecycle.assert_called_once()
self.write.assert_not_called()
def test_existing_unattended_review_age_busy_and_missing_codes_are_preserved(self):
with patch.object(reconcile, 'propose', return_value={'details': ['external change']}), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
with patch.object(menu, 'saved_inventory', return_value=[]):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 2)
with patch.object(menu, 'recover_automatically', side_effect=BlockingIOError()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 3)
self.no_effects()
def test_slovak_dialog_and_recovery_choice_have_the_approved_meaning(self):
cache = json.loads((ROOT / 'lang/sk.json').read_text())
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
self.assertEqual(i18n.translate('Recover the previous installation'), 'Obnoviť predchádzajúcu inštaláciu')
text = self.ui.review.call_args.args[0]
for part in ['novší obraz aplikácie', 'overí sa jej integrita', 'pokúsi obnoviť', 'Pripojené adresáre hostiteľa']:
self.assertIn(part, text)
self.assertNotIn('kanál obrazov', text)
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Spustiť aktualizáciu?')
self.no_effects()
if __name__ == '__main__':
unittest.main()
@@ -3,6 +3,7 @@ import ast
from contextlib import nullcontext
import copy
import json
import os
from pathlib import Path
import re
import runpy
@@ -23,9 +24,12 @@ OLD_GUARD = 'The current template changes the image or identity; an explicit mig
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with '
'the new image to update the container. If the update fails after the container was changed, the backup '
'is restored. An update that stops halfway is recovered from the OCI management menu.')
PREVIEW = ('If a newer application image is available, the container is stopped and backed up, and the backup '
'integrity is verified. The container is then updated using the new image.\n\n'
'If the update fails after the container was changed, ProxMenux attempts to restore the previous '
'installation from the backup. If the operation remains incomplete, select the application in OCI '
'management and choose "Recover the previous installation".\n\n'
'Host directories mounted in the container are not restored by the container backup.')
def extracted(path, name, scope):
@@ -129,7 +133,9 @@ class UpdateWording(unittest.TestCase):
reconcile = ModuleType('oci_instance_reconcile')
reconcile.propose = lambda *args: None
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
'translate': lambda text: text, 'check_selected': lambda project, row: row,
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
'images': SimpleNamespace(offer_removal=lambda *args: None),
'carry_records': lambda project, **options: {},
'_work_backup_ready': lambda project, ui, vmid: True,
+11 -8
View File
@@ -17,13 +17,15 @@ MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
CHOICE = 'Recover the previous installation'
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
REGISTRY_ADVICE = ('Check the image reference and registry access. If the registry is unavailable, '
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
NEW_KEYS = (
'The Compose file describes several services:',
'Only one service at a time can be installed this way.',
'No docker run command was given',
'Image selection was cancelled',
'Could not inspect the image in its registry:',
'Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.',
REGISTRY_ADVICE,
BEFORE, AFTER,
)
@@ -44,7 +46,8 @@ def function(path, name, **dependencies):
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024)
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024,
MAX_OCI_COMPOSE_BYTES=2*1024*1024, OCI_COMPOSE='oci-compose')
scope.update(dependencies)
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(path), 'exec'), scope)
@@ -116,16 +119,16 @@ class OciWordingTests(TestCase):
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
self.assertEqual(run.call_count, 1)
def test_failed_registry_inspection_has_neutral_advice(self):
def test_failed_registry_inspection_points_to_registry_accounts(self):
ui = SimpleNamespace(message=Mock())
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
fn = function(CUSTOM, '_explore', read_definition=lambda _: ('definition', 'source'),
ignored_settings=lambda _: [], template_from_compose=lambda _: {
'container_contract': {'image': {'reference': 'example:latest'}}},
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'))
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
_registry_refused=lambda _: False)
fn(ui)
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n'
'Check the image reference and registry access. If the registry is unavailable, '
'try again later. A private registry needs credentials, which are not supported yet.')
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n' + REGISTRY_ADVICE)
self.assertNotIn('not supported yet', ui.message.call_args.args[0])
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
# Pin the real translated management choice, not an English AST constant alone.
@@ -0,0 +1,135 @@
"""Wording contracts through the real metadata parser and exact-match formatter.
No post-install function or scanner is imported or executed. Node 22.6+ is
required, as in test_command_descriptions.py; no npm dependencies are needed.
"""
import ast
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
class OptimizationDescriptionWordingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Admit only the pure parser and its actual dependencies, not the scanner.
path = ROOT / "AppImage/scripts/post_install_versions.py"
functions = {"_read_text", "_version_tuple", "parse_post_install_script"}
constants = {"_FN_DEF_RE", "_VERSION_RE", "_DESC_RE", "_REGISTER_RE", "_HEREDOC_RE"}
nodes: list[ast.stmt] = [node for node in ast.parse(path.read_text(encoding="utf-8")).body
if (isinstance(node, ast.FunctionDef) and node.name in functions)
or (isinstance(node, ast.Assign) and any(
isinstance(target, ast.Name) and target.id in constants
for target in node.targets))]
if len(nodes) != len(functions) + len(constants):
raise AssertionError("Metadata parser seam changed: review its dependencies")
scope = {"re": re, "Path": Path}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), scope)
cls.metadata = {
source: scope["parse_post_install_script"](ROOT / "scripts/post_install" / filename)
for source, filename in (("auto", "auto_post_install.sh"),
("custom", "customizable_post_install.sh"))
}
cls.catalogs = {
locale: json.loads((ROOT / f"AppImage/messages/{locale}/common.json").read_text(encoding="utf-8"))
for locale in ("en", "it")
}
node = os.environ.get("NODE_BINARY") or shutil.which("node")
if not node:
raise AssertionError("Install Node.js 22.6+ or set NODE_BINARY")
cls.node = node
version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)\s*", version)
if not match or tuple(map(int, match.groups())) < (22, 6, 0):
raise AssertionError(f"Node.js 22.6+ required; detected {version.strip()}")
# Replace only the JSON import; the formatter body executes unchanged.
formatter = (ROOT / "AppImage/lib/optimization-descriptions.ts").read_text(encoding="utf-8")
json_import = 'import enMessages from "../messages/en/common.json"'
if formatter.count(json_import) != 1:
raise AssertionError("Formatter import seam changed")
fixture = formatter.replace(json_import, "const enMessages = " + json.dumps(cls.catalogs["en"]))
fixture += '''
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const [key, description, italian, catalogs] = JSON.parse(readFileSync(0, "utf8"));
for (const locale of ["en", "it"]) {
const calls: string[] = [];
const t = (path: string) => {
calls.push(path);
return path.split(".").reduce((value, part) => value[part], catalogs[locale]);
};
assert.equal(optimizationDescription(description, t), locale === "en" ? description : italian);
assert.deepEqual(calls, [`settings.optimizations.toolDescriptions.${key}`]);
calls.length = 0;
const unknown = description + " [new server wording]";
assert.equal(optimizationDescription(unknown, t), unknown);
assert.deepEqual(calls, []);
}
console.log("PASS actual exact lookup: EN/IT and unknown-description fallback");
'''
cls.directory = tempfile.TemporaryDirectory(prefix="optimization-wording-")
cls.addClassCleanup(cls.directory.cleanup)
cls.fixture = Path(cls.directory.name) / "formatter.mts"
cls.fixture.write_text(fixture, encoding="utf-8")
def assert_description(self, key, source, tool, function, english, italian):
for locale, expected in (("en", english), ("it", italian)):
with self.subTest(key=key, locale=locale):
self.assertEqual(self.catalogs[locale]["settings"]["optimizations"]["toolDescriptions"][key], expected)
metadata = self.metadata[source][tool]
self.assertEqual(metadata["function"], function)
self.assertEqual(metadata["description"], english)
result = subprocess.run(
[self.node, "--experimental-strip-types", str(self.fixture)],
input=json.dumps([key, metadata["description"], italian, self.catalogs]),
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS actual exact lookup", result.stdout)
def test_log2ram_auto_fresh_install_detection_and_update_size(self):
self.assert_description(
"log2ramAuto", "auto", "log2ram", "install_log2ram_auto",
"Fresh installs size Log2RAM from host RAM and ask for confirmation if storage is not detected as SSD. Updates preserve the configured size.",
"Alla prima installazione, dimensiona Log2RAM in base alla RAM dell'host e chiede conferma se il dispositivo di archiviazione non viene rilevato come SSD. Gli aggiornamenti mantengono la dimensione configurata.")
def test_kexec_prepares_unit_and_alias_not_hotkey(self):
self.assert_description(
"kexec", "custom", "kexec", "enable_kexec",
"Install kexec-tools and prepare a systemd unit plus the reboot-quick alias for fast reboots that skip BIOS/POST.",
"Installa kexec-tools e prepara un'unità systemd e l'alias reboot-quick per riavvii rapidi che saltano BIOS/POST.")
def test_amd_only_claims_idle_parameter_and_kvm_module_options(self):
self.assert_description(
"amd", "custom", "amd_fixes", "apply_amd_fixes",
"Apply idle=nomwait and KVM module options on AMD EPYC/Ryzen hosts.",
"Applica idle=nomwait e opzioni del modulo KVM sugli host AMD EPYC/Ryzen.")
def test_guest_agent_only_lists_supported_environments_and_skip(self):
self.assert_description(
"guestAgent", "custom", "guest_agent", "install_guest_agent",
"Detect QEMU/KVM, VMware or VirtualBox and install the matching guest agent; skip other environments.",
"Rileva QEMU/KVM, VMware o VirtualBox e installa il guest agent corrispondente; salta gli altri ambienti.")
def test_backup_describes_global_defaults_without_speed_guarantee(self):
self.assert_description(
"backupLimits", "custom", "vzdump_speed", "optimize_vzdump",
"Set global vzdump defaults to bwlimit: 0 and ionice: 5. This does not guarantee faster backups.",
"Imposta i valori predefiniti globali di vzdump su bwlimit: 0 e ionice: 5. Questo non garantisce backup più veloci.")
def test_log2ram_custom_choices_and_update_preservation(self):
self.assert_description(
"log2ramCustom", "custom", "log2ram", "configure_log2ram",
"Install Log2RAM with a chosen size, sync interval and optional autosync. Updates preserve the size and autosync choice.",
"Installa Log2RAM con dimensione e intervallo di sincronizzazione a scelta e autosync facoltativo. Gli aggiornamenti mantengono la dimensione e la scelta sull'autosync.")
if __name__ == "__main__":
unittest.main()
@@ -20,7 +20,11 @@ class OutcomePromises(TestCase):
texts = oci_menu_texts()
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
self.assertEqual(len(previews), 3)
self.assertEqual(len(previews), 2) # Coordinated stack update/recreation previews.
update = next(text for text in texts if text.startswith('If a newer application image is available'))
self.assertIn('attempts to restore the previous installation', update)
self.assertIn('If the operation remains incomplete', update)
self.assertIn('choose "Recover the previous installation"', update)
def test_the_recovery_preview_says_when_each_step_happens(self):
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
@@ -0,0 +1,250 @@
"""PBS CLI language and no-effect boundaries using real functions, isolated files."""
import contextlib
import copy
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
def function(path, name):
text = (ROOT/path).read_text()
start = text.index(name+'() {')
return text[start:text.index('\n}\n', start)+3]
def repository(components, suite='trixie'):
return dict(Types=['deb'], URIs=['https://credential.invalid/private'], Suites=[suite],
Components=components.split(), Enabled=True)
class RepositoryCliTests(unittest.TestCase):
def run_policy(self, case, language='sk', mode='plan', malformed=None, product='pbs'):
spec = importlib.util.spec_from_file_location('policy_fixture', ROOT/'scripts/global/repository_policy.py')
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
state = dict(files=[dict(path='/etc/apt/sources.list.d/pbs.sources', repositories=[repository('pbs-enterprise')]),
dict(path='/etc/apt/sources.list.d/debian.sources', repositories=[repository('main')])],
errors=[], digest='fixture', **{'standard-repos':[{'handle':'no-subscription'}]})
status='notfound'; calls=[]
if case=='inventory': state['errors']=['PRIVATE RAW DIAGNOSTIC']
if case=='status': status='unknown'
if case=='suite': state['files'][0]['repositories'][0]['Suites']=['bookworm']
if case=='active': status='active'; state['files']=state['files'][1:]
if case=='debian': state['files']=state['files'][:1]
if case=='mixed': state['files'][0]['repositories'][0]['Components'].append('main')
if case=='handle': state['standard-repos']=[]
if case=='preserve': state['files'][0]['repositories'][0]['Components']=['pbs-no-subscription']
original=copy.deepcopy(state)
def run(args):
calls.append(args)
if args[0]=='proxmox-backup-manager': return json.dumps(dict(status=status, serverid='PRIVATE SERVER ID'))
if 'create' in args:
state['files'][0]['repositories'][0]['Enabled']=False
if case=='partial': state['errors']=['PRIVATE RAW DIAGNOSTIC']
return ''
if 'set' in args:
state['files'].append(dict(path='/etc/apt/sources.list.d/public.sources',repositories=[repository('pbs-no-subscription')]))
return ''
return json.dumps(state)
with tempfile.TemporaryDirectory(prefix='pbs-policy-i18n-') as folder:
module.BASE=Path(folder)
(module.BASE/'lang').mkdir()
(module.BASE/'config.json').write_text(json.dumps({'language':language}))
catalog=(ROOT/'lang/sk.json').read_text() if malformed is None else malformed
(module.BASE/'lang/sk.json').write_text(catalog)
stdout,stderr=io.StringIO(),io.StringIO()
suite='sid' if case=='unsupported' else 'trixie'
with patch.object(module,'run',side_effect=run), patch.object(module.sys,'argv',['repository_policy.py',mode,suite,product]), contextlib.redirect_stdout(stdout),contextlib.redirect_stderr(stderr):
result=module.main()
return result,stdout.getvalue(),stderr.getvalue(),calls,state,original
def test_pbs_errors_are_localized_without_changing_decisions_or_exposing_raw_data(self):
expectations={'inventory':'Zoznam repozitárov APT', 'status':'Stav predplatného',
'suite':'iné vydanie', 'active':'aktívne predplatné', 'debian':'základný repozitár Debianu',
'mixed':'spoločný záznam APT', 'handle':'no-subscription', 'unsupported':'Nepodporované vydanie',
'partial':'stav repozitárov je neznámy'}
for case,expected in expectations.items():
with self.subTest(case=case):
result,out,error,calls,state,original=self.run_policy(case,mode='apply' if case=='partial' else 'plan')
self.assertEqual(result,1); self.assertEqual(out,''); self.assertIn(expected,error)
self.assertNotIn('PRIVATE',error); self.assertNotIn('credential.invalid',error)
writes=[c for c in calls if 'create' in c or 'set' in c]
if case=='partial':
self.assertEqual(len(writes),1)
self.assertFalse(state['files'][0]['repositories'][0]['Enabled'])
self.assertIn('Automatický návrat',error)
else:
self.assertEqual(writes,[]); self.assertEqual(state,original)
if case=='unsupported': self.assertEqual(calls,[])
def test_english_error_and_stdout_tokens_remain_compatible(self):
en=self.run_policy('unsupported',language='en')
self.assertEqual(en[2],'Unsupported Proxmox suite; no repository changed.\n')
pve=self.run_policy('unsupported',product='pve')
self.assertEqual(pve[2],en[2])
for mode,expected in [('plan','offer\n'),('apply','changed\n')]:
result,out,error,*_=self.run_policy('offer',mode=mode)
self.assertEqual((result,out,error),(0,expected,''))
result,out,error,calls,*_=self.run_policy('preserve',mode='apply')
self.assertEqual((result,out,error),(0,'preserve\n',''))
self.assertFalse(any(c[0]=='proxmox-backup-manager' or 'set' in c or 'create' in c for c in calls))
def test_bad_or_missing_translation_falls_back_to_original_english(self):
for catalog in ('{}','not json','[]',json.dumps({'Unsupported Proxmox suite; no repository changed.':42}),
json.dumps({'Unsupported Proxmox suite; no repository changed.':'{unknown}'})):
with self.subTest(catalog=catalog):
result,out,error,calls,*_=self.run_policy('unsupported',malformed=catalog)
self.assertEqual(result,1);self.assertEqual(out,'');self.assertEqual(calls,[])
self.assertEqual(error,'Unsupported Proxmox suite; no repository changed.\n')
def test_translation_cannot_drop_actual_repository_details(self):
spec=importlib.util.spec_from_file_location('policy_format_fixture',ROOT/'scripts/global/repository_policy.py')
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
with tempfile.TemporaryDirectory(prefix='pbs-policy-format-') as folder:
module.BASE=Path(folder);(module.BASE/'lang').mkdir()
(module.BASE/'config.json').write_text('{"language":"sk"}')
error=module.PolicyError('Inspect {ui}; no repository changed.',ui='Administration > Repositories')
for invalid in ('Skontrolujte repozitáre.','{ui.__class__}','{other}','{ui:.0}','{ui!r}'):
(module.BASE/'lang/sk.json').write_text(json.dumps({error.template:invalid}))
self.assertEqual(module.cli_error(error,'pbs'),str(error))
for invalid in ('not json','[]','{"language":"../../private"}'):
(module.BASE/'config.json').write_text(invalid)
self.assertEqual(module.cli_error(error,'pbs'),str(error))
UPDATE_HARNESS=r'''
BASE_DIR="$FIXTURE"
LANG_DIR="$REPO/lang"
SCRIPT_TITLE='fixture'
TAB='' BGN='' CL='' GN='' BL=''
mkdir -p "$FIXTURE/log" "$FIXTURE/tmp" "$FIXTURE/boot" "$FIXTURE/run"
: > "$FIXTURE/calls"
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
pmx_journal_context() { :; }
pmx_is_pbs() { [[ "$CASE" != pve ]]; }
pmx_platform_field() { [[ "$1" != supported ]] && printf '%s' "$SUITE" || printf true; }
ensure_pbs_repositories() { record repository-plan; return 0; }
dpkg-query() { [[ "$SUITE" == bookworm ]] && printf 3.4.0 || printf 4.0.1; }
apt-cache() { record apt-cache "$@"; printf ' Candidate: 4.0.2\n'; }
df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted\nfixture 10000 1000 %s 10%% /\n' "$([[ "$CASE" == disk ]] && echo 512000 || echo 4096000)"; }
apt-get() { record apt-get "$@"; [[ "$CASE" != refresh ]]; }
apt() {
record apt "$@"
if [[ "$*" == 'list --upgradable' ]]; then
printf 'Listing...\n'
[[ "$CASE" == current ]] || printf 'fixture/trixie 1.2 amd64 [upgradable from: 1.1]\n'
return 0
fi
[[ "$CASE" != failure ]]
}
fixture_env() { "$@"; }
APT_ENV='fixture_env'
pmx_format_upgradable() { cat; }
pmx_record_execution() { record journal "$1"; }
pmx_dpkg_changes_since() { :; }
msg_error() { printf '%s\n' "$*"; }
msg_ok() { printf '%s\n' "$*"; }
msg_info2() { printf '%s\n' "$*"; }
msg_warn() { printf '%s\n' "$*"; }
msg_title() { :; }
clear() { :; }
show_proxmenux_logo() { :; }
whiptail() { printf '%s\n' "$*" >> "$FIXTURE/dialog"; [[ "$CASE" != cancel ]]; }
uname() { printf old-kernel; }
touch "$FIXTURE/boot/vmlinuz-new-kernel"
'''
class UpdateCliTests(unittest.TestCase):
def run_update(self,case,language='sk',suite='trixie'):
with tempfile.TemporaryDirectory(prefix='pbs-update-i18n-') as folder:
body=function('scripts/global/update-pbs.sh','update_pbs')
for old,new in [('/var/log/',folder+'/log/'),('/tmp/proxmenux_screen_capture_',folder+'/tmp/proxmenux_screen_capture_'),('/var/run/',folder+'/run/'),('/boot/',folder+'/boot/')]: body=body.replace(old,new)
script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+body+'\nupdate_pbs\n'
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite})
files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()}
return result,files
def test_pve_guard_cannot_reach_repository_or_apt_calls(self):
result,files=self.run_update('pve')
self.assertEqual(result.returncode,1,result.stderr)
self.assertEqual(files['calls'],'')
self.assertFalse(any(name.startswith('log/') for name in files))
def test_disk_and_upgrade_errors_keep_their_actual_parameters(self):
result,files=self.run_update('disk')
self.assertEqual(result.returncode,1,result.stderr)
self.assertIn('Nedostatok miesta na disku. Dostupné:',result.stdout)
self.assertIn('500MB',result.stdout)
self.assertNotIn('apt',files['calls'])
result,files=self.run_update('failure')
self.assertEqual(result.returncode,1,result.stderr)
self.assertIn('Aktualizácia systému zlyhala. Skontrolujte denník:',result.stdout)
self.assertIn('/log/proxmox-update-',result.stdout)
self.assertNotIn('autoremove',files['calls'])
def test_update_status_cancel_refresh_error_and_reboot_notice(self):
for suite in ('bookworm','trixie'):
for case in ('current','cancel','refresh','success'):
with self.subTest(suite=suite,case=case):
result,files=self.run_update(case,suite=suite)
self.assertEqual(result.returncode,1 if case=='refresh' else 0,result.stderr)
if case!='success': self.assertNotIn('full-upgrade',files['calls'])
if case=='success': self.assertIn('reštartujte server',result.stdout)
if case in ('current','cancel'): self.assertIn('Aktuálna verzia PBS',files['dialog'])
class DialogTests(unittest.TestCase):
def test_automated_confirmation_and_banner_cancel_do_not_run_actions(self):
cases=[('scripts/menus/menu_post_install.sh','confirm_automated_script',
['repozitár no-subscription','upozornenie na predplatné','pamäť','jadro','sieť']),
('scripts/post_install/auto_post_install.sh','remove_subscription_banner',
['Proxmox Backup Server – odstránenie upozornenia na predplatné'])]
for path,name,expected in cases:
with tempfile.TemporaryDirectory(prefix='pbs-dialog-') as folder:
script=function('scripts/utils.sh','translate')+function(path,name)+r'''
PMX_ON_PBS=true
LOCAL_SCRIPTS='/fixture/scripts'
clear() { :; }; sleep() { :; }; stop_spinner() { :; }
msg_warn() { printf '%s\n' "$*"; }
bash() { printf forbidden >> "$CAPTURE/actions"; }
register_tool() { printf forbidden >> "$CAPTURE/actions"; }
dialog() { printf '%s\n' "$*" >> "$CAPTURE/dialog"; return 1; }
whiptail() { dialog "$@"; }
'''+name+'\n'
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'CAPTURE':folder})
self.assertEqual(result.returncode,1 if name=='remove_subscription_banner' else 0,result.stderr)
dialog=(Path(folder)/'dialog').read_text()
for text in expected:self.assertIn(text,dialog)
self.assertFalse((Path(folder)/'actions').exists())
if name=='remove_subscription_banner':self.assertIn('bolo zrušené používateľom',result.stdout)
def test_community_labels_preserve_dispatch_names_and_translate_display(self):
script=function('scripts/utils.sh','translate')+function('scripts/menus/menu_post_install.sh','post_install_label')+'\npost_install_label "$1"\n'
for text,expected in [('Proxmox Backup Server Post Install','nastavenie po inštalácii'),
('Proxmox Backup Server Microcode','mikrokód procesora')]:
result=subprocess.run(['bash','-c',script,'bash',text],capture_output=True,text=True,timeout=15,
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang')})
self.assertEqual(result.returncode,0,result.stderr);self.assertIn(expected,result.stdout)
def test_failed_apt_hook_check_removes_only_fixture_hook_with_localized_warning(self):
with tempfile.TemporaryDirectory(prefix='pbs-hook-') as folder:
script=function('scripts/utils.sh','translate')+function('scripts/global/remove-banner-pve-v3.sh','create_apt_hook')+r'''
apt-config() { return 1; }
msg_warn() { printf '%s\n' "$*"; }
create_apt_hook
'''
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'APT_HOOK':folder+'/hook'})
self.assertEqual(result.returncode,0,result.stderr)
self.assertIn('Nesprávna syntax hooku APT',result.stdout)
self.assertFalse((Path(folder)/'hook').exists())
@@ -0,0 +1,475 @@
"""Offline acquisition fixtures; never source or run the administrative script.
Requires Bash, jq, curl, wget, gzip and unzip. Only bounded native functions
are loaded; HTTP clients are confined to a loopback fixture server. No host
package manager, Proxmox command, installer or VM is executed.
"""
from contextlib import contextmanager
import gzip
import http.server
import io
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
import warnings
import zipfile
ROOT = Path(__file__).resolve().parents[3]
SOURCE = ROOT / "scripts/vm/synology.sh"
RAW = b"tiny offline loader fixture\x00\x01\x02" * 8
PLAIN = "alpine-redpill.v1.4.5.6.m-shell.img.gz"
VARIANTS = [
"alpine-redpill.v1.4.5.6.m-shell-5GB.img.gz",
"alpine-redpill.v1.4.5.6.xtcrp.img.gz",
"alpine-redpill.v1.4.5.6.m-shell.vmdk.gz",
"alpine-redpill.v1.4.5.6.xtcrp-5GB.img.gz",
]
def native_function(name):
match = re.search(
rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}",
SOURCE.read_text(), re.MULTILINE | re.DOTALL,
)
if not match:
raise AssertionError(f"Native function not found: {name}")
return match.group()
def asset(name):
return {"name": name, "browser_download_url": f"https://fixtures.invalid/{name}"}
def zip_bytes(member, payload=RAW, extras=()):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr(member, payload)
for name, content in extras:
archive.writestr(name, content)
return buffer.getvalue()
@contextmanager
def fixture_server(metadata, payload, *, metadata_status=200, asset_status=200,
partial=False):
requests = []
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
requests.append(self.path)
is_metadata = self.path.startswith("/metadata/")
body = metadata if is_metadata else payload
status = metadata_status if is_metadata else asset_status
self.send_response(status)
self.send_header("Content-Length", str(len(body) + (
37 if partial and not is_metadata else 0)))
self.end_headers()
self.wfile.write(body)
self.close_connection = True
def log_message(self, format, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever,
kwargs={"poll_interval": 0.01}, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", requests
finally:
server.shutdown()
server.server_close()
thread.join()
class SynologyLoaderTests(unittest.TestCase):
def run_loader(self, loader="tinycore", *, names=None, metadata=None,
payload=None, pretty=True, before="", invoke="download_loader",
functions=(), **server_options):
if names is None:
names = [PLAIN, *VARIANTS]
if metadata is None:
metadata = json.dumps({"assets": [asset(name) for name in names]},
indent=2 if pretty else None).encode()
if payload is None:
payload = gzip.compress(RAW)
expected = {"arc": "arc.img", "redpill": "rr.img", "tinycore": "tinycore.img"}[loader]
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
images = base / "images with spaces"
images.mkdir()
previous = images / expected
previous.write_bytes(b"previous verified image")
bin_dir = base / "bin"
bin_dir.mkdir()
for command in ("bash", "jq", "curl", "wget", "gzip", "unzip"):
self.assertIsNotNone(shutil.which(command), f"Install test prerequisite: {command}")
with fixture_server(metadata, payload, **server_options) as (url, requests):
for command in ("curl", "wget"):
real = shutil.which(command)
client_options = ["--no-config", "--tries=1"] if command == "wget" else ["-q"]
wrapper = bin_dir / command
wrapper.write_text(
f"#!{sys.executable}\n"
"import os, sys\n"
"args = sys.argv[1:]\n"
"skip = False\n"
"for arg in args:\n"
" if skip:\n"
" skip = False\n"
" continue\n"
" if arg in ('-O', '--connect-timeout', '--max-time'):\n"
" skip = True\n"
" elif not arg.startswith('-') and not arg.startswith(('https://api.github.com/repos/', 'https://fixtures.invalid/')):\n"
" sys.exit('Forbidden non-fixture argument: ' + arg)\n"
"for i, arg in enumerate(args):\n"
" if arg.startswith('https://api.github.com/repos/'):\n"
f" args[i] = {url!r} + '/metadata/' + arg.split('/repos/', 1)[1]\n"
" elif arg.startswith('https://fixtures.invalid/'):\n"
f" args[i] = {url!r} + '/asset/' + arg.rsplit('/', 1)[1]\n"
" elif '://' in arg:\n"
" sys.exit('Forbidden non-fixture URL: ' + arg)\n"
"os.environ['no_proxy'] = os.environ['NO_PROXY'] = '127.0.0.1'\n"
f"os.execv({real!r}, [{real!r}, *{client_options!r}, *args])\n"
)
wrapper.chmod(0o755)
# Administrative boundaries are fail-closed even if a test regresses.
for command in ("apt-get", "qm", "pct", "pvesm", "pvesh"):
wrapper = bin_dir / command
wrapper.write_text("#!/bin/bash\nprintf 'FORBIDDEN %s\\n' \"$0\" >&2\nexit 97\n")
wrapper.chmod(0o755)
definitions = "\n".join(native_function(name) for name in
dict.fromkeys(("download_loader", *functions)))
script = r'''
translate() { printf '%s' "$1"; }
msg_info() { printf 'INFO:%s\n' "$*"; }
msg_ok() { printf 'OK:%s\n' "$*"; }
msg_error() { printf 'ERROR:%s\n' "$*"; }
msg_warn() { printf 'WARN:%s\n' "$*"; }
stop_spinner() { :; }
sleep() { :; }
select_loader() { printf 'UNEXPECTED_RECURSION\n'; return 91; }
'''
script += definitions + "\n" + before + "\n"
script += invoke + r'''
result=$?
printf 'RESULT:%s\nFILE:%s\nLOADER_FILE:%s\nLOADER_TYPE:%s\nLOADER_NAME:%s\n' "$result" "$FILE" "$LOADER_FILE" "$LOADER_TYPE" "$LOADER_NAME"
exit "$result"
'''
environment = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}",
IMAGES_DIR=str(images), LOADER_TYPE=loader,
LOADER_NAME="fixture loader", FILE="stale.img",
LOADER_FILE="/stale.img")
result = subprocess.run(["bash", "-c", script], env=environment,
cwd=base, capture_output=True, text=True, timeout=15)
state = dict(re.findall(r"^(RESULT|FILE|LOADER_FILE|LOADER_TYPE|LOADER_NAME):(.*)$", result.stdout, re.MULTILINE))
return result, state, previous.read_bytes(), list(requests), sorted(
str(path.relative_to(images)) for path in images.rglob("*"))
def test_plain_alpine_selection_ignores_competing_variants(self):
for pretty, names in ((True, [*VARIANTS, PLAIN]),
(False, [VARIANTS[2], PLAIN, *VARIANTS[:2], VARIANTS[3]])):
with self.subTest(pretty=pretty):
result, state, contents, requests, files = self.run_loader(names=names, pretty=pretty)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(state["FILE"], "tinycore.img")
self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img"))
self.assertEqual([path for path in requests if path.startswith("/asset/")],
[f"/asset/{PLAIN}"])
self.assertEqual(files, ["tinycore.img"])
def assert_failed_acquisition(self, outcome, expected="tinycore.img"):
result, state, contents, requests, files = outcome
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(state["FILE"], "", result.stdout)
self.assertEqual(state["LOADER_FILE"], "", result.stdout)
self.assertEqual(contents, b"previous verified image")
self.assertNotIn("OK:", result.stdout)
self.assertNotIn("UNEXPECTED_RECURSION", result.stdout)
self.assertNotIn("FORBIDDEN", result.stderr)
self.assertEqual(files, [expected])
return requests
def test_zip_loaders_parse_minified_metadata(self):
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
with self.subTest(loader=loader):
result, state, contents, requests, files = self.run_loader(
loader, names=["README.txt", name], pretty=False,
payload=zip_bytes(member))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(state["FILE"], member)
self.assertTrue(state["LOADER_FILE"].endswith("/" + member))
self.assertEqual(files, [member])
self.assertEqual(requests[-1], "/asset/" + name)
def test_metadata_requires_one_typed_unambiguous_asset(self):
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
cases = {
"none": {"assets": []},
"duplicate": {"assets": [asset(name), asset(name)]},
"two_versions": {"assets": [asset(name), asset(name.replace("v1.4.5.6", "v2.0.0") if loader == "tinycore" else "another.img.zip")]},
"null_name": {"assets": [{"name": None, "browser_download_url": asset(name)["browser_download_url"]}]},
"numeric_name": {"assets": [{"name": 7, "browser_download_url": asset(name)["browser_download_url"]}]},
"missing_name": {"assets": [{"browser_download_url": asset(name)["browser_download_url"]}]},
"null_url": {"assets": [{"name": name, "browser_download_url": None}]},
"numeric_url": {"assets": [{"name": name, "browser_download_url": 7}]},
"empty_url": {"assets": [{"name": name, "browser_download_url": ""}]},
"http_url": {"assets": [{"name": name, "browser_download_url": "http://bad/" + name}]},
"mismatched_url": {"assets": [{"name": name, "browser_download_url": "https://bad/other.img.gz"}]},
"whitespace_url": {"assets": [{"name": name, "browser_download_url": "https://bad/ has space/" + name}]},
"control_url": {"assets": [{"name": name, "browser_download_url": "https://fixtures.invalid/\u0000/" + name}]},
"wrong_assets_type": {"assets": {}},
"array_root": [asset(name)],
"error_object": {"message": "API rate limit exceeded"},
}
for case, document in cases.items():
with self.subTest(loader=loader, case=case):
requests = self.assert_failed_acquisition(self.run_loader(
loader, metadata=json.dumps(document).encode()), member)
self.assertFalse(any(path.startswith("/asset/") for path in requests))
def test_metadata_transport_or_json_failure_is_not_success(self):
for label, metadata, status in (
("http_failure_with_valid_body", json.dumps({"assets": [asset(PLAIN)]}).encode(), 503),
("html_error_body", b"<html>unavailable</html>", 200),
("truncated_json", b'{"assets":[', 200),
("multiple_documents", json.dumps({"assets": [asset(PLAIN)]}).encode() * 2, 200),
):
with self.subTest(case=label):
requests = self.assert_failed_acquisition(self.run_loader(
metadata=metadata, metadata_status=status))
self.assertFalse(any(path.startswith("/asset/") for path in requests))
def test_failed_empty_or_partial_download_preserves_previous_image(self):
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
payload = gzip.compress(RAW) if loader == "tinycore" else zip_bytes(member)
for label, body, options in (
("http_error_with_archive_body", payload, {"asset_status": 503}),
("empty_download", b"", {}),
("partial_download", payload, {"partial": True}),
):
with self.subTest(loader=loader, case=label):
requests = self.assert_failed_acquisition(self.run_loader(
loader, names=[name], payload=body, **options), member)
self.assertEqual(requests[-1], "/asset/" + name)
def test_invalid_or_empty_compressed_image_cannot_replace_cached_image(self):
valid_gzip = gzip.compress(RAW)
crc_gzip = valid_gzip[:-8] + bytes([valid_gzip[-8] ^ 1]) + valid_gzip[-7:]
for label, payload in (
("not_gzip", b"<html>not an image</html>"),
("truncated_gzip", valid_gzip[:-6]),
("gzip_crc_failure", crc_gzip),
("empty_raw_image", gzip.compress(b"")),
):
with self.subTest(case=label):
self.assert_failed_acquisition(self.run_loader(payload=payload))
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
stored = io.BytesIO()
with zipfile.ZipFile(stored, "w", zipfile.ZIP_STORED) as archive:
archive.writestr(member, RAW)
crc_zip = stored.getvalue().replace(RAW, b"?" + RAW[1:], 1)
valid_zip = zip_bytes(member)
for label, payload in (
("not_zip", b"<html>not an archive</html>"),
("truncated_zip", valid_zip[:-30]),
("zip_crc_failure", crc_zip),
("empty_raw_image", zip_bytes(member, b"")),
("missing_member", zip_bytes("other.img")),
("nested_member", zip_bytes("subdirectory/" + member)),
):
with self.subTest(loader=loader, case=label):
self.assert_failed_acquisition(self.run_loader(
loader, names=[name], payload=payload), member)
def test_alpine_menu_and_selection_name_preserve_internal_contract(self):
result, state, contents, requests, files = self.run_loader(
functions=("select_loader",), invoke="select_loader",
before=r'''whiptail() { printf 'MENU:%s\n' "$*"; printf '3' >&2; }''')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Alpine RedPill Loader (PeterSuh-Q3 M-shell)", result.stderr)
self.assertIn("Selected Loader: Alpine RedPill M-shell", result.stdout)
self.assertNotIn("TinyCore RedPill", result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(state["FILE"], "tinycore.img")
self.assertIn("/metadata/PeterSuh-Q3/tinycore-redpill/releases/latest", requests)
def test_zip_expected_member_must_be_unique(self):
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
duplicate = io.BytesIO()
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
with zipfile.ZipFile(duplicate, "w") as archive:
archive.writestr(member, RAW)
archive.writestr(member, b"competing image")
with self.subTest(loader=loader):
self.assert_failed_acquisition(self.run_loader(
loader, names=[name], payload=duplicate.getvalue()), member)
def run_wizard(self, loader, mode, *, main=False, **options):
functions = (
"select_loader", "select_disk_type", "_build_storage_plan_summary",
"default_settings", "advanced_settings", "start_script", "create_vm",
)
before = f"WIZARD_MODE={mode}\nLOADER_CHOICE={ {'arc': 1, 'redpill': 2, 'tinycore': 3}[loader] }\n"
# Only UI/storage-entry/capture/host boundaries are replaced. The actual
# loader -> disk plan -> settings -> start_script chain runs in Bash.
before += r'''
NEXTID=123
NAME='Synology VM'
GEN_MAC='02:00:00:00:00:01'
header_info() { :; }
start_vm_wizard_capture() { :; }
stop_vm_wizard_capture() { :; }
prompt_optional_gpu_passthrough() { printf 'GPU_BOUNDARY\n'; }
select_virtual_disk() { VIRTUAL_DISKS+=('local:32'); }
pct() { return 1; }
qm() {
[[ "$1" == status ]] && return 1
printf 'QM_BOUNDARY:%s\n' "$*"
return 97
}
whiptail() {
case "$*" in
*'--title SETTINGS --yesno'*) [[ "$WIZARD_MODE" == default ]]; return ;;
*'VIRTUAL MACHINE ID'*) printf '123' >&2 ;;
*'MACHINE TYPE'*) printf 'q35' >&2 ;;
*'BIOS TYPE'*) printf 'ovmf' >&2 ;;
*'HOSTNAME'*) printf 'Synology-DSM' >&2 ;;
*'CPU MODEL'*) printf '1' >&2 ;;
*'CORE COUNT'*) printf '2' >&2 ;;
*'--title RAM '*) printf '4096' >&2 ;;
*'--title BRIDGE '*) printf 'vmbr0' >&2 ;;
*'MAC ADDRESS'*) printf '02:00:00:00:00:01' >&2 ;;
*'STORAGE PLAN'*)
if [[ ${#VIRTUAL_DISKS[@]} -eq 0 ]]; then printf 'a' >&2; else printf 'd' >&2; fi ;;
*'SELECT LOADER'*) printf '%s' "$LOADER_CHOICE" >&2 ;;
esac
return 0
}
'''
invoke = "start_script"
if main:
match = re.search(r"^# Main execution\n# =+\n(.*)", SOURCE.read_text(),
re.MULTILINE | re.DOTALL)
if match is None:
self.fail("Native main gate not found")
invoke = match.group(1)
return self.run_loader(loader, functions=functions, before=before,
invoke=invoke, **options)
def test_native_settings_chain_propagates_loader_failure(self):
for mode in ("default", "advanced"):
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
for failure in ("metadata", "archive", "download"):
with self.subTest(mode=mode, loader=loader, failure=failure):
result, state, contents, requests, files = self.run_wizard(
loader, mode, names=[name],
metadata=b'{"assets":[]}' if failure == "metadata" else None,
payload=b"invalid compressed image",
asset_status=503 if failure == "download" else 200)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(state["FILE"], "")
self.assertEqual(state["LOADER_FILE"], "")
self.assertEqual(contents, b"previous verified image")
self.assertNotIn("Downloaded", result.stdout)
self.assertNotIn("QM_BOUNDARY", result.stdout)
self.assertNotIn("FORBIDDEN", result.stderr)
self.assertEqual(files, [member])
def test_native_main_gate_never_reaches_qm_create_after_loader_failure(self):
for mode in ("default", "advanced"):
for loader in ("tinycore", "arc", "redpill"):
with self.subTest(mode=mode, loader=loader):
result, state, contents, requests, files = self.run_wizard(
loader, mode, main=True, metadata=b'{"assets":[]}')
# The existing cancellation convention exits zero; failure
# must still stop the native main before GPU or VM creation.
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("VM creation cancelled before disk planning.", result.stdout)
self.assertNotIn("QM_BOUNDARY", result.stdout)
self.assertNotIn("GPU_BOUNDARY", result.stdout)
self.assertNotIn("FORBIDDEN", result.stderr)
self.assertEqual(contents, b"previous verified image")
def test_native_main_positive_control_reaches_only_stubbed_qm_boundary(self):
for mode in ("default", "advanced"):
with self.subTest(mode=mode):
result, state, contents, requests, files = self.run_wizard("tinycore", mode, main=True)
self.assertEqual(result.stdout.count("QM_BOUNDARY:create "), 1, result.stdout + result.stderr)
self.assertIn("GPU_BOUNDARY", result.stdout)
self.assertEqual(contents, RAW)
def test_custom_loader_selection_keeps_existing_path_without_network(self):
result, state, contents, requests, files = self.run_loader(
functions=("select_loader", "select_custom_image"), invoke="select_loader",
before=r'''
whiptail() {
if [[ "$*" == *'SELECT CUSTOM IMAGE'* ]]; then
printf '%s/tinycore.img' "$IMAGES_DIR" >&2
else
printf '4' >&2
fi
}
''')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(state["LOADER_TYPE"], "custom")
self.assertEqual(state["FILE"], "tinycore.img")
self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img"))
self.assertEqual(contents, b"previous verified image")
self.assertEqual(requests, [])
self.assertEqual(files, ["tinycore.img"])
def test_zip_extracts_only_expected_image_not_sibling_files(self):
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
with self.subTest(loader=loader):
result, state, contents, requests, files = self.run_loader(
loader, names=[name], payload=zip_bytes(member, extras=(
("sha256sum", b"fixture checksum text"),
("other.img", b"must not be published"))))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(files, [member])
def test_alpine_selection_is_not_pinned_to_current_release(self):
name = "alpine-redpill.v2.0.1.m-shell.img.gz"
result, state, contents, requests, files = self.run_loader(names=[name, *VARIANTS])
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(requests[-1], "/asset/" + name)
def test_variants_or_legacy_family_never_substitute_for_plain_alpine(self):
self.assert_failed_acquisition(self.run_loader(
names=[*VARIANTS, "tinycore-redpill.v1.0.0.m-shell.img.gz"]))
def test_staging_or_promotion_failure_keeps_previous_image(self):
for boundary in ("mktemp", "mv"):
with self.subTest(boundary=boundary):
self.assert_failed_acquisition(self.run_loader(
before=f"{boundary}() {{ return 1; }}"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,104 @@
"""Offline regressions for copyable commands and runtime format fields."""
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
ROOT = SCRIPTS.parents[1]
sys.path.insert(0, str(SCRIPTS))
SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py')
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(MODULE)
class TerminalCatalogsTests(unittest.TestCase):
def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self):
fixtures = {
'menu': 'translate "Main launcher"',
'install_proxmenux.sh': 'translate "Stable installer"',
'install_proxmenux_beta.sh': 'translate "Beta installer"',
'scripts/check.sh': 'translate "Host shell script"',
'scripts/check.py': 'translate("Host Python script")',
'oci/src/check.py': 'N_("OCI Python script")',
'oci/remote/check.py': 'translate("Remote Python script")',
'oci/remote/check.sh': 'translate "Remote shell script"',
'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}),
}
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for name, content in fixtures.items():
file = root / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(content, encoding='utf-8')
self.assertEqual(set(MODULE.active_sources(root)), {
'Main launcher', 'Stable installer', 'Beta installer',
'Host shell script', 'Host Python script', 'OCI Python script',
'Remote Python script', 'Remote shell script', 'Application tagline',
})
def test_runtime_format_field_cannot_be_translated(self):
source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}'
with self.assertRaisesRegex(ValueError, 'placeholder'):
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}')
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}')
def test_copyable_command_and_environment_variable_are_literal(self):
examples = [
('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'),
("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."),
('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'),
]
for source, target in examples:
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
MODULE.validate_message(source, target)
MODULE.validate_message('Example: pct config 110 | grep unprivileged',
'Ejemplo: pct config 110 | grep unprivileged')
def test_dns_capitalization_does_not_relax_file_path_case(self):
MODULE.validate_message('Cannot reach download.proxmox.com.',
'Download.proxmox.com ist nicht erreichbar.')
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg')
def test_shipped_active_catalogs_keep_literal_contracts(self):
result = subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(ROOT), '--require-complete'],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for locale in MODULE.DEFAULT_LANGUAGES:
self.assertIn(f'[{locale}] active=', result.stdout)
self.assertNotIn('missing=1', result.stdout)
def test_optional_completeness_still_rejects_invalid_present_translations(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / 'scripts').mkdir()
(root / 'lang').mkdir()
(root / 'scripts/check.sh').write_text('translate "Check /etc/pve/storage.cfg"')
(root / 'lang/sk.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Skontrolujte /etc/pve/storage.cfg'}))
(root / 'lang/es.json').write_text('{}')
def check(languages, *flags):
return subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(root), '--languages', languages, *flags],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(check('es,sk').returncode, 0)
self.assertEqual(check('sk', '--require-complete').returncode, 0)
self.assertNotEqual(check('es', '--require-complete').returncode, 0)
(root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'}))
self.assertNotEqual(check('es,sk').returncode, 0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,31 @@
"""Every web terminal of the Monitor can send Ctrl+D from its Ctrl menu, so
a pasted text can be finished from a phone or a tablet."""
import json
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[3]
TERMINALS = {
'terminal-panel': 'sendSequence',
'script-terminal-modal': 'sendCommand',
'lxc-terminal-modal': 'sendKey',
}
class TerminalCtrlKeysTests(unittest.TestCase):
def test_each_terminal_sends_ctrl_d(self):
for name, send in TERMINALS.items():
source = (ROOT / f'AppImage/components/{name}.tsx').read_text(encoding='utf-8')
with self.subTest(terminal=name):
self.assertIn(f'onSelect={{() => {send}("\\x04")}}', source)
self.assertIn('t("scriptTerminal.endOfInput")', source)
def test_the_label_exists_in_english_and_spanish(self):
for language in ('en', 'es'):
catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8'))
with self.subTest(language=language):
self.assertTrue(catalog['scriptTerminal'].get('endOfInput'))
if __name__ == '__main__':
unittest.main()
@@ -15,7 +15,7 @@ class VmBackupJobsWiring(TestCase):
source = ROUTES.read_text()
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
self.assertEqual(len(routes), 8)
self.assertEqual(len(routes), 10)
for path, method, guard, name in routes:
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
self.assertEqual(guard, expected, f'{method} {path} ({name})')
@@ -0,0 +1,275 @@
"""The VM wizard must not accept failed ISO downloads as installation media.
Run its function logic against a loopback HTTP fixture. Redirect the fixed
native ISO directory only in the loaded test copy and stub Proxmox/UI services.
No product script is sourced, no guest operation runs, and no public network
or real ISO storage is accessed.
"""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[3]
CREATOR = ROOT / "scripts/vm/vm_creator.sh"
PAYLOAD = b"fixture installation media\x00\x01\n"
REAL_WGET = shutil.which("wget")
class MediaHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/missing.iso":
self.send_response(404)
self.end_headers()
return
if self.path == "/partial.iso":
self.send_response(200)
self.send_header("Content-Length", "1024")
self.end_headers()
self.wfile.write(b"partial")
self.close_connection = True
return
body = b"" if self.path == "/empty.iso" else PAYLOAD
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.send_header("Content-Disposition", 'attachment; filename="remote-name.iso"')
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
class VmIsoDownloadTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if REAL_WGET is None:
raise RuntimeError("These offline tests require wget, as the VM wizard does")
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), MediaHandler)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.http = f"http://127.0.0.1:{cls.server.server_port}"
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="vm-iso-download-")
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
self.iso = self.directory / "installation media.iso"
self.qm_log = self.directory / "qm.log"
self.wget_log = self.directory / "wget.log"
self.bin = self.directory / "bin"
self.bin.mkdir()
# Route only the optional fixed VirtIO/SourceForge endpoints to our
# fixture. Wget still performs the actual HTTP transfer and file I/O.
assert REAL_WGET is not None
wrapper = self.bin / "wget"
wrapper.write_text(
"#!/usr/bin/env bash\n"
'printf "%s\\n" "$*" >> "$WGET_LOG"\n'
'args=("$@")\n'
'for i in "${!args[@]}"; do\n'
' case "${args[$i]}" in\n'
' https://fedorapeople.org/*) args[$i]="$VIRTIO_FIXTURE_URL";;\n'
' https://sourceforge.net/*) args[$i]="$HTTP_FIXTURE/success.iso";;\n'
' esac\n'
' if [[ "${args[$i]}" == http://* || "${args[$i]}" == https://* ]]; then\n'
' [[ "${args[$i]}" == "$HTTP_FIXTURE"/* ]] || exit 98\n'
' fi\n'
'done\n'
'for i in "${!args[@]}"; do\n'
' if [[ "${args[$i]}" == -O ]]; then\n'
' case "${args[$((i+1))]}" in "$FIXTURE_DIR"/*) ;; *) exit 97;; esac\n'
' fi\n'
'done\n'
f"exec {shlex.quote(REAL_WGET)} --no-config --no-proxy --tries=1 --timeout=1 "
'"${args[@]}"\n'
)
wrapper.chmod(0o755)
def run_wizard(self, route="/success.iso", windows=False, overwrite=True, url=None,
caller_iso_dir=None):
source = CREATOR.read_text()
functions = "\n\n".join(re.findall(r"^function \w+\(\) \{\n.*?^\}", source, re.M | re.S))
self.assertIn("function create_vm()", functions)
# Only relocate this external filesystem anchor. Keep all predicates,
# transfers and promotion logic unchanged, without a production hook.
self.assertIn("/var/lib/vz/template/iso", functions)
functions = functions.replace("/var/lib/vz/template/iso", "${FIXTURE_DIR}")
prelude = r'''
translate() { printf '%s' "$1"; }
msg_ok() { printf 'OK:%s\n' "$*"; }
msg_error() { printf 'ERROR:%s\n' "$*"; }
msg_warn() { printf 'WARN:%s\n' "$*"; }
msg_info() { :; }
msg_success() { :; }
qm() { printf '%q ' "$@" >> "$QM_LOG"; printf '\n' >> "$QM_LOG"; }
pvesm() { printf 'Name Type Status\nfixture dir active\n'; }
select_storage_target() { printf fixture; }
# No next menu, start, or guest-facing process is launched.
bash() { :; }
whiptail() {
if [[ " $* " == *' --yesno '* ]]; then
return "$OVERWRITE_STATUS"
fi
if [[ ! -f "$DIALOG_ONCE" ]]; then
: > "$DIALOG_ONCE"
printf '1' >&2
return 0
fi
return 1
}
IMPORT_DISKS=(); PASSTHROUGH_DISKS=(); VIRTUAL_DISKS=(); CONTROLLER_NVME_PCIS=()
VMID=99999; HN=fixture; CORE_COUNT=1; RAM_SIZE=512; BRG=fixture
MAC='02:00:00:00:00:01'; VLAN=''; MTU=''; BIOS_TYPE=''; CPU_TYPE=''
MACHINE=''; SERIAL_PORT=''; START_VM=no; WIZARD_ADD_GPU=no
TAB=''; GN=''; CL=''
create_vm
'''
env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"],
ISO_PATH=str(self.iso), ISO_URL=url if url is not None else self.http + route,
ISO_VOLID="local:iso/installation media.iso",
OS_TYPE="2" if windows else "3",
QM_LOG=str(self.qm_log), WGET_LOG=str(self.wget_log),
DIALOG_ONCE=str(self.directory / "dialog-once"), FIXTURE_DIR=str(self.directory),
OVERWRITE_STATUS="0" if overwrite else "1", HTTP_FIXTURE=self.http,
VIRTIO_FIXTURE_URL=self.http + route)
env.pop("ISO_DIR", None)
if caller_iso_dir is not None:
env["ISO_DIR"] = str(caller_iso_dir)
return subprocess.run(["bash", "--noprofile", "--norc", "-c", functions + prelude],
env=env, stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=15)
def qm_calls(self):
return self.qm_log.read_text() if self.qm_log.exists() else ""
def test_http_failure_stops_before_creating_a_guest(self):
result = self.run_wizard("/missing.iso")
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.qm_calls(), "")
self.assertNotIn("OK:ISO image downloaded", result.stdout)
self.assertIn("ERROR:Failed to download ISO image", result.stdout)
def test_partial_transfer_is_not_published_as_cached_media(self):
result = self.run_wizard("/partial.iso")
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.qm_calls(), "")
self.assertFalse(self.iso.exists(), "A failed transfer must not become reusable media")
def test_an_empty_cached_download_is_retried(self):
self.iso.write_bytes(b"")
result = self.run_wizard()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.iso.read_bytes(), PAYLOAD)
self.assertIn("create ", self.qm_calls())
def test_virtio_download_keeps_native_storage_despite_caller_environment(self):
self.iso.write_bytes(PAYLOAD)
unrelated = self.directory / "unrelated directory"
unrelated.mkdir()
result = self.run_wizard(windows=True, caller_iso_dir=unrelated)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
driver = self.directory / "virtio-win.iso"
self.assertTrue(driver.is_file(), result.stdout + result.stderr)
self.assertEqual(driver.read_bytes(), PAYLOAD)
self.assertIn("-ide3 ", self.qm_calls())
self.assertEqual(list(unrelated.iterdir()), [])
def test_failed_virtio_fetch_is_not_selected_for_attachment(self):
self.iso.write_bytes(PAYLOAD)
result = self.run_wizard("/missing.iso", windows=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("-ide3 ", self.qm_calls())
self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout)
self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout)
self.assertFalse((self.directory / "virtio-win.iso").exists())
def test_successful_virtio_replacement_preserves_existing_permissions(self):
self.iso.write_bytes(PAYLOAD)
driver = self.directory / "virtio-win.iso"
driver.write_bytes(b"previous driver")
driver.chmod(0o640)
result = self.run_wizard(windows=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(driver.read_bytes(), PAYLOAD)
self.assertEqual(driver.stat().st_mode & 0o777, 0o640)
def test_empty_http_success_is_rejected(self):
result = self.run_wizard("/empty.iso")
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.qm_calls(), "")
self.assertFalse(self.iso.exists())
self.assertEqual({p.name for p in self.directory.iterdir()}, {"bin", "wget.log"})
def test_existing_nonempty_media_is_reused_without_fetching(self):
self.iso.write_bytes(b"existing installation media")
result = self.run_wizard("/missing.iso")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.iso.read_bytes(), b"existing installation media")
self.assertFalse(self.wget_log.exists())
self.assertIn("-ide2 ", self.qm_calls())
def test_sourceforge_download_keeps_the_selected_destination_name(self):
result = self.run_wizard(url="https://sourceforge.net/projects/fixture/iso/download")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.iso.read_bytes(), PAYLOAD)
self.assertFalse((self.directory / "remote-name.iso").exists())
self.assertEqual({p.name for p in self.directory.iterdir()},
{"bin", "wget.log", "qm.log", self.iso.name})
def test_failed_virtio_replacement_preserves_previous_media(self):
self.iso.write_bytes(PAYLOAD)
driver = self.directory / "virtio-win.iso"
driver.write_bytes(b"previous driver")
driver.chmod(0o640)
result = self.run_wizard("/partial.iso", windows=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(driver.read_bytes(), b"previous driver")
self.assertEqual(driver.stat().st_mode & 0o777, 0o640)
self.assertNotIn("-ide3 ", self.qm_calls())
self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout)
def test_declining_virtio_replacement_reuses_previous_media(self):
self.iso.write_bytes(PAYLOAD)
driver = self.directory / "virtio-win.iso"
driver.write_bytes(b"previous driver")
result = self.run_wizard("/missing.iso", windows=True, overwrite=False)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(driver.read_bytes(), b"previous driver")
self.assertFalse(self.wget_log.exists())
self.assertIn("-ide3 ", self.qm_calls())
def test_empty_cached_virtio_media_is_retried_not_attached(self):
self.iso.write_bytes(PAYLOAD)
driver = self.directory / "virtio-win.iso"
driver.write_bytes(b"")
result = self.run_wizard("/missing.iso", windows=True, overwrite=False)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertTrue(self.wget_log.exists())
self.assertNotIn("-ide3 ", self.qm_calls())
self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout)
def test_a_directory_cannot_be_published_as_media(self):
self.iso.mkdir()
result = self.run_wizard()
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.qm_calls(), "")
self.assertTrue(self.iso.is_dir())
self.assertEqual(list(self.iso.iterdir()), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,217 @@
"""Offline VM source regressions; execute bounded real functions, never selectors.
Fixtures are reduced official metadata excerpts captured 2026-10-16. Network
commands are shell stubs: no ISO downloads, utils sourcing, or host changes.
"""
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
LINUX = ROOT / "scripts/vm/select_linux_iso.sh"
NAS = ROOT / "scripts/vm/select_nas_iso.sh"
FIELDS = ("ISO_NAME", "ISO_TYPE", "ISO_URL", "ISO_FILE", "ISO_PATH", "HN")
def extract_function(path, name):
"""Only top-level function definitions with an unindented closing brace."""
match = re.search(
rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}(?=\n|$)",
path.read_text(), re.MULTILINE | re.DOTALL,
)
if match is None:
raise AssertionError(f"Missing bounded function: {name}")
return match.group(0)
class IsoSourceTests(unittest.TestCase):
def run_selector(self, path, function, *, menu_name="", metadata="",
metadata_url="", fetch_status=0, has_curl=True):
with tempfile.TemporaryDirectory(prefix="vm-iso-sources-") as directory:
temp = Path(directory)
fixture = temp / "metadata.html"
fixture.write_text(metadata)
trace = temp / "fetches.txt"
trace.touch()
iso_dir = temp / "iso"
iso_dir.mkdir()
env = os.environ.copy()
env.update(ISO_DIR=str(iso_dir), MENU_NAME=menu_name,
METADATA_FILE=str(fixture), METADATA_URL=metadata_url,
FETCH_STATUS=str(fetch_status), FETCH_TRACE=str(trace))
helpers = "\n".join(
extract_function(NAS, name)
for name in ("_has_curl", "_latest_version_from_lines")
)
script = helpers + r'''
translate() { printf '%s' "$1"; }
curl() {
local url="${@: -1}"
printf '%s\n' "$url" >> "$FETCH_TRACE"
[[ "$url" == "$METADATA_URL" ]] || return 97
[[ "$FETCH_STATUS" == 0 ]] || return "$FETCH_STATUS"
cat "$METADATA_FILE"
}
wget() { return 97; }
dialog() {
local arg previous="" name
for arg in "$@"; do
name="${arg%%│*}"
name="${name%"${name##*[![:space:]]}"}"
if [[ "$name" == "$MENU_NAME" ]]; then
printf '%s' "$previous" >&2
return 0
fi
previous="$arg"
done
return 1
}
'''
if not has_curl:
script += "\n_has_curl() { return 1; }\n"
script += extract_function(path, function)
script += f"\n{function} || exit $?\n"
script += 'printf \'%s\\0\' ' + " ".join(
f'"${{{field}:-}}"' for field in FIELDS
)
result = subprocess.run(
["bash", "--noprofile", "--norc", "-c", script],
env=env, capture_output=True, timeout=10,
)
self.assertEqual(result.returncode, 0, result.stderr.decode())
values = result.stdout.decode().split("\0")
self.assertEqual(len(values), len(FIELDS) + 1, result.stdout)
selected = dict(zip(FIELDS, values))
self.assertEqual(selected["ISO_PATH"], str(iso_dir / selected["ISO_FILE"]))
return selected, trace.read_text().splitlines()
def test_debian13_dvd_uses_fixed_official_archive(self):
selected, calls = self.run_selector(
LINUX, "select_linux_iso_official", menu_name="Debian 13",
)
self.assertEqual(selected["ISO_URL"],
"https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-dvd/"
"debian-13.6.0-amd64-DVD-1.iso")
self.assertEqual(selected["ISO_TYPE"], "Desktop")
self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-DVD-1.iso")
self.assertEqual(calls, [])
def test_debian13_netinst_uses_fixed_official_archive(self):
selected, calls = self.run_selector(
LINUX, "select_linux_iso_official", menu_name="Debian 13 Netinst",
)
self.assertEqual(selected["ISO_URL"],
"https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-cd/"
"debian-13.6.0-amd64-netinst.iso")
self.assertEqual(selected["ISO_TYPE"], "CLI")
self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-netinst.iso")
self.assertEqual(calls, [])
def test_omv_metadata_unavailable_uses_verified_fallback(self):
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
for has_curl, fetch_status, metadata in (
(False, 0, ""), (True, 22, ""),
(True, 0, "<html>No release links</html>"),
):
with self.subTest(has_curl=has_curl, fetch_status=fetch_status):
selected, calls = self.run_selector(
NAS, "resolve_omv_iso", metadata_url=url, has_curl=has_curl,
fetch_status=fetch_status, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
url + "8.3.1/openmediavault_8.3.1-amd64.iso/download")
self.assertEqual(selected["ISO_NAME"], "OpenMediaVault 8.3.1")
self.assertEqual(selected["ISO_FILE"], "openmediavault_8.3.1-amd64.iso")
self.assertEqual(calls, [url] if has_curl else [])
def test_rockstor_resolves_official_landing_without_directory_listing(self):
# Exact installer anchor excerpt from https://rockstor.com/dls/index.html.
url = "https://rockstor.com/dls/index.html"
metadata = (
'<a href="https://rockstor.com/downloads/installer/leap/15.6/x86_64/'
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso">'
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso</a> <br/>'
)
selected, calls = self.run_selector(
NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
self.assertEqual(selected["ISO_FILE"],
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
self.assertEqual(selected["HN"], "Rockstor")
self.assertEqual(calls, [url])
def test_rockstor_metadata_unavailable_uses_verified_fallback(self):
url = "https://rockstor.com/dls/index.html"
for has_curl, fetch_status, metadata in (
(False, 0, ""), (True, 22, ""),
(True, 0, "<html>No installer links</html>"),
):
with self.subTest(has_curl=has_curl, fetch_status=fetch_status):
selected, calls = self.run_selector(
NAS, "resolve_rockstor_iso", metadata_url=url, has_curl=has_curl,
fetch_status=fetch_status, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
self.assertEqual(selected["ISO_NAME"], "Rockstor")
self.assertEqual(selected["ISO_FILE"],
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
self.assertEqual(calls, [url] if has_curl else [])
def test_omv_current_metadata_resolution_is_preserved(self):
# Reduced release links from the official SourceForge ISO index.
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
metadata = (
'<a href="/projects/openmediavault/files/iso/8.3.1/" '
'title="Click to enter 8.3.1">8.3.1</a>\n'
'<a href="/projects/openmediavault/files/iso/7.4.17/" '
'title="Click to enter 7.4.17">7.4.17</a>'
)
selected, calls = self.run_selector(
NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
url + "8.3.1/openmediavault_8.3.1-amd64.iso/download")
self.assertEqual(calls, [url])
def test_omv_detected_release_takes_precedence_over_fallback(self):
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
# Synthetic ordering case: must execute the real version parser.
metadata = ('<a href="/projects/openmediavault/files/iso/8.10.0/">new</a>'
'<a href="/projects/openmediavault/files/iso/8.9.0/">old</a>')
selected, calls = self.run_selector(
NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
url + "8.10.0/openmediavault_8.10.0-amd64.iso/download")
self.assertEqual(calls, [url])
def test_rockstor_retains_generic_x86_64_leap156_edition(self):
url = "https://rockstor.com/dls/index.html"
# Synthetic ordering / edition cases; not claims of published images.
metadata = "\n".join((
"Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso",
"Rockstor-Leap15.6-generic.x86_64-5.9.0-0.install.iso",
"Rockstor-Leap15.6-generic.aarch64-9.0.0-0.install.iso",
"Rockstor-Leap16.0-generic.x86_64-9.0.0-0.install.iso",
"Rockstor-Leap15.5-generic.x86_64-9.0.0-0.install.iso",
))
selected, calls = self.run_selector(
NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
"Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso")
self.assertEqual(calls, [url])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,94 @@
"""Validate complete catalogs without rejecting intentional English fallback."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py"
class WebDocsCatalogTests(unittest.TestCase):
def run_check(self, root, *options):
return subprocess.run(
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root),
"--languages", "de", *options],
capture_output=True, text=True,
)
def write_catalog(self, root, locale, content):
file = root / locale / "common.json"
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(json.dumps(content), encoding="utf-8")
def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."})
self.write_catalog(root, "de", {"title": "Aktualisierungen"})
partial = self.run_check(root)
self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr)
self.assertIn("missing=1", partial.stdout)
complete = self.run_check(root, "--require-complete")
self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr)
self.assertIn("schema differs", complete.stdout)
def test_translated_code_is_rejected_in_both_modes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"body": "Run <code>apt update</code>."})
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> aus."})
for options in ((), ("--require-complete",)):
with self.subTest(options=options):
result = self.run_check(root, *options)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
def test_bare_commands_and_operators_are_not_prose(self):
examples = [
("Restore with pct restore.", "Mit pct restart wiederherstellen."),
("pveversion | grep pve-manager", "pveversion grep pve-manager"),
("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"),
]
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for source, target in examples:
with self.subTest(source=source):
self.write_catalog(root, "en", {"body": source})
self.write_catalog(root, "de", {"body": target})
result = self.run_check(root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("literal command or identifier", result.stdout)
def test_title_separator_can_change_without_relaxing_body_pipes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}})
self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}})
result = self.run_check(root, "--require-complete")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}})
self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}})
result = self.run_check(root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
def test_shipped_web_catalogs_keep_literal_contracts(self):
messages = SCRIPT.parents[2] / "web" / "messages"
result = subprocess.run(
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_literal_metadata_is_required_but_labels_can_be_localized(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"})
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"})
invalid = self.run_check(root, "--require-complete")
self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr)
self.assertIn("rendering metadata differ", invalid.stdout)
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"})
valid = self.run_check(root, "--require-complete")
self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr)
@@ -0,0 +1,41 @@
"""Exercise the static-export language repair on real HTML files."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_command_descriptions import require_node
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / "web/scripts/localize-export.mjs"
class WebExportLanguageTests(unittest.TestCase):
def test_locales_nested_pages_preserved_content_and_idempotence(self):
node = require_node()
html = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"]
for locale in locales:
file = root / "out" / locale / "docs/monitor/index.html"
file.parent.mkdir(parents=True)
file.write_text(html)
for relative in ["out/index.html", "out/assets/index.html"]:
file = root / relative
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(html)
result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("8 localized pages", result.stdout)
for locale in locales:
actual = (root / "out" / locale / "docs/monitor/index.html").read_text()
self.assertEqual(actual, html.replace('<html lang="en"', f'<html lang="{locale}"', 1))
for relative in ["out/index.html", "out/assets/index.html"]:
self.assertEqual((root / relative).read_text(), html)
tracked = root / "out/de/docs/monitor/index.html"
before = tracked.stat().st_mtime_ns
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
self.assertEqual(second.returncode, 0, second.stderr)
self.assertEqual(tracked.stat().st_mtime_ns, before)
@@ -0,0 +1,168 @@
"""Render the real ZRAM CLI paths in isolated files with host effects stubbed."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
DETAILS = {
"version": ("Cannot determine installed systemd-zram-generator version", "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator"),
"mismatch": ("Existing ZRAM profile is active but does not match its configuration", "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii"),
"stop": ("Could not stop existing dev-zram0.swap", "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap"),
"directory": ("Could not create ZRAM generator directory", "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM"),
"config": ("Could not write ZRAM generator configuration", "Nepodarilo sa zapísať konfiguráciu generátora ZRAM"),
"marker": ("Could not write ZRAM package ownership state", "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux"),
"start": ("Could not activate dev-zram0.swap", "Nepodarilo sa aktivovať jednotku dev-zram0.swap"),
"verify": ("Active ZRAM algorithm, capacity or swap sources did not match the profile", "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu"),
}
REFUSAL = "Another ZRAM generator configuration exists; refusing to overwrite it:"
REFUSAL_SK = "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:"
def function(path, name):
text = path.read_text()
start = text.index(name + "() {")
return text[start:text.index("\n}\n", start) + 3]
HARNESS = r'''
set -u
BASE_DIR="$FIXTURE/state"
LANG_DIR="$REPO/lang"
mkdir -p "$BASE_DIR" "$FIXTURE/etc/systemd/zram-generator.conf.d" "$FIXTURE/proc"
CONFIG="$FIXTURE/etc/systemd/zram-generator.conf.d/90-proxmenux.conf"
MARKER="$BASE_DIR/zram-swap.state"
printf 'Filename\tType\tSize\tUsed\tPriority\n' > "$FIXTURE/proc/swaps"
printf '/swapfile none swap sw 0 0\n' > "$FIXTURE/etc/fstab"
: > "$FIXTURE/calls"
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
pmx_journal_context() { :; }
msg_error() { printf '%s\n' "$*"; }
msg_success() { printf '%s\n' "$*"; }
register_tool() { record register "$@"; }
pmx_install_pkg() { record package-install; return 1; }
apt-get() { record apt "$@"; return 1; }
dpkg-query() {
record dpkg-query
case "$*" in
*'${Status}'*) printf 'install ok installed\n';;
*'${Version}'*) [[ "$CASE" == version ]] || printf '1.2.0-1\n';;
esac
}
dpkg() { return 0; }
install() { record directory; [[ "$CASE" != directory ]]; }
pmx_write_file() {
record write "$1"
if [[ "$CASE" == config && "$1" == "$CONFIG" ]] ||
[[ "$CASE" == marker && "$1" == "$MARKER" ]]; then
cat >/dev/null
return 1
fi
cat > "$1"
}
pmx_remove_file() { record remove "$1"; rm -f -- "$1"; }
pmx_record_uninstall() { record record-uninstall; }
swapoff() { record swapoff; return 0; }
systemctl() {
record systemctl "$@"
[[ "$CASE" != start || "$1" != start ]] &&
[[ "$CASE" != stop || "$1" != stop ]]
}
source "$REPO/scripts/post_install/zram_profile_helpers.sh"
zram_profile_verify_active() { [[ "$CASE" != verify && "$CASE" != mismatch ]]; }
if [[ "$CASE" == mismatch || "$CASE" == stop ]]; then
printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
[[ "$CASE" != mismatch ]] || zram_render_generator_config true > "$CONFIG"
printf 'installed_by_proxmenux=false\n' > "$MARKER"
printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$FIXTURE/proc/swaps"
fi
if [[ "$CASE" == foreign ]]; then
printf '[zram1]\nzram-size = ram / 5\n' > "$FIXTURE/etc/systemd/zram-generator.conf"
fi
if [[ "$CASE" == unreadable ]]; then
zram_foreign_generator_config() { return 2; }
fi
'''
class ZramLocalizationTests(unittest.TestCase):
def test_missing_optional_translation_uses_original_english(self):
with tempfile.TemporaryDirectory(prefix="zram-fallback-") as folder:
(Path(folder) / 'xx.json').write_text('{}')
script = function(ROOT / "scripts/utils.sh", "translate") + '\ntranslate "$1"\n'
result = subprocess.run(["bash", "-c", script, "bash", DETAILS['version'][0]],
text=True, capture_output=True, timeout=15,
env={**os.environ, 'LANGUAGE': 'xx', 'LANG_DIR': folder})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), DETAILS['version'][0])
def run_case(self, case, language="sk"):
with tempfile.TemporaryDirectory(prefix="zram-i18n-") as folder:
directory = Path(folder)
installer = function(ROOT / "scripts/post_install/customizable_post_install.sh", "configure_zram_swap")
installer = installer.replace("/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", folder + "/etc/systemd/zram-generator.conf.d/90-proxmenux.conf")
installer = installer.replace("/proc/swaps", folder + "/proc/swaps").replace("/etc/fstab", folder + "/etc/fstab")
installer = installer.replace('zram_foreign_generator_config "$config"', 'zram_foreign_generator_config "$config" "$FIXTURE"')
installer = installer.replace('zram_list_foreign_systemd_zram_units)', 'zram_list_foreign_systemd_zram_units "$FIXTURE")')
script = HARNESS + function(ROOT / "scripts/utils.sh", "translate") + installer + "\nconfigure_zram_swap\n"
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
env={**os.environ, "REPO": str(ROOT), "FIXTURE": folder, "CASE": case, "LANGUAGE": language}, timeout=15)
calls = (directory / "calls").read_text().splitlines()
files = {str(p.relative_to(directory)): p.read_text() for p in directory.rglob("*") if p.is_file()}
return result, calls, files
def test_all_activation_details_are_localized_and_english_is_preserved(self):
for case, (english, slovak) in DETAILS.items():
for language, expected in (("sk", slovak), ("en", english)):
with self.subTest(case=case, language=language):
result, calls, files = self.run_case(case, language)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn(expected, result.stdout)
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
if case in ("version", "mismatch"):
self.assertTrue(all(call == "dpkg-query" for call in calls), calls)
elif case == "stop":
self.assertEqual(files["state/zram-swap.state"], "installed_by_proxmenux=false\n")
self.assertIn("# ProxMenux ZRAM profile", files["etc/systemd/zram-generator.conf.d/90-proxmenux.conf"])
else:
self.assertNotIn("state/zram-swap.state", files)
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
self.assertFalse(any(call.startswith(("apt ", "package-install", "register ")) for call in calls), calls)
def test_foreign_generator_is_refused_in_both_languages_without_any_effect(self):
for language, expected in (("sk", REFUSAL_SK), ("en", REFUSAL)):
result, calls, files = self.run_case("foreign", language)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn(expected, result.stdout)
self.assertIn("/etc/systemd/zram-generator.conf", result.stdout)
self.assertEqual(calls, [])
self.assertEqual(files["etc/systemd/zram-generator.conf"], "[zram1]\nzram-size = ram / 5\n")
self.assertNotIn("state/zram-swap.state", files)
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
self.assertEqual(files["proc/swaps"], "Filename\tType\tSize\tUsed\tPriority\n")
def test_failed_inspection_is_not_mistaken_for_no_conflict(self):
result, calls, files = self.run_case("unreadable")
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn("Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", result.stdout)
self.assertEqual(calls, [])
self.assertNotIn("state/zram-swap.state", files)
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
def test_uninstall_label_and_cancel_do_not_call_uninstall(self):
for language, label in (("sk", "Profil swapu ZRAM"), ("en", "ZRAM swap profile")):
with tempfile.TemporaryDirectory(prefix="zram-menu-") as folder:
tools = Path(folder) / "tools.json"
tools.write_text('{"zram_swap":true}')
script = function(ROOT / "scripts/utils.sh", "translate")
script += function(ROOT / "scripts/post_install/uninstall-tools.sh", "show_uninstall_menu")
script += '\nensure_tools_json() { :; }; migrate_installed_tools() { :; }\nuninstall_zram_swap() { printf forbidden; return 1; }\ndialog() { printf "%s\\n" "$@" > "$MENU_CAPTURE"; return 1; }\nshow_uninstall_menu\n'
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
env={**os.environ, "LANGUAGE": language, "LANG_DIR": str(ROOT / "lang"), "TOOLS_JSON": str(tools), "MENU_CAPTURE": folder + "/dialog.txt"}, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(label, (Path(folder) / "dialog.txt").read_text())
self.assertNotIn("forbidden", result.stdout + result.stderr)
self.assertEqual(json.loads(tools.read_text()), {"zram_swap": True})
+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:
@@ -0,0 +1,50 @@
name: Apprise Slack and Matrix
on:
pull_request:
paths:
- '.github/workflows/test-apprise-services.yml'
- 'AppImage/scripts/notification_channels.py'
- 'AppImage/components/apprise-service-help.tsx'
- 'AppImage/components/notification-settings.tsx'
- 'tests/test_apprise_services.py'
- 'tests/test_apprise_service_help.cjs'
push:
branches: [main, develop]
paths:
- 'AppImage/scripts/notification_channels.py'
- 'AppImage/components/apprise-service-help.tsx'
- 'tests/test_apprise_services.py'
- 'tests/test_apprise_service_help.cjs'
workflow_dispatch:
permissions:
contents: read
jobs:
apprise-services:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
apprise: ['1.7.0', '2.0.1']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'apprise==${{ matrix.apprise }}'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test real provider plugins with mocked HTTP
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
- name: Render provider help and locale fallback
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_apprise_service_help.cjs
@@ -0,0 +1,50 @@
name: Backup storage retention
on:
pull_request:
paths:
- '.github/workflows/test-backup-storage-retention.yml'
- 'AppImage/scripts/flask_vm_backup_routes.py'
- 'AppImage/components/backup-storage-retention.tsx'
- 'AppImage/components/backup-tab.tsx'
- 'AppImage/components/vm-backup-jobs.tsx'
- 'AppImage/lib/backup-retention.ts'
- 'tests/test_vm_backup_jobs_api.py'
- 'tests/test_backup_storage_retention.cjs'
push:
branches: [main, develop]
paths:
- 'AppImage/scripts/flask_vm_backup_routes.py'
- 'AppImage/components/backup-storage-retention.tsx'
- 'AppImage/lib/backup-retention.ts'
- 'tests/test_vm_backup_jobs_api.py'
- 'tests/test_backup_storage_retention.cjs'
workflow_dispatch:
permissions:
contents: read
jobs:
storage-retention:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'Flask==3.1.2'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test native job and storage configuration APIs
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
- name: Test shared retention properties
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_backup_storage_retention.cjs
+73
View File
@@ -0,0 +1,73 @@
name: Figurine installation
on:
pull_request:
paths:
- '.github/workflows/test-figurine.yml'
- 'tests/test_figurine_installation.py'
- 'scripts/post_install/customizable_post_install.sh'
- 'AppImage/scripts/post_install_versions.py'
- 'AppImage/scripts/flask_proxmenux_routes.py'
push:
branches: [main, develop]
paths:
- '.github/workflows/test-figurine.yml'
- 'tests/test_figurine_installation.py'
- 'scripts/post_install/customizable_post_install.sh'
- 'AppImage/scripts/post_install_versions.py'
- 'AppImage/scripts/flask_proxmenux_routes.py'
workflow_dispatch:
permissions:
contents: read
jobs:
installer-and-release:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check shell syntax and isolated installer regressions
run: |
bash -n scripts/post_install/customizable_post_install.sh
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
- name: Install ARM user-mode emulation for release smoke tests
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends qemu-user
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
env:
FIGURINE_VERSION: '2.1.1'
shell: bash
run: |
set -euo pipefail
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
while read -r arch sha; do
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
target="$scratch/$arch"
mkdir -p "$target"
curl --fail --location --retry 3 --output "$target/$archive" \
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
tar -xf "$target/$archive" -C "$target"
runner=()
case "$arch" in
arm64) runner=(qemu-aarch64) ;;
arm) runner=(qemu-arm) ;;
esac
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
test -s "$target/banner"
printf 'PASS: %s version and login banner\n' "$arch"
done <<'ASSETS'
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
ASSETS
+54
View File
@@ -0,0 +1,54 @@
name: Guest backup archives
on:
pull_request:
paths:
- '.github/workflows/test-guest-backups.yml'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
- 'AppImage/lib/guest-backup-archives.ts'
- 'AppImage/messages/**'
- 'tests/test_guest_backup_archives.*'
push:
branches: [main, develop]
paths:
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
- 'AppImage/lib/guest-backup-archives.ts'
- 'tests/test_guest_backup_archives.*'
workflow_dispatch:
permissions:
contents: read
jobs:
guest-backups:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'Flask==3.1.2'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test native deletion, protection, authorization and cache invalidation
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
- name: Test frontend deletion and completion handling
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_guest_backup_archives.cjs
+16 -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,28 @@ on:
- 'AppImage/messages/**'
- 'AppImage/lib/i18n/**'
- 'AppImage/components/terminal-panel.tsx'
- 'web/**'
- 'CONTRIBUTING.md'
# Scheduler consumers and the offline extractor's .sh/.func inputs.
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
- 'scripts/**/*.py'
- 'lang/*.json'
- 'menu'
- 'install_proxmenux.sh'
- 'install_proxmenux_beta.sh'
- 'oci/src/**/*.py'
- 'oci/catalog/**/*.json'
- 'oci/remote/**/*.sh'
- 'oci/remote/**/*.py'
workflow_dispatch:
permissions:
contents: read
jobs:
# Preserve this check ID for existing branch protection rules. This suite
# includes menu, download and scheduler regressions as well as i18n tests.
offline-i18n:
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -59,9 +71,9 @@ jobs:
run: |
test -x /bin/bash
test -x /usr/bin/jq
- name: Install OCI wording test dependency
run: python3 -m pip install 'PyYAML>=6,<7'
- name: Run complete offline script test suite
- name: Install OCI wording test dependencies
run: python3 -m pip install 'PyYAML>=6,<7' 'jsonschema==4.25.1'
- name: Run offline i18n and script regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s .github/scripts/tests -v
@@ -0,0 +1,55 @@
name: Disk policy and host monitor regressions
on:
pull_request:
paths:
- '.github/workflows/test-lvm-disk-policy.yml'
- 'AppImage/scripts/lvm_device_scope.py'
- 'AppImage/scripts/disk_temperature_history.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/flask_notification_routes.py'
- 'AppImage/scripts/proxmox_storage_monitor.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/scripts/tests/test_host_backup_node.py'
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
- 'tests/test_lvm_disk_read_policy.py'
push:
branches: [main, develop]
paths:
- 'AppImage/scripts/lvm_device_scope.py'
- 'AppImage/scripts/disk_temperature_history.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/flask_notification_routes.py'
- 'AppImage/scripts/proxmox_storage_monitor.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/scripts/tests/test_host_backup_node.py'
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
- 'tests/test_lvm_disk_read_policy.py'
workflow_dispatch:
permissions:
contents: read
jobs:
disk-policy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test automatic LVM readers with mocked inventory and policy
run: python -I -B -m unittest discover -s tests -p test_lvm_disk_read_policy.py -v
- name: Test backup node ownership and former jobs
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_host_backup_node.py -v
- name: Test local node lookup without Proxmox API commands
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_proxmox_node_name.py -v
- name: Test notification configuration writes against a cluster filesystem fixture
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_pve_notifications_cfg_writes.py -v
+109
View File
@@ -0,0 +1,109 @@
name: Offline OCI registry tests
on:
pull_request:
paths:
- 'oci/**'
- 'AppImage/scripts/oci_menu_bridge.py'
- 'AppImage/scripts/lxc_apps.py'
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
- 'AppImage/scripts/flask_server.py'
- '.github/workflows/test-oci-registries.yml'
push:
branches: [main, develop]
paths:
- 'oci/**'
- 'AppImage/scripts/oci_menu_bridge.py'
- 'AppImage/scripts/lxc_apps.py'
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
- 'AppImage/scripts/flask_server.py'
- '.github/workflows/test-oci-registries.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
registry-accounts:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install OCI test dependencies
run: python3 -m pip install -r oci/requirements.txt
- name: Run watchdog identity and lifecycle regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p test_watchdog.py -v
- name: Run OCI image reuse and resource edit regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
- name: Run carried record, recovery and configuration cache regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
- name: Run read-only volume preparation regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
test -x /bin/bash
test -x /usr/bin/jq
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
- name: Run offline registry account tests
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
- name: Run OCI menu update checks and selection regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_update*.py' -v
python3 -m unittest discover -s AppImage/scripts/tests -p 'test_oci*.py' -v
python3 -m unittest discover -s oci/tests -p test_recreate_registry_digest.py -v
- name: Run OCI Modify latency regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
- name: Run OCI modification menu regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
- name: Run external OCI settings regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
- name: Run managed volume startup regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_managed_volume_*.py' -v
- name: Run catalog web-link regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p test_catalog_web_links.py -v
+22 -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/**'
@@ -54,10 +68,16 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run eight qualified Python files
- name: Install native discovery route fixture dependency
run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1'
- name: Run qualified Python files
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
- name: Run health recurrence tests without TMPDIR
env:
PYTHONDONTWRITEBYTECODE: '1'
run: env -u TMPDIR python3 -I -B -m unittest discover -s AppImage/scripts/tests -p test_health_persistence_recurrence.py -v
offline-node:
runs-on: ubuntu-latest
@@ -76,7 +96,7 @@ jobs:
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules
- name: Run eleven qualified Node files
- name: Run qualified Node files
env:
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
PYTHONDONTWRITEBYTECODE: '1'
+68
View File
@@ -0,0 +1,68 @@
name: PBS support
on:
pull_request:
paths:
- '.github/workflows/test-pbs.yml'
- 'AppImage/scripts/*pbs*.py'
- 'AppImage/scripts/tests/test_pbs.py'
- 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/auth_manager.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/components/pbs-*.tsx'
- 'AppImage/components/proxmox-dashboard.tsx'
- 'AppImage/tests/pbs-navigation.cjs'
- 'scripts/global/proxmox_platform.py'
- 'scripts/global/platform-functions.sh'
- 'scripts/global/repository-functions.sh'
- 'scripts/menus/pbs_menu.sh'
- 'scripts/utilities/pbs_update.sh'
- 'scripts/global/update-pbs.sh'
- 'scripts/menus/menu_post_install.sh'
- 'scripts/post_install/*.sh'
push:
branches: [main, develop]
paths:
- 'AppImage/scripts/*pbs*.py'
- 'scripts/global/proxmox_platform.py'
- '.github/workflows/test-pbs.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
pbs:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python: ['3.11', '3.13']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install test dependencies
run: python -m pip install Flask PyJWT cryptography pyotp segno
- name: Platform, real TLS, APIs, health and AppImage packaging
run: |
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
bash -n "$script"
done
- uses: actions/setup-node@v6
if: matrix.python == '3.11'
with:
node-version: '22.14.0'
- name: Dashboard platform routing
if: matrix.python == '3.11'
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
+1 -1
View File
@@ -1 +1 @@
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
5e8afe3b297f21b8646e58eed720ba2eeb5f16681a3d2eb89bcac600065167c9 ProxMenux-1.2.7.AppImage
@@ -0,0 +1,32 @@
"use client"
import { ExternalLink } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// These are examples, never defaults: opening help must not replace a saved
// destination or put placeholder credentials into the notification settings.
export function AppriseServiceHelp() {
const t = useT()
return <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
<div className="grid gap-3 pt-3 sm:grid-cols-2">
<div className="space-y-1.5 min-w-0">
<h4 className="font-medium">Slack</h4>
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
{t("settings.notifications.ui.appriseSlackDocs")} <ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="space-y-1.5 min-w-0">
<h4 className="font-medium">Matrix</h4>
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
{t("settings.notifications.ui.appriseMatrixDocs")} <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
</details>
}
+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>
)
}
+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>
</>
}
+13 -6
View File
@@ -42,6 +42,7 @@ import {
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform"
interface CategoryCheck {
status: string
@@ -119,6 +120,7 @@ const CATEGORIES = [
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const t = useT()
const isPbs = useIsPbs()
const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
@@ -460,6 +462,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
return originalStatus?.toUpperCase() || "UNKNOWN"
}
// Checks the host reports as not applicable (guests on PBS) are not listed or counted.
const visibleCategories = CATEGORIES.filter(({ key }) =>
!(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable)
const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`)
const getHealthStats = () => {
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
@@ -469,7 +476,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
let critical = 0
let unknown = 0
CATEGORIES.forEach(({ key }) => {
visibleCategories.forEach(({ key }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
if (categoryData) {
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
@@ -481,7 +488,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
})
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
return { total: visibleCategories.length, healthy, info, warnings, critical, unknown }
}
const stats = getHealthStats()
@@ -759,7 +766,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{/* Category List */}
<div className="space-y-2">
{CATEGORIES.map(({ key, Icon }) => {
{visibleCategories.map(({ key, Icon }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
const originalStatus = categoryData?.status || "UNKNOWN"
const status = getEffectiveStatus(key, originalStatus)
@@ -784,7 +791,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 sm:gap-2">
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
<p className="font-medium text-xs sm:text-sm truncate">{categoryLabel(key)}</p>
{hasChecks && (
<span className="text-[10px] text-muted-foreground shrink-0">
({Object.values(checks).filter(c => c.installed !== false).length})
@@ -871,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{filteredDismissed.map((item) => {
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
const CatIcon = catMeta?.Icon || BellOff
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
return (
@@ -936,7 +943,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div key={cs.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? categoryLabel(catMeta.key) : cs.label}</span>
</div>
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
{durationLabel}
+6 -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 {
@@ -283,8 +284,12 @@ function computeVisualRange(
// ─── Component ───────────────────────────────────────────────────────────────
// Guests and PVE storages do not exist on a Proxmox Backup Server.
const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "lxc_mount", "pve_storage"])
export function HealthThresholds() {
const t = useT()
const isPbs = useIsPbs()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
@@ -773,7 +778,7 @@ export function HealthThresholds() {
Mobile (<md) stays single-column as today.
*/}
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
{SECTIONS.map((section) => {
{SECTIONS.filter((section) => !isPbs || !PVE_ONLY_SECTIONS.has(section.id)).map((section) => {
const Icon = section.icon
return (
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
+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>
)
+287 -35
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
@@ -398,7 +413,40 @@ function selfhstThemeLogos(src: string) {
return match ? SELFHST_THEME_LOGOS[match[1]] : null
}
const LOCAL_LOGO_CACHE_PATH = /^\/api\/vms\/[1-9]\d*\/apps\/logo-cache\/[a-f0-9]{32}$/
function CachedLocalLogo({ src, className }: { src: string; className: string }) {
const [image, setImage] = useState<{ src: string; url: string } | null>(null)
useEffect(() => {
const controller = new AbortController()
let active = true
let url: string | null = null
const token = getAuthToken()
fetch(getApiUrl(src), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal: controller.signal, cache: "no-store",
}).then(async response => {
if (!response.ok) throw new Error("cached icon unavailable")
const blob = await response.blob()
const mime = blob.type.split(";", 1)[0].trim().toLowerCase()
if (!["image/png", "image/svg+xml"].includes(mime) || blob.size > 262144) {
throw new Error("invalid cached icon")
}
if (!active) return
url = URL.createObjectURL(blob)
setImage({ src, url })
}).catch(() => { if (active) setImage(null) })
return () => {
active = false
controller.abort()
if (url) URL.revokeObjectURL(url)
}
}, [src])
return image?.src === src ? <img src={image.url} alt="" className={className} /> : null
}
export function ThemeAwareLogo({ src, className }: { src: string; className: string }) {
if (LOCAL_LOGO_CACHE_PATH.test(src)) return <CachedLocalLogo src={src} className={className} />
const themed = selfhstThemeLogos(src)
const hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.style.display = "none"
@@ -451,7 +499,7 @@ function parseArgvInput(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter(Boolean)
}
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, cacheRevision = 0 }: Props) {
const t = useT()
const isLightTheme = useIsLightTheme()
// Seed from `initialData` first, then fall back to the shared cache
@@ -462,12 +510,77 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
const [loading, setLoading] = useState(!seed)
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
const nativeRequest = useRef(0)
const [dockerDiscovery, setDockerDiscovery] = useState<NativeDiscovery | null>(null)
const dockerRequest = useRef(0)
const targetRef = useRef(vmid)
targetRef.current = vmid
const targetGeneration = useRef(0)
const previousTarget = useRef(vmid)
if (previousTarget.current !== vmid) {
previousTarget.current = vmid
targetGeneration.current += 1
}
useEffect(() => {
setNativeDiscovery(null)
setDockerDiscovery(null)
setEditing(null)
setDetectionNotice(null)
setSearchingApplications(false)
setSaving(false)
return () => { targetGeneration.current += 1 }
}, [vmid, managed])
useEffect(() => {
const generation = targetGeneration.current
const request = nativeRequest.current
let cancelled = false
if (managed) return
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
&& nativeRequest.current === request) setNativeDiscovery(result)
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
return () => { cancelled = true }
}, [vmid, managed, cacheRevision])
useEffect(() => {
const generation = targetGeneration.current
const request = dockerRequest.current
let cancelled = false
if (managed) return
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`).then(result => {
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
&& dockerRequest.current === request) setDockerDiscovery(result)
}).catch(() => { /* optional cached result; never initiate a scan */ })
return () => { cancelled = true }
}, [vmid, managed])
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchingApplications, setSearchingApplications] = useState(false)
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
// Editor state
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
const [editing, setEditingState] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null)
const editingRef = useRef(editing)
editingRef.current = editing
const [iconEndpoint, setIconEndpoint] = useState("")
const [discoveringLocalLogo, setDiscoveringLocalLogo] = useState(false)
const iconRequest = useRef(0)
const iconAbort = useRef<AbortController | null>(null)
const setEditing: typeof setEditingState = value => {
// Invalidate synchronously, including before React commits a batched edit.
iconRequest.current += 1
iconAbort.current?.abort()
setDiscoveringLocalLogo(false)
setEditingState(value)
}
useEffect(() => {
iconRequest.current += 1
iconAbort.current?.abort()
setDiscoveringLocalLogo(false)
return () => {
iconRequest.current += 1
iconAbort.current?.abort()
}
}, [editing, vmid, iconEndpoint])
const [binaryArgsInput, setBinaryArgsInput] = useState("")
const [commandArgvInput, setCommandArgvInput] = useState("")
const [saving, setSaving] = useState(false)
@@ -522,6 +635,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
// `initialData` or the shared cache, the sidecar is already
// populated and the user shouldn't see a flash of "Loading…"
// while we revalidate.
const generation = targetGeneration.current
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
if (!sidecar) setLoading(true)
setError(null)
try {
@@ -533,17 +648,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
// the "Loading applications…" flash that used to show while a
// second, racing fetch caught up.
const bundle = await fetchLxcApps(vmid)
if (bundle) {
if (bundle && isCurrent()) {
setSidecar(bundle.sidecar)
setSuggestions(bundle.suggestions)
}
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.loadFailed"))
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.loadFailed"))
} finally {
setLoading(false)
if (isCurrent()) setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vmid, managed])
}, [vmid, managed, cacheRevision])
useEffect(() => {
if (managed) return
@@ -614,10 +729,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
if (lower.startsWith("registry unreachable")) {
return t("vmLxc.appEditor.upstreamErrorRegistry")
}
return msg
return localizeAppEditorError(msg, t)
}
const editorOpen = !!editing
const editorOpen = !!editing && editing.targetVmid === vmid
// The picker catalog is only needed after the user opens the editor.
useEffect(() => {
@@ -653,8 +768,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
// (`extras[]` from the backend). Both use the same DetectedApp
// shape so the empty state renders them uniformly.
const detectedList: DetectedApp[] = useMemo(() => {
if (!suggestions) return []
const out: DetectedApp[] = []
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : []),
...(dockerDiscovery?.vmid === vmid ? dockerDiscovery.candidates || [] : [])]
if (!suggestions) return out
// A ProxMenux OCI install has no helper slug — nothing was installed by a
// community script — so its template id identifies it instead.
const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id
@@ -679,12 +795,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
}
return out
}, [suggestions])
}, [suggestions, nativeDiscovery, dockerDiscovery, vmid])
// Registered slugs — used to filter the detection list down to
// what the user hasn't already registered on this CT.
const registeredSlugs = useMemo(
() => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]),
() => new Set(apps.flatMap((a) => [a.helper_slug, a.discovery_id]).filter(Boolean) as string[]),
[apps],
)
// Dismissed slugs — persisted in the sidecar. Chips the user
@@ -750,14 +866,44 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
const searchInstalledApplications = async () => {
const generation = targetGeneration.current
const request = ++nativeRequest.current
++dockerRequest.current
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
&& nativeRequest.current === request
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
setSearchingApplications(true)
setDetectionNotice(null)
setError(null)
try {
let native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`, {
method: "POST",
})
if (!isCurrent()) return
setNativeDiscovery(native)
if (native.status === "error" || native.status === "busy") {
const stopped = native.status === "error" && Array.isArray(native.issues)
&& native.issues.includes("guest_stopped")
setDetectionNotice({ found: false, text: t(stopped
? "vmLxc.appEditor.nativeScanStopped" : "vmLxc.appEditor.nativeScanFailed") })
return
}
let docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`, {
method: "POST",
})
if (!isCurrent()) return
setDockerDiscovery(docker)
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
method: "POST",
})
if (!isCurrent()) return
// Recompose against the refreshed catalog without another native scan.
native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
if (!isCurrent()) return
setNativeDiscovery(native)
docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`)
if (!isCurrent()) return
setDockerDiscovery(docker)
setSuggestions(result)
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
@@ -765,11 +911,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
if (result.helper_slug) detected.add(result.helper_slug)
for (const item of result.extras || []) detected.add(item.slug)
for (const item of result.docker_workloads || []) detected.add(item.slug)
for (const item of native.candidates) detected.add(item.slug)
for (const item of docker.candidates || []) detected.add(item.slug)
const visible = [...detected].filter(
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
)
const newCount = visible.filter((slug) => !before.has(slug)).length
if (newCount === 1) {
if (docker.status === "error" || docker.status === "busy") {
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanFailed") })
} else if (docker.status === "partial" || docker.stale) {
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanPartial") })
} else if (native.status === "partial") {
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") })
} else if (newCount === 1) {
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
} else if (newCount > 1) {
setDetectionNotice({
@@ -785,9 +939,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
})
}
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
} finally {
setSearchingApplications(false)
if (isCurrent()) setSearchingApplications(false)
}
}
@@ -804,6 +958,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
existing?: AppEntry,
opts?: { withTracking?: boolean, preset?: DetectedApp },
) => {
const generation = targetGeneration.current
let seed: AppConfig
if (existing) {
seed = {
@@ -832,6 +987,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
health_path: existing.health_path || "",
logo_url: existing.logo_url || "",
helper_slug: existing.helper_slug || "",
...(existing.discovery_id ? { discovery_id: existing.discovery_id } : {}),
// Preserved explicitly: saving replaces the whole record, so dropping
// it here would silently un-delegate the app the first time someone
// edits one of its ports.
@@ -862,7 +1018,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
const p = opts.preset
seed.name = p.name
seed.logo_url = p.logo_url || ""
seed.helper_slug = p.slug
if (p.discovery_id) {
seed.discovery_id = p.discovery_id
seed.update_method = "none"
seed.update_command = ""
} else seed.helper_slug = p.slug
// Docker endpoints come from the real published host-port mappings
// listed under Web links. Do not pre-save a catalog default such as
// 9000; the user explicitly chooses which workload links to add.
@@ -943,9 +1103,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
setShowAdvanced(false)
}
}
if (targetRef.current !== vmid || targetGeneration.current !== generation) return
setBinaryArgsInput((seed.binary_args || []).join(", "))
setCommandArgvInput((seed.command_argv || []).join(", "))
setEditing({ appId: existing?.id || null, draft: seed })
setEditing({ appId: existing?.id || null, draft: seed, targetVmid: vmid })
setDetectorTest(null)
setError(null)
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
@@ -959,7 +1120,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
const save = async () => {
if (!editing) return
if (!editing || editing.targetVmid !== vmid || targetRef.current !== vmid) return
const generation = targetGeneration.current
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
setSaving(true)
setError(null)
try {
@@ -973,6 +1136,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
body: JSON.stringify(editing.draft),
})
if ((r as any).error) throw new Error((r as any).error)
if (!isCurrent()) return
setSidecar(r)
setLxcAppsCached(vmid, r, suggestions)
setEditing(null)
@@ -980,9 +1144,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
setCommandArgvInput("")
onChange?.()
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.saveFailed"))
} finally {
setSaving(false)
if (isCurrent()) setSaving(false)
}
}
@@ -1049,7 +1213,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
const removeOne = async (appId: string) => {
if (!confirm("Remove this application from the CT's App tab?")) return
if (!confirm(t("vmLxc.appEditor.confirmRemove"))) return
setBusyAppId(appId)
setError(null)
try {
@@ -1283,7 +1447,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
// ── Editor ─────────────────────────────────────────────────────
if (editing) {
if (editorOpen && editing) {
const draft = editing.draft
const method = draft.installed_via || ""
const isPackaged = method === "dpkg" || method === "apk"
@@ -1291,6 +1455,40 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
setDetectorTest(null)
setEditing({ ...editing, draft: { ...draft, ...patch } })
}
const iconPorts = draft.ports.filter(entry => typeof entry.port === "number" && entry.port > 0 && entry.port <= 65535)
const endpointKey = (entry: PortEntry) => `${entry.scheme || defaultSchemeFor(entry.port)}:${entry.port}`
const selectedIconPort = iconPorts.find(entry => endpointKey(entry) === iconEndpoint) || iconPorts[0]
const discoverLocalLogo = async () => {
if (!selectedIconPort || discoveringLocalLogo) return
const snapshot = editing
const generation = targetGeneration.current
const requestId = ++iconRequest.current
const controller = new AbortController()
iconAbort.current = controller
const current = () => iconRequest.current === requestId && editingRef.current === snapshot
&& targetRef.current === vmid && targetGeneration.current === generation
setDiscoveringLocalLogo(true)
setError(null)
try {
const result = await fetchApi<{ logo_url: string }>(`/api/vms/${vmid}/apps/logo-discovery`, {
method: "POST", signal: controller.signal,
body: JSON.stringify({ port: selectedIconPort.port, scheme: selectedIconPort.scheme || defaultSchemeFor(selectedIconPort.port) }),
})
if (!current()) return
if (!LOCAL_LOGO_CACHE_PATH.test(result.logo_url) || !result.logo_url.startsWith(`/api/vms/${vmid}/`)) {
throw new Error("invalid icon response")
}
setEditing(previous => previous === snapshot
? { ...previous, draft: { ...previous.draft, logo_url: result.logo_url } } : previous)
} catch (error: unknown) {
if (current()) {
const code = (error as { body?: { code?: string } })?.body?.code
setError(t(code === "local_logo_stopped" ? "vmLxc.appEditor.localLogoStopped" : "vmLxc.appEditor.localLogoUnavailable"))
}
} finally {
if (current()) setDiscoveringLocalLogo(false)
}
}
// Editing the Name auto-fills the Package field on packaged
// methods when it's still empty. Rationale: 90% of the time the
// dpkg/apk package name mirrors the friendly app name (jellyfin,
@@ -1523,18 +1721,47 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
from https://selfh.st/icons); empty → no logo in the
app card header. */}
<div className="pt-2 border-t border-border/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="app-logo" className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
</Label>
<Button type="button" variant="outline" size="sm"
className="h-8 px-2 text-xs"
disabled={!selectedIconPort || discoveringLocalLogo || saving}
onClick={discoverLocalLogo}>
{discoveringLocalLogo ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Search className="h-3.5 w-3.5 mr-1" />}
{t("vmLxc.appEditor.discoverLocalLogo")}
</Button>
</div>
<div className="flex items-center gap-2 mt-2">
<Input
id="app-logo"
type="url"
type="text"
value={draft.logo_url || ""}
onChange={(e) => setField({ logo_url: e.target.value })}
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
maxLength={512}
className="text-sm mt-2 font-mono"
className="text-sm font-mono"
/>
{draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) &&
<ThemeAwareLogo src={draft.logo_url} className="h-8 w-8 shrink-0 object-contain" />}
</div>
{iconPorts.length > 1 && <select
aria-label={t("vmLxc.appEditor.localLogoPort")}
value={selectedIconPort ? endpointKey(selectedIconPort) : ""}
onChange={event => {
iconRequest.current += 1
iconAbort.current?.abort()
setDiscoveringLocalLogo(false)
setIconEndpoint(event.target.value)
}}
className="mt-2 rounded border bg-background px-2 py-1 text-xs"
>
{iconPorts.map(entry => <option key={endpointKey(entry)} value={endpointKey(entry)}>
{endpointKey(entry)}
</option>)}
</select>}
<p className="text-[10px] text-muted-foreground mt-1">{t("vmLxc.appEditor.localLogoHelp")}</p>
</div>
{/* Web links — each port becomes a clickable link (built as
@@ -1843,14 +2070,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
id="app-package"
value={draft.package || ""}
onChange={(e) => setField({ package: e.target.value })}
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
placeholder={t("vmLxc.appEditor.packageExample", { package: method === "dpkg" ? "jellyfin-server" : "tailscale" })}
/>
<div className="text-[10px] text-muted-foreground mt-1">
Auto-filled from Name. Verify with{" "}
{t("vmLxc.appEditor.packageHelpBefore")}{" "}
<code className="text-foreground/70">
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
</code>{" "}
inside the CT.
{t("vmLxc.appEditor.packageHelpAfter")}
</div>
</div>
)}
@@ -1866,8 +2093,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
Absolute path. Find it with{" "}
<code className="text-foreground/70">which &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 +2559,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
{detectorTest.installed.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.installed.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
{detectorTest.installed.method || "—"}
@@ -2348,7 +2575,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
) : detectorTest.upstream.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.upstream.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
{detectorTest.upstream.type && (
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
@@ -2361,7 +2588,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<span>{localizeAppEditorError(error, t)}</span>
</div>
)}
@@ -2452,6 +2679,31 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
: t("vmLxc.appEditor.detectedInContainer")}
</div>
{(d.source === "native_metadata" || d.source === "docker_metadata") && (
<div className="text-xs text-muted-foreground mt-1 break-all">
<div>{d.installed_version
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
{(d.source === "docker_metadata"
? dockerDiscovery?.stale || dockerDiscovery?.status === "partial"
: nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
)}
{d.evidence?.map((e, index) => <div key={index}>
{t(e.kind === "docker_container" ? "vmLxc.appEditor.dockerEvidenceContainer"
: e.kind === "docker_image" ? "vmLxc.appEditor.dockerEvidenceImage"
: e.kind === "compose_project" ? "vmLxc.appEditor.dockerEvidenceProject"
: e.kind === "tcp_listener" ? (d.source === "docker_metadata"
? "vmLxc.appEditor.dockerEvidencePort" : "vmLxc.appEditor.nativeEvidencePort")
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
</div>)}
<div>{t(d.source === "docker_metadata"
? "vmLxc.appEditor.dockerEndpointReview" : "vmLxc.appEditor.nativeRegisterOnly")}</div>
</div>
)}
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
<div className="text-[10px] text-amber-400/90 mt-0.5">
{t("vmLxc.appEditor.legacyDetectorUsed")}
@@ -3092,7 +3344,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<span>{localizeAppEditorError(error, t)}</span>
</div>
)}
</div>
@@ -382,6 +382,19 @@ export function LxcTerminalModal({
return
}
// The host prompt came back: pct enter could not enter the
// container. Show why and close, so the session is never left
// on the host shell under the container's name.
const reason = afterPctEnter.split(/\r?\n/).slice(0, -1)
.map((line) => line.trim()).filter(Boolean).join("\r\n")
if (reason) term.writeln(`\x1b[31m${reason}\x1b[0m`)
term.writeln(`\r\n\x1b[33m${/Failed to exec/.test(reason)
? t("lxcTerminal.errors.noShell")
: t("lxcTerminal.errors.enterFailed")}\x1b[0m`)
isInsideLxcRef.current = true
ws.close()
return
}
}
} else {
@@ -688,6 +701,10 @@ export function LxcTerminalModal({
<span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x04")}>
<span className="font-mono text-xs mr-2">Ctrl+D</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
+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>
}
+93 -22
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
@@ -919,7 +926,7 @@ export function NotificationSettings() {
const wasEnabled = originalConfig.enabled
const isNowDisabled = !nextConfig.enabled
if (wasEnabled && isNowDisabled) {
if (wasEnabled && isNowDisabled && !isPbs) {
try {
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
} catch {
@@ -1097,15 +1104,6 @@ export function NotificationSettings() {
}
}
const handleClearHistory = async () => {
try {
await fetchApi("/api/notifications/history", { method: "DELETE" })
setHistory([])
} catch {
// Ignore
}
}
// Maps each eye-button local key to the body shape the backend
// expects for /api/notifications/reveal-secret. Centralised here so
// the JSX call site stays a one-liner — `toggleSecret(key)`.
@@ -1223,8 +1221,10 @@ export function NotificationSettings() {
setConfig(newConfig)
setOriginalConfig(newConfig)
// 2) Auto-configure PVE webhook
try {
// 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications
if (isPbs) {
setWebhookSetup({ status: "idle", fallback_commands: [], error: "" })
} else try {
const setup = await fetchApi<{
configured: boolean
secret?: string
@@ -1530,7 +1530,7 @@ export function NotificationSettings() {
<Input
type={showSecrets["tg_token"] ? "text" : "password"}
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
placeholder={t("settings.notifications.ui.botTokenPlaceholder")}
value={secretValue("tg_token", config.channels.telegram?.bot_token || "")}
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
disabled={!editMode}
@@ -1542,6 +1542,7 @@ export function NotificationSettings() {
{showSecrets["tg_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.botTokenHelp")}</p>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.chatId")}</Label>
@@ -2068,6 +2069,7 @@ export function NotificationSettings() {
Enable toggle + Test button pattern as the other
channels. */}
<TabsContent value="apprise" className="space-y-3 pt-2">
<AppriseServiceHelp />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
@@ -2761,6 +2763,75 @@ export function NotificationSettings() {
)}
</div>
{/* ── Notification history ── */}
<div className="space-y-3">
<button
type="button"
className="w-full flex items-start gap-3 py-1 text-left"
onClick={() => setShowHistory(open => !open)}
aria-expanded={showHistory}
>
<History className="h-5 w-5 text-blue-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{t("settings.notifications.history.title")}</span>
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.description")}</p>
</div>
<ChevronDown className={`h-4 w-4 mt-1 shrink-0 text-muted-foreground transition-transform ${showHistory ? "rotate-180" : ""}`} />
</button>
{showHistory && (
historyError ? (
<p className="text-xs sm:text-sm text-red-400">{t("settings.notifications.history.loadFailed")}</p>
) : history === null ? (
<div className="flex justify-center py-3"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
) : history.length === 0 ? (
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.empty")}</p>
) : (
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
{history.map(entry => {
const sent = Boolean(entry.success)
const severity = (entry.severity || "").toLowerCase()
const detail = !sent ? entry.error_message : entry.event_type === "digest" ? entry.message : null
return (
<div key={entry.id} className="flex items-start gap-2.5 p-2.5 rounded-md bg-muted/30 border border-border/50">
{sent ? (
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.sent")} />
) : (
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.notSent")} />
)}
<div className="flex-1 min-w-0">
<span className="text-xs sm:text-sm font-medium block truncate">{entry.title || entry.event_type}</span>
<span className="text-[11px] sm:text-xs text-muted-foreground">
{entry.channel ? entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1) : ""} · {new Date(entry.sent_at).toLocaleString()}
</span>
{detail && (
<p className={`text-[11px] sm:text-xs mt-0.5 break-words ${sent ? "text-muted-foreground" : "text-red-400"}`}>{detail}</p>
)}
</div>
{(severity === "critical" || severity === "warning" || severity === "info") && (
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
severity === "critical"
? "border-red-500/30 text-red-400"
: severity === "warning"
? "border-amber-500/30 text-amber-400"
: "border-blue-500/30 text-blue-400"
}`}
>
{severity === "critical"
? t("settings.notifications.history.severityCritical")
: severity === "warning"
? t("settings.notifications.history.severityWarning")
: t("settings.notifications.history.severityInfo")}
</Badge>
)}
</div>
)
})}
</div>
)
)}
</div>
</>
)}
+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>
}
+71 -25
View File
@@ -18,6 +18,8 @@ import { Security } from "./security"
import { Profile } from "./profile"
import { About } from "./about"
import { BackupTab } from "./backup-tab"
import { PBSPanel } from "./pbs-panel"
import { MonitorSwitcher } from "./monitor-switcher"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
@@ -30,6 +32,7 @@ import {
CheckCircle,
XCircle,
Server,
ListChecks,
Menu,
LayoutDashboard,
HardDrive,
@@ -45,7 +48,9 @@ import {
ClipboardCheck,
Info,
DatabaseBackup,
Database,
ChevronDown,
Clock,
} from "lucide-react"
import Image from "next/image"
import { ThemeToggle } from "./theme-toggle"
@@ -97,9 +102,34 @@ const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
export function ProxmoxDashboard() {
const t = useT()
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
const { order: tabOrder } = useTabOrder()
const { data, error } = useSWR<{ product: string }>("/api/platform", url => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
if (!data && !error) return <p role="status" className="p-8">{t("app.loading")}</p>
if (data?.product === "pbs") return <PBSDashboard />
return <PVEDashboard />
}
function PVEDashboard() {
return <MonitorDashboard product="pve" />
}
function PBSDashboard() {
return <MonitorDashboard product="pbs" />
}
type Product = "pve" | "pbs"
type TopTab = TabId | "datastores" | "pbs-tasks"
// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the
// same header, health and administration with its own top-level sections.
const PBS_TABS: TopTab[] = ["overview", "datastores", "pbs-tasks", "terminal", "admin"]
function MonitorDashboard({ product }: { product: Product }) {
const t = useT()
const isPbs = product === "pbs"
useSWR(isPbs ? null : "/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR(isPbs ? null : "/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
const { order: userTabOrder } = useTabOrder()
const tabOrder: TopTab[] = isPbs ? PBS_TABS : userTabOrder
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy",
uptime: "Loading...",
@@ -117,8 +147,8 @@ export function ProxmoxDashboard() {
// runs post-hydration; the tiny flash is acceptable and matches the
// pattern next-themes uses for the same reason.
useEffect(() => {
setActiveTab(firstActualTab())
}, [])
setActiveTab(isPbs ? "overview" : firstActualTab())
}, [isPbs])
const [infoCount, setInfoCount] = useState(0)
const [updateAvailable, setUpdateAvailable] = useState(false)
const [showNavigation, setShowNavigation] = useState(true)
@@ -391,6 +421,8 @@ export function ProxmoxDashboard() {
const getActiveTabLabel = () => {
switch (activeTab) {
case "overview": return t("navigation.overview")
case "datastores": return t("pbs.datastores")
case "pbs-tasks": return t("pbs.tasksTab")
case "apps": return t("navigation.apps")
case "vms": return t("navigation.virtualMachines")
case "storage": return t("navigation.storage")
@@ -410,8 +442,9 @@ export function ProxmoxDashboard() {
return (
<div className="min-h-screen bg-background">
<OnboardingCarousel />
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
{/* Both describe the PVE Monitor. */}
{!isPbs && <OnboardingCarousel />}
{!isPbs && <ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />}
{!isServerConnected && (
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
@@ -464,22 +497,17 @@ export function ProxmoxDashboard() {
</div>
<div className="min-w-0">
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
<p className="text-xs md:text-sm text-muted-foreground">{t(isPbs ? "app.descriptionPbs" : "app.description")}</p>
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Server className="h-3 w-3" />
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
<Clock className="h-3 w-3" />
<span className="truncate">{t("status.uptime", { uptime: displayUptime })}</span>
</div>
</div>
</div>
{/* Desktop Actions */}
<div className="hidden lg:flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Server className="h-4 w-4 text-muted-foreground" />
<div className="text-sm">
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
</div>
</div>
<MonitorSwitcher nodeName={displayServerName} />
<div className="flex items-center gap-2">
<Badge variant="outline" className={statusColor}>
@@ -580,9 +608,7 @@ export function ProxmoxDashboard() {
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{t("status.uptime", { uptime: displayUptime })}
</span>
<MonitorSwitcher nodeName={displayServerName} compact />
</div>
</div>
</header>
@@ -623,7 +649,7 @@ export function ProxmoxDashboard() {
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
]
].filter(item => !isPbs || item.value !== "audit")
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
const isNodeActive = activeNodeItem !== undefined
@@ -689,9 +715,11 @@ export function ProxmoxDashboard() {
</DropdownMenuContent>
</DropdownMenu>
)
const renderTop = (id: TabId) => {
const renderTop = (id: TopTab) => {
switch (id) {
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
case "pbs-tasks": return renderDirect("pbs-tasks", ListChecks, t("pbs.tasksTab"))
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
@@ -701,7 +729,7 @@ export function ProxmoxDashboard() {
}
}
return (
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-5" : "grid-cols-7"} bg-card border border-border`}>
{tabOrder.map(renderTop)}
</TabsList>
)
@@ -762,6 +790,8 @@ export function ProxmoxDashboard() {
{tabOrder.map((id): React.ReactNode => {
switch (id) {
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
case "pbs-tasks": return btn("pbs-tasks", ListChecks, t("pbs.tasksTab"))
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
@@ -777,7 +807,7 @@ export function ProxmoxDashboard() {
<React.Fragment key="admin">
{btn("logs", ScrollText, t("navigation.systemLogs"))}
{btn("security", ShieldCheck, t("navigation.security"))}
{btn("audit", ClipboardCheck, t("navigation.audit"))}
{!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))}
{btn("settings", SettingsIcon, t("navigation.settings"))}
{btn("about", Info, t("navigation.about"))}
</React.Fragment>
@@ -811,9 +841,22 @@ export function ProxmoxDashboard() {
5 s / 59 s refresh intervals keep the data fresh in the
background — reopening the tab is instant. */}
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
<SystemOverview key={`overview-${componentKey}`} />
<SystemOverview key={`overview-${componentKey}`} product={product} />
</TabsContent>
{isPbs && (
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`datastores-${componentKey}`} localOnly view="datastores" />
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
</TabsContent>
)}
{isPbs && (
<TabsContent value="pbs-tasks" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`pbs-tasks-${componentKey}`} localOnly view="tasks" />
</TabsContent>
)}
{!isPbs && <>
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
<AppsDashboard key={`apps-${componentKey}`} />
</TabsContent>
@@ -839,11 +882,13 @@ export function ProxmoxDashboard() {
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
<Hardware key={`hardware-${componentKey}`} />
</TabsContent>
</>}
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
{!isPbs && <>
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
<AuditReport key={`audit-${componentKey}`} />
</TabsContent>
@@ -851,13 +896,14 @@ export function ProxmoxDashboard() {
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
<BackupTab key={`backup-${componentKey}`} />
</TabsContent>
</>}
<TabsContent value="terminal" className="mt-0">
<TerminalPanel key={`terminal-${componentKey}`} />
</TabsContent>
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
<Security key={`security-${componentKey}`} />
<Security key={`security-${componentKey}`} hostType={product} />
</TabsContent>
{/* Profile tab — not surfaced in the top tabs nav. The only
+58 -14
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,35 @@ interface ReleaseNote {
}
export const CHANGELOG: Record<string, ReleaseNote> = {
"1.2.7": {
date: "October 26, 2026",
changes: {
added: [
"Audit & Report — a new page under Admin that assesses the node against forty-three Proxmox checks, keeps a journal of everything ProxMenux changed on the host, lets the expected state be declared as a policy and prints focused reports.",
"OCI manager Apps (Technology Preview) — applications installed from their official container images as native Proxmox LXC, with no Docker engine: a catalog of 514 applications, multi-container applications, hardware acceleration, updates that keep the data and recovery after a restore.",
"OCI applications in the Monitor — registered in the App tab, updated and modified from the Updates tab, with a Logs tab that follows their console and a watchdog switch.",
"ProxMenux on Proxmox Backup Server — the same installer, a menu, post-install and a Monitor of its own; a remote PBS can also be watched from PVE with a read-only token (contributed by @jcastro).",
"Search in the main menu across ProxMenux options, OCI applications and Helper-Scripts, and Helper-Scripts addons run on the host or inside the LXC they belong to.",
"Backup jobs of VMs and LXCs listed, created, edited and run from the Backup tab (contributed by @MattiaC46); VM and CT backups can be deleted and the retention of each backup storage edited (contributed by @jcastro).",
"Notifications in the reader's language (contributed by @Vaso73), a delivery history in the notification settings and grouped application update notifications.",
"The NVIDIA Container Toolkit installs with the driver, a ZRAM swap profile joins the customizable post-install (contributed by @phyrexia) and FygoOS joins the NAS systems of Create VM.",
],
changed: [
"Read-only API tokens only read: every action that changes something, opening a terminal and revealing a stored credential require a full-admin token (reported by @f3rs3n).",
"Docker applications are registered with their own identity while their updates stay tied to the image (contributed by @byGarcia).",
"The observations of a disk belong to its serial number, and idle mechanical disks are left to spin down (reported by @spectante).",
"Find applications recognises services installed by hand and Docker containers with published ports, and LXC package checks and installs are separate steps (contributed by @Vaso73).",
"The node name in the header opens the other Monitors of the cluster and the configured PBS servers.",
],
fixed: [
"Proxmox notifications arrive with HTTPS enabled on the Monitor, through a local listener that needs no certificate.",
"The Monitor leaves /etc/pve alone while Proxmox restarts its cluster service (reported by @shtefko).",
"Host backups work on hosts with SELinux (reported by @sbaghdadi), and the node web certificate is restored on the node it belongs to (reported by @tropicaljoe).",
"Log2RAM keeps the newest records of the journal and compresses rotated logs at once (reported by @f3rs3n).",
"Changing the mode of a GPU leaves the devices that share its slot alone (reported by @LucasR3C3).",
],
},
},
"1.2.6": {
date: "September 2, 2026",
changes: {
@@ -304,10 +333,30 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
// build-i18n-messages workflow feeds to Google Translate for locales
// that haven't been curated by hand.
const CURRENT_VERSION_FEATURES = [
{
icon: <Shield className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditAssessment",
text: "Audit & Report — a new page that documents and assesses the node. The Assessment runs a catalogue of checks across security, backups, capacity, storage, network, hardware, guests and system, classifying each finding as critical, warning, observation or conformant and stating what it read rather than judging it.",
},
{
icon: <ScrollText className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.changeJournal",
text: "Change journal — a Changes view that lists exactly what ProxMenux modified on this host: every configuration file, package and service it touched, with the prior state of each, deduplicated to a current-state view that shows the host as it stands now rather than a log of every run.",
},
{
icon: <Activity className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditReports",
text: "Reports — a full audit plus focused Security review, Backup assurance and Capacity & wear reports, each opening on its own posture header, and a printable Inventory; all export to PDF. A security assessment asks before running Lynis so a run stays fast.",
},
{
icon: <Sliders className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditPolicyBaseline",
text: "Policy and baseline — declare what the host is expected to be (backup requirements, firewall, root SSH login) so findings grade against it, mark a run as the reference, and see what changed since, with new, resolved and accepted findings kept apart.",
},
{
icon: <Package className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociManager",
text: "OCI manager Apps (beta) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.",
key: "releaseNotes.currentFeatures.ociCatalog",
text: "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries 514 applications.",
},
{
icon: <ScrollText className="h-5 w-5" />,
@@ -325,14 +374,14 @@ const CURRENT_VERSION_FEATURES = [
text: "An OCI application travels with its backup — restored on this host or on another one, it is registered and managed again, and in a cluster it starts on whichever node it moves to.",
},
{
icon: <RefreshCw className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociWatchdog",
text: "Watchdog for OCI applications — an application that stops on its own is started again and a notification says so. It is not restarted when the user stops it, or while a backup or a migration is in progress.",
icon: <Server className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.pbsSupport",
text: "ProxMenux on Proxmox Backup Server — it installs with the same command and opens a menu of its own, with post-install and a Monitor showing the health, datastores and tasks of the server (contributed by @jcastro).",
},
{
icon: <Server className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.shareCoveX",
text: "ShareCoveX joins the catalog — a file server with a web panel that shares host directories or container volumes over SMB, NFS and Time Machine.",
icon: <Search className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.menuSearch",
text: "Search in the main menu — a name or part of it finds a ProxMenux option, an OCI application or a Helper-Script, and opens it at its first screen.",
},
{
icon: <Calendar className="h-5 w-5" />,
@@ -344,11 +393,6 @@ const CURRENT_VERSION_FEATURES = [
key: "releaseNotes.currentFeatures.notificationLanguage",
text: "Notifications in your language — titles, bodies and daily digests are written in the language chosen for notifications (contributed by @Vaso73).",
},
{
icon: <Shield className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditReviewDate",
text: "Audit & Report — an accepted finding can carry a review date that brings it back without withdrawing the decision, and the comparison says whether a finding got worse, got better or held (with @Vaso73).",
},
]
// Turn any "@handle" mention inside a release-notes string into a
+153 -20
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
// Only LXC Updates opts into a separate server-owned job transport.
backgroundUpdate?: { runId: string; vmid: number; resume: boolean }
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
@@ -69,6 +73,8 @@ export function ScriptTerminalModal({
params = { EXECUTION_MODE: "web" },
completedSuccessfullyMessage,
completedWithErrorMessage,
cancelledExitCode,
backgroundUpdate,
onComplete,
}: ScriptTerminalModalProps) {
const t = useT()
@@ -78,6 +84,7 @@ export function ScriptTerminalModal({
// after dynamic imports resolve — captures the latest value without
// re-binding the closure.
const isOpenRef = useRef<boolean>(false)
const openGenerationRef = useRef(0)
const fitAddonRef = useRef<any>(null)
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
@@ -90,6 +97,7 @@ export function ScriptTerminalModal({
const reconnectAttemptsRef = useRef(0)
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
const completionReceivedRef = useRef(false)
const outputOffsetRef = useRef(0)
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
@@ -103,10 +111,12 @@ export function ScriptTerminalModal({
const getCompletionMessage = useCallback(
(exitCode: number) =>
exitCode === 0
scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
? t("scriptTerminal.cancelled")
: exitCode === 0
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
[completedSuccessfullyMessage, completedWithErrorMessage, t],
[completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
)
const terminalContainerRef = useRef<HTMLDivElement>(null)
@@ -125,7 +135,7 @@ export function ScriptTerminalModal({
}, [onComplete])
const attemptReconnect = useCallback(() => {
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
if (!isOpenRef.current || isComplete || reconnectAttemptsRef.current >= 3) {
return
}
@@ -139,12 +149,20 @@ export function ScriptTerminalModal({
reconnectTimeoutRef.current = setTimeout(async () => {
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
if (wsRef.current) {
// Replacing a stale socket is not a script result. Its close event
// must not complete the operation before the new socket reports it.
wsRef.current.onclose = null
wsRef.current.onerror = null
wsRef.current.onmessage = null
wsRef.current.close()
}
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
const generation = openGenerationRef.current
const ticketedUrl = await getTicketedWsUrl(wsUrl)
if (!isOpenRef.current || !termRef.current || generation !== openGenerationRef.current) return
const ws = new WebSocket(ticketedUrl)
wsRef.current = ws
ws.onopen = () => {
@@ -160,9 +178,11 @@ export function ScriptTerminalModal({
}
}, 30000)
const initMessage = {
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
offset: outputOffsetRef.current } : {}),
}
ws.send(JSON.stringify(initMessage))
@@ -176,6 +196,7 @@ const initMessage = {
}
ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
@@ -184,15 +205,15 @@ const initMessage = {
// The PTY worker always emits this final line. Treat it as a
// completion fallback because some WebSocket servers tear down the
// connection before the following structured message is flushed.
const exitMatch = typeof event.data === "string"
const exitMatch = !backgroundUpdate && typeof event.data === "string"
? event.data.match(/\[Script exited with code (-?\d+)\]/)
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
termRef.current?.write(event.data)
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -200,6 +221,31 @@ const initMessage = {
try {
const msg = JSON.parse(event.data)
if (msg.type === "job_output") {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
if (msg.truncated) termRef.current?.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
termRef.current?.write(msg.text)
outputOffsetRef.current = msg.offset
return
}
if (msg.type === "job_unavailable") {
setCurrentInteraction(null)
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
completionReceivedRef.current = true
setIsComplete(true)
ws.close()
return
}
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
setCurrentInteraction(null)
setInteractionInput("")
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
return
}
if (msg.type === "web_interaction" && msg.interaction) {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
@@ -220,10 +266,15 @@ const initMessage = {
return
}
if (msg.type === "script_complete") {
if (backgroundUpdate) {
setCurrentInteraction(null)
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
}
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -244,6 +295,7 @@ const initMessage = {
}
ws.onclose = (event) => {
if (!isOpenRef.current) return
setConnectionStatus("offline")
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
@@ -254,14 +306,18 @@ const initMessage = {
}
if (!isComplete && reconnectAttemptsRef.current < 3) {
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
} else {
if (backgroundUpdate) {
termRef.current?.writeln(t("vmLxc.updates.backgroundDetached"))
} else {
setIsComplete(true)
onCompleteRef.current?.(-1)
}
}
}
}
}, 1000)
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t, backgroundUpdate])
const sendKey = useCallback((key: string) => {
if (!termRef.current) return
@@ -289,12 +345,13 @@ const initMessage = {
// otherwise we attach a Terminal to a stale ref and open a WS that
// nobody reads.
const wasOpenAtCall = isOpenRef.current
const generation = ++openGenerationRef.current
const [TerminalClass, FitAddonClass] = await Promise.all([
import("xterm").then((mod) => mod.Terminal),
import("xterm-addon-fit").then((mod) => mod.FitAddon),
import("xterm/css/xterm.css"),
])
if (!wasOpenAtCall || !isOpenRef.current) return
if (!wasOpenAtCall || !isOpenRef.current || generation !== openGenerationRef.current) return
const fontSize = window.innerWidth < 768 ? 12 : 16
@@ -355,7 +412,9 @@ const initMessage = {
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
const ticketedUrl = await getTicketedWsUrl(wsUrl)
if (!isOpenRef.current || termRef.current !== term) return
const ws = new WebSocket(ticketedUrl)
wsRef.current = ws
ws.onopen = () => {
@@ -373,6 +432,8 @@ const initMessage = {
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
vmid: backgroundUpdate.vmid, offset: outputOffsetRef.current } : {}),
}
ws.send(JSON.stringify(initMessage))
@@ -392,6 +453,7 @@ const initMessage = {
}
ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat - don't display in terminal
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
@@ -400,15 +462,15 @@ const initMessage = {
// See the reconnect handler above. The exit line is guaranteed to be
// sent with the PTY output and is therefore a robust fallback when a
// final JSON frame is lost during server-side socket teardown.
const exitMatch = typeof event.data === "string"
const exitMatch = !backgroundUpdate && typeof event.data === "string"
? event.data.match(/\[Script exited with code (-?\d+)\]/)
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
term.write(event.data)
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -416,7 +478,32 @@ const initMessage = {
try {
const msg = JSON.parse(event.data)
if (msg.type === "job_output") {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
if (msg.truncated) term.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
term.write(msg.text)
outputOffsetRef.current = msg.offset
return
}
if (msg.type === "job_unavailable") {
setCurrentInteraction(null)
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
completionReceivedRef.current = true
setIsComplete(true)
ws.close()
return
}
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
setCurrentInteraction(null)
setInteractionInput("")
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
return
}
if (msg.type === "web_interaction" && msg.interaction) {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
@@ -438,10 +525,15 @@ const initMessage = {
return
}
if (msg.type === "script_complete") {
if (backgroundUpdate) {
setCurrentInteraction(null)
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
}
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -466,6 +558,7 @@ const initMessage = {
}
ws.onclose = (event) => {
if (!isOpenRef.current) return
setConnectionStatus("offline")
if (!completionReceivedRef.current) {
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
@@ -477,10 +570,15 @@ const initMessage = {
}
if (!completionReceivedRef.current && !isComplete) {
if (backgroundUpdate) {
term.writeln(t("vmLxc.updates.backgroundDetached"))
attemptReconnect()
} else {
setIsComplete(true)
onCompleteRef.current?.(-1)
}
}
}
// Read `wsRef.current` inside the handler so reconnect (which swaps
// `wsRef.current` to a fresh WebSocket) doesn't leave us writing to the
@@ -540,6 +638,7 @@ const initMessage = {
if (isOpen) {
initializeTerminal()
} else {
openGenerationRef.current++
if (checkConnectionInterval.current) {
clearInterval(checkConnectionInterval.current)
}
@@ -566,6 +665,7 @@ const initMessage = {
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
reconnectAttemptsRef.current = 0
completionReceivedRef.current = false
outputOffsetRef.current = 0
setIsComplete(false)
setInteractionInput("")
setCurrentInteraction(null)
@@ -574,6 +674,28 @@ const initMessage = {
}
}, [isOpen])
useEffect(() => () => {
if (!backgroundUpdate) return // Transport mode is fixed for this instance.
// Conditional parents can unmount an open modal. Detach that viewer too;
// only the background job owns its process and completion result.
isOpenRef.current = false
openGenerationRef.current++
for (const ref of [checkConnectionInterval, keepAliveIntervalRef]) {
if (ref.current) clearInterval(ref.current)
}
for (const ref of [waitingTimeoutRef, reconnectTimeoutRef]) {
if (ref.current) clearTimeout(ref.current)
}
if (wsRef.current) {
wsRef.current.onclose = null
wsRef.current.onmessage = null
wsRef.current.close()
wsRef.current = null
}
termRef.current?.dispose()
termRef.current = null
}, [])
useEffect(() => {
const updateDeviceType = () => {
const width = window.innerWidth
@@ -629,8 +751,11 @@ const initMessage = {
}, [isOpen, isComplete, attemptReconnect])
const getScriptWebSocketUrl = (sid: string): string => {
const route = backgroundUpdate
? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}`
: `/ws/script/${sid}`
if (typeof window === "undefined") {
return `ws://localhost:${API_PORT}/ws/script/${sid}`
return `ws://localhost:${API_PORT}${route}`
}
const { protocol, hostname, port } = window.location
@@ -638,9 +763,9 @@ const initMessage = {
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
if (isStandardPort) {
return `${wsProtocol}//${hostname}/ws/script/${sid}`
return `${wsProtocol}//${hostname}${route}`
} else {
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
return `${wsProtocol}//${hostname}:${API_PORT}${route}`
}
}
@@ -652,6 +777,10 @@ const initMessage = {
if (value === "cancel" || value === "") {
setCurrentInteraction(null)
setInteractionInput("")
if (backgroundUpdate) {
wsRef.current.send(JSON.stringify({ type: "cancel" }))
return
}
handleCloseModal()
return
}
@@ -814,7 +943,7 @@ const initMessage = {
)}
{(isMobile || isTablet) && (
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex flex-wrap items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button
onPointerDown={(e) => {
e.preventDefault()
@@ -918,6 +1047,10 @@ const initMessage = {
<span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x04")}>
<span className="font-mono text-xs mr-2">Ctrl+D</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
+6 -1
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()
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)}
+66 -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,10 @@ import { Input } from "./ui/input"
import { getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import { useIsPbs } from "../lib/use-platform"
import type { LanguageCode } from "../lib/i18n/languages"
import { NavTabOrderCard } from "./nav-tab-order-card"
import { optimizationSourceError, optimizationSourceName, type OptimizationSourceError, type OptimizationSourceResponse } from "../lib/optimization-source-errors"
// GitHub Dark color palette for bash syntax highlighting
const BASH_KEYWORDS = new Set([
@@ -312,6 +315,7 @@ interface NetworkInterface {
export function Settings() {
const { language, setLanguage, t } = useI18n()
const isPbs = useIsPbs()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
@@ -352,15 +356,31 @@ export function Settings() {
open: boolean
loading: boolean
toolName: string
toolKey: string
version: string
availableVersion: string
functionName: string
source: string
script: string
error: string
error: OptimizationSourceError | null
deprecated: boolean
}>({ open: false, loading: false, toolName: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: '', deprecated: false })
}>({ open: false, loading: false, toolName: '', toolKey: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: null, deprecated: false })
const [codeCopied, setCodeCopied] = useState(false)
const codeRequest = useRef(0)
const closeCodeModal = () => {
codeRequest.current += 1
setCodeModal(prev => ({ ...prev, open: false }))
setCodeCopied(false)
}
useEffect(() => () => { codeRequest.current += 1 }, [])
useEffect(() => {
if (!codeModal.open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeCodeModal()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [codeModal.open])
// Health Monitor suppression settings
const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([])
@@ -578,7 +598,7 @@ export function Settings() {
if (entries.length === 0) return
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
const title = entries.length === 1
? t("settings.optimizations.updateOneTitle", { name: entries[0].name })
? t("settings.optimizations.updateOneTitle", { name: optimizationToolName(entries[0].key, entries[0].name, t) })
: t("settings.optimizations.updateManyTitle", { count: entries.length })
const description = entries.length === 1
? t("settings.optimizations.updateOneDescription", { functionName: entries[0].function, source: entries[0].source })
@@ -661,31 +681,39 @@ export function Settings() {
}
const viewToolSource = async (tool: ProxMenuxTool) => {
const request = ++codeRequest.current
setCodeCopied(false)
setCodeModal({
open: true,
loading: true,
toolName: tool.name,
toolKey: tool.key,
version: tool.version || '1.0',
availableVersion: tool.available_version || tool.version || '1.0',
functionName: '',
source: '',
script: '',
error: '',
error: null,
deprecated: !!tool.deprecated,
})
try {
const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`)
if (data.success) {
setCodeModal(prev => ({ ...prev, loading: false, functionName: data.function, source: data.source, script: data.script, deprecated: !!data.deprecated }))
const data = await fetchApi<OptimizationSourceResponse | null>(`/api/proxmenux/tool-source/${tool.key}`)
if (request !== codeRequest.current) return
if (data?.success === true && typeof data.source === "string" && data.source.trim()) {
const source = data.source
setCodeModal(prev => ({ ...prev, loading: false, functionName: typeof data.function === "string" ? data.function : '', source, script: typeof data.script === "string" ? data.script : '', deprecated: !!data.deprecated }))
} else {
setCodeModal(prev => ({ ...prev, loading: false, error: data.error || 'Source code not available' }))
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "unavailable", detail: typeof data?.error === "string" ? data.error : undefined } }))
}
} catch {
setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' }))
} catch (error: any) {
if (request !== codeRequest.current) return
const detail = typeof error?.body?.error === "string" ? error.body.error : undefined
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "loadFailed", detail } }))
}
}
const copySourceCode = async () => {
const request = codeRequest.current
const text = codeModal.source
let ok = false
@@ -719,9 +747,9 @@ export function Settings() {
}
}
if (ok) {
if (ok && request === codeRequest.current) {
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
setTimeout(() => { if (request === codeRequest.current) setCodeCopied(false) }, 2000)
}
}
@@ -1124,7 +1152,7 @@ export function Settings() {
</Card>
{/* Navigation Tab Order — user-orderable top-level tabs */}
<NavTabOrderCard />
{!isPbs && <NavTabOrderCard />}
{/* Network Units Settings */}
<Card>
@@ -1280,7 +1308,7 @@ export function Settings() {
{/* Per-category rows */}
<div className="divide-y divide-border/50">
{suppressionCategories.map((cat) => {
{suppressionCategories.filter(cat => !isPbs || healthCategoryKey(cat) !== "vms").map((cat) => {
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
const effectiveHours = getEffectiveHours(cat)
const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
@@ -1288,7 +1316,8 @@ export function Settings() {
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
const categoryKey = isPbs && healthCategoryKey(cat) === "services" ? "pbsServices" : healthCategoryKey(cat)
const catLabel = tFallback(`settings.healthMonitor.categories.${categoryKey}`, cat.label)
return (
<div key={cat.key}>
@@ -1496,6 +1525,7 @@ export function Settings() {
</CardContent>
</Card>
{!isPbs && <>
{/* Remote Storage Exclusions */}
<Card>
<CardHeader>
@@ -1678,6 +1708,7 @@ export function Settings() {
)}
</CardContent>
</Card>
</>}
{/* Network Interface Exclusions */}
<Card>
@@ -1873,8 +1904,9 @@ export function Settings() {
the matching toggle in NotificationSettings is hidden (the
preference is preserved in the DB and reappears when detection
is re-enabled). */}
<LxcUpdateDetection />
{!isPbs && <LxcUpdateDetection />}
{!isPbs && <>
{/* GitHub API — optional authentication for app upstream checks. */}
<Card>
<CardHeader>
@@ -1981,15 +2013,16 @@ export function Settings() {
)}
</CardContent>
</Card>
</>}
{/* Notification Settings */}
<NotificationSettings />
{/* Notification Settings (PVE only for now) */}
{!isPbs && <NotificationSettings />}
{/* Issue #195: snippets storage selector. Only renders when more
than one storage advertises content=snippets — on a typical
standalone host with just `local` there's nothing to choose,
so showing an empty selector would be noise. */}
{snippetsCandidates.length > 1 && (
{!isPbs && snippetsCandidates.length > 1 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
@@ -2110,7 +2143,7 @@ export function Settings() {
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
hasUpdate ? 'bg-purple-400' : (isDeprecated ? 'bg-amber-500' : 'bg-green-500')
}`} />
<span className="text-sm font-medium truncate">{tool.name}</span>
<span className="text-sm font-medium truncate">{optimizationToolName(tool.key, tool.name, t)}</span>
{isDeprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
{t("settings.optimizations.legacy")}
@@ -2126,7 +2159,7 @@ export function Settings() {
<button
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
className="text-purple-300 hover:text-purple-200 transition-colors"
title={t("settings.optimizations.updateToolTitle", { name: tool.name, version: tool.available_version || "?" })}
title={t("settings.optimizations.updateToolTitle", { name: optimizationToolName(tool.key, tool.name, t), version: tool.available_version || "?" })}
>
<ArrowUpCircle className="h-4 w-4" />
</button>
@@ -2146,10 +2179,13 @@ export function Settings() {
{/* Code Viewer Modal */}
{codeModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ margin: 0 }} onClick={closeCodeModal}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-label={t("settings.optimizations.sourceDialogTitle", { name: optimizationSourceName(codeModal.toolKey, codeModal.toolName, t) })}
onClick={e => e.stopPropagation()}
>
{/* Header */}
@@ -2158,7 +2194,7 @@ export function Settings() {
<Code className={`h-5 w-5 flex-shrink-0 ${codeModal.deprecated ? 'text-amber-500' : 'text-orange-500'}`} />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
<h3 className="text-sm font-semibold truncate">{optimizationSourceName(codeModal.toolKey, codeModal.toolName, t)}</h3>
{codeModal.deprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
{t("settings.optimizations.legacy")}
@@ -2194,7 +2230,8 @@ export function Settings() {
</button>
)}
<button
onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}
onClick={closeCodeModal}
aria-label={t("actions.close")}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
>
<X className="h-4 w-4" />
@@ -2204,13 +2241,13 @@ export function Settings() {
{/* Body */}
<div className="flex-1 overflow-auto p-0">
{codeModal.loading ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center justify-center py-16" role="status" aria-label={t("settings.optimizations.sourceLoading")}>
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
</div>
) : codeModal.error ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Code className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">{codeModal.error}</p>
<p className="text-sm" role="alert">{optimizationSourceError(codeModal.error, t)}</p>
</div>
) : (
<pre
@@ -2282,13 +2319,13 @@ export function Settings() {
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{tool.name}</span>
<span className="text-sm font-medium">{optimizationToolName(tool.key, tool.name, t)}</span>
<span className="text-[10px] text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.5 rounded font-mono">
v{tool.version || '1.0'} → v{tool.available_version || '?'}
</span>
</div>
{tool.description && (
<p className="text-xs text-muted-foreground mt-1 leading-snug">{tool.description}</p>
<p className="text-xs text-muted-foreground mt-1 leading-snug">{optimizationDescription(tool.description, t)}</p>
)}
</div>
</label>
+6 -5
View File
@@ -231,7 +231,8 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
return out.length ? out.join('; ') : null
}
export function StorageOverview() {
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
const t = useT()
// User-configurable disk temperature thresholds (Settings → Health
@@ -284,7 +285,7 @@ export function StorageOverview() {
try {
const [data, proxmoxData, mountsData] = await Promise.all([
fetchApi<StorageData>("/api/storage"),
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
disksOnly ? Promise.resolve(null) : fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
// Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch
// so a failure here doesn't blank the whole storage tab.
fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
@@ -1134,7 +1135,7 @@ export function StorageOverview() {
return (
<div className="space-y-6">
{/* Storage Summary */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
{!disksOnly && <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
{(() => {
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
@@ -1355,9 +1356,9 @@ export function StorageOverview() {
</Card>
)
})()}
</div>
</div>}
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
{!disksOnly && proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
+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>
File diff suppressed because it is too large Load Diff
+1 -18
View File
@@ -21,6 +21,7 @@ import {
import { fetchApi } from "../lib/api-config"
import { cancelButtonClass } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { RETENTION, parseRetention, buildRetention } from "../lib/backup-retention"
interface VmBackupJob {
id: string
@@ -70,24 +71,6 @@ const EMPTY_FORM: FormState = {
mode: "snapshot", compress: "zstd", retention: {}, keepAll: false, notes: "{{guestname}}", attached: [],
}
// The limits a job can keep, in the order Proxmox lists them.
const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
const parseRetention = (prune: string) => {
const fields: Record<string, string> = {}
let keepAll = false
for (const part of prune.split(",")) {
const [key, value] = part.trim().split("=")
if (key === "keep-all") keepAll = value === "1"
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
}
return { fields, keepAll }
}
const buildRetention = (fields: Record<string, string>, keepAll: boolean) => {
const parts = RETENTION.filter((key) => Number(fields[key]) > 0).map((key) => `keep-${key}=${Number(fields[key])}`)
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
}
const isOn = (value: unknown) => value === 1 || value === true || value === "1"
const jobEnabled = (job: VmBackupJob) => job.enabled === undefined || isOn(job.enabled)
+38 -42
View File
@@ -1,16 +1,16 @@
{
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
"_updated": "2026-09-02",
"_updated": "2026-10-26",
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
"groq": {
"models": [
"allam-2-7b",
"qwen/qwen3.8-27b",
"openai/gpt-oss-20b",
"groq/compound-mini"
"openai/gpt-oss-120b"
],
"recommended": "openai/gpt-oss-20b",
"_note": "Verified 2026-09-02 with the Groq API. 4 of 12 tested pass. `llama-3.3-70b-versatile` no longer served by Groq. Recommended kept as `openai/gpt-oss-20b` (english-capable) — `allam-2-7b` is fastest but Arabic-focused; add manually if serving Arabic notifications."
"_note": "Verified 2026-10-26 with the Groq API. 4 of 8 tested pass. `groq/compound-mini` is no longer served and was dropped; `openai/gpt-oss-120b` added. Recommended kept as `openai/gpt-oss-20b` (english-capable) — `allam-2-7b` is fastest but Arabic-focused."
},
"gemini": {
"models": [
@@ -21,7 +21,10 @@
"gemini-flash-lite-latest",
"gemini-2.5-flash",
"gemini-3-flash-preview",
"gemini-3.5-flash"
"gemini-3.5-flash",
"gemini-3.6-flash",
"gemini-3.7-flash",
"gemini-3.8-flash"
],
"recommended": "gemini-2.5-flash-lite",
"_deprecated": [
@@ -31,50 +34,46 @@
"gemini-1.0-pro",
"gemini-pro"
],
"_note": "Verified 2026-09-02. gemini-flash-lite-latest passes consistently but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift. gemini-3.1-flash-lite is the stable successor to 3-flash-preview. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation."
"_note": "Verified 2026-10-26. Added gemini-3.6-flash, 3.7-flash and 3.8-flash. gemini-flash-lite-latest passes consistently but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation; gemini-4-argon passes but takes ~20s per message."
},
"openai": {
"models": [
"gpt-4.1-nano-2025-04-14",
"gpt-4.1-nano",
"gpt-4.1-2025-04-14",
"gpt-4.1",
"gpt-4o-2024-11-20",
"gpt-4o-2024-05-13",
"gpt-4o-mini",
"gpt-3.5-turbo-16k",
"gpt-4o-mini-2024-07-18",
"gpt-5-nano",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-0125",
"gpt-5-nano-2025-08-07",
"gpt-4.1-mini",
"gpt-4.1-mini-2025-04-14",
"gpt-3.5-turbo",
"gpt-4o",
"gpt-4o-2024-08-06",
"gpt-4",
"gpt-4-turbo",
"gpt-4-turbo-2024-04-09",
"gpt-4-0613"
"gpt-6-luna",
"gpt-5.6-luna",
"gpt-5.4-mini",
"gpt-5.4-mini-2026-03-17",
"gpt-6-sol",
"gpt-6.1-sol",
"gpt-5.6-terra",
"gpt-5.2",
"gpt-5.2-2025-12-11",
"gpt-5.4",
"gpt-5.4-2026-03-05",
"gpt-5.6-sol",
"gpt-5.5",
"gpt-5.5-2026-04-23",
"gpt-6-astra"
],
"recommended": "gpt-4.1-nano",
"_note": "Verified 2026-09-02 with the OpenAI API — 22 of 74 tested pass. Includes stable aliases (gpt-4.1-nano, gpt-4o-mini, ...), dated snapshots for pinning (gpt-4o-2024-11-20, gpt-4.1-nano-2025-04-14) and legacy families (gpt-3.5-turbo, gpt-4) for cost-optimised use cases. Recommended kept as `gpt-4.1-nano` (stable alias, 1.39s)."
"recommended": "gpt-6-luna",
"_note": "Verified 2026-10-26 against the live API. Recommended `gpt-6-luna` (cheapest current model). Dropped every model that is retired (gpt-4.1-nano, gpt-3.5-turbo, gpt-4, gpt-4-turbo, ...) or has a shutdown date (gpt-4.1, gpt-4o, gpt-4o-mini, gpt-5-nano, gpt-5.1, gpt-5.4-nano)."
},
"anthropic": {
"models": [
"claude-haiku-5-5",
"claude-haiku-4-5-20251001",
"claude-opus-4-8",
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101",
"claude-opus-4-7",
"claude-sonnet-5-5",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-opus-5-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-5-20251101",
"claude-fable-5",
"claude-fable-5-1",
"claude-opus-4-6"
"claude-fable-5-1"
],
"recommended": "claude-haiku-4-5-20251001",
"_note": "Verified 2026-09-02 — 9 of 11 tested pass. The `claude-haiku-4-5` alias no longer resolves upstream; the working snapshot is `claude-haiku-4-5-20251001` — used as recommended (2.89s). Full family surfaced: haiku/sonnet/opus/fable across generations 4.5/4.6/4.7/4.8/5."
"recommended": "claude-haiku-5-5",
"_note": "Verified 2026-10-26 against the live API. Added the 5.x family, recommended `claude-haiku-5-5`. Dropped `claude-sonnet-4-5` (retires 2026-11-30). `claude-opus-5` left out: false refusals on plain notifications."
},
"openrouter": {
"models": [
@@ -113,13 +112,10 @@
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"nvidia/nemotron-3-super-120b-a12b:free",
"google/gemma-4-26b-a4b-it:free",
"nvidia/nemotron-nano-12b-v2-vl:free",
"nvidia/nemotron-3-nano-30b-a3b:free",
"poolside/laguna-s-2.1:free",
"openai/gpt-oss-20b:free"
"poolside/laguna-s-2.1:free"
],
"recommended": "meta-llama/llama-3.3-70b-instruct",
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test."
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test. Reviewed 2026-10-26: three `:free` variants no longer served by OpenRouter were dropped."
},
"ollama": {
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
+11 -5
View File
@@ -2,6 +2,12 @@
* API Configuration for ProxMenux Monitor
* Handles API URL generation with automatic proxy detection
*/
// Resolve wording only for a generic transport failure. Normal responses and
// backend error payloads keep the transport free of locale-loading work.
async function apiFailure(...args: Parameters<typeof import("./api-errors").apiFailure>): Promise<string> {
const messages = await import("./api-errors")
return messages.apiFailure(...args)
}
/**
* API Server Port Configuration
@@ -114,7 +120,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
hadToken = true
}
if (!hadToken) {
throw new Error(`Unauthorized: ${endpoint}`)
throw new Error(await apiFailure("unauthorized", { endpoint }))
}
try {
localStorage.removeItem("proxmenux-auth-token")
@@ -131,7 +137,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
window.location.reload()
}
}
throw new Error(`Unauthorized: ${endpoint}`)
throw new Error(await apiFailure("unauthorized", { endpoint }))
}
// Try to surface the backend's JSON error payload instead of a
// bare `500 INTERNAL SERVER ERROR`. The Flask routes consistently
@@ -168,7 +174,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
throw parseErr
}
}
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
throw new Error(await apiFailure("http", { status: response.status, statusText: response.statusText }))
}
// Check content type to ensure we're getting JSON
@@ -176,13 +182,13 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text()
console.error("fetchApi: Expected JSON but got:", contentType, "- Body preview:", text.substring(0, 200))
throw new Error(`Expected JSON response but got ${contentType || "unknown content type"}`)
throw new Error(await apiFailure("unexpectedContentType", { contentType: contentType || await apiFailure("unknownContentType") }))
}
try {
return await response.json()
} catch (jsonError) {
console.error("fetchApi: JSON parse error for", endpoint, "-", jsonError)
throw new Error(`Invalid JSON response from ${endpoint}`)
throw new Error(await apiFailure("invalidJson", { endpoint }))
}
}
+19
View File
@@ -0,0 +1,19 @@
import { MESSAGE_CATALOG } from "./i18n/catalogs"
import { detectBrowserLanguage, isSupportedLanguage, LANGUAGE_STORAGE_KEY } from "./i18n/languages"
type Failure = keyof (typeof import("../messages/en/common.json"))["apiErrors"]
// fetchApi also runs outside React hooks. Read the provider's current document
// language, with its stored/browser preference as a fallback.
export function apiFailure(key: Failure, params: Record<string, string | number> = {}): string {
let preference = typeof document === "undefined" ? "" : document.documentElement?.lang
if (!isSupportedLanguage(preference) && typeof window !== "undefined") {
try { preference = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) || "" } catch { /* private browsing */ }
}
const language = isSupportedLanguage(preference) ? preference : detectBrowserLanguage()
const messages = MESSAGE_CATALOG[language].apiErrors
const localized = messages && typeof messages === "object" ? (messages as Record<string, unknown>)[key] : undefined
const fallback = (MESSAGE_CATALOG.en.apiErrors as Record<Failure, string>)[key]
const template = typeof localized === "string" ? localized : fallback
return template.replace(/\{(\w+)\}/g, (match, name) => params[name] === undefined ? match : String(params[name]))
}
+18
View File
@@ -0,0 +1,18 @@
// Native Proxmox retention properties, shared by storage and job editors.
export const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
export function parseRetention(prune: string) {
const fields: Record<string, string> = {}
let keepAll = false
for (const part of prune.split(",")) {
const [key, value] = part.trim().split("=")
if (key === "keep-all") keepAll = value === "1"
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
}
return { fields, keepAll }
}
export function buildRetention(fields: Record<string, string>, keepAll: boolean) {
const parts = RETENTION.filter(key => Number(fields[key]) > 0).map(key => `keep-${key}=${Number(fields[key])}`)
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
}
+55
View File
@@ -0,0 +1,55 @@
import { fetchApi } from './api-config'
export interface GuestBackup {
volid: string
storage: string
type: string
size: number
size_human: string
timestamp: number
date: string
notes?: string
protected?: boolean
}
export interface BackupDeletionResult {
volid: string
status: 'deleted' | 'pending' | 'unconfirmed'
task?: string
error?: string
}
// Exposed separately so callers can invalidate their inventory even after a
// lost POST response. Never retry the destructive request automatically.
export async function deleteGuestBackups(vmid: number, backups: GuestBackup[]): Promise<BackupDeletionResult[]> {
const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, {
method: 'POST',
body: JSON.stringify({ confirm: true, backups: backups.map(({ volid, storage }) => ({ volid, storage })) }),
// Up to 20 archives, checked first and then deleted four at a time.
signal: AbortSignal.timeout(300_000),
})
if (!Array.isArray(response.results) || response.results.length !== backups.length ||
response.results.some((result, index) => result.volid !== backups[index].volid ||
!['deleted', 'pending', 'unconfirmed'].includes(result.status))) throw new Error('deletionUnconfirmed')
return Promise.all(response.results.map(async result => {
if (result.status !== 'pending' || !result.task) return result
const deadline = Date.now() + 120_000
let failures = 0
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1500))
try {
const status = await fetchApi<BackupDeletionResult>(
`/api/vms/${vmid}/backups/delete-status?task=${encodeURIComponent(result.task!)}`,
{ signal: AbortSignal.timeout(10_000) },
)
if (status.volid !== result.volid || !['deleted', 'pending', 'unconfirmed'].includes(status.status)) break
if (status.status !== 'pending') return status
failures = 0
} catch {
if (++failures >= 3) break
}
}
return { ...result, status: 'unconfirmed', error: 'deletionUnconfirmed' }
}))
}
+141
View File
@@ -0,0 +1,141 @@
// Legacy HostBackup API messages are an English wire contract shared with CLI callers.
// Match complete, source-verified messages only; never derive a translation key from input.
// This presentation-only adapter keeps unknown tool diagnostics and parameter text intact.
type Translate = (key: string, params?: Record<string, string | number>) => string
const fixed = new Map<string, string>([
["path must be absolute", "backup.apiErrors.absolutePath"],
["this path is already configured", "backup.apiErrors.pathConfigured"],
["path is required", "backup.apiErrors.pathRequired"],
["path query parameter is required", "backup.apiErrors.pathQuery"],
["path not found", "backup.apiErrors.pathNotFound"],
["invalid job id", "backup.apiErrors.invalidJobId"],
["job not found", "backup.apiErrors.jobNotFound"],
["runner script not installed", "backup.apiErrors.runnerMissing"],
["no restore state to dismiss", "backup.apiErrors.noRestore"],
["invalid file name", "backup.apiErrors.invalidFile"],
["not found", "backup.apiErrors.notFound"],
["log path outside allowed directory", "backup.apiErrors.logOutside"],
["targets must be a list", "backup.apiErrors.targetsList"],
["empty expression", "backup.apiErrors.emptyExpression"],
["invalid id (use letters, digits, _ or -)", "backup.apiErrors.invalidId"],
["backend must be pbs, local or borg", "backup.apiErrors.backend"],
["backend must be pbs or borg", "backup.apiErrors.remoteBackend"],
["backend must be 'pbs' or 'borg'", "backup.apiErrors.remoteBackendQuoted"],
["profile_mode must be default or custom", "backup.apiErrors.profileMode"],
["borg cannot be attached (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttached"],
["borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttachedLong"],
["pve_parent_job_id and pve_storage are required for attached", "backup.apiErrors.parentRequired"],
["paths must be a list of absolute paths", "backup.apiErrors.absolutePaths"],
["custom profile needs at least one path", "backup.apiErrors.customPath"],
["could not resolve default profile paths", "backup.apiErrors.defaultPaths"],
["retention must be an object", "backup.apiErrors.retentionObject"],
["pbs_repository is required", "backup.apiErrors.pbsRepository"],
["pbs_encrypt_mode must be 'none', 'new' or 'existing'", "backup.apiErrors.pbsEncryption"],
["failed to create PBS encryption keyfile", "backup.apiErrors.pbsKeyFailed"],
["failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", "backup.apiErrors.pbsClientMissing"],
["borg_repo is required", "backup.apiErrors.borgRepository"],
["borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.borgEncryption"],
["encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.encryption"],
["borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", "backup.apiErrors.borgPassphrase"],
["passphrase is required when encryption is enabled", "backup.apiErrors.passphrase"],
["password is required", "backup.apiErrors.password"],
["confirm_device must match device exactly (typo / safety check)", "backup.apiErrors.deviceConfirmation"],
["invalid device path", "backup.apiErrors.invalidDevice"],
["absolute mountpoint required", "backup.apiErrors.absoluteMountpoint"],
["archive layout not recognized — no rootfs/ found", "backup.apiErrors.archiveLayout"],
["archive not found", "backup.apiErrors.archiveMissing"],
["archive path is not a regular file", "backup.apiErrors.archiveNotFile"],
["invalid name", "backup.apiErrors.invalidName"],
["invalid staging_path", "backup.apiErrors.invalidStaging"],
["job name cannot be changed (delete + recreate to rename)", "backup.apiErrors.jobRename"],
["key_path must be absolute", "backup.apiErrors.absoluteKey"],
["mode must be \"local\" or \"ssh\"", "backup.apiErrors.mode"],
["name is required (letters, digits, _ or -)", "backup.apiErrors.nameRequired"],
["no log available for this job yet", "backup.apiErrors.noLog"],
["no manual PBS configs file", "backup.apiErrors.noPbsConfig"],
["output file missing", "backup.apiErrors.outputMissing"],
["parser output was not valid JSON", "backup.apiErrors.parserJson"],
["run_restore output was not valid JSON", "backup.apiErrors.restoreJson"],
["repo is required", "backup.apiErrors.repoRequired"],
["repo_name and snapshot are required", "backup.apiErrors.repoSnapshot"],
["repo_name and snapshot are required for borg", "backup.apiErrors.borgRepoSnapshot"],
["repo_name and snapshot are required for pbs", "backup.apiErrors.pbsRepoSnapshot"],
["repo_name is required", "backup.apiErrors.repoName"],
["restore tooling not installed on this host", "backup.apiErrors.restoreMissing"],
["server and datastore are required", "backup.apiErrors.serverDatastore"],
["snapshot is required", "backup.apiErrors.snapshotRequired"],
["source must be pbs, borg, or local", "backup.apiErrors.source"],
["ssh_port must be an integer between 1 and 65535", "backup.apiErrors.sshPort"],
["ssh_user, ssh_host and ssh_remote_path are required for ssh mode", "backup.apiErrors.sshRequired"],
["target has no repository path", "backup.apiErrors.repositoryPath"],
["task not found or expired", "backup.apiErrors.taskExpired"],
["invalid expression", "backup.apiErrors.invalidExpression"],
["no report emitted", "backup.apiErrors.noReport"],
["parse_manifest exited non-zero", "backup.apiErrors.parseManifestFailed"],
])
const parameterized: ReadonlyArray<readonly [RegExp, string]> = [
[new RegExp("^job already exists: ([\\s\\S]+)$"), "backup.apiErrors.jobExists"],
[new RegExp("^path does not exist on this host: ([\\s\\S]+)$"), "backup.apiErrors.pathMissing"],
[new RegExp("^device does not exist: ([\\s\\S]+)$"), "backup.apiErrors.deviceMissing"],
[new RegExp("^not a mountpoint: ([\\s\\S]+)$"), "backup.apiErrors.mountpoint"],
[new RegExp("^local archive not found: ([\\s\\S]+)$"), "backup.apiErrors.localArchiveMissing"],
[new RegExp("^Borg destination '([\\s\\S]+)' not found$"), "backup.apiErrors.borgDestination"],
[new RegExp("^PBS destination '([\\s\\S]+)' not found$"), "backup.apiErrors.pbsDestination"],
[new RegExp("^no password available for PBS repo '([\\s\\S]+)'$"), "backup.apiErrors.pbsPassword"],
[new RegExp("^Borg repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.borgRepoMissing"],
[new RegExp("^PBS repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.pbsRepoMissing"],
[new RegExp("^Borg target not found: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetMissing"],
[new RegExp("^PBS repo not found: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetMissing"],
[new RegExp("^PVE vzdump job not found: ([\\s\\S]+)$"), "backup.apiErrors.pveJobMissing"],
[new RegExp("^cannot persist state: ([\\s\\S]+)$"), "backup.apiErrors.statePersist"],
[new RegExp("^cannot prepare ssh dir: ([\\s\\S]+)$"), "backup.apiErrors.sshDirectory"],
[new RegExp("^cannot read log: ([\\s\\S]+)$"), "backup.apiErrors.logRead"],
[new RegExp("^could not update \\.env: ([\\s\\S]+)$"), "backup.apiErrors.envUpdate"],
[new RegExp("^failed to persist PBS config: ([\\s\\S]+)$"), "backup.apiErrors.pbsConfigWrite"],
[new RegExp("^failed to persist: ([\\s\\S]+)$"), "backup.apiErrors.persist"],
[new RegExp("^failed to remove archive: ([\\s\\S]+)$"), "backup.apiErrors.archiveRemove"],
[new RegExp("^failed to start: ([\\s\\S]+)$"), "backup.apiErrors.start"],
[new RegExp("^failed to update borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsUpdate"],
[new RegExp("^failed to update pbs-manual-configs\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetsUpdate"],
[new RegExp("^failed to update systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsUpdate"],
[new RegExp("^failed to write borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsWrite"],
[new RegExp("^failed to write job files: ([\\s\\S]+)$"), "backup.apiErrors.jobWrite"],
[new RegExp("^failed to write local-target\\.conf: ([\\s\\S]+)$"), "backup.apiErrors.localTargetWrite"],
[new RegExp("^failed to write systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsWrite"],
[new RegExp("^format failed: ([\\s\\S]+)$"), "backup.apiErrors.format"],
[new RegExp("^invalid on_calendar: ([\\s\\S]+)$"), "backup.apiErrors.calendar"],
[new RegExp("^mount failed: ([\\s\\S]+)$"), "backup.apiErrors.mount"],
[new RegExp("^parser invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.parserInvocation"],
[new RegExp("^preflight invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.preflightInvocation"],
[new RegExp("^public key not found after generation: ([\\s\\S]+)$"), "backup.apiErrors.publicKeyMissing"],
[new RegExp("^retention\\.([\\s\\S]+) must be an integer$"), "backup.apiErrors.retentionInteger"],
[new RegExp("^retention\\.([\\s\\S]+) must be non-negative$"), "backup.apiErrors.retentionNegative"],
[new RegExp("^ssh-keygen failed: ([\\s\\S]+)$"), "backup.apiErrors.keygen"],
[new RegExp("^systemctl failed: ([\\s\\S]+)$"), "backup.apiErrors.systemctl"],
[new RegExp("^umount failed: ([\\s\\S]+)$"), "backup.apiErrors.unmount"],
[new RegExp("^task is in state \"([\\s\\S]+)\", not completed$"), "backup.apiErrors.taskIncomplete"],
[new RegExp("^unknown kind: ([\\s\\S]+)$"), "backup.apiErrors.unknownKind"],
[new RegExp("^unknown mode \"([\\s\\S]+)\"$"), "backup.apiErrors.unknownMode"],
[new RegExp("^([\\s\\S]+) is the built-in default and cannot be removed$"), "backup.apiErrors.defaultRemove"],
[new RegExp("^([\\s\\S]+) is the built-in default; no need to add it$"), "backup.apiErrors.defaultAdd"],
[new RegExp("^pbs_encrypt_mode='existing' but no keyfile is installed at ([\\s\\S]+) — import one first with POST /api/host-backups/pbs-encryption/import$"), "backup.apiErrors.keyImport"],
[new RegExp("^PVE job storage mismatch \\(job uses ([\\s\\S]+), payload says ([\\s\\S]+)\\)$"), "backup.apiErrors.storageMismatch"],
]
export function hostBackupErrorText(problem: unknown, t: Translate): string {
const message = typeof problem === "string" ? problem
: problem && typeof problem === "object" && "message" in problem && typeof problem.message === "string"
? problem.message : ""
if (!message) return t("backup.apiErrors.requestFailed")
const key = fixed.get(message)
if (key) return t(key)
// Keep unusually large tool output verbatim, without running pattern matching over it.
if (message.length > 16384) return message
for (const [pattern, patternKey] of parameterized) {
const match = pattern.exec(message)
if (match) return t(patternKey, { value: match[1], other: match[2] ?? "" })
}
return message
}
+23
View File
@@ -0,0 +1,23 @@
import enMessages from "../../messages/en/common.json"
import deMessages from "../../messages/de/common.json"
import esMessages from "../../messages/es/common.json"
import frMessages from "../../messages/fr/common.json"
import itMessages from "../../messages/it/common.json"
import ptMessages from "../../messages/pt/common.json"
import skMessages from "../../messages/sk/common.json"
import svMessages from "../../messages/sv/common.json"
import type { LanguageCode } from "./languages"
type MessageTree = Record<string, unknown>
// Shared by React translations and error handling outside React hooks.
export const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
en: enMessages as MessageTree,
de: deMessages as MessageTree,
es: esMessages as MessageTree,
fr: frMessages as MessageTree,
it: itMessages as MessageTree,
pt: ptMessages as MessageTree,
sk: skMessages as MessageTree,
sv: svMessages as MessageTree,
}
+1 -19
View File
@@ -1,14 +1,7 @@
"use client"
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
import enMessages from "../../messages/en/common.json"
import deMessages from "../../messages/de/common.json"
import esMessages from "../../messages/es/common.json"
import frMessages from "../../messages/fr/common.json"
import itMessages from "../../messages/it/common.json"
import ptMessages from "../../messages/pt/common.json"
import skMessages from "../../messages/sk/common.json"
import svMessages from "../../messages/sv/common.json"
import { MESSAGE_CATALOG } from "./catalogs"
import {
DEFAULT_LANGUAGE,
LANGUAGE_STORAGE_KEY,
@@ -21,17 +14,6 @@ import {
type MessageTree = Record<string, unknown>
type TranslationParams = Record<string, string | number>
const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
en: enMessages as MessageTree,
de: deMessages as MessageTree,
es: esMessages as MessageTree,
fr: frMessages as MessageTree,
it: itMessages as MessageTree,
pt: ptMessages as MessageTree,
sk: skMessages as MessageTree,
sv: svMessages as MessageTree,
}
interface I18nContextValue {
language: LanguageCode
setLanguage: (language: LanguageCode) => void
+130
View File
@@ -0,0 +1,130 @@
type Translate = (key: string, params?: Record<string, string | number>) => string
// Exact lxc_apps validation/persistence messages; never change their API contract.
// Anchored patterns leave unknown diagnostics intact, including paths and commands.
const RULES: [RegExp, string, string[]][] = [
[/^an OCI operation on this container is running$/, "ociBusy", []],
[/^([\s\S]+) is not installed via dpkg$/, "dpkgMissing", ["package"]],
[/^dpkg\-query failed$/, "dpkgFailed", []],
[/^no version matched in dpkg output$/, "dpkgNoVersion", []],
[/^apk info failed$/, "apkFailed", []],
[/^no version matched in apk output$/, "apkNoVersion", []],
[/^could not read ([\s\S]+)$/, "fileRead", ["path"]],
[/^file_regex did not match ([\s\S]+)$/, "fileNoMatch", ["path"]],
[/^binary invocation failed$/, "binaryFailed", []],
[/^no version matched in binary output$/, "binaryNoVersion", []],
[/^python \-c importlib\.metadata failed$/, "pythonFailed", []],
[/^no version matched in Python distribution output$/, "pythonNoVersion", []],
[/^could not resolve the container's installed image$/, "dockerImageResolve", []],
[/^docker inspect failed$/, "dockerInspectFailed", []],
[/^container has no ([\s\S]+) label$/, "dockerLabelMissing", ["label"]],
[/^docker label reports ([\s\S]+) \(mutable tag, not a version\)$/, "dockerLabelMutable", ["tag"]],
[/^no version matched in docker label output$/, "dockerLabelNoVersion", []],
[/^docker exec failed$/, "dockerExecFailed", []],
[/^no version matched in docker exec output$/, "dockerExecNoVersion", []],
[/^command_argv is empty$/, "commandEmpty", []],
[/^command failed$/, "commandFailed", []],
[/^no version matched in command output$/, "commandNoVersion", []],
[/^unsupported method: ([\s\S]+)$/, "unsupportedMethod", ["method"]],
[/^unknown upstream_type: ([\s\S]+)$/, "unknownUpstream", ["type"]],
[/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+): not found$/, "githubNotFound", ["repo"]],
[/^github rejected the request \(403\)$/, "githubRejected", []],
[/^github error (\d+)$/, "githubError", ["code"]],
[/^no tag \/ release name in response$/, "noRelease", []],
[/^tag_regex did not match '([\s\S]+)'$/, "tagNoMatch", ["tag"]],
[/^http error (\d+)$/, "httpError", ["code"]],
[/^invalid JSON response: ([\s\S]+)$/, "invalidJson", ["detail"]],
[/^json_path '([\s\S]+)' did not resolve$/, "jsonPathUnresolved", ["path"]],
[/^empty value at json_path$/, "jsonEmpty", []],
[/^no tags matched \(docker_hub\)$/, "dockerNoTags", []],
[/^tag_regex matched only moving tags; use Docker image digest tracking$/, "dockerMovingOnly", []],
[/^docker hub error (\d+)$/, "dockerHubError", ["code"]],
[/^invalid docker hub JSON: ([\s\S]+)$/, "dockerHubJson", ["detail"]],
[/^no tags returned by docker hub$/, "dockerHubEmpty", []],
[/^payload must be a JSON object$/, "payload", []],
[/^name is required and must be 1\-64 chars of letters\/digits\/spaces\/\._\+\-\(\)\/$/, "name", []],
[/^installed_via must be one of: ([\s\S]+) or empty$/, "method", ["values"]],
[/^package is required \(letters\/digits\/\._\+@:\/ up to 127 chars\)$/, "package", []],
[/^file_path is required and must be an absolute path$/, "filePath", []],
[/^file_regex is required$/, "fileRegex", []],
[/^file_regex is not a valid regex: ([\s\S]+)$/, "fileRegexInvalid", ["detail"]],
[/^binary_path is required and must be an absolute path$/, "binaryPath", []],
[/^python_path is required and must be an absolute path$/, "pythonPath", []],
[/^distribution is required \(PEP 503 name\)$/, "distribution", []],
[/^container_name is required \(docker naming rules\)$/, "container", []],
[/^label is required \(OCI label naming rules\)$/, "label", []],
[/^binary_path is required \(absolute path or bare command\)$/, "dockerBinary", []],
[/^installed_version is required for the manual method$/, "manualVersion", []],
[/^installed_version exceeds (\d+) chars$/, "versionLength", ["max"]],
[/^installed_version contains a forbidden control character$/, "versionControl", []],
[/^installed_regex is not a valid regex: ([\s\S]+)$/, "installedRegexInvalid", ["detail"]],
[/^upstream_type must be one of: ([\s\S]+)$/, "upstreamType", ["values"]],
[/^tag_regex is not a valid regex: ([\s\S]+)$/, "tagRegexInvalid", ["detail"]],
[/^repo is required for upstream_type=github$/, "repoRequired", []],
[/^repo must be 'owner\/name'$/, "repoFormat", []],
[/^github_source must be one of: ([\s\S]+)$/, "githubSource", ["values"]],
[/^upstream_url is required for upstream_type=http_json$/, "upstreamUrlRequired", []],
[/^upstream_url must be an http\(s\) URL$/, "upstreamUrl", []],
[/^upstream_url exceeds (\d+) chars$/, "upstreamUrlLength", ["max"]],
[/^upstream_json_path is required for upstream_type=http_json$/, "jsonPathRequired", []],
[/^upstream_json_path exceeds (\d+) chars$/, "jsonPathLength", ["max"]],
[/^upstream_json_path uses forbidden characters$/, "jsonPathCharacters", []],
[/^docker_image is required for upstream_type=docker_hub$/, "dockerImageRequired", []],
[/^docker_image exceeds (\d+) chars$/, "dockerImageLength", ["max"]],
[/^docker_image must match Docker Hub naming \(owner\/name or name\)$/, "dockerImageFormat", []],
[/^health_path must be a valid URL path \(max 255 chars\)$/, "healthPath", []],
[/^logo_url must be an http\(s\) URL or this VM's cached local icon$/, "logoUrl", []],
[/^invalid discovery_id$/, "discoveryId", []],
[/^helper_slug must be a lowercase slug \(letters\/digits\/\._\-\)$/, "helperSlug", []],
[/^update_via only accepts 'docker'$/, "updateVia", []],
[/^update_via=docker requires a docker_label or docker_exec detector$/, "dockerDetector", []],
[/^update_via=docker cannot be combined with ([\s\S]+)$/, "dockerConflict", ["field"]],
[/^update_command must be a string$/, "updateCommandType", []],
[/^update_command exceeds (\d+) chars$/, "updateCommandLength", ["max"]],
[/^update_command contains a null byte$/, "updateCommandNull", []],
[/^update_method must be none, helper or custom$/, "updateMethod", []],
[/^update_command is required for update_method=custom$/, "updateCommandRequired", []],
[/^update_command is only allowed for update_method=custom$/, "updateCommandOnlyCustom", []],
[/^a supported helper_slug is required for update_method=helper$/, "helperRequired", []],
[/^Docker\-delegated apps update through their image, not a separate app updater$/, "dockerUpdater", []],
[/^command_argv is required \(non\-empty list\)$/, "commandRequired", []],
[/^command_argv must be a non\-empty list of strings$/, "commandList", []],
[/^command_argv accepts at most (\d+) entries$/, "commandCount", ["max"]],
[/^command_argv\[(\d+)\] must be a non\-empty string$/, "commandEntry", ["index"]],
[/^command_argv\[(\d+)\] exceeds (\d+) chars$/, "commandEntryLength", ["index", "max"]],
[/^command_argv\[(\d+)\] contains a forbidden control character$/, "commandEntryControl", ["index"]],
[/^binary_args must be a list of strings$/, "binaryArgsList", []],
[/^binary_args accepts at most (\d+) entries$/, "binaryArgsCount", ["max"]],
[/^binary_args\[(\d+)\] must be a non\-empty string$/, "binaryArgEntry", ["index"]],
[/^binary_args\[(\d+)\] exceeds (\d+) chars$/, "binaryArgLength", ["index", "max"]],
[/^binary_args\[(\d+)\] contains a forbidden control character$/, "binaryArgControl", ["index"]],
[/^ports must be a list of \{port, description, scheme\}$/, "portsList", []],
[/^ports\[(\d+)\] must be an object$/, "portObject", ["index"]],
[/^ports\[(\d+)\]\.port is required$/, "portRequired", ["index"]],
[/^ports\[(\d+)\]\.port must be an integer$/, "portInteger", ["index"]],
[/^ports\[(\d+)\]\.port must be 1\-65535$/, "portRange", ["index"]],
[/^port (\d+) appears more than once for this app$/, "portDuplicate", ["port"]],
[/^ports\[(\d+)\]\.description has invalid characters$/, "portDescription", ["index"]],
[/^ports\[(\d+)\]\.scheme must be 'http' or 'https'$/, "portScheme", ["index"]],
[/^ports\[(\d+)\]\.web_path must be a valid URL path \(max 255 chars\)$/, "portPath", ["index"]],
[/^ports\[(\d+)\]\.logo_url must be an http\(s\) URL \(max 512 chars\)$/, "portLogo", ["index"]],
[/^ports\[(\d+)\]\.category has invalid characters or is too long$/, "portCategory", ["index"]],
[/^ports\[(\d+)\]\.custom_url must be an http\(s\) URL \(max 512 chars\)$/, "portCustomUrl", ["index"]],
[/^tag_regex exceeds 512 chars$/, "tagRegexLength", []],
[/^could not persist sidecar \(permission\?\)$/, "persistPermission", []],
[/^could not persist sidecar$/, "persist", []],
[/^no apps registered for this vmid$/, "noApps", []],
[/^app_id '([\s\S]+)' not found$/, "appMissing", ["appId"]],
[/^app not found$/, "appNotFound", []],
]
export function localizeAppEditorError(message: string | null | undefined, t: Translate): string {
if (!message) return ""
for (const [pattern, key, names] of RULES) {
const match = pattern.exec(message)
if (!match) continue
const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]]))
return t(`vmLxc.appEditor.errors.${key}`, params)
}
return message
}
+31
View File
@@ -0,0 +1,31 @@
/** Opaque viewer metadata only; updater commands and credentials stay server-side. */
export type LxcUpdateJob = {
id: string
vmid: number
state: "running" | "finalizing" | "complete"
target: "os" | "app" | "both"
ct_name: string
script_path: string
created_at: number
exit_code: number | null
}
const SEEN = "proxmenux-viewed-update-jobs"
export function viewedUpdateJobs(): string[] {
try {
const value = JSON.parse(localStorage.getItem(SEEN) || "[]")
return Array.isArray(value) ? value.filter((id) => typeof id === "string").slice(-128) : []
} catch {
return []
}
}
export function markUpdateJobViewed(id: string) {
try {
localStorage.setItem(SEEN, JSON.stringify([...new Set([...viewedUpdateJobs(), id])].slice(-128)))
window.dispatchEvent(new Event("proxmenux-update-jobs-viewed"))
} catch {
// Unavailable browser storage never controls or restarts server work.
}
}
+47
View File
@@ -0,0 +1,47 @@
import { fetchApi } from "./api-config"
interface UpdateObservation {
last_check?: string | null
stale?: boolean
stale_after_seconds?: number
available?: boolean
count?: number | null
error?: string | null
}
export function getLxcOsUpdateState(observation?: UpdateObservation, now = Date.now()): "unknown" | "pending" | "current" {
if (isLxcUpdateStale(observation, now) || observation?.error
|| typeof observation?.available !== "boolean"
|| !Number.isInteger(observation.count) || (observation.count ?? -1) < 0
|| observation.available !== ((observation.count ?? 0) > 0)) return "unknown"
return observation.available ? "pending" : "current"
}
export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean {
if (!observation || observation.stale === true) return true
const checked = Date.parse(observation.last_check || "")
const age = now - checked
const limit = (observation.stale_after_seconds ?? 172800) * 1000
return !Number.isFinite(checked) || age < 0 || age >= limit
}
// Called only by the OS section's explicit button. Uses installed package
// state and existing indexes; fetching this never upgrades or refreshes APT.
export function checkLxcUpdates<T>(vmid: number): Promise<T> {
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
}
// Explicit action: refresh indexes and check an already-running CT. The
// backend owns the job even if the browser closes or navigates away.
export async function refreshCheckLxcUpdates<T>(vmid: number): Promise<T> {
const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" })
const deadline = Date.now() + 8 * 60_000
while (Date.now() < deadline) {
const result = await fetchApi<{ state: string; error?: string; update_check?: T }>(
`/api/vms/${vmid}/updates/refresh-check/${job.job_id}`)
if (result.state === "failed") throw new Error("osStatusUnavailable")
if (result.state === "complete") return result.update_check as T
await new Promise(resolve => setTimeout(resolve, 1000))
}
throw new Error("checkStillRunning")
}

Some files were not shown because too many files have changed in this diff Show More