Compare commits

..
315 Commits
Author SHA1 Message Date
MacRimi 5bfc524359 fix(monitor): welcome dialogs one at a time and a usable terminal on phones 2026-11-05 23:03:05 +01:00
MacRimi e945051f88 fix(monitor): serve Next.js static files only from their folder
The /_next route joined the requested name to its folder and served it
with send_file, so a path with parent components could read a file
outside it without signing in. It now uses send_from_directory, like
the other static routes, and a regression test covers plain, encoded
and absolute escapes on the three of them.
2026-11-05 22:07:19 +01:00
github-actions[bot] 88fa230e10 Update AppImage beta build (2026-11-05 19:26:42) 2026-11-05 19:26:42 +00:00
MacRimi 795f77f76f fix(oci): write the Nextcloud MariaDB bootstrap as the application user 2026-11-05 20:14:39 +01:00
MacRimi ecd94b45ec fix(oci): keep Tailscale starting once its web client is on 2026-11-05 20:09:40 +01:00
ProxMenuxBot eea782e4d1 chore(lang): auto-rebuild translation cache
Source: dc18463
Triggered by: push
2026-11-05 18:51:08 +00:00
MacRimiandGitHub dc18463965 Merge pull request #590 from jcastro/feature/oci-nextcloud-mariadb
feat(oci): add LinuxServer Nextcloud with MariaDB and Redis
2026-11-05 19:50:06 +01:00
MacRimi 256d1d911d fix(oci): generate the Nextcloud MariaDB administrator and keep its umask private
- The administrator password is generated at install and written to
  autoconfig.php, so Nextcloud installs itself on the first request and
  the setup page cannot be claimed by anyone else. The summary shows it.
- The private umask applies to the bootstrap only, not to /init.
- The bootstrap creates its files without following links and gives
  them to the application user with lchown.
- The startup check leaves out the default port of its scheme, so an
  application that trusts the host of its first request, like
  Nextcloud, opens from the LAN address.

Installed on a Proxmox host: Nextcloud 35.0.2 installs with the
generated administrator, opens from its LAN address and accepts a
WebDAV upload; the application runs with umask 0022.
2026-11-05 19:48:39 +01:00
MacRimi 745896e617 Merge develop into feature/oci-nextcloud-mariadb 2026-11-05 19:21:29 +01:00
MacRimi 13ac55ac82 fix(monitor): show a light Hermes logo in the dark theme 2026-11-05 19:20:37 +01:00
ProxMenuxBot 683595aefc chore(lang): auto-rebuild translation cache
Source: 3d19c93
Triggered by: push
2026-11-05 18:13:08 +00:00
MacRimiandGitHub 3d19c93c02 Merge pull request #468 from jcastro/feature/usbip-ssh-sharing
Add USB/IP device sharing over SSH
2026-11-05 19:11:55 +01:00
MacRimi 1753d3aaa5 fix(usbip): remove the transport account on uninstall and refuse USB NICs
- Uninstall removes the proxmenux-usbip account and its home when
  ProxMenux created them, so a reinstall does not revive old client keys.
- The client tunnel port can be bound again while earlier connections
  are in TIME_WAIT; restarting a client no longer waits a minute.
- USB network adapters are not offered: on many mini PCs one is the
  management link of the host.
- The share dialog explains the exceptional case that needs a reboot.
- The hardware menu keeps its name, so the scripts and docs that point
  to it stay correct.
2026-11-05 19:10:49 +01:00
MacRimi 7783f9a2ec Merge develop into feature/usbip-ssh-sharing 2026-11-05 19:04:00 +01:00
MacRimiandGitHub e30c592217 Merge pull request #595 from jcastro/feature/monitor-ovs-topology
feat(monitor): show Open vSwitch network topology
2026-11-05 18:46:54 +01:00
MacRimi 3090f1d772 fix(monitor): place guests behind the firewall on their OVS bridge
On an OVS bridge Proxmox links the firewall bridge fwbrNiM through its
own internal port fwlnNoM and creates no fwprNpM peer, so the guest was
shown on fwbrNiM. Check both links. Verified on an OVS 3.5 host with a
firewalled container.
2026-11-05 18:40:33 +01:00
MacRimi 163317f727 Merge develop into feature/monitor-ovs-topology 2026-11-05 18:40:33 +01:00
MacRimiandGitHub ce8ccab4de Merge pull request #596 from jcastro/feature/monitor-native-arm64-build
feat(monitor): add native ARM64 AppImage build validation
2026-11-05 18:21:10 +01:00
MacRimiandGitHub 202557f540 Merge pull request #594 from jcastro/fix/provider-model-availability
fix(ai): refresh Groq ordering and unavailable OpenRouter models
2026-11-05 18:20:32 +01:00
MacRimi d4525fb13e fix(oci): keep a device GID changed in Proxmox since the last operation 2026-11-05 18:19:49 +01:00
MacRimi 2d1d7bcc7b fix(build): pin the ARM AppImage runtime and align two translations
appimagetool 1.9.1 embeds the unpinned continuous runtime unless one
is given. Use the 20251108 release checked by its digest. The package
index refresh no longer fails the job on a broken runner repository.
2026-11-05 18:11:29 +01:00
MacRimi 7a73fa03f1 Merge develop into feature/monitor-native-arm64-build 2026-11-05 18:10:41 +01:00
MacRimi 9d6c1edbc7 ci: run the AI model availability tests in Feature tests 2026-11-05 18:10:23 +01:00
MacRimi c2fd894083 Merge develop into fix/provider-model-availability 2026-11-05 18:10:05 +01:00
ProxMenuxBot cd6b331a84 chore(lang): auto-rebuild translation cache
Source: 50a1d05
Triggered by: push
2026-11-05 17:09:21 +00:00
MacRimiandGitHub 50a1d050bc Merge pull request #601 from jcastro/fix/oci-preserve-container-device-gid
fix(oci): preserve recorded container device GIDs
2026-11-05 18:07:24 +01:00
MacRimiandGitHub bb697c4c23 Merge pull request #598 from jcastro/fix/oci-private-image-import
fix(oci): import private cached images with restrictive caller permissions
2026-11-05 18:06:52 +01:00
MacRimiandGitHub 41cc933e0a Merge pull request #591 from jcastro/fix/oci-stack-https-readiness
fix(oci): align stack HTTPS readiness with the single-container installer
2026-11-05 18:06:18 +01:00
MacRimiandGitHub b37e6610d1 Merge pull request #597 from jcastro/fix/oci-pbs-backup-umask
fix(oci): keep PBS backups working with a private caller umask
2026-11-05 18:05:52 +01:00
MacRimi 2ef15f94a5 fix(oci): read device lines Proxmox writes without path= 2026-11-05 17:21:37 +01:00
github-actions[bot] 8644d5021e Update AppImage beta build (2026-11-04 21:45:44) 2026-11-04 21:45:44 +00:00
MacRimiandGitHub a8d1146e66 Delete AppImage/ProxMenux-1.2.8.AppImage 2026-11-04 22:42:38 +01:00
MacRimiandGitHub 321ebadea3 Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-11-04 22:42:27 +01:00
github-actions[bot] 7775a29172 Update AppImage beta build (2026-11-04 21:32:42) 2026-11-04 21:32:42 +00:00
MacRimiandGitHub 20a4d08d05 Merge pull request #600 from jcastro/fix/stirling-pdf-ghcr
Use official GHCR image for Stirling PDF
2026-11-04 22:30:01 +01:00
MacRimi a18a008108 Merge develop into fix/stirling-pdf-ghcr
Keep the Slovak menu test fix already on develop.
2026-11-04 22:24:30 +01:00
MacRimi 9aa378fccb fix(monitor): announce the NVIDIA driver the installer itself recommends 2026-11-04 22:20:44 +01:00
MacRimi 05ed3925da test: give the Slovak menu test the catalog and update entry labels 2026-11-04 22:11:04 +01:00
MacRimiandGitHub 0c98bb2c6c Merge pull request #593 from jcastro/fix/nvidia-subsystem-device-detection
fix: recognize subsystem-qualified NVIDIA GPUs
2026-11-04 22:04:41 +01:00
Jonatan Castro 7605a6a2fa fix(oci): preserve recorded container device GIDs 2026-11-04 21:24:04 +01:00
Jonatan Castro 0e42f658da Return text labels from OCI menu test checkers 2026-11-04 19:55:16 +01:00
Jonatan Castro b6006554c6 Use official GHCR image for Stirling PDF 2026-11-04 19:47:43 +01:00
ProxMenuxBot 6324d41a1d chore(lang): auto-rebuild translation cache
Source: ef00a34
Triggered by: push
2026-11-04 18:05:56 +00:00
MacRimi ef00a34654 feat(oci): show new and installed application counts in the OCI menu entries, lined up in one column 2026-11-04 19:02:03 +01:00
ProxMenuxBot 143c7db610 chore(lang): auto-rebuild translation cache
Source: 17786e0
Triggered by: push
2026-11-04 17:20:16 +00:00
MacRimi 17786e0f54 perf(oci): read the Proxmox version from the package database when opening the OCI menu 2026-11-04 18:19:13 +01:00
MacRimi 5c91e1c661 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-04 18:18:17 +01:00
MacRimi f0871607fa i18n: translate the Selkies NVIDIA acceleration label 2026-11-04 18:16:33 +01:00
ProxMenuxBot 8e1b2ccc9d chore(lang): auto-rebuild translation cache
Source: b7167ac
Triggered by: push
2026-11-04 17:14:35 +00:00
MacRimi b7167ac498 feat(oci): offer GPU acceleration to the ArtCraft apps built on the LinuxServer Selkies base 2026-11-04 18:11:41 +01:00
MacRimi e916c3b8bb fix(oci): offer what the AMD GPU can run without warning about ROCm support 2026-11-04 17:25:08 +01:00
MacRimi 64e9e0e8dd fix(monitor): read the error frequency for AI context from the real errors table columns 2026-11-04 17:05:44 +01:00
MacRimi 7cbe92ad9f fix(monitor): read the error frequency for AI context from the real errors table columns 2026-11-04 16:57:51 +01:00
MacRimi 8ca445ff3b fix(oci): let AdGuard Home pass its update check on the web port once it is set up 2026-11-04 16:47:35 +01:00
Jonatan Castro 007cf78ec2 Authorize USB/IP imports and retain USB references through hotplug cleanup 2026-11-04 11:05:19 +01:00
Jonatan Castro 0ca936f3b4 fix(oci): stage private images for the mapped Proxmox reader 2026-11-04 03:25:39 +01:00
Jonatan Castro bf27c3e1da fix(oci): isolate retained PBS backup creation permissions 2026-11-04 03:09:47 +01:00
Jonatan Castro 150d15950d fix(backup): isolate native Borg from AppImage Python and library paths 2026-11-04 02:52:29 +01:00
Jonatan Castro fd061054d4 fix(monitor): avoid foreign Borg binary in ARM builds and smoke-test launcher 2026-11-04 02:52:05 +01:00
Jonatan Castro d3005bbaff Wait for disposable SSH account processes before fixture cleanup 2026-11-04 02:48:31 +01:00
Jonatan Castro e361195abc Fail closed when the kernel retains an unplugged USB/IP match 2026-11-04 02:48:06 +01:00
Jonatan Castro 5d2c95a22b Exercise restricted SSH with an accessible isolated account home 2026-11-04 02:47:01 +01:00
Jonatan Castro 76696a5271 fix(monitor): preserve an OVS VLAN tag of zero 2026-11-04 02:46:58 +01:00
Jonatan Castro 2bcc0c1e61 feat(monitor): validate native ARM64 and AMD64 AppImage builds 2026-11-04 02:46:13 +01:00
Jonatan Castro 2fd55f8c88 feat(monitor): discover Open vSwitch network topology 2026-11-04 02:45:28 +01:00
Jonatan Castro 3c09800ab5 Restrict USB/IP SSH access and clean stale device matches 2026-11-04 02:45:19 +01:00
Jonatan Castro 84d860f71b test(ai): run availability regressions on provider catalog changes 2026-11-04 02:42:43 +01:00
Jonatan Castro 344346ff9d fix(ai): refresh Groq recommendations and remove unavailable OpenRouter models 2026-11-04 02:41:58 +01:00
Jonatan Castro e1243ea2ff fix: detect subsystem-qualified NVIDIA GPU support 2026-11-04 02:41:45 +01:00
Jonatan Castro 0a4824e2f1 Preserve installed Nextcloud cache settings and configuration ownership 2026-11-04 00:40:52 +01:00
Jonatan Castro a97dafacf6 Fix local HTTPS readiness for native OCI stacks 2026-11-04 00:17:35 +01:00
Jonatan Castro eef425666a Fix native Nextcloud bootstrap quoting and initial configuration seeding 2026-11-04 00:04:57 +01:00
Jonatan Castro f4e7f7d525 Keep the MariaDB catalog description aligned with the recipe 2026-11-03 23:40:23 +01:00
Jonatan Castro 0d4c6e6bd3 Merge remote-tracking branch 'origin/develop' into feature/oci-nextcloud-mariadb 2026-11-03 23:39:23 +01:00
Jonatan Castro 3ff9885394 Add LinuxServer Nextcloud stack with MariaDB and Redis 2026-11-03 23:39:23 +01:00
github-actions[bot] a026ab5926 Update AppImage beta build (2026-11-03 22:24:19) 2026-11-03 22:24:20 +00:00
MacRimi 1dca76aaa5 fix(oci): check high availability in the preflight so tests without Proxmox keep working 2026-11-03 23:16:25 +01:00
MacRimi dcb1d65ec8 fix(monitor): keep the theme-aware logo list and add Tailscale 2026-11-03 23:08:30 +01:00
ProxMenuxBot 7bb99d30ff chore(lang): auto-rebuild translation cache
Source: a89eb92
Triggered by: push
2026-11-03 22:00:23 +00:00
MacRimi a89eb921cc fix(oci): refuse an HA-managed container before downloading the image and explain how to proceed 2026-11-03 22:49:47 +01:00
github-actions[bot] 97da4321c8 Update AppImage beta build (2026-11-03 20:27:40) 2026-11-03 20:27:40 +00:00
MacRimi 11c0c683d7 fix(monitor): run scheduled backup jobs detached and follow them by their task UPID 2026-11-03 21:15:40 +01:00
MacRimi 6628744900 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-03 21:02:17 +01:00
MacRimi 194a959492 fix(monitor): show the light variant of selfh.st logos in the dark theme 2026-11-03 21:02:06 +01:00
MacRimiandGitHub 74e5b7a4ff Merge pull request #587 from MaEscalanteHe/docs/drift-utils
docs(web): fix Utils drift
2026-11-03 20:58:55 +01:00
MacRimiandGitHub 244181f9df Merge pull request #586 from MaEscalanteHe/docs/drift-backup-restore
docs(web): fix Backup & Restore drift
2026-11-03 20:58:33 +01:00
Manuel Escalante e3f85b3e09 docs(web): translate utils drift fixes 2026-11-03 20:00:03 +01:00
Manuel Escalante c0067d35a6 docs(web): fix Utils drift found in docs audit 2026-11-03 19:59:52 +01:00
Manuel Escalante cd3d4061d0 docs(web): translate backup-restore drift fixes 2026-11-03 19:52:20 +01:00
Manuel Escalante e9272c90a0 docs(web): fix Backup & Restore drift found in docs audit 2026-11-03 19:52:20 +01:00
MacRimi e51553ada8 test: give the cached health check a safe margin before the cache expires 2026-11-03 19:50:03 +01:00
MacRimiandGitHub c07b328bb9 Merge pull request #582 from jcastro/feature/oci-catalog-refresh
Refresh the OCI application catalog automatically with a local cache
2026-11-03 19:46:54 +01:00
MacRimiandGitHub 9a9d0f2379 Merge pull request #576 from dodog/main
Refine Slovak translations in common.json
2026-11-03 19:42:30 +01:00
MacRimiandGitHub 389a07c265 Merge pull request #575 from MaEscalanteHe/docs/drift-monitor
docs(web): fix Monitor drift
2026-11-03 19:41:56 +01:00
MacRimiandGitHub c5df337db6 Merge pull request #574 from MaEscalanteHe/docs/drift-settings-intro
docs(web): fix Settings, Introduction and Installation drift
2026-11-03 19:41:29 +01:00
ProxMenuxBot 3e2b8d72d1 chore(lang): auto-rebuild translation cache
Source: 3bf4c0d
Triggered by: push
2026-11-03 18:14:42 +00:00
MacRimiandGitHub 3bf4c0d82f Merge pull request #585 from jcastro/feature/oci-artcraft-suite
feat(oci): add thirteen ArtCraft desktop applications
2026-11-03 19:00:47 +01:00
MacRimi c3024b8448 Merge develop into feature/oci-artcraft-suite 2026-11-03 18:52:37 +01:00
ProxMenuxBot ab76ab161b chore(lang): auto-rebuild translation cache
Source: 7f59ee5
Triggered by: push
2026-11-03 17:50:46 +00:00
MacRimi f61c59d093 Merge develop into feature/oci-artcraft-suite
Resolve the catalog conflicts and regenerate VALIDATION.md.
2026-11-03 18:43:52 +01:00
MacRimiandGitHub 7f59ee5103 Merge pull request #580 from jcastro/feature/oci-app-catalog-additions
Add KitchenOwl, Krusader, Spoolman, Unbound and Z-Wave JS UI
2026-11-03 18:30:15 +01:00
github-actions[bot] cef1dc7422 Update AppImage beta build (2026-11-03 17:25:45) 2026-11-03 17:25:45 +00:00
MacRimi 888b99330b Merge develop into feature/oci-app-catalog-additions 2026-11-03 18:20:08 +01:00
ProxMenuxBot af0dd74067 chore(lang): auto-rebuild translation cache
Source: 7ec022a
Triggered by: push
2026-11-03 17:13:55 +00:00
Jonatan Castro 75a96b2658 Complete Swedish Authelia label after syncing develop 2026-11-03 18:06:58 +01:00
MacRimi 114882fcba Merge develop into feature/oci-app-catalog-additions
Resolve the catalog conflicts and regenerate VALIDATION.md.
2026-11-03 18:06:57 +01:00
Jonatan Castro 79c0f268fd Merge develop and refresh the OCI validation summary 2026-11-03 18:05:19 +01:00
MacRimiandGitHub 7ec022af81 Merge pull request #584 from jcastro/feature/oci-airdcpp
feat(oci): add AirDC++ Web Client
2026-11-03 18:02:36 +01:00
Jonatan Castro 07f7f9a6a1 Apply profile compatibility gate using effective menu visibility 2026-11-03 17:58:52 +01:00
Jonatan Castro fec97c57d9 Prune catalog snapshots and defer recipes requiring newer profile keys 2026-11-03 17:56:15 +01:00
MacRimi 4ba0d6a236 Merge develop into feature/oci-airdcpp
Resolve the catalog index conflict and regenerate VALIDATION.md.
2026-11-03 17:47:35 +01:00
ProxMenuxBot 96b3a919ba chore(lang): auto-rebuild translation cache
Source: c628e65
Triggered by: push
2026-11-03 16:46:28 +00:00
MacRimiandGitHub c628e65e2f Merge pull request #581 from jcastro/feature/oci-authelia
Add Authelia to the OCI catalog
2026-11-03 17:42:30 +01:00
MacRimi 1e08a5939b ci: cancel superseded test runs on the same branch or pull request 2026-11-03 17:10:11 +01:00
MacRimiandGitHub 163c7c3505 Merge pull request #583 from jcastro/fix/oci-failed-update-recovery
fix(oci): recover automatically after failed image replacements
2026-11-03 16:50:36 +01:00
MacRimi eb46fffd45 fix(oci): apply the llama.cpp context size and stable Vulkan settings for integrated GPUs 2026-11-03 16:46:38 +01:00
Manuel Escalante a4c2c34597 fix(i18n): use localized UI labels in Monitor docs 2026-11-03 16:04:15 +01:00
Jonatan Castro aa19af66eb Add thirteen ArtCraft desktop applications to the OCI catalog 2026-11-03 14:50:20 +01:00
Jonatan Castro f19c300387 Restore the previous OCI installation after a failed image replacement 2026-11-03 13:32:16 +01:00
Jonatan Castro c1183bdeed Use the AirDC++ index page for startup health checks 2026-11-03 13:22:55 +01:00
Jonatan Castro 19e35a567f Add AirDC++ Web Client OCI recipe 2026-11-03 12:51:00 +01:00
Jonatan Castro f428dc76f0 Isolate catalog worker in offline Slovak menu regression 2026-11-03 12:38:57 +01:00
Jonatan Castro e412d3161d Harden OCI catalog refresh recovery and retries 2026-11-03 12:30:54 +01:00
Jonatan Castro 13a8ac1001 Refresh OCI catalog automatically with local cache and app counters 2026-11-03 12:20:23 +01:00
Jonatan Castro f0d54fca13 Leave lab verification to maintainers 2026-11-03 11:35:59 +01:00
Jonatan Castro d9973635fb Add Authelia to OCI catalog 2026-11-03 11:34:27 +01:00
Manuel Escalante 75b13d87da fix(i18n/de): correct the backups tab alt text in the guest detail dialog 2026-11-03 10:25:41 +01:00
Manuel Escalante ac97e2c5a6 docs(web): apply review feedback on Monitor docs 2026-11-03 09:54:33 +01:00
Manuel Escalante 700f72babd docs(web): fix release channel wording from review 2026-11-03 09:45:39 +01:00
Jonatan Castro 0ab40b4eea feat(oci): add five application recipes 2026-11-03 00:27:00 +01:00
MacRimi 9940f05adf fix(oci): llama.cpp lists the models in /models, with resources and notes to add one 2026-11-02 23:45:10 +01:00
Jozef GaalandJozef Gaal 8328d5d366 Refine 'onServer' explanation in common.json 2026-11-02 23:17:00 +01:00
Jozef GaalandJozef Gaal 7e6a942d16 Fix Slovak translations in common.json 2026-11-02 23:17:00 +01:00
Jozef GaalandJozef Gaal 03928c4277 Refine Slovak translations in common.json 2026-11-02 23:16:59 +01:00
MacRimiandGitHub 8aefd70e52 Merge pull request #578 from jcastro/feature/oci-paperclip
Add Paperclip to the OCI catalog
2026-11-02 23:02:03 +01:00
MacRimiandGitHub 6bee13114c Merge pull request #579 from f3rs3n/fix/log2ram-journal-transition
fix(log2ram): keep journal protection enabled until disk migration
2026-11-02 23:00:50 +01:00
martino 6a1513a014 fix(log2ram): keep journal protection enabled until disk migration 2026-11-02 22:32:28 +01:00
Jonatan Castro fb083e92cb feat(oci): add Paperclip application recipe 2026-11-02 20:37:29 +01:00
github-actions[bot] 781d2500ad Update AppImage beta build (2026-11-02 18:39:06) 2026-11-02 18:39:06 +00:00
MacRimi 98931fe4a7 New version 1.2.8 2026-11-02 19:22:47 +01:00
MacRimi 46337f162f New version 1.2.8 2026-11-02 19:20:30 +01:00
MacRimiandGitHub 064c2a6c39 Merge pull request #557 from MaEscalanteHe/feature/web-refresh
fix(web): dependency cleanup, HTML entity rendering and sidebar fixes
2026-11-02 19:10:00 +01:00
MacRimi 3feb5ca5cb fix(web): wrap inline code and scroll tables on narrow screens 2026-11-02 19:01:03 +01:00
MacRimi b0215d4bbb fix: OCI image data paths, LAN endpoints for OpenAI-compatible AI and lab-validated apps 2026-11-02 18:42:32 +01:00
MacRimi 9f3fffd249 fix: one source for the disk type and a config volume for SearXNG 2026-11-02 17:28:31 +01:00
Manuel Escalante 511211f10b fix(web): keep ICU quotes out of messages inserted as raw HTML 2026-11-02 10:32:41 +01:00
github-actions[bot] 41081121f8 Update AppImage beta build (2026-11-02 07:28:12) 2026-11-02 07:28:12 +00:00
MacRimi e83794b7e2 fix(web): send only the client translations to each page 2026-11-02 08:25:28 +01:00
MacRimi 1f56d6b1ce Restore the AppImage checksum for the merge 2026-11-02 08:06:15 +01:00
MacRimi 7df9fdf381 New version 1.2.7 2026-11-02 08:00:57 +01:00
MacRimi 9084df7ebd docs: set the 1.2.8 release date 2026-11-02 07:58:18 +01:00
MacRimiandGitHub 0ae3fd19f4 Merge pull request #572 from f3rs3n/fix/italian-1.2.8-review
fix(i18n): review Italian translations for 1.2.8
2026-11-02 07:51:53 +01:00
MacRimi 8d8ae53a75 fix: stop the NVIDIA install under Secure Boot and keep the 2FA field visible to Bitwarden 2026-11-02 07:47:35 +01:00
martino fa8f021ee9 fix(i18n): review Italian translations for 1.2.8 2026-11-02 01:09:21 +01:00
MacRimi d6f6b4c914 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-01 17:56:15 +01:00
MacRimi 28b23c8ffd i18n: use the Slovak term for application in two terminal strings 2026-11-01 17:56:11 +01:00
MacRimiandGitHub 9976183a54 Merge pull request #569 from Vaso73/slovak-translation-review-1.2.8
i18n: review Slovak translations for 1.2.8
2026-11-01 17:53:32 +01:00
VAIO73 f2328624ab i18n: finish consistent Slovak container lifecycle labels 2026-11-01 15:14:27 +00:00
VAIO73 93e2161355 i18n: explain moving Docker image tags in Slovak 2026-11-01 15:14:27 +00:00
VAIO73 fa58d2f7fe i18n: use consistent Slovak ZFS pool notification terminology 2026-11-01 15:14:27 +00:00
VAIO73 0251dd3c7a i18n: clarify Slovak index documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 253e71a85f i18n: clarify Slovak hardware documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 d4cc80cb4c i18n: clarify Slovak architecture documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 3c9dc45d68 i18n: clarify Slovak custom-image documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 b9ee0514c6 i18n: clarify Slovak monitor documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 537bb5a815 i18n: clarify Slovak storage-network documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 2e227a87c5 i18n: clarify Slovak lifecycle documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 6fe5c8e25c i18n: clarify Slovak multi-container application documentation 2026-11-01 15:14:27 +00:00
VAIO73 7fe03d96f5 i18n: correct Slovak backup audit guidance 2026-11-01 15:14:27 +00:00
VAIO73 2c59356e66 i18n: clarify Slovak wording for all application containers 2026-11-01 15:14:27 +00:00
VAIO73 c68d57feb5 i18n: preserve the full pct create command in Slovak release notes 2026-11-01 15:14:27 +00:00
VAIO73 5395dd488a i18n: preserve the executable example in the Slovak app editor 2026-11-01 15:14:27 +00:00
VAIO73 3048ed8e5b i18n: translate remaining Slovak documentation navigation labels 2026-11-01 15:14:27 +00:00
VAIO73 468f57ebed i18n: translate the OCI catalog contribution guide into Slovak 2026-11-01 15:14:27 +00:00
VAIO73 e42fca75ec i18n: add Slovak VLAN networking documentation 2026-11-01 15:14:27 +00:00
VAIO73 1a18432cee i18n: clarify Slovak custom Compose endpoint documentation 2026-11-01 15:14:27 +00:00
VAIO73 b2e84fde08 i18n: correct Slovak scheduled OCI update documentation 2026-11-01 15:14:27 +00:00
VAIO73 cf7269c123 i18n: align Slovak OCI lifecycle docs with preserved Proxmox settings 2026-11-01 15:14:27 +00:00
VAIO73 ef1e6932b4 i18n: review Slovak release notes for version 1.2.8 2026-11-01 15:14:27 +00:00
VAIO73 aadd90810f i18n: translate Slovak hardware storage controller label 2026-11-01 15:14:27 +00:00
VAIO73 fc2a5ff4fe i18n: clarify Slovak storage display and disk exclusion settings 2026-11-01 15:14:27 +00:00
VAIO73 ff78912764 i18n: review Slovak storage controller and ZFS messages 2026-11-01 15:14:27 +00:00
github-actions[bot] 256df1ef3e Update AppImage beta build (2026-11-01 14:27:37) 2026-11-01 14:27:37 +00:00
MacRimi 0333d95479 fix(monitor): do not warn about volume groups without active volumes 2026-11-01 15:13:18 +01:00
Manuel Escalante 418afe385b docs(web): translate AI explanations of backup errors 2026-11-01 14:38:05 +01:00
Manuel Escalante 9ed09cc28e docs(web): document AI explanations of backup errors 2026-11-01 14:36:54 +01:00
github-actions[bot] e3113df0a2 Update AppImage beta build (2026-11-01 13:07:50) 2026-11-01 13:07:50 +00:00
Manuel Escalante 4261019549 docs(web): translate settings-intro drift fixes 2026-11-01 14:05:13 +01:00
Manuel Escalante 5dc98e9daf docs(web): translate monitor drift fixes 2026-11-01 14:05:13 +01:00
Manuel Escalante 488044e0b9 docs(web): fix remaining Settings and Introduction drift from docs audit 2026-11-01 14:05:13 +01:00
Manuel Escalante 7320fc6ff6 docs(web): fix Monitor drift found in docs audit 2026-11-01 14:05:13 +01:00
Manuel Escalante 398ca55247 docs(web): fix Monitor API paths and token and SSL location 2026-11-01 14:05:13 +01:00
Manuel Escalante 4f415c98cf docs(web): fix Monitor install and Change Language drift in Settings 2026-11-01 14:05:12 +01:00
MacRimi 24d069e1dd Show the optional ZFS feature upgrade notice in blue 2026-11-01 14:04:10 +01:00
github-actions[bot] 3a000458b2 Update AppImage beta build (2026-11-01 12:45:30) 2026-11-01 12:45:30 +00:00
MacRimi d5b7d386ea fix(notifications): preserve VM and CT identities in AI diagnoses 2026-11-01 13:35:24 +01:00
MacRimi 624ff628bd fix(notifications): restore AI suggestions and explain backup failures 2026-11-01 13:20:13 +01:00
MacRimi 5ea0645e31 fix(oci): confirm the release of a private network by its result 2026-11-01 11:39:56 +01:00
MacRimi ac2bd28d25 ProxMenux 1.2.8: release notes, changelog, image and version 2026-11-01 10:55:07 +01:00
github-actions[bot] 2327f96479 Update AppImage beta build (2026-11-01 00:50:01) 2026-11-01 00:50:01 +00:00
MacRimi ead77f0d1f fix(monitor): stop intel_gpu_top leaving a process on every GPU reading 2026-11-01 01:38:52 +01:00
MacRimi 52a9034676 fix(monitor): remove the idle disk note from Disk exclusions 2026-11-01 01:30:59 +01:00
MacRimi e8e9e07e6b feat(oci): add Scanopy and force the stop of a container that ignores it 2026-11-01 01:25:04 +01:00
MacRimi cb97fc4c98 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-31 19:45:03 +01:00
MacRimi 75fd803443 fix(app-tracking): support Helper-Scripts core repository split 2026-10-31 19:44:45 +01:00
github-actions[bot] c0a0cdca34 Update AppImage beta build (2026-10-31 18:39:13) 2026-10-31 18:39:13 +00:00
MacRimi d75d44c601 fix(monitor): do not report a service reloading or restarting as failed 2026-10-31 18:49:34 +01:00
MacRimi 276a116696 feat(oci): VLAN tag and updates that keep Proxmox changes 2026-10-31 18:34:14 +01:00
ProxMenuxBot b1010a194a chore(lang): auto-rebuild translation cache
Source: 0faa781
Triggered by: push
2026-10-31 09:40:28 +00:00
MacRimiandGitHub 0faa781dd1 Merge pull request #566 from jcastro/fix/ollama-api-endpoint
fix(oci): correct Ollama API endpoint metadata
2026-10-31 10:39:21 +01:00
ProxMenuxBot 60b66c4668 chore(lang): auto-rebuild translation cache
Source: 393e609
Triggered by: push
2026-10-31 09:32:48 +00:00
MacRimi 393e6095fa fix(backup): repair host backup schedules systemd rejects 2026-10-31 10:31:47 +01:00
ProxMenuxBot 380a9b653c chore(lang): auto-rebuild translation cache
Source: 1920dc2
Triggered by: push
2026-10-31 09:21:18 +00:00
MacRimi 1920dc2937 fix(backup): check the schedule of a host backup job before saving it 2026-10-31 10:19:59 +01:00
Jonatan bfdcd4a42c i18n: translate Ollama catalog metadata 2026-10-31 10:07:52 +01:00
Jonatan 139a4b4089 fix(oci): label Ollama API endpoint accurately 2026-10-31 10:07:51 +01:00
ProxMenuxBot 14e2718275 chore(lang): auto-rebuild translation cache
Source: 6befea0
Triggered by: push
2026-10-31 08:54:29 +00:00
MacRimiandGitHub 6befea0757 Merge pull request #563 from jcastro/feature/oci-authentik
Add Authentik to OCI catalog
2026-10-31 09:53:15 +01:00
MacRimi ed2a696c01 fix(oci): point the Authentik summary to the first-run setup
akadmin has no password until it is created in the setup wizard, which
opens from the root address. The summary now says so instead of reporting
a password that could not be retrieved.
2026-10-31 09:45:27 +01:00
Jonatan 6968409ae5 fix(oci): give Authentik worker outbound access 2026-10-31 09:00:46 +01:00
ProxMenuxBot a6ada401b5 chore(lang): auto-rebuild translation cache
Source: 98d8e78
Triggered by: push
2026-10-30 23:43:27 +00:00
MacRimi 98d8e78dcb fix(i18n): add the Slovak label of the Vulkan profile 2026-10-31 00:42:21 +01:00
MacRimi f9fe4d2793 fix(oci): run Ollama and llama.cpp on Intel and AMD integrated GPUs 2026-10-31 00:25:21 +01:00
ProxMenuxBot 81cc746e19 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: aa4adc3
Triggered by: push
2026-10-30 21:59:48 +00:00
MacRimi aa4adc34a9 fix(monitor): place the Secure Gateway template on an active storage 2026-10-30 22:58:12 +01:00
ProxMenuxBot dc823bc21b chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 72908e5
Triggered by: push
2026-10-30 20:50:19 +00:00
ProxMenuxBot 297a41d58a chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 668b557
Triggered by: push
2026-10-30 20:47:44 +00:00
MacRimiandGitHub 72908e55aa Merge pull request #562 from DrOwl/feature/disk-controller-mapping
Map storage devices to their PCI storage controller
2026-10-30 21:39:07 +01:00
MacRimiandGitHub 668b557fd5 Merge pull request #561 from DrOwl/feature/zfs-pool-details
Show ZFS pool layout, scrub status and device health in Storage view
2026-10-30 21:38:12 +01:00
Jonatan f5ce0438f9 test(oci): run the full engine suite in CI 2026-10-30 19:32:24 +01:00
Jonatan 21616fe53d fix(i18n): translate Authentik catalog strings to Slovak 2026-10-30 19:31:48 +01:00
Jonatan 8e6e9d5cab test(oci): run Authentik recipe checks in CI 2026-10-30 19:30:00 +01:00
Jonatan 3b15e14625 feat(oci): add Authentik catalog profile 2026-10-30 19:24:01 +01:00
MacRimi 89531bbab8 fix(monitor): record kernel I/O errors on the right disk 2026-10-30 18:51:55 +01:00
MacRimi c13d3de023 docs(web): use "backups" in the Spanish sidebar 2026-10-30 18:07:51 +01:00
MacRimiandGitHub dd9c21ed75 Merge pull request #558 from jcastro/docs/oci-catalog-contributor-guide
docs(oci): add reusable app catalog guide
2026-10-30 17:56:50 +01:00
MacRimi aaffe4e08a docs(oci): align the catalog guide with the project flow
Suggest the app in discussion #440 first, call the file an oci-compose,
use the names the interface shows, run the engine tests as an
unprivileged user, and add the page to the docs sidebar.
2026-10-30 17:52:32 +01:00
MacRimiandGitHub d3fedeb201 Merge pull request #555 from jcastro/feature/oci-teslamate
feat(oci): add TeslaMate stack
2026-10-30 17:35:30 +01:00
MacRimi bd42df3ead fix(monitor): keep the LVM filter when the scan names its devices 2026-10-30 17:32:25 +01:00
MacRimi cf7b8363da fix(monitor): serve the bundled PWA manifest and service worker 2026-10-30 16:59:02 +01:00
Jonatan d3752f0417 docs(oci): add reusable app catalog guide 2026-10-30 10:43:32 +01:00
Manuel Escalante 64fa4f8115 fix(web): restore Change Language entry in docs sidebar 2026-10-29 19:54:41 +01:00
Jonatan 88ba698758 fix(oci): use readable names for stack LXCs 2026-10-29 18:18:57 +01:00
DrOwlandClaude Opus 5.5 72491b2445 Show ZFS pool layout, scrub status and device health in Storage view
/api/storage now runs a single `zpool status -j --json-int -P` (OpenZFS
2.3+) for all pools and returns, per pool:
- top-level vdevs with type, raidz/draid parity, class (data, special,
  dedup, log, cache, spare), state and read/write/checksum errors
- member devices with path, resolved base disk, state, error counters,
  slow I/Os, and the spare-N/replacing-N they sit under while swapping
- scrub/resilver status (progress only while running), the pool's
  status/action text and data-error count
- fragmentation, capacity and dedup ratio from `zpool list`

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:47:00 +00:00
Manuel Escalante c26eb49e2c fix(web): render HTML entities in translated text 2026-10-29 09:41:32 +01:00
Manuel Escalante db12972253 fix(web): pass em tag to host-local-disk format steps 2026-10-29 09:41:32 +01:00
Manuel Escalante fac7ac5021 chore(web): drop unused dependencies and apply minor updates and audit fixes 2026-10-29 09:41:32 +01:00
Jonatan 512b268815 feat(oci): add TeslaMate native stack profile 2026-10-29 01:08:16 +01:00
Jonatan e52460e991 fix(oci): validate managed routes during transaction recovery 2026-10-29 00:51:58 +01:00
ProxMenuxBot 5db3ae9c92 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 581eed6
Triggered by: push
2026-10-28 22:32:09 +00:00
ProxMenuxBot 79bc8d33fd chore(lang): auto-rebuild translation cache
Source: 581eed6
Triggered by: push
2026-10-28 22:26:01 +00:00
MacRimiandGitHub 581eed61e5 Merge pull request #551 from Vaso73/fix/host-update-recovery
Keep Proxmox updates running across sign-out and show pending reboots
2026-10-28 23:24:38 +01:00
MacRimiandGitHub d27dc834db Merge pull request #550 from jcastro/feature/oci-custom-compose-stacks
feat(oci): import multi-service Compose files from Custom image
2026-10-28 23:05:36 +01:00
MacRimi 2ab83c0b88 fix(oci): clarify the summary of imported Compose stacks
Each registry line names its image, the variables of a stack are listed
without announcing questions that are not asked, and the name given to an
imported stack names its containers.
2026-10-28 22:57:15 +01:00
VAIO73 bdf9075639 fix: distinguish the waiting unattended upgrades shutdown helper 2026-10-28 17:30:44 +00:00
VAIO73 65b10cea09 ui: compact the current ProxMenux version status 2026-10-28 17:30:44 +00:00
VAIO73 9147ec2f52 fix: keep reboot actions blocked during health recovery 2026-10-28 17:30:44 +00:00
VAIO73 ba3fb1197f fix: read completed update results without a live terminal 2026-10-28 17:30:44 +00:00
VAIO73 889409de8d fix: preserve dated health results while update cache refreshes 2026-10-28 17:30:44 +00:00
VAIO73 43973e1d06 fix: clarify system update action and package plan 2026-10-28 17:30:44 +00:00
VAIO73 e1be5c22c8 fix: include host update controls in AppImage assembly 2026-10-28 17:30:44 +00:00
VAIO73 79cdf814ed fix: keep restart controls disabled while request is pending 2026-10-28 17:30:44 +00:00
VAIO73 707a45e639 fix: guard host reboot with native package locks 2026-10-28 17:30:44 +00:00
VAIO73 52cd21dcb1 feat: add guarded host restart and read-only ProxMenux version status 2026-10-28 17:30:44 +00:00
VAIO73 503a946d85 fix: retain translated reboot summary in cached health responses 2026-10-28 17:30:44 +00:00
VAIO73 ac9565fa19 fix: refresh reboot evidence after external host updates 2026-10-28 17:30:44 +00:00
VAIO73 f40e0a53e1 Preserve host updates across sign-out and report pending kernel activation 2026-10-28 17:30:44 +00:00
MacRimi 2d4b0cb746 ci(oci): fetch only the commits the validation checks need 2026-10-28 18:12:17 +01:00
MacRimiandGitHub 616ba129b2 Merge pull request #545 from jcastro/feature/zigbee2mqtt-oci-20261027
Add Zigbee2MQTT to the OCI catalog
2026-10-28 18:08:12 +01:00
MacRimi 6a91487d21 Merge branch 'develop' into feature/zigbee2mqtt-oci-20261027 2026-10-28 17:49:43 +01:00
MacRimi 8ee3b29991 fix(update): confirm with Yes focused and keep the package list aligned 2026-10-28 17:24:14 +01:00
github-actions[bot] 52d44a7435 Update AppImage beta build (2026-10-28 10:51:01) 2026-10-28 10:51:01 +00:00
Jonatan 5e89b2bdf5 fix(i18n): translate managed stack routing errors 2026-10-28 11:43:37 +01:00
Jonatan a259b945eb test(oci): isolate cleanup and recovery fixtures from the host 2026-10-28 11:43:36 +01:00
Jonatan bf8c495d3b fix(oci): validate the installation identity before stack backups 2026-10-28 11:43:36 +01:00
Jonatan 05790b494e fix(oci): preserve private access to stack frontends across lifecycle operations 2026-10-28 11:26:59 +01:00
Jonatan a7786a6874 fix(oci): bind stack public URLs to their frontend before startup 2026-10-28 10:56:36 +01:00
Jonatan 3d1c542a05 fix(oci): normalize pinned references for registry inspection 2026-10-28 10:10:57 +01:00
Jonatan e587aa7d79 fix(oci): carry verified stack metadata into native backups 2026-10-28 09:14:11 +01:00
Jonatan 6a30d92ba7 fix(oci): verify web readiness and persist private hook contracts 2026-10-28 08:52:57 +01:00
Jonatan 3a0a7eaf14 feat(oci): support custom Compose workers and migration jobs 2026-10-28 08:41:41 +01:00
Jonatan b383fb9311 fix(oci): normalize read-only time mounts in custom stacks 2026-10-28 06:41:20 +01:00
Jonatan 68ae23df82 fix(oci): allow IPv6 entrypoints during native updates 2026-10-28 06:41:20 +01:00
Jonatan f23f1912c2 fix(oci): reserve down and pending private network definitions 2026-10-28 06:41:20 +01:00
Jonatan 692c7f7256 feat(oci): support custom multi-service Compose applications 2026-10-28 06:28:18 +01:00
Jonatan a079f3fc90 feat(oci): add optional Zigbee serial adapter 2026-10-27 23:21:32 +01:00
ProxMenuxBot cdb1bd0cbf chore(lang): auto-rebuild translation cache
Source: 87c4883
Triggered by: push
2026-10-27 21:31:10 +00:00
MacRimiandGitHub 87c48830c5 Merge pull request #547 from jcastro/feature/netalertx-oci-20261027
Add NetAlertX to OCI catalog
2026-10-27 22:30:11 +01:00
MacRimi ecc8017f71 Merge develop into feature/netalertx 2026-10-27 22:22:57 +01:00
MacRimi fc8c23fd66 feat(monitor): VM disk thresholds in Settings 2026-10-27 22:22:05 +01:00
MacRimiandGitHub 5adafeaa96 Merge pull request #546 from jcastro/feature/scrypted-oci-20261027
Add Scrypted to the OCI app catalog
2026-10-27 22:18:41 +01:00
Jonatan aeb55236c1 Add NetAlertX to OCI catalog 2026-10-27 22:01:12 +01:00
Jonatan f623d29e12 Add Scrypted OCI application profile 2026-10-27 21:36:38 +01:00
Jonatan 9ab744e784 feat(oci): add Zigbee2MQTT 2026-10-27 20:49:25 +01:00
MacRimiandGitHub 9fef8d89a7 Merge pull request #541 from jcastro/feature/proxmenux-oci-apps-20261027
Add Umami OCI application profile
2026-10-27 20:04:41 +01:00
MacRimi 85857acaf9 Merge develop into feature/proxmenux-oci-apps-20261027 2026-10-27 19:58:28 +01:00
ProxMenuxBot f5c98fb700 chore(lang): auto-rebuild translation cache
Source: f79c35b
Triggered by: push
2026-10-27 18:54:38 +00:00
MacRimiandGitHub f79c35b038 Merge pull request #538 from jcastro/feature/proxmenux-oci-next-10
Add ten OCI application profiles
2026-10-27 19:53:44 +01:00
MacRimiandGitHub 6173c012e6 Merge pull request #539 from f3rs3n/fix/overview-chart-axis-clipping
fix(monitor): keep overview Y-axis titles inside charts
2026-10-27 19:53:01 +01:00
MacRimi 4cefc0484b feat(post-install): rebuild Log2RAM with the journal and task logs kept on disk 2026-10-27 19:48:26 +01:00
MacRimi ac9b8c2705 Update remove-banner-pve-v3.sh 2026-10-27 18:14:40 +01:00
MacRimi 085bb084eb fix(monitor): show quiet rotational disks by their identity after a restart 2026-10-27 17:20:44 +01:00
Jonatan 8f8a80785a Add Umami OCI application profile 2026-10-27 11:32:59 +01:00
martino e6d39f6015 fix(monitor): keep overview Y-axis titles inside charts 2026-10-27 02:48:25 +01:00
Jonatan ec5fbd668f Add ten OCI application profiles 2026-10-27 01:50:12 +01:00
MacRimi 688b7f54bc Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-27 00:16:17 +01:00
MacRimi c676d883d9 ci: merge the feature test workflows into one 2026-10-27 00:16:02 +01:00
github-actions[bot] 6feb11ffb9 Update AppImage beta build (2026-10-26 23:09:48) 2026-10-26 23:09:48 +00:00
MacRimi bec4606724 Wait for the attached USB/IP port and release USB/IP sharing on uninstall 2026-10-18 17:58:05 +02:00
MacRimi f3be456062 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 17:52:01 +02:00
Jonatan b4c9ffb54b Complete translations for all supported languages 2026-10-16 23:00:17 +02:00
Jonatan 548e237096 Keep SSH fixture keys under a StrictModes-safe directory 2026-10-16 22:07:05 +02:00
Jonatan 440c3df16f Add selected USB/IP device sharing over SSH 2026-10-16 22:04:36 +02:00
819 changed files with 78183 additions and 6416 deletions
@@ -102,6 +102,12 @@ PROTECTED_TECHNICAL_TERMS = (
"WebUI",
"Intel",
"CUDA",
"ROCm",
"Vulkan",
"SYCL",
"QSV",
"OpenVINO",
"OpenCL",
"KFD",
"GPU",
"CPU",
+33
View File
@@ -0,0 +1,33 @@
"""Reject foreign ELF files in a native AppDir without executing any binary."""
import argparse
from pathlib import Path
import struct
def check(directory, architecture):
expected = {'x86_64': 62, 'aarch64': 183}[architecture]
checked = 0
for path in Path(directory).rglob('*'):
if path.is_symlink() or not path.is_file():
continue
with path.open('rb') as stream:
header = stream.read(20)
if not header.startswith(b'\x7fELF'):
continue
if len(header) != 20 or header[5] not in (1, 2):
raise ValueError(f'Invalid ELF header: {path}')
machine = struct.unpack('<H' if header[5] == 1 else '>H', header[18:20])[0]
if machine != expected:
raise ValueError(f'Foreign ELF machine {machine} in {architecture} AppDir: {path}')
checked += 1
if not checked:
raise ValueError('No native ELF files found in AppDir')
return checked
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('directory', type=Path)
parser.add_argument('architecture', choices=('x86_64', 'aarch64'))
args = parser.parse_args()
print(f'PASS: {check(args.directory, args.architecture)} native ELF files')
+301
View File
@@ -0,0 +1,301 @@
"""Disposable QEMU-only real-kernel USB/IP regression lab. Never use on a host."""
import contextlib, importlib.util, os, select, socket, subprocess, sys, tempfile, termios, time, tty
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts/devices"))
import usbip_manager as m
from usbip_protocol import Gate
def run(*args):
result = subprocess.run(args, text=True, capture_output=True)
assert result.returncode == 0, (args, result.stdout, result.stderr)
return result.stdout
def put(p, v):
Path(p).write_text(v)
def wait(test):
for _ in range(100):
value = test()
if value:
return value
time.sleep(0.05)
raise AssertionError("condition timed out")
def gadget(name, serial, udc):
g = Path("/sys/kernel/config/usb_gadget") / name
g.mkdir()
for f, v in [("idVendor", "0x1d6b"), ("idProduct", "0x0104")]:
put(g / f, v)
(g / "strings/0x409").mkdir()
for f, v in [
("serialnumber", serial),
("manufacturer", "Regression lab"),
("product", name),
]:
put(g / "strings/0x409" / f, v)
(g / "configs/c.1").mkdir()
(g / "functions/acm.usb0").mkdir()
(g / "configs/c.1/acm.usb0").symlink_to(g / "functions/acm.usb0")
put(g / "UDC", udc)
return g
def find(serial):
return next((d for d in m.inventory() if d["serial"] == serial), None)
def chosen(serial, id):
return dict(wait(lambda: find(serial)), id=id)
def control(port, busid):
import struct
with socket.create_connection(("127.0.0.1", port)) as s:
s.sendall(
struct.pack("!HHI", 0x111, 0x8003, 0) + busid.encode().ljust(32, b"\0")
)
reply = s.recv(8)
assert len(reply) == 8
return struct.unpack("!HHI", reply)[2]
def main():
assert sys.argv[1:] == [
"--disposable-vm"
], "Requires --disposable-vm on a fresh throwaway QEMU VM"
assert (
os.geteuid() == 0 and "QEMU" in Path("/sys/class/dmi/id/sys_vendor").read_text()
)
assert not Path("/sys/kernel/config/usb_gadget").exists() or not list(
Path("/sys/kernel/config/usb_gadget").iterdir()
), "Start with a fresh disposable VM"
run("modprobe", "dummy_hcd", "num=3")
run("modprobe", "libcomposite")
run("modprobe", "usbip-host")
run("modprobe", "vhci-hcd")
udcs = sorted(p.name for p in Path("/sys/class/udc").iterdir())
assert len(udcs) == 3
a_udc = udcs[0]
a = gadget("selected-a", "original-a", udcs[0])
b = gadget("selected-b", "original-b", udcs[1])
time.sleep(0.5)
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
m.CONFIG_DIR = root / "config"
m.SERVER_RUN_DIR = root / "run"
m.RUN_DIR = root / "client"
selected_a = chosen("original-a", "abcdef123456")
selected_b = chosen("original-b", "fedcba654321")
config = {"server": True, "exports": [selected_a, selected_b], "imports": []}
m.atomic_json(m.CONFIG_DIR / "config.json", config)
owned = {}
leases = {}
daemon = subprocess.Popen(
["usbipd", "-4"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
gate = None
proxy = None
try:
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
def claim(name):
for selected in config["exports"]:
id = selected["id"]
lease = leases.get(id)
record = owned.get(id)
if lease and lease.ready and record and record["busid"] == name:
return dict(record, id=id)
gate = Gate(root / "gate.sock", 0, claim)
proxy = subprocess.Popen(
[
"socat",
"TCP-LISTEN:33240,bind=127.0.0.1,reuseaddr,fork",
"UNIX-CONNECT:" + str(root / "gate.sock"),
]
)
time.sleep(0.2)
# A native independent client remains attached throughout A's physical cycle.
print(
run(
"usbip",
"--tcp-port",
"33240",
"attach",
"-r",
"127.0.0.1",
"-b",
selected_b["busid"],
),
flush=True,
)
def serial_transfer(gadget=b, serial="original-b"):
# Gadget B's function tty and remote vhci serial port, not local A's tty.
gadget_tty = (
"/dev/ttyGS"
+ (gadget / "functions/acm.usb0/port_num").read_text().strip()
)
client = wait(
lambda: next(
(
p
for p in Path("/sys/class/tty").glob("ttyACM*")
if "vhci_hcd" in str(p.resolve())
and any(
(q / "serial").exists()
and (q / "serial").read_text().strip() == serial
for q in p.resolve().parents
)
),
None,
)
)
with contextlib.ExitStack() as stack:
fds = [
os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
for path in (gadget_tty, "/dev/" + client.name)
]
for fd in fds:
stack.callback(os.close, fd)
tty.setraw(fd)
termios.tcflush(fd, termios.TCIOFLUSH)
payload = b"independent-b-still-transferring\n"
os.write(fds[0], payload)
assert select.select([fds[1]], [], [], 3)[0]
assert os.read(fds[1], len(payload)) == payload
serial_transfer()
for iteration in range(20):
put(a / "UDC", "\n")
time.sleep(0.03)
m.reconcile_exports(config, owned, leases)
assert selected_a["busid"] not in m.MATCH_BUSID.read_text().split()
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (
errors := m.reconcile_exports(config, owned, leases)
), errors
assert leases[selected_a["id"]].ready
serial_transfer()
print(
"PASS: 20 unplug/replug cycles; stale slot removed and original exported; independent native serial transfer survives",
flush=True,
)
put(a / "UDC", "\n")
time.sleep(0.05)
m.reconcile_exports(config, owned, leases)
a_udc = udcs[2]
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
assert owned[selected_a["id"]]["busid"] != selected_a["busid"]
serial_transfer()
print(
"PASS: moving original to another USB port preserves independent serial transfer",
flush=True,
)
# An unselected replacement returns to a local driver and cannot import.
put(a / "UDC", "\n")
time.sleep(0.05)
put(a / "strings/0x409/serialnumber", "unselected-replacement")
put(a / "UDC", a_udc)
replacement = wait(lambda: find("unselected-replacement"))
errors = m.reconcile_exports(config, owned, leases)
assert selected_a["id"] in errors
assert find("unselected-replacement")["driver"] == "usb"
assert control(33240, replacement["busid"]) != 0
# Also bind that unselected replacement in a separate native setup: the
# daemon can see it, but a direct import through our gate must still fail.
run("usbip", "bind", "-b", replacement["busid"])
assert replacement["busid"] in run("usbip", "list", "-r", "127.0.0.1")
assert control(33240, replacement["busid"]) != 0
m.reconcile_exports(config, owned, leases)
assert find("unselected-replacement")["driver"] == "usbip-host"
run("usbip", "unbind", "-b", replacement["busid"])
serial_transfer()
print(
"PASS: same-port replacement stays local, native import rejected, independent serial survives",
flush=True,
)
# Same original then can be selected, imported and exchange native URBs.
put(a / "UDC", "\n")
time.sleep(0.1)
put(a / "strings/0x409/serialnumber", "original-a")
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
print(
run(
"usbip",
"--tcp-port",
"33240",
"attach",
"-r",
"127.0.0.1",
"-b",
find("original-a")["busid"],
),
flush=True,
)
wait(lambda: len(m.imported_ports()) == 2)
serial_transfer(a, "original-a")
serial_transfer()
for port in m.imported_ports():
if port["busid"] == find("original-a")["busid"]:
run("usbip", "detach", "-p", str(port["port"]))
# Simulate losing process-owned descriptors before a present-device
# release. The manager must acquire and verify fresh usbfs pins.
leases.pop(selected_a["id"]).close()
config["exports"] = [selected_b]
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
assert find("original-a")["driver"] == "usb"
serial_transfer()
print(
"PASS: original reimport and present-device release restore local driver",
flush=True,
)
finally:
if proxy:
proxy.terminate()
proxy.wait()
if gate:
gate.terminate()
for port in m.imported_ports():
with contextlib.suppress(Exception):
run("usbip", "detach", "-p", str(port["port"]))
config["exports"] = []
print("cleanup", m.reconcile_exports(config, owned, leases), flush=True)
daemon.terminate()
daemon.wait()
for lease in leases.values():
lease.close()
print("PASS: no Oops/BUG in kernel log", flush=True)
for g in (a, b):
if (g / "UDC").read_text().strip():
put(g / "UDC", "\n")
(g / "configs/c.1/acm.usb0").unlink()
(g / "functions/acm.usb0").rmdir()
(g / "configs/c.1").rmdir()
(g / "strings/0x409").rmdir()
g.rmdir()
assert not any(
x in run("dmesg") for x in ["BUG:", "Oops:", "general protection fault"]
)
if __name__ == "__main__":
main()
+153
View File
@@ -0,0 +1,153 @@
"""Opt-in fresh-QEMU lab: actual generated units, restricted SSH and native USB URBs."""
import contextlib, os, select, shutil, sys, termios, time, tty
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts/devices"))
from check_usbip_kernel import m, run, put, gadget, find
def wait(test, seconds=30):
end = time.monotonic() + seconds
while time.monotonic() < end:
value = test()
if value:
return value
time.sleep(0.1)
raise AssertionError("timeout")
def transfer(g, serial):
client = wait(
lambda: next(
(
p
for p in Path("/sys/class/tty").glob("ttyACM*")
if "vhci_hcd" in str(p.resolve())
and any(
(q / "serial").exists()
and (q / "serial").read_text().strip() == serial
for q in p.resolve().parents
)
),
None,
)
)
with contextlib.ExitStack() as stack:
fds = [
os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
for path in (
"/dev/ttyGS" + (g / "functions/acm.usb0/port_num").read_text().strip(),
"/dev/" + client.name,
)
]
for fd in fds:
stack.callback(os.close, fd)
tty.setraw(fd)
termios.tcflush(fd, termios.TCIOFLUSH)
for src, dst in ((fds[0], fds[1]), (fds[1], fds[0])):
payload = b"actual-systemd-ssh-usbip-serial\n"
os.write(src, payload)
assert select.select([dst], [], [], 5)[0]
assert os.read(dst, len(payload)) == payload
def main():
assert sys.argv[1:] == [
"--disposable-vm"
], "Requires --disposable-vm on a fresh throwaway QEMU VM"
assert not Path("/etc/proxmenux/usbip").exists()
assert "QEMU" in Path("/sys/class/dmi/id/sys_vendor").read_text()
for name in ("usbip_manager.py", "usbip_protocol.py", "usbip_lifetime.py"):
target = Path("/usr/local/share/proxmenux/scripts/devices") / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(m.SCRIPT.parent / name, target)
run("modprobe", "dummy_hcd", "num=3")
run("modprobe", "libcomposite")
run("modprobe", "usbip-host")
run("modprobe", "vhci-hcd")
udcs = sorted(p.name for p in Path("/sys/class/udc").iterdir())
assert len(udcs) == 3
assert not list(Path("/sys/kernel/config/usb_gadget").iterdir())
a = gadget("service-a", "service-a", udcs[0])
b = gadget("service-b", "service-b", udcs[1])
time.sleep(0.5)
key = Path("/root/service-usbip-key")
run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key))
m.authorize_key(str(key) + ".pub")
public = Path("/etc/ssh/ssh_host_ed25519_key.pub").read_text().split()
Path("/root/.ssh/known_hosts").write_text(
"127.0.0.1 " + public[0] + " " + public[1] + "\n"
)
imports = []
try:
m.update_server(True)
sa = m.add_export(wait(lambda: find("service-a"))["busid"])
sb = m.add_export(wait(lambda: find("service-b"))["busid"])
wait(lambda: len([e for e in m.catalog() if e["status"] == "exported"]) == 2)
remote = m.validate_remote("127.0.0.1", 22, str(key))
for selected, port in ((sa, 33240), (sb, 33241)):
imports.append(m.add_import(remote, selected["id"], port))
wait(lambda: len(m.imported_ports()) == 2)
transfer(a, "service-a")
transfer(b, "service-b")
print(
"PASS: actual generated server/client systemd units and restricted SSH carry bidirectional native serial on two USBs",
flush=True,
)
for cycle in range(3):
put(a / "UDC", "\n")
time.sleep(0.2)
transfer(b, "service-b")
put(a / "UDC", udcs[0])
wait(lambda: find("service-a"))
time.sleep(0.5)
# An old vhci port may still exist; wait for the new guest driver and verify data.
wait(
lambda: next(
(
e
for e in m.catalog()
if e["id"] == sa["id"] and e["status"] == "exported"
),
None,
)
)
time.sleep(6)
transfer(a, "service-a")
transfer(b, "service-b")
print(
"PASS: three physical reconnects automatically reattach through restricted SSH; second serial connection survives",
flush=True,
)
m.remove_export(sa["id"])
assert find("service-a")["driver"] == "usb"
transfer(b, "service-b")
print(
"PASS: stop-sharing waits for real release and returns local driver while independent client keeps transferring",
flush=True,
)
for item in imports:
m.remove_import(item["id"])
imports = []
m.update_server(False)
assert not m.read_json(m.state_path(), {})
assert find("service-b")["driver"] == "usb"
print(
"PASS: disable stops listeners, removes owned bindings, leaves both local USB drivers restored",
flush=True,
)
finally:
for item in imports:
with contextlib.suppress(Exception):
m.remove_import(item["id"])
with contextlib.suppress(Exception):
m.update_server(False)
print(
run("journalctl", "-u", m.SERVER_UNIT, "--no-pager", "-n", "20"), flush=True
)
if __name__ == "__main__":
main()
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Linux-only integration: real namespaces, socat and SSH; no USB hardware.
Run as root in the disposable CI runner. All keys, configuration, listeners and
child processes belong to a temporary fixture; no systemd units are installed.
"""
import importlib.util
import os
from pathlib import Path
import pwd
import signal
import socket
import stat
import subprocess
import sys
import tempfile
import time
SOURCE = Path(__file__).resolve().parents[2] / "scripts/devices/usbip_manager.py"
def port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def wait_until(test, processes, log, timeout=15):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for process in processes:
if process.poll() is not None:
raise AssertionError(f"Fixture exited ({process.returncode}): {log.read_text()}")
try:
if test():
return
except (ConnectionError, FileNotFoundError, socket.timeout):
pass
time.sleep(0.1)
raise AssertionError(f"Fixture did not become ready: {log.read_text()}")
def echo(sock):
# Exercise the production protocol gate through the real SSH stream. Empty
# fixture selection must produce an empty native DEVLIST, never raw echo.
import struct
sock.settimeout(10)
sock.sendall(struct.pack("!HHI", 0x0111, 0x8005, 0))
received = bytearray()
while len(received) < 12:
chunk = sock.recv(12 - len(received))
if not chunk:
break
received.extend(chunk)
assert bytes(received) == struct.pack("!HHII", 0x0111, 5, 0, 0), received
def main():
assert sys.platform == "linux" and os.geteuid() == 0, "Use the disposable Linux CI runner as root"
spec = importlib.util.spec_from_file_location("usbip_manager", SOURCE)
manager = importlib.util.module_from_spec(spec)
spec.loader.exec_module(manager)
# A mistaken direct launch must never create a listener in this namespace.
try:
manager.require_private_network()
except manager.USBIPError:
pass
else:
raise AssertionError("Expected the host namespace to be rejected")
processes = []
tunnel = None
username = f"pmx-usbip-test-{os.getpid()}"
subprocess.run(["useradd", "--system", "--user-group", "--home-dir", "/nonexistent", "--shell", "/bin/sh", "--password", "*", username], check=True)
account = pwd.getpwnam(username)
manager.TRANSPORT_USER = username
# StrictModes checks every ancestor of AuthorizedKeysFile. A private
# subdirectory of /tmp still fails because /tmp itself is world writable.
# Keep the disposable fixture below root's home without changing its SSH
# configuration, account settings or existing authorized_keys.
with tempfile.TemporaryDirectory(prefix="pmx-usbip-", dir="/var/lib") as directory:
root = Path(directory)
os.chown(root, 0, account.pw_gid)
root.chmod(0o750)
fixture_source = root / "usbip_manager.py"
fixture_source.write_bytes(SOURCE.read_bytes())
fixture_source.chmod(0o644)
for name in ("usbip_protocol.py", "usbip_lifetime.py"):
(root / name).write_bytes((SOURCE.parent / name).read_bytes())
subprocess.run(["usermod", "--home", str(root), username], check=True)
log = root / "transport.log"
with log.open("w") as log_handle:
try:
fake_bin = root / "bin"
fake_bin.mkdir()
# The real daemon requires usbip-host and physical devices.
# Echo stands in only for its TCP endpoint, inside the namespace.
fake_daemon = fake_bin / "usbipd"
fake_daemon.write_text("""#!/usr/bin/python3
import socketserver
class Echo(socketserver.BaseRequestHandler):
def handle(self):
import struct
data = b''
while len(data) < 8:
block = self.request.recv(8 - len(data))
if not block:
return
data += block
if data == struct.pack('!HHI', 0x0111, 0x8005, 0):
self.request.sendall(struct.pack('!HHII', 0x0111, 5, 0, 0))
socketserver.ThreadingTCPServer.allow_reuse_address = True
socketserver.ThreadingTCPServer(('0.0.0.0', 3240), Echo).serve_forever()
""")
fake_daemon.chmod(0o755)
config = root / "config"
manager.atomic_json(config / "config.json", {"server": True, "exports": [], "imports": []})
server_run = root / "server"
runner = root / "server.py"
runner.write_text(f"""import importlib.util
from pathlib import Path
import subprocess
spec = importlib.util.spec_from_file_location('usbip_manager', {str(fixture_source)!r})
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
m.CONFIG_DIR = Path({str(config)!r})
m.SERVER_RUN_DIR = Path({str(server_run)!r})
m.SYSFS = Path({str(root / 'no-usb-devices')!r})
m.TRANSPORT_USER = {username!r}
m.TRANSPORT_HOME = Path({str(root)!r})
subprocess.run(['ip', 'link', 'set', 'lo', 'up'], check=True)
m.serve()
""")
server = subprocess.Popen(["unshare", "--net", sys.executable, str(runner)],
env={**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}"},
stdout=log_handle, stderr=log_handle, start_new_session=True)
processes.append(server)
unix_path = server_run / "usbip.sock"
wait_until(lambda: unix_path.exists(), [server], log)
assert os.readlink(f"/proc/{server.pid}/ns/net") != os.readlink("/proc/self/ns/net")
assert stat.S_IMODE(server_run.stat().st_mode) == 0o750
assert stat.S_IMODE(unix_path.stat().st_mode) == 0o660
# Linux /proc/net reflects the caller's network namespace.
listening = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", ":3240"], text=True)
assert not listening.strip(), "Raw USB/IP TCP leaked into the host network namespace"
# Wait for both the proxy and its private TCP peer.
def unix_ready():
with socket.socket(socket.AF_UNIX) as connection:
connection.connect(str(unix_path))
echo(connection)
return True
wait_until(unix_ready, [server], log)
for name in ("host", "client", "wrong-client"):
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(root / name)], check=True)
ssh_dir = root / ".ssh"
ssh_dir.mkdir(mode=0o750)
os.chown(ssh_dir, 0, account.pw_gid)
authorized = ssh_dir / "authorized_keys"
gate = root / "gate.py"
gate.write_text(f"""import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location('usbip_manager', {str(fixture_source)!r})
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
m.TRANSPORT_USER = {username!r}
m.TRANSPORT_HOME = Path({str(root)!r})
m.SERVER_RUN_DIR = Path({str(server_run)!r})
m.REMOTE_SOCKET = {str(unix_path)!r}
m.transport()
""")
gate.chmod(0o644)
authorized.write_text(f'restrict,command="{sys.executable} {gate}" ' + (root / "client.pub").read_text())
os.chown(authorized, 0, account.pw_gid)
authorized.chmod(0o640)
known = root / "known_hosts"
known.write_text("")
known.chmod(0o600)
ssh_port, local_port = port(), port()
ssh_config = root / "sshd_config"
ssh_config.write_text(f"""Port {ssh_port}
ListenAddress 127.0.0.1
HostKey {root / 'host'}
PidFile {root / 'sshd.pid'}
AuthorizedKeysFile {authorized}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
StrictModes yes
AllowUsers {username}
AllowTcpForwarding local
AllowStreamLocalForwarding local
LogLevel VERBOSE
""")
Path("/run/sshd").mkdir(exist_ok=True, mode=0o755)
sshd = subprocess.Popen(["/usr/sbin/sshd", "-D", "-e", "-f", str(ssh_config)],
stdout=log_handle, stderr=log_handle, start_new_session=True)
processes.append(sshd)
def ssh_ready():
with socket.create_connection(("127.0.0.1", ssh_port), timeout=1):
return True
wait_until(ssh_ready, [server, sshd], log)
remote = manager.validate_remote("127.0.0.1", ssh_port, str(root / "client"))
extra = ["-o", f"UserKnownHostsFile={known}", "-o", "GlobalKnownHostsFile=/dev/null"]
def check_ssh(selected):
args = manager.ssh_args(selected)
args[1:1] = extra
return subprocess.run(args + ["--", selected["host"], "catalog"], capture_output=True, text=True, timeout=15)
unknown = check_ssh(remote)
assert unknown.returncode != 0 and "Host key verification failed" in unknown.stderr, unknown.stderr
host_public = (root / "host.pub").read_text().split()
known.write_text(f"[127.0.0.1]:{ssh_port} {host_public[0]} {host_public[1]}\n")
wrong = check_ssh(remote | {"key": str(root / "wrong-client")})
assert wrong.returncode != 0 and "Permission denied" in wrong.stderr, wrong.stderr
accepted = check_ssh(remote)
assert accepted.returncode == 0, accepted.stderr + "\n" + log.read_text()
forbidden = manager.ssh_args(remote)
forbidden[1:1] = extra
denied = subprocess.run(forbidden + ["--", remote["host"], "id"], capture_output=True, text=True, timeout=15)
assert denied.returncode != 0, "Forced command allowed a shell command"
forwarded = subprocess.run(forbidden + ["-W", "127.0.0.1:22", "--", remote["host"]], capture_output=True, text=True, timeout=15)
assert forwarded.returncode != 0 and "prohibited" in forwarded.stderr, forwarded.stderr
original = manager.ssh_args
def fixture_ssh(selected):
args = original(selected)
args[1:1] = extra
return args
manager.ssh_args = fixture_ssh
tunnel = manager.LoopbackTunnel(remote | {"local_port": local_port})
with socket.socket() as attacker:
try:
attacker.bind(("127.0.0.1", local_port))
except OSError:
pass
else:
raise AssertionError("Another process stole the tunnel port")
def tunnel_ready():
with socket.create_connection(("127.0.0.1", local_port), timeout=1) as connection:
echo(connection)
return True
wait_until(tunnel_ready, processes, log)
listener = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", f":{local_port}"], text=True)
assert f"127.0.0.1:{local_port}" in listener and f"0.0.0.0:{local_port}" not in listener, listener
print("PASS: private namespace, dedicated-group Unix socket, no host USB/IP listener")
print("PASS: unknown/wrong keys, shell commands and arbitrary forwarding rejected; dedicated key accepted")
print("PASS: native USB/IP DEVLIST across real SSH -> Unix protocol gate -> private TCP")
finally:
if tunnel:
tunnel.terminate()
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
for process in reversed(processes):
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
subprocess.run(["pkill", "-KILL", "-u", str(account.pw_uid)], check=False)
# PAM/sshd children need to exit and be reaped before userdel.
for attempt in range(50):
cleanup = subprocess.run(["userdel", username], capture_output=True, text=True)
if cleanup.returncode == 0:
break
if cleanup.returncode != 8:
raise AssertionError(cleanup.stderr)
time.sleep(0.1)
else:
subprocess.run(["ps", "-fu", username], check=False)
raise AssertionError(cleanup.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -2,7 +2,8 @@
"""Build a verified ProxMenux LXC application-version tracking catalog.
The source of truth is a pinned snapshot of community-scripts/ProxmoxVE,
downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
plus community-scripts/core for its shared version helpers after the split.
Both are downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
An operational hint is emitted only when two independent pieces of the helper
scripts agree:
@@ -37,6 +38,7 @@ from typing import Any, Iterable
DEFAULT_REPOSITORY = "community-scripts/ProxmoxVE"
DEFAULT_CORE_REPOSITORY = "community-scripts/core"
DEFAULT_REF = "main"
API_VERSION = "2022-11-28"
USER_AGENT = "ProxMenux-app-tracking-catalog/1.0"
@@ -169,6 +171,7 @@ def obtain_snapshot(
repository: str,
ref: str,
cache_dir: Path,
required_paths: tuple[str, ...] = ("ct",),
) -> tuple[Path, str, str]:
commit = client.json(f"/repos/{repository}/commits/{ref}")
commit_sha = str(commit.get("sha") or "")
@@ -177,7 +180,7 @@ def obtain_snapshot(
snapshot_dir = cache_dir / repository.replace("/", "--") / commit_sha
marker = snapshot_dir / ".snapshot-complete"
if marker.exists() and (snapshot_dir / "ct").is_dir():
if marker.exists() and all((snapshot_dir / path).exists() for path in required_paths):
return snapshot_dir, commit_sha, marker.read_text(encoding="utf-8").strip()
payload = client.request(
@@ -190,6 +193,8 @@ def obtain_snapshot(
temp_dir = Path(tempfile.mkdtemp(prefix=f"{commit_sha}.tmp-", dir=temp_parent))
try:
safe_extract_tar(payload, temp_dir)
if not all((temp_dir / path).exists() for path in required_paths):
raise CatalogError(f"Invalid source snapshot for {repository}@{commit_sha}")
(temp_dir / ".snapshot-complete").write_text(archive_sha + "\n", encoding="utf-8")
if snapshot_dir.exists():
shutil.rmtree(snapshot_dir)
@@ -521,23 +526,66 @@ def repo_from_helper(item: dict[str, Any]) -> str:
return match.group(1) if match else ""
def shared_helper_path(source: Path, core_source: Path | None = None) -> Path:
"""Locate source text only; never source or execute upstream shell code."""
candidates = [source / "misc" / "tools.func"]
if core_source is not None:
candidates.extend(core_source / path for path in ("lib/forge.func", "lib/tools.func"))
for candidate in candidates:
if candidate.is_file():
return candidate
raise CatalogError(
"Shared version helpers not found. Older trees use misc/tools.func; "
"current trees require community-scripts/core (--core-source-dir for a local checkout)."
)
def shell_function(text: str, name: str) -> str:
match = re.search(
rf"(?ms)^(?:function\s+)?{re.escape(name)}\s*\(\)\s*\{{[^\n]*\n(.*?)^\}}",
text,
)
return match.group(1) if match else ""
def verify_shared_helper(path: Path) -> None:
"""Check both ends of the cache contract, including the new forge wrappers."""
text = read_text(path)
read_body = shell_function(text, "check_for_gh_release")
write_body = shell_function(text, "fetch_and_deploy_gh_release")
if re.search(r'\bcheck_for_release\s+github\s+"\$1"\s+"\$2"', read_body):
read_body = shell_function(text, "check_for_release")
if re.search(r'\bfetch_and_deploy_release\s+github\s+"\$1"\s+"\$2"', write_body):
write_body = shell_function(text, "fetch_and_deploy_release")
normalization = r'app_lc="\$\(echo "\$\{app,,\}" \| tr -d [\'\"] [\'\"]\)"'
valid = (
re.search(normalization, read_body)
and re.search(normalization, write_body)
and re.search(r'(?:local\s+)?current_file="\$HOME/\.\$\{app_lc\}"', read_body)
and re.search(r'(?:local\s+)?version_file="\$HOME/\.\$\{app_lc\}"', write_body)
and re.search(r'echo "\$version" >"\$version_file"', write_body)
and re.search(r'\$current_file', read_body)
)
if not valid:
raise CatalogError(f"Could not verify the shared GitHub release cache contract in {path}")
def build_catalog(
source: Path,
helpers: dict[str, dict[str, Any]],
*,
core_source: Path | None = None,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
catalog: dict[str, Any] = {}
v2_apps: dict[str, Any] = {}
records: list[dict[str, Any]] = []
launchers = sorted((source / "ct").glob("*.sh"))
tools_func = source / "misc" / "tools.func"
tools_text = read_text(tools_func)
shared_helper_verified = bool(
re.search(r'local version_file="\$HOME/\.\$\{app_lc\}"', tools_text)
and re.search(r'echo "\$version" >"\$version_file"', tools_text)
and re.search(r'local current_file="\$HOME/\.\$\{app_lc\}"', tools_text)
tools_func = shared_helper_path(source, core_source)
verify_shared_helper(tools_func)
helper_contract = (
"misc/tools.func" if tools_func == source / "misc" / "tools.func"
else f"core/{tools_func.relative_to(core_source).as_posix()}"
)
if not shared_helper_verified:
raise CatalogError("Could not verify the shared GitHub release cache contract in misc/tools.func")
for launcher_path in launchers:
slug = launcher_path.stem
@@ -696,7 +744,7 @@ def build_catalog(
"evidence": {
"install": f"install/{installer_path.name}: matching deploy helper",
"update": f"ct/{launcher_path.name}: matching release check",
"contract": "misc/tools.func: shared version-cache contract",
"contract": f"{helper_contract}: shared version-cache contract",
},
}
)
@@ -712,7 +760,7 @@ def build_catalog(
"evidence": {
"install": f"install/{installer_path.name}: fetch_and_deploy_gh_release({release.app}, {release.repo})",
"update": f"ct/{launcher_path.name}: check_for_gh_release({release.app}, {release.repo})",
"contract": "misc/tools.func writes and reads /root/.<normalized-app>",
"contract": f"{helper_contract} writes and reads /root/.<normalized-app>",
},
}
)
@@ -874,7 +922,7 @@ def build_catalog(
"status_counts": status_counts,
"method_counts": method_counts,
"reason_counts": reason_counts,
"shared_release_cache_contract_verified": shared_helper_verified,
"shared_release_cache_contract_verified": True,
"helper_upstream_tag_validation": tag_validation,
},
"records": records,
@@ -1307,6 +1355,12 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", default=DEFAULT_REPOSITORY)
parser.add_argument("--ref", default=DEFAULT_REF)
parser.add_argument("--core-repository", default=DEFAULT_CORE_REPOSITORY)
parser.add_argument("--core-ref", default=DEFAULT_REF)
parser.add_argument(
"--core-source-dir", type=Path,
help="Local community-scripts/core checkout for split source trees (no download)",
)
parser.add_argument(
"--source-dir",
type=Path,
@@ -1346,6 +1400,7 @@ def main(argv: list[str] | None = None) -> int:
client = GitHubClient()
commit_sha = "local-source"
archive_sha = "not-applicable"
core_provenance = None
try:
if args.source_dir:
source = args.source_dir.resolve()
@@ -1355,10 +1410,27 @@ def main(argv: list[str] | None = None) -> int:
source, commit_sha, archive_sha = obtain_snapshot(
client, args.repository, args.ref, args.cache_dir.expanduser().resolve()
)
if not (source / "ct").is_dir() or not (source / "misc" / "tools.func").is_file():
if not (source / "ct").is_dir() or not (source / "install").is_dir():
raise CatalogError(f"Not a valid ProxmoxVE source tree: {source}")
core_source = None
if not (source / "misc" / "tools.func").is_file():
if args.core_source_dir:
core_source = args.core_source_dir.resolve()
core_commit, core_archive = "local-source", "not-applicable"
elif args.source_dir:
raise CatalogError("Split local source tree: provide --core-source-dir for its shared helpers")
else:
core_source, core_commit, core_archive = obtain_snapshot(
client, args.core_repository, args.core_ref,
args.cache_dir.expanduser().resolve(), required_paths=("lib/forge.func",),
)
core_provenance = {
"repository": args.core_repository, "ref": args.core_ref,
"commit_sha": core_commit, "archive_sha256": core_archive,
"source_dir": str(core_source),
}
helpers = load_helper_catalog(args.helpers_cache)
catalog, audit, v2 = build_catalog(source, helpers)
catalog, audit, v2 = build_catalog(source, helpers, core_source=core_source)
audit["demoted_helper_markers"] = (
[] if args.include_helper_markers else demote_generic_helper_markers(catalog, v2, audit)
)
@@ -1376,6 +1448,7 @@ def main(argv: list[str] | None = None) -> int:
"ref": args.ref,
"commit_sha": commit_sha,
"archive_sha256": archive_sha,
"core": core_provenance,
"source_dir": str(source),
"generated_at_unix": int(time.time()),
"generator_sha256": sha256_file(Path(__file__).resolve()),
+15
View File
@@ -13,6 +13,15 @@ import sys
import tempfile
PYTHON_FILES = (
"tests/test_host_update_jobs.py",
"tests/test_host_update_transport.py",
"tests/test_host_update_shell.py",
"tests/test_host_update_auth.py",
"tests/test_kernel_reboot.py",
"tests/test_reboot_health_refresh.py",
"tests/test_host_update_controls.py",
"tests/test_host_update_packaging.py",
"tests/test_host_update_version.py",
"tests/test_lxc_update_jobs.py",
"tests/test_post_install_i18n.py",
"tests/test_remaining_cli_i18n.py",
@@ -34,6 +43,11 @@ PYTHON_FILES = (
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
)
NODE_FILES = (
"tests/test_host_update_ui.cjs",
"tests/test_reboot_health_refresh.cjs",
"tests/test_host_update_controls.cjs",
"tests/test_reboot_recovery.cjs",
"tests/test_proxmenux_version_row.cjs",
"tests/test_lxc_update_jobs.cjs",
"tests/test_api_import_boundaries.cjs",
"tests/test_api_errors.cjs",
@@ -58,6 +72,7 @@ NODE_FILES = (
"tests/test_borg_ssh_guidance.cjs",
"tests/test_storage_messages.cjs",
"tests/test_health_text.cjs",
"tests/test_overview_chart_axis_gutters.cjs",
)
# unittest's CLI accepts a missing pattern as a successful zero-test run.
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Exercise the extracted launcher and its real native Python environment,
# intercepting only the final server exec so no host-monitoring server starts.
set -eu
appdir="$1"
export PMX_SMOKE_PYTHON
PMX_SMOKE_PYTHON="$(command -v python)"
fixture_bin="$(mktemp -d)"
trap 'rm -rf "$fixture_bin"' EXIT
cat > "$fixture_bin/python3" <<'WRAPPER'
#!/bin/sh
case "$1" in
*/flask_server.py)
exec "$PMX_SMOKE_PYTHON" -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print("PASS: extracted AppRun native runtime")'
;;
*) exec "$PMX_SMOKE_PYTHON" "$@" ;;
esac
WRAPPER
chmod +x "$fixture_bin/python3"
PATH="$fixture_bin:$PATH" bash "$appdir/AppRun"
@@ -1,4 +1,5 @@
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
import ast
import datetime
import sqlite3
import tempfile
@@ -36,6 +37,10 @@ def restore_event(warnings):
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
captured = []
delivery_errors = []
def record_history(event_type, channel, title, body, severity, success, error, source):
if not success:
delivery_errors.append(f'{channel}: {error}')
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
def sink(title, body, severity, data):
markup = channel._format_html(title, body, severity, data)
@@ -48,12 +53,25 @@ def deliver(event_type, data, severity='INFO', language='en', manual=False, quie
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
_record_history=lambda *a:None, _stats={'total_sent':0,'total_errors':0},
_record_history=record_history, _stats={'total_sent':0,'total_errors':0},
is_event_enabled=lambda event:True)
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
resolve_notification_hostname=lambda host, config:host or 'node-a',
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
_should_bypass_ai=lambda event:True, _AI_BYPASS_EVENTS=frozenset({'backup_complete','backup_fail'}))
format_backup_diagnostics_with_ai=templates.format_backup_diagnostics_with_ai,
format_with_ai_full=templates.format_with_ai_full,
# AI is disabled in this fixture. Execute the actual formatting helpers
# synchronously, without a worker pool, providers or operational imports.
_format_with_ai_bounded=lambda function,*args,**kwargs:function(*args,**kwargs),
enrich_context_for_ai=lambda **kwargs:'')
manager_path = SCRIPTS/'notification_manager.py'
for node in ast.parse(manager_path.read_text()).body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id in ('_AI_BYPASS_EVENTS', '_AI_NO_SUGGESTION_EVENTS'):
ns[target.id] = frozenset(ast.literal_eval(node.value.args[0]))
for name in ('_should_bypass_ai', '_format_backup_with_ai_bounded'):
extract(manager_path, name, None, ns)
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
@@ -73,10 +91,11 @@ def deliver(event_type, data, severity='INFO', language='en', manual=False, quie
if manual:
assert not quiet
result = manager.send_notification(event_type, severity, '', '', dict(data))
assert result['success']
assert result['success'], result.get('errors')
else:
event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert')
manager._dispatch_event(event)
assert not delivery_errors, '; '.join(delivery_errors)
if quiet:
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
assert len(rows)==1+len(quiet_before) and not captured
@@ -0,0 +1,169 @@
"""Source layout regressions for the Helper-Scripts/core repository split."""
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
SCRIPT = Path(__file__).resolve().parents[1] / "generate_app_tracking_catalog.py"
SPEC = importlib.util.spec_from_file_location("tracking_sources_under_test", SCRIPT)
generator = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = generator
SPEC.loader.exec_module(generator)
NORMALIZE = ''' app_lc="$(echo "${app,,}" | tr -d ' ')"'''
READ_BODY = NORMALIZE + '''
current_file="$HOME/.${app_lc}"
current="$(<"$current_file")"
'''
WRITE_BODY = NORMALIZE + '''
version_file="$HOME/.${app_lc}"
echo "$version" >"$version_file"
'''
def function(name, body):
return f"{name}() {{\n{body}\n}}\n"
LEGACY = (
function("check_for_gh_release", READ_BODY.replace(' current_file=', ' local current_file='))
+ function("fetch_and_deploy_gh_release", WRITE_BODY.replace(' version_file=', ' local version_file='))
)
SPLIT = (
function("check_for_gh_release", ' check_for_release github "$1" "$2" "${3:-}"')
+ function("fetch_and_deploy_gh_release", ' fetch_and_deploy_release github "$1" "$2" "${3:-tarball}"')
+ function("check_for_release", READ_BODY)
+ function("fetch_and_deploy_release", WRITE_BODY)
)
class TrackingSourcesTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.apps = self.root / "apps"
self.core = self.root / "core"
self.write(self.apps / "ct/example.sh", '''APP="Example"
check_for_gh_release "Example" "example/example"
''')
self.write(self.apps / "install/example-install.sh", '''
fetch_and_deploy_gh_release "Example" "example/example" "tarball" "latest"
''')
def write(self, path, content):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def run_main(self, *extra):
output = self.root / "output.json"
args = ["--source-dir", str(self.apps), "--output", str(output),
"--include-helper-markers",
"--audit-output", str(self.root / "audit.json"),
"--v2-output", str(self.root / "v2.json"), *extra]
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
result = generator.main(args)
return result, output
def test_legacy_monolithic_contract_is_supported(self):
self.write(self.apps / "misc/tools.func", LEGACY)
result, output = self.run_main()
self.assertEqual(result, 0)
self.assertEqual(json.loads(output.read_text())["example"]["file_path"], "/root/.example")
def test_split_contract_builds_catalog_and_records_actual_evidence(self):
self.write(self.core / "lib/forge.func", SPLIT)
result, output = self.run_main("--core-source-dir", str(self.core))
self.assertEqual(result, 0)
self.assertEqual(json.loads(output.read_text())["example"]["repo"], "example/example")
audit = json.loads((self.root / "audit.json").read_text())
self.assertEqual(audit["provenance"]["core"]["source_dir"], str(self.core.resolve()))
v2 = (self.root / "v2.json").read_text()
self.assertIn("core/lib/forge.func", v2)
self.assertNotIn("misc/tools.func", v2)
def test_unsplit_core_tools_library_is_supported(self):
path = self.write(self.core / "lib/tools.func", LEGACY)
self.assertEqual(generator.shared_helper_path(self.apps, self.core), path)
generator.verify_shared_helper(path)
def test_changed_contract_never_overwrites_existing_outputs(self):
for old, new in [('echo "$version" >"$version_file"', 'echo "$version" >/tmp/version'),
('current_file="$HOME/.${app_lc}"', 'current_file="/tmp/version"'),
('tr -d \' \'', 'tr -d \'-\'')]:
with self.subTest(change=old):
self.write(self.core / "lib/forge.func", SPLIT.replace(old, new))
output = self.write(self.root / "output.json", '{"keep": true}\n')
result, _ = self.run_main("--core-source-dir", str(self.core))
self.assertEqual(result, 1)
self.assertEqual(output.read_text(), '{"keep": true}\n')
def test_matching_text_in_unrelated_functions_is_not_evidence(self):
path = self.write(self.core / "lib/forge.func", SPLIT.replace(
function("check_for_release", READ_BODY), function("unrelated", READ_BODY)))
with self.assertRaises(generator.CatalogError):
generator.verify_shared_helper(path)
def test_split_local_tree_requires_explicit_local_core_without_network(self):
with patch.object(generator.GitHubClient, "request", side_effect=AssertionError("Network")):
result, output = self.run_main()
self.assertEqual(result, 1)
self.assertFalse(output.exists())
def test_invalid_local_core_fails_without_writing_a_catalog(self):
result, output = self.run_main("--core-source-dir", str(self.core))
self.assertEqual(result, 1)
self.assertFalse(output.exists())
def test_snapshot_cache_accepts_core_without_ct_directory(self):
sha = "a" * 40
source = self.root / "cache/community-scripts--core" / sha
self.write(source / "lib/forge.func", SPLIT)
self.write(source / ".snapshot-complete", "archive-hash\n")
client = Mock()
client.json.return_value = {"sha": sha}
path, commit, archive = generator.obtain_snapshot(
client, "community-scripts/core", "main", self.root / "cache",
required_paths=("lib/forge.func",))
self.assertEqual((path, commit, archive), (source, sha, "archive-hash"))
client.request.assert_not_called()
def test_remote_split_source_pins_both_repositories(self):
self.write(self.core / "lib/forge.func", SPLIT)
output = self.root / "remote.json"
with patch.object(generator, "obtain_snapshot", side_effect=[
(self.apps, "a" * 40, "apps-archive"),
(self.core, "b" * 40, "core-archive"),
]) as snapshots, contextlib.redirect_stdout(io.StringIO()):
result = generator.main([
"--output", str(output), "--include-helper-markers",
"--audit-output", str(self.root / "remote-audit.json"),
"--v2-output", str(self.root / "remote-v2.json"),
])
self.assertEqual(result, 0)
provenance = json.loads((self.root / "remote-audit.json").read_text())["provenance"]
self.assertEqual(provenance["commit_sha"], "a" * 40)
self.assertEqual(provenance["core"]["commit_sha"], "b" * 40)
self.assertEqual(provenance["core"]["archive_sha256"], "core-archive")
self.assertEqual(snapshots.call_args_list[1].args[1], "community-scripts/core")
self.assertEqual(snapshots.call_args_list[1].kwargs["required_paths"], ("lib/forge.func",))
def test_invalid_download_is_not_marked_complete(self):
client = Mock()
client.json.return_value = {"sha": "b" * 40}
client.request.return_value = b"invalid-layout"
with patch.object(generator, "safe_extract_tar"):
with self.assertRaises(generator.CatalogError):
generator.obtain_snapshot(client, "community-scripts/core", "main", self.root / "cache",
required_paths=("lib/forge.func",))
self.assertEqual(list((self.root / "cache").rglob(".snapshot-complete")), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,102 @@
"""A schedule systemd rejects leaves a timer that never fires and reports no
error, so the wizard asks for it again instead of saving it."""
from pathlib import Path
import subprocess
import tempfile
import unittest
SCHEDULER = Path(__file__).resolve().parents[3] / 'scripts/backup_restore/backup_scheduler.sh'
ANALYZE = '#!/bin/sh\ncase "$2" in "daily 02:00") exit 1;; esac\nexit 0\n'
def function(name):
text = SCHEDULER.read_text()
start = text.index(name + '() {')
return text[start:text.index('\n}', start) + 2] + '\n'
class ScheduleValidation(unittest.TestCase):
def valid(self, expression, **environment):
with tempfile.TemporaryDirectory() as directory:
analyze = Path(directory) / 'systemd-analyze'
analyze.write_text(ANALYZE)
analyze.chmod(0o755)
return subprocess.run(
['/bin/bash', '-c', function('_calendar_is_valid') + '_calendar_is_valid "$1"', 'test', expression],
env={'PATH': f'{directory}:/usr/bin:/bin', **environment},
).returncode == 0
def test_systemd_decides_what_is_valid(self):
self.assertTrue(self.valid('02:30'))
self.assertFalse(self.valid('daily 02:00'))
def test_a_scheduler_without_systemctl_is_not_checked(self):
self.assertTrue(self.valid('daily 02:00', PMX_BACKUP_NO_SYSTEMCTL='1'))
def test_the_wizard_asks_again_before_saving_the_job(self):
text = SCHEDULER.read_text()
check = text.index('while ! _calendar_is_valid "$on_calendar"; do')
self.assertLess(check, text.index('"ON_CALENDAR=$on_calendar"'))
self.assertGreater(check, text.index('on_calendar=$(dialog'))
STUB_ANALYZE = '#!/bin/sh\ncase "$2" in daily\\ *|weekly\\ *|monthly\\ *) exit 1;; esac\nexit 0\n'
JOBS = {
'nas': 'ON_CALENDAR=daily\\ 02:00\nENABLED=1\n',
'week': 'ON_CALENDAR=weekly\\ 03:15\nENABLED=1\n',
'month': 'ON_CALENDAR=monthly\\ 04:00\nENABLED=0\n',
'good': 'ON_CALENDAR=Mon..Fri\\ 03:00\nENABLED=1\n',
'manual': 'ON_CALENDAR=daily\\ 05:00\nMANUAL_RUN=1\n',
'attached': 'ON_CALENDAR=daily\\ 06:00\nPVE_STORAGE=local\n',
'custom': 'ON_CALENDAR=every\\ night\nENABLED=1\n',
}
class ScheduleRepair(unittest.TestCase):
def run_repair(self):
names = ('_calendar_is_valid', '_scheduler_systemctl', '_job_env_get', '_job_is_attached',
'_write_job_units', '_repaired_calendar', '_repair_job_schedules')
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
root = Path(self.folder.name)
for name, body in (('systemd-analyze', STUB_ANALYZE),
('systemctl', f'#!/bin/sh\necho "$@" >> {root}/systemctl.log\n')):
(root / name).write_text(body)
(root / name).chmod(0o755)
jobs, units = root / 'jobs', root / 'units'
jobs.mkdir()
for job, body in JOBS.items():
(jobs / f'{job}.env').write_text(f'JOB_ID={job}\n{body}')
script = (f'JOBS_DIR={jobs}\nSYSTEMD_DIR={units}\nLOCAL_SCRIPTS={root}\nSCRIPT_DIR={root}\n'
'_job_file() { echo "${JOBS_DIR}/$1.env"; }\n'
'_service_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.service"; }\n'
'_timer_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.timer"; }\n'
+ ''.join(function(name) for name in names) + '_repair_job_schedules\n')
subprocess.run(['/bin/bash', '-c', script], check=True,
env={'PATH': f'{root}:/usr/bin:/bin'})
return jobs, units, root
def test_a_time_after_daily_weekly_or_monthly_is_repaired(self):
jobs, units, root = self.run_repair()
self.assertIn('ON_CALENDAR=02:00\n', (jobs / 'nas.env').read_text())
self.assertIn('OnCalendar=02:00\n', (units / 'proxmenux-backup-nas.timer').read_text())
self.assertIn('OnCalendar=Mon 03:15\n', (units / 'proxmenux-backup-week.timer').read_text())
self.assertIn('OnCalendar=*-*-01 04:00\n', (units / 'proxmenux-backup-month.timer').read_text())
log = (root / 'systemctl.log').read_text()
self.assertIn('enable --now proxmenux-backup-nas.timer', log)
self.assertNotIn('proxmenux-backup-month.timer', log)
def test_the_rest_of_the_jobs_are_left_as_they_are(self):
jobs, units, _ = self.run_repair()
for job in ('good', 'manual', 'attached', 'custom'):
self.assertEqual((jobs / f'{job}.env').read_text(), f'JOB_ID={job}\n{JOBS[job]}', job)
self.assertFalse((units / f'proxmenux-backup-{job}.timer').exists(), job)
def test_both_installers_repair_on_update(self):
root = SCHEDULER.parents[2]
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
self.assertIn('backup_scheduler.sh" --repair-schedules', (root / installer).read_text())
if __name__ == '__main__':
unittest.main()
@@ -1,105 +0,0 @@
"""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()
+311
View File
@@ -0,0 +1,311 @@
"""Log2RAM keeps /var/log in RAM while the journal and the task logs of
Proxmox VE and Proxmox Backup Server stay on disk behind a link. An update keeps the size
and interval the host already has."""
import os
from pathlib import Path
import shutil
import shlex
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
KEEP = ROOT / "scripts/global/log2ram_keep_on_disk.sh"
SETUP = ROOT / "scripts/global/log2ram_setup.sh"
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
class KeepOnDiskTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
self.root = Path(folder.name)
self.log = self.root / "var/log"
(self.log / "journal/abc").mkdir(parents=True)
(self.log / "journal/abc/system.journal").write_text("entries")
(self.log / "pveproxy").mkdir()
(self.log / "pveproxy/access.log").write_text("GET /")
self.config = self.root / "etc/log2ram.conf"
self.config.parent.mkdir()
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=true\nNOTIFICATION=true\n")
def run_setup(self, edit_failure=False):
# Extract only the shared setup function, never an administrative caller.
body = SETUP.read_text().split("pmx_log2ram_setup() {", 1)[1].split("\n}\n", 1)[0]
body = body.replace("/etc/", str(self.root / "etc") + "/")
for directory in ("cron.d", "tmpfiles.d"):
(self.root / "etc" / directory).mkdir(exist_ok=True)
stubs = r'''
translate() { printf '%s' "$1"; }
msg_info() { :; }
msg_ok() { :; }
pmx_install_pkg() { :; }
_pmx_log2ram_install_release() { :; }
_pmx_log2ram_pbs_logrotate() { :; }
_pmx_log2ram_remove_legacy() { :; }
pmx_edit_file() { local file="$1"; shift; sed -i "$@" "$file"; }
pmx_append_file() { cat >> "$1"; }
pmx_write_file() { cat > "$1"; }
pmx_remove_file() { rm -f "$1"; }
pmx_apply_setting() { :; }
systemctl() { printf '%s\n' "$*" >> "$SYSTEMCTL_TRACE"; }
'''
if edit_failure:
# A failed configuration writer must be handled even by root runners.
stubs += "\npmx_edit_file() { return 1; }\n"
script = (stubs + "\npmx_log2ram_setup() {" + body + "\n}\n"
+ "pmx_log2ram_setup 256 3 false\n")
env = dict(os.environ, SYSTEMCTL_TRACE=str(self.root / "systemctl.trace"),
PMX_L2R_TAG="1.7.2", PMX_L2R_SCRIPTS=str(KEEP.parent),
PMX_L2R_REVERT_UNIT=str(self.root / "etc/revert.service"))
return subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=10)
def test_setup_keeps_journal_aware_syncing_until_boot(self):
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=true\n")
result = self.run_setup()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
self.assertIn("SIZE=256M\n", self.config.read_text())
self.assertFalse((self.log / "journal").is_symlink())
self.assertEqual((self.root / "systemctl.trace").read_text(), "daemon-reload\n")
def test_boot_disables_journal_aware_syncing_after_relocation(self):
self.assertEqual(self.run_setup().returncode, 0)
result = self.run_script()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
self.assertIn("SIZE=256M\n", self.config.read_text())
# Reapplying the repair to an already-relocated installation is safe.
self.assertEqual(self.run_setup().returncode, 0)
self.assertEqual(self.run_script().returncode, 0)
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
def test_boot_migration_failure_is_not_ignored_by_startup(self):
self.assertEqual(self.run_setup().returncode, 0)
# A non-directory destination causes a real copy failure, without mounts.
conflict = self.root / "var/hdd.journal"
conflict.write_text("blocked")
unit = self.root / "etc/systemd/system/log2ram.service.d/proxmenux.conf"
command = next(line.split("=", 1)[1] for line in unit.read_text().splitlines()
if line.startswith("ExecStartPre="))
result = subprocess.run(["bash", *shlex.split(command.lstrip("-"))],
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)),
capture_output=True, text=True, timeout=10)
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
self.assertFalse(command.startswith("-"), "systemd must not ignore the helper's failure")
conflict.unlink()
self.assertEqual(self.run_script().returncode, 0)
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
def test_setup_adds_journal_aware_setting_when_missing(self):
self.config.write_text("SIZE=128M\n#JOURNALD_AWARE=false\n")
result = self.run_setup()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("\nJOURNALD_AWARE=true\n", self.config.read_text())
self.assertEqual(self.run_script().returncode, 0)
self.assertIn("\nJOURNALD_AWARE=false\n", self.config.read_text())
def check_missing_setting_without_final_newline(self, content):
self.config.write_text(content)
result = self.run_setup()
self.assertEqual(result.returncode, 0, result.stderr)
for expected in ("true", "false"):
with self.subTest(journal_aware=expected):
# Read shell semantics in a fresh Bash, not a substring match.
sourced = subprocess.run(
["bash", "--noprofile", "--norc", "-c",
'unset JOURNALD_AWARE NOTIFICATION; source "$1"; '
'printf "%s\\n%s\\n" "${JOURNALD_AWARE-UNSET}" "${NOTIFICATION-UNSET}"',
"fixture", str(self.config)],
capture_output=True, text=True, timeout=10)
self.assertEqual(sourced.returncode, 0, sourced.stderr)
self.assertEqual(sourced.stdout, expected + "\nfalse\n")
if expected == "true":
self.assertFalse((self.log / "journal").is_symlink())
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
result = self.run_script()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
def test_missing_setting_after_comment_without_final_newline(self):
self.check_missing_setting_without_final_newline(
"SIZE=128M\nNOTIFICATION=false\n#JOURNALD_AWARE=false")
def test_missing_setting_after_assignment_without_final_newline(self):
self.check_missing_setting_without_final_newline("SIZE=128M\nNOTIFICATION=false")
def test_setup_reports_configuration_edit_failure(self):
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=false\n")
result = self.run_setup(edit_failure=True)
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
def test_an_existing_link_with_an_unusable_disk_target_fails(self):
self.assertEqual(self.run_script().returncode, 0)
self.config.write_text("JOURNALD_AWARE=true\n")
disk = self.root / "var/hdd.journal"
shutil.rmtree(disk)
disk.write_text("blocked")
result = self.run_script()
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=true\n")
def test_an_unexpected_journal_link_does_not_complete_migration(self):
shutil.rmtree(self.log / "journal")
(self.log / "journal").symlink_to("pveproxy")
result = self.run_script()
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(self.log / "journal"), "pveproxy")
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
def test_a_missing_journal_with_an_unusable_disk_target_fails(self):
shutil.rmtree(self.log / "journal")
(self.root / "var/hdd.journal").write_text("blocked")
result = self.run_script()
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
self.assertFalse((self.log / "journal").is_symlink())
def test_failed_source_removal_does_not_complete_migration(self):
(self.root / "var/hdd.journal").mkdir()
result = subprocess.run(
["bash", "-c", 'rm() { return 1; }; export -f rm; exec bash "$1"', "fixture", str(KEEP)],
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)), capture_output=True, text=True, timeout=10)
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
self.assertTrue((self.log / "journal/abc/system.journal").is_file())
def test_boot_configuration_failure_is_reported_and_can_be_retried(self):
self.config.unlink()
self.config.mkdir()
result = self.run_script()
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
self.config.rmdir()
self.config.write_text("JOURNALD_AWARE=true\n")
self.assertEqual(self.run_script().returncode, 0)
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=false\n")
def run_script(self, revert=False):
env = dict(os.environ, PMX_L2R_ROOT=str(self.root), PMX_L2R_REVERT="1" if revert else "0")
return subprocess.run(["bash", str(KEEP)], env=env, capture_output=True, text=True, timeout=10)
def test_the_journal_moves_to_disk_behind_a_link(self):
self.assertEqual(self.run_script().returncode, 0)
journal = self.log / "journal"
self.assertTrue(journal.is_symlink())
self.assertEqual(os.readlink(journal), "/var/hdd.journal")
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
self.assertTrue((self.log / "pveproxy/access.log").exists())
self.assertFalse((self.root / "var/hdd.proxmox-backup").exists())
def test_running_again_changes_nothing(self):
self.run_script()
self.assertEqual(self.run_script().returncode, 0)
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
def test_the_task_logs_of_backup_server_stay_on_disk_too(self):
(self.log / "proxmox-backup/tasks").mkdir(parents=True)
(self.log / "proxmox-backup/tasks/active").write_text("task")
self.run_script()
self.assertEqual(os.readlink(self.log / "proxmox-backup"), "/var/hdd.proxmox-backup")
self.assertEqual((self.root / "var/hdd.proxmox-backup/tasks/active").read_text(), "task")
def test_files_written_back_to_the_folder_join_the_copy_on_disk(self):
self.run_script()
(self.log / "journal").unlink()
(self.log / "journal/def").mkdir(parents=True)
(self.log / "journal/def/system.journal").write_text("later")
self.run_script()
disk = self.root / "var/hdd.journal"
self.assertEqual(sorted(p.name for p in disk.iterdir()), ["abc", "def"])
self.assertTrue((self.log / "journal").is_symlink())
def test_a_host_without_a_journal_folder_gets_one_on_disk(self):
shutil.rmtree(self.log / "journal")
self.run_script()
self.assertTrue((self.root / "var/hdd.journal").is_dir())
self.assertTrue((self.log / "journal").is_symlink())
def test_the_task_logs_of_proxmox_ve_stay_on_disk(self):
(self.log / "pve/tasks").mkdir(parents=True)
(self.log / "pve/tasks/index").write_text("UPID")
self.run_script()
self.assertEqual(os.readlink(self.log / "pve"), "/var/hdd.pve")
self.assertEqual((self.root / "var/hdd.pve/tasks/index").read_text(), "UPID")
def test_removing_log2ram_puts_the_folders_back(self):
(self.log / "proxmox-backup").mkdir()
self.run_script()
self.config.unlink() # Revert must not require or rewrite Log2RAM settings.
self.assertEqual(self.run_script(revert=True).returncode, 0)
for name in ("journal", "proxmox-backup"):
self.assertFalse((self.log / name).is_symlink(), name)
self.assertTrue((self.log / name).is_dir(), name)
self.assertFalse((self.root / f"var/hdd.{name}").exists(), name)
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
class KeptSettingsTests(unittest.TestCase):
def read(self, function, files):
with tempfile.TemporaryDirectory() as folder:
script = SETUP.read_text()
for path, content in files.items():
target = Path(folder) / path.lstrip("/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
script = script.replace(path, str(target))
library = Path(folder) / "setup.sh"
library.write_text(script)
result = subprocess.run(["bash", "-c", f'source "{library}"; {function}'],
capture_output=True, text=True, timeout=10)
return result.stdout.strip()
def test_the_size_comes_from_the_installed_configuration(self):
self.assertEqual(self.read("pmx_log2ram_current_size", {"/etc/log2ram.conf": "SIZE=256M\n"}), "256")
def test_the_interval_comes_from_the_timer_of_this_version(self):
timer = "[Timer]\nOnCalendar=\nOnCalendar=*-*-* 00/6:55:00\n"
self.assertEqual(self.read("pmx_log2ram_current_hours",
{"/etc/systemd/system/log2ram-daily.timer.d/proxmenux.conf": timer}), "6")
def test_the_interval_of_an_earlier_version_is_kept(self):
cron = "MAILTO=\"\"\n0 */3 * * * root /usr/local/bin/log2ram write >/dev/null 2>&1\n"
self.assertEqual(self.read("pmx_log2ram_current_hours", {"/etc/cron.d/log2ram": cron}), "3")
def test_both_modes_offer_a_repair_to_version_2_0_installs(self):
for filename, function, updater in (
("auto_post_install.sh", "install_log2ram_auto", "_update_existing_log2ram_auto"),
("customizable_post_install.sh", "configure_log2ram", "_update_existing_log2ram_custom"),
):
with self.subTest(mode=filename), tempfile.TemporaryDirectory() as folder:
config = Path(folder) / "log2ram.conf"
config.write_text("JOURNALD_AWARE=false\n")
source = (ROOT / "scripts/post_install" / filename).read_text()
body = source.split(function + "() {", 1)[1].split("\n}\n", 1)[0]
body = body.replace("/etc/log2ram.conf", str(config))
script = ("pmx_journal_context() { :; }\n"
+ updater + "() { printf '%s\\n' \"$1\"; }\n"
+ function + "() {" + body + "\n}\n" + function)
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "2.1")
def test_the_release_is_pinned_to_a_verified_commit(self):
text = SETUP.read_text()
self.assertIn('PMX_L2R_TAG="1.7.2"', text)
self.assertIn('PMX_L2R_COMMIT="f171449530f8984374c8f69fff3c92af813a694b"', text)
self.assertNotIn("install.sh", text.split("_pmx_log2ram_install_release() {", 1)[1].split("\n}\n", 1)[0])
if __name__ == "__main__":
unittest.main()
@@ -14,15 +14,16 @@ 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.function = ''.join(re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', text, re.M | re.S).group(0)
for name in ('pve_manager_version', 'pve_supports_oci'))
cls.text = text
def supported(self, version):
with tempfile.TemporaryDirectory() as folder:
fake = Path(folder, '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 = Path(folder, 'dpkg-query')
# Without Proxmox the package is not installed: dpkg-query fails.
fake.write_text('#!/bin/sh\nexit 1\n' if version is None else
f'#!/bin/sh\nprintf %s "{version}"\n')
fake.chmod(0o755)
result = subprocess.run(['bash', '-c', f'PATH={folder}:/usr/bin:/bin\n{self.function}pve_supports_oci'],
capture_output=True, text=True)
@@ -38,6 +39,9 @@ class OciPveVersionGateTests(unittest.TestCase):
with self.subTest(version=version):
self.assertFalse(self.supported(version))
def test_the_version_is_read_without_the_slow_pveversion(self):
self.assertNotIn('pveversion', self.function)
def test_it_is_checked_before_anything_is_installed(self):
self.assertLess(self.text.index('if ! pve_supports_oci; then'), self.text.index('ensure_oci_dependencies || exit 1'))
@@ -29,6 +29,10 @@ TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
def extracted(path, name, scope):
if name == 'remove':
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
@@ -71,6 +75,9 @@ class InventoryMessages(unittest.TestCase):
self.assertNotIn('Host directories that are kept, with their content:', text)
def test_removal_notice_qualifies_saved_record_source(self):
# Extracted operational functions must never inspect the real pmxcfs.
host = Path(self.enterContext(tempfile.TemporaryDirectory()))
inert_path = lambda value: host / Path(value).name if str(value).startswith('/etc/pve/') else Path(value)
events = []
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
@@ -81,7 +88,7 @@ class InventoryMessages(unittest.TestCase):
'remove_owned_host_firewall': lambda primary: True,
'run': lambda *args: events.append(('run', args)),
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
'Path': inert_path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
@@ -20,7 +20,7 @@ INSTALL = ROOT / 'oci/remote/install_oci.sh'
PREVIEW = 'Private network targeted for release if no other guest uses it:'
CONFIRM = 'Remove the application? Its container disks are deleted, and only a backup can bring them back.'
RESULT = 'Removal command finished; review any warnings above.'
BRIDGE = 'Private network release attempted:'
BRIDGE = 'Private network of the application released:'
BRIDGE_FAILURE = 'Could not complete private network release:'
SUCCESS = 'The application was removed'
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
@@ -35,6 +35,10 @@ KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, POR
def extract(path, name, scope):
if name == 'remove':
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
node = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
return scope[name]
@@ -257,7 +261,7 @@ class RemovalWordings(unittest.TestCase):
self.assertIn(('warn', FIREWALL), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_ignored_bridge_command_failures_do_not_claim_release(self):
def test_a_failed_bridge_release_warns_instead_of_claiming_it(self):
events = []
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
@@ -274,11 +278,12 @@ class RemovalWordings(unittest.TestCase):
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text, 'msg_info': lambda text: None,
'msg_ok': lambda text: events.append(('ok', text)), 'msg_warn': lambda text: None}
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
self.assertIn(('attempt', 'vmbr9'), events)
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
self.assertNotIn(('ok', 'Private network of the application released: vmbr9'), events)
self.assertIn(('warn', BRIDGE_FAILURE + ' vmbr9'), events)
self.assertNotIn(('ok', BRIDGE + ' vmbr9'), events)
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
events = []
@@ -133,7 +133,7 @@ class SelectionSetupWording(TestCase):
with self.subTest(app=app):
path = ROOT / f'oci/remote/install_{app}_stack.sh'
lines = path.read_text().splitlines()
start = next(i for i, line in enumerate(lines) if line.strip().startswith('curl -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
start = next(i for i, line in enumerate(lines) if line.strip().startswith('oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
# Actual Bash failure expression, never source the parent installer.
harness = '''set -u
@@ -141,6 +141,7 @@ APPLICATION_LAN_IP=192.0.2.1
APPLICATION_ID=101
OCI_LOG=/dev/null
curl() { return 7; }
oci_curl() { shift; curl "$@"; }
translate() { printf '%s' "$1"; }
print_first_boot_diagnostics() { :; }
die() { printf 'ERROR:%s\\n' "$1"; }
+11 -3
View File
@@ -12,7 +12,7 @@ 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 import catalog_refresh, cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
from proxmenux_oci.catalog import Catalog
@@ -202,9 +202,17 @@ class InputAndCancellationTests(StorageFixture):
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:
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(cli,'_catalog_options',return_value=([],{}, {}, {}, [])), \
patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
patch.object(updates,'UpdateCheck') as checker, \
patch.object(catalog_refresh,'CatalogRefresh') as catalog_checker:
# Keep this wording/error-handler test offline and free of writes.
# The real background worker has its own catalog lifecycle tests.
catalog_check=catalog_checker.return_value.__enter__.return_value
catalog_check.active=False;catalog_check.consume.return_value=False
catalog_check.failed=False;catalog_check.label.return_value='Refresh catalog'
check=checker.return_value.__enter__.return_value
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
check.label.return_value='Check for updates'
self.assertEqual(cli.interactive(self.catalog),0)
ui.message.assert_called_once_with('Operáciu sa nepodarilo dokončiť:\n\nfiction-detail')
@@ -141,7 +141,11 @@ class UpdateCancellationTests(CancellationFixture):
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()):
def declined(unattended, *_args, **_kwargs):
unattended.declined = 'review'
return False
with patch.object(reconcile, 'propose', return_value={'details': ['external change'], 'review': True}), \
patch.object(menu.UnattendedUI, 'review', declined), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
@@ -1,9 +1,13 @@
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
import ast
import copy
import json
from pathlib import Path
import re
from unittest import TestCase
from unittest.mock import Mock
from types import SimpleNamespace
from typing import Any
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
@@ -65,9 +69,45 @@ class OciWatchdogInstaller(TestCase):
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
self.assertLess(asked, install.index('approved = wizard.review('))
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
self.assertIn('set_watchdog(PROJECT_ROOT, sorted(vmids), True)', install)
self._assert_jobs_are_excluded_from_installed_watchdog()
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
def _assert_jobs_are_excluded_from_installed_watchdog(self):
node = next(item for item in ast.parse(self.CLI).body
if isinstance(item, ast.FunctionDef) and item.name == 'install_template')
class InertImports(ast.NodeTransformer):
def visit_ImportFrom(self, item):
return None
node = InertImports().visit(node)
deployment = {'services': [
{'name': 'migration', 'deployment': {'execution_mode': 'one-shot'}},
{'name': 'database', 'deployment': {}},
{'name': 'worker', 'deployment': {}}]}
result = {'vmid': 4800, 'stack_vmids': {'web': 4800, 'migration': 4801,
'database': 4802, 'worker': 4803}}
set_watchdog = Mock(return_value=True)
class UI:
def confirm(self, *args, **kwargs): return True
def review(self, *args, **kwargs): return True
def close(self): pass
def install(project, template, plan, target):
self.assertNotIn('watchdog', plan)
return result
scope = {'Any': Any, 'copy': copy, 'BacktrackUI': lambda ui: ui,
'RestartWizard': RuntimeError, 'InstallError': ValueError,
'build_deployment': lambda *args: copy.deepcopy(deployment),
'watchdog_default': lambda *args: False,
'translate': lambda value: value, '_deployment_summary_text': lambda *args: '',
'PROJECT_ROOT': Path('/inert'), 'source_text': lambda value: 'test', 'APP_TITLE': 'test',
'console': SimpleNamespace(**{name: lambda *args: None for name in
('show_logo', 'msg_title', 'msg_error', 'msg_ok', 'msg_warn', 'wait_for_enter')}),
'run_remote_install': install, 'carry_records': lambda *args, **kwargs: None,
'images': SimpleNamespace(offer_removal=lambda *args: (False, None)),
'_print_installation_summary': lambda *args: None, 'set_watchdog': set_watchdog}
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), 'cli.py', 'exec'), scope)
scope['install_template'](UI(), {'catalog_ui': {'title': {'en_US': 'test'}}}, 'test', 'advanced')
set_watchdog.assert_called_once_with(Path('/inert'), [4800, 4802, 4803], True)
def test_both_management_menus_offer_it(self):
self.assertEqual(self.MENU.count("('watchdog', "), 2)
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
+15 -11
View File
@@ -20,8 +20,7 @@ AFTER = 'The recovery did not complete. Review the log and choose "{choice}" aga
REGISTRY_ADVICE = ('Check the image reference and registry access. If the registry is unavailable, '
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
NEW_KEYS = (
'The Compose file describes several services:',
'Only one service at a time can be installed this way.',
'Containers of the application:',
'No docker run command was given',
'Image selection was cancelled',
'Could not inspect the image in its registry:',
@@ -77,15 +76,19 @@ class OciWordingTests(TestCase):
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
def test_multiple_services_named_not_images(self):
def test_multiple_services_keep_their_names_and_enter_stack_conversion(self):
# Reusing one image twice still creates two distinct Compose services.
document = {'services': {'alpha': {'image': 'same:latest'},
'beta': {'image': 'same:latest'}}}
converted = object()
stack = Mock(return_value=converted)
template = function(CUSTOM, 'template_from_compose',
_services=lambda doc: doc['services'], _check_mounts=lambda _: None)
with self.assertRaises(ConversionError) as caught:
template(json.dumps({'services': {'alpha': {'image': 'same:latest'},
'beta': {'image': 'same:latest'}}}))
self.assertEqual(str(caught.exception),
'The Compose file describes several services: alpha, beta. '
'Only one service at a time can be installed this way.')
_services=lambda doc: doc['services'], _template_from_stack=stack)
self.assertIs(template(json.dumps(document)), converted)
stack.assert_called_once()
self.assertEqual(stack.call_args.args, (document, None))
self.assertFalse(stack.call_args.kwargs['interpolated'])
self.assertIsNone(stack.call_args.kwargs.get('image_entrypoints'))
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
for title, expected in ((None, 'No Compose file was given'),
@@ -108,7 +111,8 @@ class OciWordingTests(TestCase):
def test_registry_nonzero_and_success_command_unchanged(self):
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
run = Mock(return_value=result)
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run))
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run),
_registry_lookup=lambda reference: reference)
self.assertEqual(report('example:latest'),
(False, 'Could not inspect the image in its registry: example:latest'))
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
@@ -167,7 +167,7 @@ class UpdateCliTests(unittest.TestCase):
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'
script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+function('scripts/global/common-functions.sh','pmx_confirm_update')+body+'\nupdate_pbs\n'
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite})
files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()}
@@ -69,13 +69,22 @@ class TerminalCatalogsTests(unittest.TestCase):
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'],
'--root', str(ROOT)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for locale in MODULE.DEFAULT_LANGUAGES:
self.assertIn(f'[{locale}] active=', result.stdout)
self.assertNotIn('missing=1', result.stdout)
# New strings are reviewed in Slovak. Other locales keep the runtime
# English fallback until the translation bot supplies their values;
# still validate every translation already present in those locales.
result = subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(ROOT), '--languages', 'sk', '--require-complete'],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('missing=0', result.stdout)
def test_optional_completeness_still_rejects_invalid_present_translations(self):
with tempfile.TemporaryDirectory() as temporary:
@@ -0,0 +1,25 @@
"""A label made only of product and API names is right when it comes back
unchanged, so the translation bot keeps it instead of retrying and dropping it."""
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import build_translation_cache as cache # noqa: E402
class ProtectedTermsTests(unittest.TestCase):
def test_acceleration_profile_labels_are_kept_unchanged(self):
for label in ("Intel/AMD (Vulkan)", "AMD (ROCm)", "Intel (SYCL)", "NVIDIA (CUDA)",
"Intel/AMD (VA-API)", "Intel (OpenVINO)"):
with self.subTest(label=label):
self.assertTrue(cache.is_fully_protected(label))
self.assertFalse(cache.looks_untranslated(label, label))
def test_a_sentence_coming_back_unchanged_is_still_a_failure(self):
sentence = "The GPU is used through Vulkan when it is available"
self.assertTrue(cache.looks_untranslated(sentence, sentence))
if __name__ == "__main__":
unittest.main()
+488
View File
@@ -0,0 +1,488 @@
"""USB/IP ownership, device identity, SSH policy and service regression tests.
No physical USB devices, live systemd units or host configuration are changed.
The optional Linux integration lane exercises actual SSH, socat and namespaces.
"""
import copy
import importlib.util
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
SOURCE = ROOT / "scripts/devices/usbip_manager.py"
EXPORT = "abcdef123456"
CLIENT = "fedcba654321"
def load():
spec = importlib.util.spec_from_file_location("usbip_manager", SOURCE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class USBIPTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.m = load()
for attr, path in (("CONFIG_DIR", "config"), ("RUN_DIR", "client-run"),
("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs"),
("NET_SYSFS", "net")):
setattr(self.m, attr, self.root / path)
getattr(self.m, attr).mkdir()
class MatchTable:
# Mirror the kernel contract, not regular-file write semantics:
# del(ALLOC)=REMOV, del(ADDED/REMOV) retains the named slot.
def __init__(self):
self.slots = {}
def write_text(self, value):
action, busid = value.split()
if self.slots.get(busid) == "ALLOC":
self.slots[busid] = "REMOV"
def read_text(self):
return " ".join(self.slots)
self.m.MATCH_BUSID = MatchTable()
self.account = SimpleNamespace(pw_uid=1234, pw_gid=1234)
self.calls = []
self.key = self.root / "id_ed25519"
self.key.write_text("test-key")
self.key.chmod(0o600)
self.remote = {"host": "usb.example.test", "ssh_port": 2222, "key": str(self.key)}
def config(self, exports=None, imports=None, server=True):
value = {"server": server, "exports": exports or [], "imports": imports or []}
self.m.atomic_json(self.m.CONFIG_DIR / "config.json", value)
return value
def test_a_usb_network_adapter_is_not_offered(self):
self.device("1-3", serial="nic-1")
(self.m.NET_SYSFS / "enx001122").mkdir()
(self.m.NET_SYSFS / "enx001122" / "device").symlink_to(self.m.SYSFS / "1-3" / "1-3:1.0")
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-3")["blocked"],
"USB network adapters cannot be shared")
self.device("1-4", serial="zigbee-9")
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-4")["blocked"], "")
def device(self, busid="1-2", serial="zigbee-123", device_class="00", interface="03", driver="usb"):
path = self.m.SYSFS / busid
path.mkdir()
values = {"idVendor": "10c4", "idProduct": "ea60", "serial": serial,
"product": "Test Zigbee adapter", "bDeviceClass": device_class}
for key, value in values.items():
(path / key).write_text(value)
child = path / f"{busid}:1.0"
child.mkdir()
(child / "bInterfaceClass").write_text(interface)
self.set_driver(busid, driver)
return next(d for d in self.m.inventory() if d["busid"] == busid)
def set_driver(self, busid, driver):
target = self.root / "drivers" / driver
target.mkdir(parents=True, exist_ok=True)
path = self.m.SYSFS / busid / "driver"
path.unlink(missing_ok=True)
path.symlink_to(target, target_is_directory=True)
def selected(self, device):
return {k: device[k] for k in ("busid", "vendor", "product", "serial", "name")} | {"id": EXPORT}
def usb(self, args, timeout=20):
self.calls.append(args)
if args[:2] == ["usbip", "bind"]:
self.set_driver(args[-1], "usbip-host")
self.m.MATCH_BUSID.slots[args[-1]] = "ALLOC"
elif args[:2] == ["usbip", "unbind"]:
self.set_driver(args[-1], "usb")
self.m.MATCH_BUSID.slots.pop(args[-1], None)
return ""
def test_storage_composite_interfaces_and_hubs_are_never_exported(self):
for index, (base, interface) in enumerate((("08", "06"), ("09", "00"), ("00", "08"), ("", "03"))):
device = self.device(busid=f"1-{index + 1}", device_class=base, interface=interface)
self.assertTrue(device["blocked"])
self.config()
with self.assertRaises(self.m.USBIPError), patch.object(self.m, "run") as command:
self.m.add_export(device["busid"])
command.assert_not_called()
self.assertEqual(self.m.load_config()["exports"], [])
def test_export_intent_is_private_and_bind_is_idempotent(self):
device = self.device()
self.config()
selected = self.m.add_export(device["busid"])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
self.assertEqual(self.m.reconcile_exports(self.m.load_config(), owned), {})
self.assertEqual(self.m.reconcile_exports(self.m.load_config(), owned), {})
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
self.assertEqual(self.m.catalog()[0]["id"], selected["id"])
self.assertEqual(self.m.catalog()[0]["status"], "exported")
for path in (self.m.CONFIG_DIR / "config.json", self.m.state_path()):
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertNotEqual(self.m.RUN_DIR, self.m.SERVER_RUN_DIR)
def test_port_move_fails_closed_when_kernel_retains_old_slot(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
self.m.reconcile_exports(config, owned)
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(serial="other-device", driver="usbip-host")
self.device(busid="2-4")
errors = self.m.reconcile_exports(config, owned)
self.assertIn("blocked", errors[EXPORT])
self.assertEqual(owned[EXPORT]["busid"], "1-2")
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
self.assertIsNone(self.m.catalog()[0]["busid"])
def test_retained_slot_is_rejected_before_detaching_reconnected_local_usb(self):
device = self.device()
selected = self.selected(device)
config = self.config([selected])
self.m.MATCH_BUSID.slots[device["busid"]] = "REMOV"
with patch.object(self.m, "run", side_effect=self.usb):
errors = self.m.reconcile_exports(config, {EXPORT: selected}, {})
self.assertIn("local driver", errors[EXPORT])
self.assertEqual(self.calls, [])
self.assertEqual(self.m.inventory()[0]["driver"], "usb")
def test_imported_usb_does_not_shadow_physical_original(self):
device = self.device()
imported = self.m.SYSFS / "2-1"
imported.symlink_to(self.m.SYSFS / "1-2", target_is_directory=True)
target = self.root / "vhci_hcd.0" / "2-1"
target.parent.mkdir()
shutil.copytree(self.m.SYSFS / "1-2", target, symlinks=True)
imported.unlink()
imported.symlink_to(target, target_is_directory=True)
devices = self.m.inventory()
self.assertEqual(len(devices), 1)
self.assertEqual(self.m.resolve_device(device, devices)["busid"], "1-2")
def test_no_serial_requires_same_busid_and_ambiguous_serial_is_rejected(self):
selected = self.selected(self.device(serial=""))
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(busid="2-4", serial="")
with self.assertRaises(self.m.USBIPError):
self.m.resolve_device(selected, self.m.inventory())
self.device(busid="1-3", serial="duplicate")
first = self.device(busid="1-4", serial="duplicate")
with self.assertRaises(self.m.USBIPError):
self.m.resolve_device(first, self.m.inventory())
def test_foreign_exports_are_not_adopted_and_disable_only_releases_owned_devices(self):
own = self.selected(self.device())
foreign = self.device(busid="1-3", serial="foreign", driver="usbip-host")
config = self.config([own, self.selected(foreign) | {"id": CLIENT}])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
errors = self.m.reconcile_exports(config, owned)
self.assertIn("outside ProxMenux", errors[CLIENT])
config["server"] = False
self.m.reconcile_exports(config, owned)
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"], ["usbip", "unbind", "--busid", "1-2"]])
self.assertEqual(owned, {})
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-3")["driver"], "usbip-host")
def test_recover_bind_intent_and_remove_selected_export(self):
selected = self.selected(self.device(driver="usbip-host"))
self.config([selected])
owned = {EXPORT: selected}
self.m.atomic_json(self.m.state_path(), owned)
with patch.object(self.m, "run", side_effect=self.usb):
self.m.reconcile_exports(self.m.load_config(), owned)
self.assertEqual(self.calls, [])
with patch.object(self.m.time, "sleep", side_effect=lambda _: self.m.reconcile_exports(self.m.load_config(), owned)):
self.m.remove_export(EXPORT)
self.m.reconcile_exports(self.m.load_config(), owned)
self.assertEqual(self.calls, [["usbip", "unbind", "--busid", "1-2"]])
def test_failed_bind_is_reported_and_retains_recoverable_intent(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {}
with patch.object(self.m, "run", side_effect=self.m.USBIPError("bind failed")):
self.assertEqual(self.m.reconcile_exports(config, owned), {EXPORT: "bind failed"})
self.assertIn(EXPORT, self.m.read_json(self.m.state_path(), {}))
self.assertEqual(self.m.status()["server_status"]["errors"], {EXPORT: "bind failed"})
self.assertEqual(self.m.catalog()[0]["status"], "waiting")
def test_unplugged_kernel_match_is_disarmed_but_not_falsely_forgotten(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
self.m.reconcile_exports(config, owned)
shutil.rmtree(self.m.SYSFS / "1-2")
errors = self.m.reconcile_exports(config, owned)
self.assertEqual(owned[EXPORT]["busid"], "1-2")
self.assertIn("blocked", errors[EXPORT])
self.assertEqual(self.m.MATCH_BUSID.slots["1-2"], "REMOV")
self.assertEqual(len(self.calls), 1)
def test_failed_stale_cleanup_does_not_forget_or_replace_ownership(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {EXPORT: selected}
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(busid="2-4")
with patch.object(self.m, "disarm_match", side_effect=self.m.USBIPError("disarm failed")), patch.object(self.m, "run") as command:
errors = self.m.reconcile_exports(config, owned)
self.assertIn("disarm", errors[EXPORT])
self.assertEqual(owned[EXPORT]["busid"], "1-2")
command.assert_not_called()
def test_authorized_key_is_restricted_and_policy_stays_root_owned(self):
self.m.TRANSPORT_HOME = self.root / "transport-home"
account = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_dir=str(self.m.TRANSPORT_HOME))
public = self.root / "client.pub"
public.write_text("ssh-ed25519 AAAA test-client")
with patch.object(self.m.pwd, "getpwnam", side_effect=[KeyError(), account]), patch.object(
self.m, "run"
) as command, patch.object(self.m.os, "chown"), patch.object(self.m.os, "fchown"):
self.m.authorize_key(public)
command.assert_called_once()
self.assertEqual(command.call_args.args[0][0], "useradd")
policy = self.m.TRANSPORT_HOME / ".ssh/authorized_keys"
self.assertEqual(policy.stat().st_mode & 0o777, 0o640)
self.assertEqual(policy.read_text(), 'restrict,command="/usr/bin/python3 ' + self.m.REMOTE_SCRIPT + ' transport" ssh-ed25519 AAAA\n')
with patch.object(self.m.pwd, "getpwnam", return_value=account), patch.object(
self.m.os, "chown"
), patch.object(self.m.os, "fchown"):
self.m.authorize_key(public)
self.assertEqual(len(policy.read_text().splitlines()), 1)
def test_authorize_never_adopts_an_existing_foreign_account(self):
self.m.TRANSPORT_HOME = self.root / "foreign-home"
account = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_dir=str(self.m.TRANSPORT_HOME))
public = self.root / "client.pub"
public.write_text("ssh-ed25519 AAAA test-client")
with patch.object(self.m.pwd, "getpwnam", return_value=account), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "existing"):
self.m.authorize_key(public)
command.assert_not_called()
self.assertFalse(self.m.TRANSPORT_HOME.exists())
def test_forced_command_rejects_shell_and_wrong_identity(self):
for command in ("id", "catalog; id", "", "stream --socket /run/other.sock"):
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(
self.m.os, "geteuid", return_value=1234
), patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": command}), patch.object(self.m.os, "execvp") as execute:
with self.assertRaisesRegex(self.m.USBIPError, "Only USB/IP"):
self.m.transport()
execute.assert_not_called()
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m.os, "geteuid", return_value=0):
with self.assertRaisesRegex(self.m.USBIPError, "unprivileged"):
self.m.transport()
def test_forced_command_catalog_and_stream_are_fixed(self):
(self.m.SERVER_RUN_DIR / "catalog.json").write_text("[]")
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m.os, "geteuid", return_value=1234):
with patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": "catalog"}), patch("builtins.print") as output:
self.m.transport()
output.assert_called_once_with("[]")
with patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": "stream"}), patch.object(self.m.os, "execvp") as execute:
self.m.transport()
execute.assert_called_once_with("socat", ["socat", "STDIO", "UNIX-CONNECT:" + self.m.REMOTE_SOCKET])
def test_loopback_port_is_held_until_tunnel_stops(self):
with socket.socket() as unused:
unused.bind(("127.0.0.1", 0))
port = unused.getsockname()[1]
tunnel = self.m.LoopbackTunnel(self.remote | {"local_port": port})
try:
with socket.socket() as attacker:
with self.assertRaises(OSError):
attacker.bind(("127.0.0.1", port))
with self.assertRaisesRegex(self.m.USBIPError, "in use"):
self.m.LoopbackTunnel(self.remote | {"local_port": port})
finally:
tunnel.terminate()
self.assertEqual(tunnel.poll(), 0)
def test_namespace_guard_fails_before_spawning_a_listener(self):
with patch.object(self.m.os, "readlink", return_value="net:[1]"), patch.object(self.m.subprocess, "Popen") as process:
with self.assertRaisesRegex(self.m.USBIPError, "PrivateNetwork"):
self.m.serve()
process.assert_not_called()
def test_generated_units_are_isolated_quoted_and_owned(self):
self.m.SCRIPT = self.root / 'with space/%and$dollar"quote.py'
with patch("shutil.which", return_value="/usr/sbin/modprobe"):
server = self.m.unit_text("server")
client = self.m.unit_text("client", CLIENT)
self.assertIn("PrivateNetwork=yes", server)
self.assertIn("RuntimeDirectory=proxmenux-usbip-server", server)
self.assertIn("RuntimeDirectoryPreserve=yes", server)
self.assertIn('%%and$$dollar\\"quote.py" serve', server)
self.assertNotIn("PrivateNetwork=yes", client)
self.assertNotIn("RuntimeDirectory=", client)
self.assertIn("vhci-hcd", client)
self.assertIn("Restart=on-failure", client)
unit = self.m.UNIT_DIR / self.m.SERVER_UNIT
unit.write_text("[Unit]\nDescription=Someone else's service\n")
with self.assertRaises(self.m.USBIPError), patch.object(self.m, "run") as command:
self.m.update_server(True)
command.assert_not_called()
self.assertFalse((self.m.CONFIG_DIR / "config.json").exists())
def test_failed_server_start_restores_configuration_and_removes_new_unit(self):
previous = self.config(server=False)
def fail_start(args, timeout=20):
self.calls.append(args)
if args[:3] == ["systemctl", "enable", "--now"]:
raise self.m.USBIPError("start failed")
return ""
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m, "run", side_effect=fail_start), patch("shutil.which", return_value="/sbin/modprobe"):
with self.assertRaisesRegex(self.m.USBIPError, "start failed"):
self.m.update_server(True)
self.assertEqual(self.m.load_config(), previous)
self.assertFalse((self.m.UNIT_DIR / self.m.SERVER_UNIT).exists())
self.assertIn(["systemctl", "disable", "--now", self.m.SERVER_UNIT], self.calls)
def test_ssh_policy_and_argument_boundaries(self):
remote = self.m.validate_remote(self.remote["host"], 2222, str(self.key))
args = self.m.tunnel_args(remote | {"local_port": 33240})
for required in ("BatchMode=yes", "StrictHostKeyChecking=yes", "IdentitiesOnly=yes", "ForwardAgent=no",
"PasswordAuthentication=no", "ExitOnForwardFailure=yes"):
self.assertIn(required, args)
self.assertIn("proxmenux-usbip", args)
self.assertNotIn("root", args)
self.assertNotIn("-L", args)
self.assertNotIn("-N", args)
self.assertNotIn("StrictHostKeyChecking=no", args)
self.assertEqual(args[-3:], ["--", "usb.example.test", "stream"])
for host in ("-oProxyCommand=touch /tmp/no", "root@host", "host;id", "host\nother", "$(id)"):
with self.assertRaises(self.m.USBIPError):
self.m.validate_remote(host, 22, str(self.key))
self.key.chmod(0o644)
with self.assertRaisesRegex(self.m.USBIPError, "permissions"):
self.m.validate_remote("192.0.2.1", 22, str(self.key))
def test_ssh_credential_or_catalog_failure_never_saves_a_connection(self):
for failure in (self.m.USBIPError("Permission denied"), self.m.USBIPError("Connection timed out")):
with patch.object(self.m, "run", side_effect=failure):
with self.assertRaises(self.m.USBIPError):
self.m.add_import(self.remote, EXPORT, 33240)
self.assertFalse((self.m.CONFIG_DIR / "config.json").exists())
with patch.object(self.m, "run", return_value='[{"id":"--bad","busid":"1-2"}]'):
with self.assertRaisesRegex(self.m.USBIPError, "valid"):
self.m.remote_catalog(self.remote)
def test_import_service_failure_rolls_back_and_occupied_port_is_rejected(self):
exports = [{"id": EXPORT, "status": "exported", "busid": "1-2"}]
previous = self.config()
with patch.object(self.m, "remote_catalog", return_value=exports):
with socket.socket() as busy:
busy.bind(("127.0.0.1", 0))
with self.assertRaisesRegex(self.m.USBIPError, "in use"):
self.m.add_import(self.remote, EXPORT, busy.getsockname()[1])
with socket.socket() as unused:
unused.bind(("127.0.0.1", 0))
port = unused.getsockname()[1]
with patch("shutil.which", return_value="/sbin/modprobe"), patch.object(self.m, "enable_unit", side_effect=self.m.USBIPError("failed")):
with self.assertRaises(self.m.USBIPError):
self.m.add_import(self.remote, EXPORT, port)
self.assertEqual(self.m.load_config(), previous)
def port(self, **overrides):
return {"port": 2, "status": "Port in Use", "local_busid": "3-1", "host": "127.0.0.1", "tcp_port": 33240,
"busid": "1-2"} | overrides
def test_real_upstream_port_output_and_unowned_port_cleanup(self):
output = """Imported USB devices
====================
Port 02: <Port in Use> at High Speed(480Mbps)
Test Zigbee adapter (10c4:ea60)
3-1 -> usbip://127.0.0.1:33240/1-2
-> remote bus/dev 001/002
Port 03: <Port Error> at Full Speed(12Mbps)
Another adapter
3-2 -> usbip://192.0.2.1:3240/1-3
"""
ports = self.m.parse_ports(output)
self.assertEqual(ports[0], self.port())
self.assertEqual(ports[1]["status"], "Port Error")
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
self.m.atomic_json(owner, ports[0])
reused = self.port(local_busid="3-7")
with patch.object(self.m, "imported_ports", return_value=[reused, ports[1]]), patch.object(self.m, "run") as command:
self.m.release_import(owner)
command.assert_not_called()
self.assertFalse(owner.exists())
self.m.atomic_json(owner, ports[0])
with patch.object(self.m, "imported_ports", return_value=ports), patch.object(self.m, "run") as command:
self.m.release_import(owner)
command.assert_called_once_with(["usbip", "detach", "--port", "2"])
def test_attach_reuses_live_port_and_recovers_error_with_new_remote_busid(self):
selected = self.remote | {"id": CLIENT, "local_port": 33240, "export_id": EXPORT}
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
self.m.atomic_json(owner, self.port())
with patch.object(self.m, "imported_ports", return_value=[self.port()]), patch.object(self.m, "remote_catalog") as remote:
self.assertEqual(self.m.attach_once(selected, owner)["port"], 2)
remote.assert_not_called()
ports = [self.port(status="Port Error")]
def usb(args, timeout=20):
self.calls.append(args)
if "detach" in args:
ports.clear()
elif "attach" in args:
ports.append(self.port(busid="2-4", local_busid="3-5"))
return ""
with patch.object(self.m, "imported_ports", side_effect=lambda: copy.deepcopy(ports)), patch.object(
self.m, "remote_catalog", return_value=[{"id": EXPORT, "status": "exported", "busid": "2-4"}]
), patch.object(self.m, "run", side_effect=usb):
attached = self.m.attach_once(selected, owner)
self.assertEqual(attached["busid"], "2-4")
self.assertEqual(self.calls, [["usbip", "detach", "--port", "2"],
["usbip", "--tcp-port", "33240", "attach", "--remote", "127.0.0.1", "--busid", "2-4"]])
self.assertEqual(self.m.read_json(owner, None), attached)
def test_missing_remote_or_unowned_import_is_never_detached(self):
selected = self.remote | {"id": CLIENT, "local_port": 33240, "export_id": EXPORT}
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
with patch.object(self.m, "remote_catalog", return_value=[]), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "unplugged"):
self.m.attach_once(selected, owner)
command.assert_not_called()
with patch.object(self.m, "remote_catalog", return_value=[{"id": EXPORT, "status": "exported", "busid": "1-2"}]), patch.object(
self.m, "imported_ports", return_value=[self.port()]
), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "unowned"):
self.m.attach_once(selected, owner)
command.assert_not_called()
@unittest.skipUnless(sys.platform == "linux" and shutil.which("systemd-analyze"), "systemd verifier requires Linux")
def test_systemd_accepts_both_generated_service_files(self):
with patch("shutil.which", return_value="/bin/true"):
for name, content in ((self.m.SERVER_UNIT, self.m.unit_text("server")),
(self.m.unit_name(CLIENT), self.m.unit_text("client", CLIENT))):
path = self.m.UNIT_DIR / name
path.write_text(content)
result = subprocess.run(["systemd-analyze", "verify", str(path)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_menu_and_search_expose_server_and_client_actions(self):
menu = (ROOT / "scripts/menus/hw_grafics_menu.sh").read_text()
self.assertIn('bash "$LOCAL_SCRIPTS/devices/usbip_menu.sh"', menu)
index = json.loads((ROOT / "scripts/menus/search_index.json").read_text())
self.assertTrue(any(i["script"] == "devices/usbip_menu.sh" and "usbip" in i["keywords"] for i in index))
for script in (ROOT / "scripts/devices/usbip_menu.sh", ROOT / "scripts/menus/hw_grafics_menu.sh"):
subprocess.run(["bash", "-n", str(script)], check=True)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,112 @@
"""Absent-slot recovery must require a verified pin and completed kernel removal."""
import importlib.util
import errno
from pathlib import Path
import socket
import tempfile
import unittest
from unittest.mock import Mock, patch
SOURCE = Path(__file__).resolve().parents[3] / "scripts/devices/usbip_lifetime.py"
spec = importlib.util.spec_from_file_location("usbip_lifetime", SOURCE)
l = importlib.util.module_from_spec(spec)
spec.loader.exec_module(l)
class LifetimeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
lease = l.Lease.__new__(l.Lease)
lease.path = self.root / "1-2"
lease.devpath = "/devices/test/usb1/1-2"
lease.bus = 1
lease.number = 3
lease.sequence = 100
lease.ready = True
lease.removed = False
lease.fds = []
lease.events = Mock()
lease.events.recvmsg.side_effect = BlockingIOError()
self.lease = lease
self.match = Mock()
self.match.parent = self.root
self.match.read_text.side_effect = lambda: (
"" if (self.root / "rebind").exists() else "1-2"
)
def event(self, **changes):
values = {
"ACTION": "remove",
"DEVTYPE": "usb_device",
"DEVPATH": self.lease.devpath,
"BUSNUM": "001",
"DEVNUM": "003",
"SEQNUM": "101",
} | changes
return b"\0".join(f"{k}={v}".encode() for k, v in values.items()) + b"\0"
def test_no_rebind_without_a_verified_lifetime_pin(self):
self.lease.ready = False
self.lease.removed = True
with self.assertRaises(l.LifetimeError):
self.lease.release(self.match, Mock())
self.assertFalse((self.root / "rebind").exists())
def test_rebind_requires_completed_removal_not_only_missing_sysfs(self):
with patch.object(
self.lease, "validate", side_effect=FileNotFoundError()
), patch.object(l.time, "monotonic", side_effect=[0, 3, 4]):
with self.assertRaisesRegex(l.LifetimeError, "Waiting"):
self.lease.release(self.match, Mock())
self.assertFalse((self.root / "rebind").exists())
def test_unrelated_or_userspace_events_do_not_authorize_slot_recovery(self):
packets = [
(self.event(SEQNUM="99"), [], 0, (0, 1)),
(self.event(DEVNUM="004"), [], 0, (0, 1)),
(self.event(DEVPATH="/devices/other"), [], 0, (0, 1)),
(self.event(), [], 0, (12345, 1)),
(self.event(DEVTYPE="usb_interface"), [], 0, (0, 1)),
BlockingIOError(),
]
self.lease.events.recvmsg.side_effect = packets
self.assertFalse(self.lease.poll_removed())
def test_lost_or_truncated_events_invalidate_recovery(self):
for fault in (
OSError(errno.ENOBUFS, "overflow"),
(self.event(), [], socket.MSG_TRUNC, (0, 1)),
):
self.lease.ready = True
self.lease.events.recvmsg.side_effect = [fault]
with self.assertRaises(l.LifetimeError):
self.lease.poll_removed()
self.assertFalse(self.lease.ready)
self.assertFalse((self.root / "rebind").exists())
def test_pinned_completed_removal_cleans_only_its_slot(self):
self.lease.events.recvmsg.side_effect = [
(self.event(), [], 0, (0, 1)),
BlockingIOError(),
]
unbind = Mock()
self.lease.release(self.match, unbind)
unbind.assert_not_called()
self.assertEqual((self.root / "rebind").read_text(), "1-2")
self.assertFalse(self.lease.ready)
def test_present_device_uses_native_unbind_not_absent_recovery(self):
self.match.read_text.return_value = ""
self.match.read_text.side_effect = None
unbind = Mock()
with patch.object(self.lease, "validate"):
self.lease.release(self.match, unbind)
unbind.assert_called_once_with("1-2")
self.assertFalse((self.root / "rebind").exists())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,303 @@
"""Real socket control/URB tests with disposable sysfs-shaped device objects."""
import importlib.util
import os
from pathlib import Path
import socket
import socketserver
import struct
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location(
"usbip_protocol", ROOT / "scripts/devices/usbip_protocol.py"
)
p = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p)
def descriptor(
name="2-1", bus=2, number=3, vendor=0x1D6B, product=0x0104, interfaces=1
):
return p.DEVICE.pack(
b"/sys/devices/disposable",
name.encode(),
bus,
number,
3,
vendor,
product,
0x0100,
0,
0,
0,
1,
1,
interfaces,
)
def exact(connection, count):
result = bytearray()
while len(result) < count:
block = connection.recv(count - len(result))
if not block:
break
result.extend(block)
return bytes(result)
class GateTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="usbip-gate-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.sysfs = self.root / "sysfs"
self.sysfs.mkdir()
self.device("original")
self.selected = {
"2-1": {
"id": "abcdef123456",
"vendor": "1d6b",
"product": "0104",
"serial": "selected-serial",
}
}
self.calls = []
self.reply = descriptor()
self.listed = [
(descriptor(), b"\x02\x02\x01\0"),
(descriptor("3-1", bus=3, number=4), b"\x02\x02\x01\0"),
]
self.hook = lambda: None
self.header_override = None
fixture = self
class Handler(socketserver.BaseRequestHandler):
def handle(self):
try:
header = exact(self.request, 8)
if len(header) != 8:
return
_, code, _ = p.HEADER.unpack(header)
request = header + (
exact(self.request, 32) if code == p.IMPORT else b""
)
fixture.calls.append(request)
fixture.hook()
self.request.sendall(
fixture.header_override
or p.HEADER.pack(p.VERSION, code & 0x7FFF, 0)
)
if code == p.DEVLIST:
self.request.sendall(struct.pack("!I", len(fixture.listed)))
for raw, interfaces in fixture.listed:
self.request.sendall(raw + interfaces)
return
self.request.sendall(fixture.reply)
while True:
block = self.request.recv(65536)
if not block:
self.request.shutdown(socket.SHUT_WR)
return
self.request.sendall(block)
except OSError:
return
class Backend(socketserver.ThreadingMixIn, socketserver.TCPServer):
daemon_threads = True
self.backend = Backend(("127.0.0.1", 0), Handler)
self.backend_thread = threading.Thread(
target=self.backend.serve_forever,
kwargs={"poll_interval": 0.05},
daemon=True,
)
self.backend_thread.start()
self.addCleanup(self.backend.server_close)
self.addCleanup(self.backend.shutdown)
# Production calls this as root; these protocol tests do not alter users.
with patch.object(p.os, "chown"):
self.gate = p.Gate(
self.root / "gate.sock",
os.getgid(),
lambda name: self.selected.get(name),
self.sysfs,
self.backend.server_address,
max_connections=2,
)
self.addCleanup(self.gate.terminate)
def device(self, directory, serial="selected-serial", number=3):
device = self.root / directory
device.mkdir()
for name, value in {
"idVendor": "1d6b",
"idProduct": "0104",
"serial": serial,
"busnum": "2",
"devnum": str(number),
"bDeviceClass": "00",
}.items():
(device / name).write_text(value)
interface = device / "2-1:1.0"
interface.mkdir()
(interface / "bInterfaceClass").write_text("02")
(device / "driver").symlink_to("/sys/bus/usb/drivers/usbip-host")
link = self.sysfs / "2-1"
link.unlink(missing_ok=True)
link.symlink_to(device, target_is_directory=True)
return device
def connect(self):
connection = socket.socket(socket.AF_UNIX)
connection.settimeout(3)
connection.connect(str(self.gate.path))
self.addCleanup(connection.close)
return connection
def request(self, name=b"2-1\0", code=p.IMPORT):
return p.HEADER.pack(p.VERSION, code, 0) + (
name.ljust(32, b"\0") if code == p.IMPORT else b""
)
def reject(self, request):
connection = self.connect()
connection.sendall(request)
result = exact(connection, 8)
self.assertTrue(not result or p.HEADER.unpack(result)[2] != 0, result)
self.assertEqual(connection.recv(1), b"")
return result
def test_fragmented_selected_import_and_large_binary_full_duplex(self):
connection = self.connect()
for value in self.request():
connection.sendall(bytes([value]))
self.assertEqual(
exact(connection, 320), p.HEADER.pack(p.VERSION, 3, 0) + self.reply
)
payload = bytes(range(256)) * 4096
writer = threading.Thread(target=connection.sendall, args=(payload,))
writer.start()
self.assertEqual(exact(connection, len(payload)), payload)
writer.join(3)
self.assertFalse(writer.is_alive())
connection.shutdown(socket.SHUT_WR)
self.assertEqual(connection.recv(1), b"")
def test_foreign_device_is_rejected_before_native_backend_import(self):
self.reject(self.request(b"3-1\0"))
self.assertEqual(self.calls, [])
def test_malformed_busids_never_reach_backend(self):
for value in (b"2-1\0x", b"../../etc\0", b"2-1\xff\0", b"x" * 32):
with self.subTest(value=value):
self.reject(self.request(value))
self.assertEqual(self.calls, [])
def test_unsupported_control_headers_never_reach_backend(self):
for header in (
p.HEADER.pack(0x0110, p.IMPORT, 0),
p.HEADER.pack(p.VERSION, 0x8001, 0),
p.HEADER.pack(p.VERSION, p.IMPORT, 1),
):
self.reject(header)
self.assertEqual(self.calls, [])
def test_devlist_filters_unselected_export_and_preserves_interface_records(self):
connection = self.connect()
connection.sendall(self.request(code=p.DEVLIST))
reply = exact(connection, 12)
self.assertEqual(reply, p.HEADER.pack(p.VERSION, 5, 0) + struct.pack("!I", 1))
self.assertEqual(exact(connection, 316), self.reply + b"\x02\x02\x01\0")
self.assertEqual(connection.recv(1), b"")
def test_changed_selection_during_import_receives_no_success_payload(self):
self.hook = self.selected.clear
self.reject(self.request())
self.assertEqual(len(self.calls), 1)
def test_mutated_callback_dictionary_cannot_rewrite_saved_selection(self):
self.hook = lambda: self.selected["2-1"].update(id="fedcba654321")
self.reject(self.request())
def test_replaced_sysfs_object_cannot_use_identical_reply_metadata(self):
self.hook = lambda: self.device("replacement")
self.reject(self.request())
def test_driver_change_during_import_is_rejected(self):
def replace():
driver = self.root / "original/driver"
driver.unlink()
driver.symlink_to("/sys/bus/usb/drivers/usb")
self.hook = replace
self.reject(self.request())
def test_descriptor_identity_and_devnum_must_match_pinned_object(self):
for reply in (
descriptor(number=4),
descriptor(vendor=0x05AC),
descriptor("3-1"),
descriptor(bus=3),
):
self.reply = reply
self.reject(self.request())
def test_composite_storage_or_hub_device_is_rejected_before_backend(self):
for name in ("bDeviceClass", "2-1:1.0/bInterfaceClass"):
path = self.root / "original" / name
old = path.read_text()
for value in ("08", "09"):
path.write_text(value)
self.reject(self.request())
path.write_text(old)
self.assertEqual(self.calls, [])
def test_unicode_serial_is_valid_without_changing_busid_encoding(self):
serial = "selected-ñ-设备"
(self.root / "original/serial").write_text(serial)
self.selected["2-1"]["serial"] = serial
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(
exact(connection, 320), p.HEADER.pack(p.VERSION, 3, 0) + self.reply
)
def test_backend_response_version_opcode_and_status_are_validated(self):
for header in (
p.HEADER.pack(0x0110, 3, 0),
p.HEADER.pack(p.VERSION, 5, 0),
p.HEADER.pack(p.VERSION, 3, 0xFFFFFFFF),
):
self.header_override = header
self.reject(self.request())
def test_incomplete_header_times_out_but_accepted_idle_stream_does_not(self):
with patch.object(p, "CONTROL_SECONDS", 0.1):
partial = self.connect()
partial.sendall(b"\x01")
self.assertEqual(partial.recv(1), b"")
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(len(exact(connection, 320)), 320)
time.sleep(0.2)
connection.sendall(b"idle-still-works")
self.assertEqual(exact(connection, 16), b"idle-still-works")
def test_stop_closes_idle_backend_and_client_and_removes_listener(self):
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(len(exact(connection, 320)), 320)
self.gate.terminate()
self.assertEqual(connection.recv(1), b"")
self.assertFalse(self.gate.path.exists())
self.assertIsNotNone(self.gate.poll())
if __name__ == "__main__":
unittest.main()
@@ -24,7 +24,10 @@ class VmBackupJobsWiring(TestCase):
source = ROUTES.read_text()
self.assertNotIn('shell=True', source)
self.assertNotIn('os.system', source)
self.assertEqual(source.count('subprocess.run('), 1)
# pvesh, and the transient unit a backup started by hand runs in.
programs = re.findall(r'subprocess\.run\(\s*\[\s*"([a-z-]+)"', source)
self.assertEqual(len(programs), source.count('subprocess.run('))
self.assertEqual(set(programs), {'pvesh', 'systemd-run', 'systemctl'})
self.assertIn('subprocess.run(["pvesh", *args]', source)
def test_the_monitor_ships_and_registers_it(self):
@@ -33,7 +33,6 @@ jobs:
- uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.11'
+6 -1
View File
@@ -25,6 +25,11 @@ on:
permissions:
contents: read
# A newer push to the same branch or pull request replaces the run in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
@@ -32,7 +37,6 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v5
with:
@@ -45,5 +49,6 @@ jobs:
AUTHOR: ${{ github.event.pull_request.user.login }}
BASE: ${{ github.event.pull_request.base.sha }}
run: |
git fetch --no-tags --depth=1 origin "$BASE"
git show "$BASE:oci/catalog/verification.json" > "$RUNNER_TEMP/base.json" 2>/dev/null || : > "$RUNNER_TEMP/base.json"
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
@@ -0,0 +1,79 @@
name: Native Monitor AppImage validation
on:
pull_request:
paths:
- '.github/workflows/test-appimage-native.yml'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/scripts/appimage_arch.sh'
- 'AppImage/scripts/borg_native_arm.sh'
- '.github/scripts/smoke_native_apprun.sh'
- '.github/scripts/check_appimage_elf.py'
- 'AppImage/scripts/AppRun'
- 'tests/test_appimage_arch.py'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: native-appimage-${{ github.ref }}
cancel-in-progress: true
jobs:
native-build:
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-22.04
arch: x86_64
- runner: ubuntu-22.04-arm
arch: aarch64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Validate native architecture selection
run: |
test "$(uname -m)" = '${{ matrix.arch }}'
python -B -m unittest discover -s tests -p test_appimage_arch.py -v
- name: Refresh package indexes for bundled monitoring tools
# Third-party runner repositories occasionally fail; the build reports missing tools.
run: sudo apt-get update || true
- name: Build native AppImage without publishing it
run: bash AppImage/scripts/build_appimage.sh
- name: Reject foreign ELF payloads
run: python .github/scripts/check_appimage_elf.py /tmp/proxmenux_build/ProxMenux.AppDir '${{ matrix.arch }}'
- name: Extract native AppImage without FUSE
run: |
mkdir /tmp/proxmenux-native-extract
cd /tmp/proxmenux-native-extract
image=$(find "$GITHUB_WORKSPACE/AppImage/dist" -name '*.AppImage' -type f -print -quit)
test -n "$image"
chmod +x "$image"
"$image" --appimage-extract
test -f squashfs-root/usr/bin/flask_server.py
test -f squashfs-root/usr/bin/appimage_arch.sh
test -f squashfs-root/web/index.html
- name: Import bundled Python 3.11 native WebSocket runtime
env:
PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp311:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages
run: |
python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)'
bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Import bundled Python 3.13 native WebSocket runtime
env:
PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp313:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages
run: |
python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)'
bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root
@@ -1,50 +0,0 @@
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
@@ -1,50 +0,0 @@
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
+484
View File
@@ -0,0 +1,484 @@
name: Feature tests
on:
pull_request:
paths:
- '.github/workflows/test-features.yml'
- 'AppImage/scripts/notification_manager.py'
- 'AppImage/scripts/notification_templates.py'
- 'AppImage/scripts/ai_context_enrichment.py'
- 'AppImage/scripts/ai_providers/**'
- 'AppImage/config/verified_ai_models.json'
- 'AppImage/scripts/tests/test_groq_model_recommendations.py'
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
- 'AppImage/scripts/notification_channels.py'
- 'AppImage/components/apprise-service-help.tsx'
- 'AppImage/components/notification-settings.tsx'
- 'tests/test_apprise_services.py'
- 'tests/test_apprise_service_help.cjs'
- 'AppImage/scripts/flask_vm_backup_routes.py'
- 'AppImage/components/backup-storage-retention.tsx'
- 'AppImage/components/backup-tab.tsx'
- 'AppImage/components/vm-backup-jobs.tsx'
- 'AppImage/lib/backup-retention.ts'
- 'tests/test_vm_backup_jobs_api.py'
- 'tests/test_backup_storage_retention.cjs'
- 'tests/test_figurine_installation.py'
- 'scripts/gpu_tpu/nvidia_installer.sh'
- 'tests/test_nvidia*.py'
- 'AppImage/scripts/managed_installs.py'
- 'scripts/post_install/customizable_post_install.sh'
- 'AppImage/scripts/post_install_versions.py'
- 'AppImage/scripts/flask_proxmenux_routes.py'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/tests/test_static_path_containment.py'
- 'AppImage/scripts/ovs_topology.py'
- 'AppImage/scripts/tests/test_ovs_topology.py'
- 'AppImage/components/network-flow.tsx'
- 'AppImage/components/network-metrics.tsx'
- 'AppImage/tests/ovs-network-flow.cjs'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
- 'AppImage/lib/guest-backup-archives.ts'
- 'AppImage/messages/**'
- 'tests/test_guest_backup_archives.*'
- 'AppImage/scripts/lvm_device_scope.py'
- 'AppImage/scripts/disk_temperature_history.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/flask_notification_routes.py'
- 'AppImage/scripts/proxmox_storage_monitor.py'
- 'AppImage/scripts/tests/test_host_backup_node.py'
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
- 'tests/test_lvm_disk_read_policy.py'
- 'oci/**'
- 'AppImage/scripts/oci_menu_bridge.py'
- 'AppImage/scripts/lxc_apps.py'
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
- 'AppImage/scripts/*pbs*.py'
- 'AppImage/scripts/tests/test_pbs.py'
- 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/auth_manager.py'
- 'AppImage/components/pbs-*.tsx'
- 'AppImage/components/proxmox-dashboard.tsx'
- 'AppImage/tests/pbs-navigation.cjs'
- 'scripts/global/proxmox_platform.py'
- 'scripts/global/platform-functions.sh'
- 'scripts/global/repository-functions.sh'
- 'scripts/menus/pbs_menu.sh'
- 'scripts/utilities/pbs_update.sh'
- 'scripts/global/update-pbs.sh'
- 'scripts/menus/menu_post_install.sh'
- 'scripts/post_install/*.sh'
push:
branches: [main, develop]
paths:
- '.github/workflows/test-features.yml'
- 'AppImage/scripts/notification_manager.py'
- 'AppImage/scripts/notification_templates.py'
- 'AppImage/scripts/ai_context_enrichment.py'
- 'AppImage/scripts/ai_providers/**'
- 'AppImage/config/verified_ai_models.json'
- 'AppImage/scripts/tests/test_groq_model_recommendations.py'
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
- 'AppImage/scripts/notification_channels.py'
- 'AppImage/components/apprise-service-help.tsx'
- 'tests/test_apprise_services.py'
- 'tests/test_apprise_service_help.cjs'
- 'AppImage/scripts/flask_vm_backup_routes.py'
- 'AppImage/components/backup-storage-retention.tsx'
- 'AppImage/lib/backup-retention.ts'
- 'tests/test_vm_backup_jobs_api.py'
- 'tests/test_backup_storage_retention.cjs'
- 'tests/test_figurine_installation.py'
- 'scripts/post_install/customizable_post_install.sh'
- 'AppImage/scripts/post_install_versions.py'
- 'AppImage/scripts/flask_proxmenux_routes.py'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/tests/test_static_path_containment.py'
- 'AppImage/scripts/ovs_topology.py'
- 'AppImage/scripts/tests/test_ovs_topology.py'
- 'AppImage/components/network-flow.tsx'
- 'AppImage/components/network-metrics.tsx'
- 'AppImage/tests/ovs-network-flow.cjs'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
- 'AppImage/lib/guest-backup-archives.ts'
- 'tests/test_guest_backup_archives.*'
- 'AppImage/scripts/lvm_device_scope.py'
- 'AppImage/scripts/disk_temperature_history.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/flask_notification_routes.py'
- 'AppImage/scripts/proxmox_storage_monitor.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/scripts/tests/test_host_backup_node.py'
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
- 'tests/test_lvm_disk_read_policy.py'
- 'scripts/gpu_tpu/nvidia_installer.sh'
- 'tests/test_nvidia*.py'
- 'AppImage/scripts/managed_installs.py'
- 'oci/**'
- 'AppImage/scripts/oci_menu_bridge.py'
- 'AppImage/scripts/lxc_apps.py'
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
- 'AppImage/scripts/*pbs*.py'
- 'scripts/global/proxmox_platform.py'
workflow_dispatch:
permissions:
contents: read
# A newer push to the same branch or pull request replaces the run in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
apprise-services:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
apprise: ['1.7.0', '2.0.1']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'apprise==${{ matrix.apprise }}'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test real provider plugins with mocked HTTP
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
- name: Render provider help and locale fallback
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_apprise_service_help.cjs
storage-retention:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'Flask==3.1.2'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test native job and storage configuration APIs
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
- name: Test shared retention properties
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_backup_storage_retention.cjs
static-files:
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: Install Flask
run: python -m pip install 'Flask==3.1.2'
- name: Test that the Monitor's own files never leave their folder
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_static_path_containment.py -v
nvidia-installer:
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: Check NVIDIA parser, cache and Secure Boot regressions
run: |
bash -n scripts/gpu_tpu/nvidia_installer.sh
python3 -B -m unittest discover -s tests -p 'test_nvidia*.py' -v
ai-model-availability:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test model ordering and curated availability without network or credentials
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_groq_model_recommendations.py -v
installer-and-release:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check shell syntax and isolated installer regressions
run: |
bash -n scripts/post_install/customizable_post_install.sh
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
- name: Install ARM user-mode emulation for release smoke tests
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends qemu-user
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
env:
FIGURINE_VERSION: '2.1.1'
shell: bash
run: |
set -euo pipefail
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
while read -r arch sha; do
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
target="$scratch/$arch"
mkdir -p "$target"
curl --fail --location --retry 3 --output "$target/$archive" \
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
tar -xf "$target/$archive" -C "$target"
runner=()
case "$arch" in
arm64) runner=(qemu-aarch64) ;;
arm) runner=(qemu-arm) ;;
esac
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
test -s "$target/banner"
printf 'PASS: %s version and login banner\n' "$arch"
done <<'ASSETS'
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
ASSETS
guest-backups:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'Flask==3.1.2'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test native deletion, protection, authorization and cache invalidation
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
- name: Test frontend deletion and completion handling
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_guest_backup_archives.cjs
disk-policy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test automatic LVM readers with mocked inventory and policy
run: python -I -B -m unittest discover -s tests -p test_lvm_disk_read_policy.py -v
- name: Test backup node ownership and former jobs
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_host_backup_node.py -v
- name: Test local node lookup without Proxmox API commands
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_proxmox_node_name.py -v
- name: Test notification configuration writes against a cluster filesystem fixture
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_pve_notifications_cfg_writes.py -v
- name: Test AI diagnostic explanations and backup inventory integrity
run: |
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_backup_ai_diagnostics.py -v
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_vzdump_ai_integrity.py -v
registry-accounts:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install OCI test dependencies
run: python3 -m pip install -r oci/requirements.txt
- name: Run watchdog identity and lifecycle regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p test_watchdog.py -v
- name: Run OCI image reuse and resource edit regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
- name: Run carried record, recovery and configuration cache regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
- name: Run read-only volume preparation regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
test -x /bin/bash
test -x /usr/bin/jq
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
- name: Run offline registry account tests
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
- name: Run OCI menu update checks and selection regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_update*.py' -v
python3 -m unittest discover -s AppImage/scripts/tests -p 'test_oci*.py' -v
python3 -m unittest discover -s oci/tests -p test_recreate_registry_digest.py -v
- name: Run OCI Modify latency regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
- name: Run OCI modification menu regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
- name: Run external OCI settings regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
- name: Run managed volume startup regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_managed_volume_*.py' -v
- name: Run catalog web-link regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p test_catalog_web_links.py -v
- name: Run full OCI engine test suite
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -v
pbs:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python: ['3.11', '3.13']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install test dependencies
run: python -m pip install Flask PyJWT cryptography pyotp segno
- name: Platform, real TLS, APIs, health and AppImage packaging
run: |
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
bash -n "$script"
done
- uses: actions/setup-node@v6
if: matrix.python == '3.11'
with:
node-version: '22.14.0'
- name: Dashboard platform routing
if: matrix.python == '3.11'
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
native-image-import:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Exercise private images with isolated mapped Linux readers
run: sudo /usr/bin/python3 -B -m unittest discover -s oci/tests -p test_native_import_access.py -v
network-topology:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Test read-only OVS discovery and mixed network API
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_ovs_topology.py -v
- name: Test both Network Flow layouts
run: |
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/ovs-network-flow.cjs
-73
View File
@@ -1,73 +0,0 @@
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
@@ -1,54 +0,0 @@
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
+5
View File
@@ -51,6 +51,11 @@ on:
permissions:
contents: read
# A newer push to the same branch or pull request replaces the run in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Preserve this check ID for existing branch protection rules. This suite
# includes menu, download and scheduler regressions as well as i18n tests.
@@ -1,55 +0,0 @@
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
@@ -1,109 +0,0 @@
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
+6 -1
View File
@@ -57,6 +57,11 @@ on:
permissions:
contents: read
# A newer push to the same branch or pull request replaces the run in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
offline-python:
runs-on: ubuntu-latest
@@ -69,7 +74,7 @@ jobs:
with:
python-version: '3.11'
- name: Install native discovery route fixture dependency
run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1'
run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1' 'PyJWT>=2,<3' 'gevent>=25,<27' 'flask-sock==0.7.0' requests
- name: Run qualified Python files
env:
PYTHONDONTWRITEBYTECODE: '1'
-68
View File
@@ -1,68 +0,0 @@
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
+50
View File
@@ -0,0 +1,50 @@
name: USB/IP over SSH tests
on:
pull_request:
paths:
- 'scripts/devices/**'
- 'scripts/menus/hw_grafics_menu.sh'
- 'scripts/menus/main_menu.sh'
- 'scripts/menus/search_index.json'
- '.github/scripts/tests/test_usbip*.py'
- '.github/scripts/check_usbip_transport.py'
- '.github/workflows/test-usbip.yml'
- 'docs/usbip.md'
push:
branches: [main, develop]
paths:
- 'scripts/devices/**'
- '.github/scripts/tests/test_usbip*.py'
- '.github/scripts/check_usbip_transport.py'
- '.github/workflows/test-usbip.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
usbip:
runs-on: ubuntu-latest
timeout-minutes: 8
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: Device ownership, reconnect and systemd validation
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -B -m unittest discover -s .github/scripts/tests -p 'test_usbip*.py' -v
- name: Install fixture-only transport dependencies
run: |
sudo apt-get update -o APT::Update::Error-Mode=any
sudo apt-get install -y socat openssh-client openssh-server iproute2 util-linux
- name: Real SSH and private namespace transport, without USB hardware
run: sudo "$(command -v python3)" -B .github/scripts/check_usbip_transport.py
@@ -14,6 +14,7 @@ on:
branches: [main]
paths:
- ".github/scripts/generate_app_tracking_catalog.py"
- ".github/scripts/tests/test_app_tracking_sources.py"
- ".github/workflows/update-app-tracking-hints.yml"
- "json/runtime_verified_overrides.json"
@@ -37,6 +38,9 @@ jobs:
with:
python-version: "3.11"
- name: Test Helper-Scripts source layouts
run: python -B -m unittest discover -s .github/scripts/tests -p test_app_tracking_sources.py -v
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -54,6 +54,7 @@ Thumbs.db
# Cache
.cache
/oci/.catalog-cache/
/web/.cache
# IDE/editor files
+1 -1
View File
@@ -1 +1 @@
5e8afe3b297f21b8646e58eed720ba2eeb5f16681a3d2eb89bcac600065167c9 ProxMenux-1.2.7.AppImage
69ed639710aba5804e3b8f33383357f3bcc981b94a5e9b272d214f31e2e87563 ProxMenux-1.2.8.AppImage
-12
View File
@@ -18,7 +18,6 @@ interface DiskEntry {
rotational: boolean
excluded: boolean
excluded_at: string | null
idle: boolean
present: boolean
}
@@ -206,15 +205,6 @@ export function DiskExclusions() {
{t("settings.diskExclusions.excluded")}
</Badge>
)}
{!excluded && disk.idle && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground"
title={t("storage.idleTitle")}
>
{t("storage.idle")}
</Badge>
)}
{!disk.present && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
{t("settings.diskExclusions.notConnected")}
@@ -252,8 +242,6 @@ export function DiskExclusions() {
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("settings.diskExclusions.help")}
<br />
{t("settings.diskExclusions.idleHelp")}
</p>
</div>
</div>
+79 -3
View File
@@ -633,6 +633,13 @@ export default function Hardware() {
return realtimeGPUData.has_monitoring_tool === true
}
const diskCountBySlot = new Map<string, number>()
for (const disk of hardwareData?.storage_devices ?? []) {
if (disk.controller_slot) {
diskCountBySlot.set(disk.controller_slot, (diskCountBySlot.get(disk.controller_slot) ?? 0) + 1)
}
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
@@ -2358,14 +2365,23 @@ return (
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{hardwareData.pci_devices.map((device, index) => (
{hardwareData.pci_devices.map((device, index) => {
const diskCount = diskCountBySlot.get(device.slot) ?? 0
return (
<div
key={index}
onClick={() => setSelectedPCIDevice(device)}
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
>
<div className="flex items-center justify-between gap-2 mb-2">
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
<div className="flex items-center gap-1.5 min-w-0">
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
{diskCount > 0 && (
<Badge variant="outline" className="text-xs shrink-0">
{t("hardware.counts.devices", { count: diskCount })}
</Badge>
)}
</div>
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
</div>
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
@@ -2374,7 +2390,8 @@ return (
<p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
)}
</div>
))}
)
})}
</div>
</Card>
)}
@@ -2434,6 +2451,37 @@ return (
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
</div>
)}
{(() => {
const attachedDisks = (hardwareData?.storage_devices ?? []).filter(
(disk) => disk.controller_slot === selectedPCIDevice.slot,
)
if (attachedDisks.length === 0) return null
return (
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">
{t("hardware.labels.attachedDisks")} ({attachedDisks.length})
</span>
<div className="flex flex-wrap justify-end gap-1">
{attachedDisks.map((disk) => (
<button
key={disk.name}
type="button"
title={disk.model}
onClick={() => {
setSelectedPCIDevice(null)
setSelectedDisk(disk)
}}
>
<Badge variant="outline" className="font-mono hover:bg-accent">
{disk.name}
</Badge>
</button>
))}
</div>
</div>
)
})()}
</div>
)}
</DialogContent>
@@ -2827,6 +2875,34 @@ return (
</div>
)}
{selectedDisk.controller_slot && (() => {
// Fall back to the bare slot when the controller's PCI class isn't one we list
const controller = hardwareData?.pci_devices?.find((d) => d.slot === selectedDisk.controller_slot)
return (
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.storageController")}</span>
<span className="text-sm text-right">
{controller ? (
<button
type="button"
className="text-blue-400 hover:underline text-right"
onClick={() => {
setSelectedDisk(null)
setSelectedPCIDevice(controller)
}}
>
{/* lspci's "Device 84ca" placeholder means no subsystem name, so use the chip name */}
{controller.sdevice && !/^Device [0-9a-f]{4}$/.test(controller.sdevice)
? controller.sdevice
: controller.device}
</button>
) : null}
<span className="ml-2 font-mono text-muted-foreground">{selectedDisk.controller_slot}</span>
</span>
</div>
)
})()}
{selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.rotationRate")}</span>
+132 -21
View File
@@ -2,7 +2,7 @@
import type React from "react"
import { useState, useEffect, useCallback } from "react"
import { useState, useEffect, useCallback, useRef } from "react"
import { getAuthToken } from "@/lib/api-config"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
@@ -41,6 +41,9 @@ import {
HelpCircle,
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
import { HostUpdateResult } from "./host-update-result"
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform"
@@ -126,12 +129,23 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
const [error, setError] = useState<string | null>(null)
const [snapshotUnavailable, setSnapshotUnavailable] = useState(false)
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
const [resultJobId, setResultJobId] = useState<string | null>(null)
const healthRequest = useRef<AbortController | null>(null)
const previousAutoExpanded = useRef<Set<string>>(new Set())
const fetchHealthDetails = useCallback(async (force = false) => {
setLoading(true)
const fetchHealthDetails = useCallback(async (force = false, cachedOnly = false) => {
if (cachedOnly && healthRequest.current) return
healthRequest.current?.abort()
const controller = new AbortController()
healthRequest.current = controller
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
if (!cachedOnly) setLoading(true)
setError(null)
try {
@@ -144,14 +158,26 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
authHeaders["Authorization"] = `Bearer ${token}`
}
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders })
const path = cachedOnly ? "/api/health/full?cached=1" : force ? "/api/health/full?refresh=1" : "/api/health/full"
const response = await fetch(getApiUrl(path), { headers: authHeaders, signal: controller.signal })
if (healthRequest.current !== controller) return
let infoCount = 0
if (!response.ok) {
if (cachedOnly && response.status === 503) {
// Completion invalidates the snapshot before the collector refills it.
// Keep the dated result, explicitly unavailable; never start an audit.
setSnapshotUnavailable(true)
return
}
// Polling cannot fall back to a legacy endpoint that runs a full audit.
if (cachedOnly) throw new Error(t("healthStatus.errors.fetchFailed"))
// Fallback to legacy endpoint
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders, signal: controller.signal })
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
const data = await legacyResponse.json()
if (healthRequest.current !== controller) return
setSnapshotUnavailable(false)
setHealthData(data)
setDismissedItems([])
setCustomSuppressions([])
@@ -168,6 +194,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
} else {
const fullData: FullHealthData = await response.json()
if (healthRequest.current !== controller) return
if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed"))
setSnapshotUnavailable(false)
setHealthData(fullData.health)
setDismissedItems(fullData.dismissed || [])
setCustomSuppressions(fullData.custom_suppressions || [])
@@ -207,9 +236,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
})
window.dispatchEvent(event)
} catch (err) {
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
if (healthRequest.current === controller) {
setSnapshotUnavailable(false)
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
}
} finally {
setLoading(false)
if (timeout !== undefined) clearTimeout(timeout)
if (healthRequest.current === controller) {
healthRequest.current = null
setLoading(false)
}
}
}, [getApiUrl, t])
@@ -225,9 +261,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
useEffect(() => {
if (open) {
fetchHealthDetails()
// Auto-refresh every 5 minutes while modal is open
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
return () => clearInterval(refreshInterval)
// Recheck reboot evidence promptly without running APT/full health checks.
const refresh = () => {
if (document.visibilityState === "visible") void fetchHealthDetails(false, true)
}
const refreshInterval = setInterval(refresh, 30000)
window.addEventListener("focus", refresh)
document.addEventListener("visibilitychange", refresh)
return () => {
clearInterval(refreshInterval)
window.removeEventListener("focus", refresh)
document.removeEventListener("visibilitychange", refresh)
const controller = healthRequest.current
healthRequest.current = null
controller?.abort()
}
}
}, [open, fetchHealthDetails])
@@ -245,7 +293,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
nonOkCategories.add(key)
}
})
setExpandedCategories(nonOkCategories)
const newlyDegraded = [...nonOkCategories].filter(key => !previousAutoExpanded.current.has(key))
previousAutoExpanded.current = nonOkCategories
if (newlyDegraded.length) {
setExpandedCategories(previous => new Set([...previous, ...newlyDegraded]))
}
}
}, [healthData])
@@ -300,6 +352,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const translateHealthText = (value?: string): string => {
if (!value) return ""
// Health summaries include the category before the same detail text.
if (value.startsWith("Updates: ")) {
return `${t("healthStatus.categories.updates")}: ${translateHealthText(value.slice(9))}`
}
const exact: Record<string, string> = {
"No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"),
"Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"),
@@ -330,6 +386,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"Active": t("healthStatus.details.active"),
"UP": t("healthStatus.details.up"),
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
"No reboot required": t("healthStatus.hostUpdate.noReboot"),
"No pending updates": t("healthStatus.details.noPendingUpdates"),
"Reboot required by the operating system": t("healthStatus.hostUpdate.rebootMarker"),
"Could not determine whether a reboot is required": t("healthStatus.hostUpdate.rebootUnknown"),
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
"No container startup errors": t("healthStatus.details.noContainerErrors"),
@@ -341,6 +401,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
const reboot = value.match(/^Reboot required: running (.+); installed (.+)$/)
if (reboot) return t("healthStatus.hostUpdate.rebootKernel", { running: reboot[1], installed: reboot[2] })
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
match = value.match(/^(\d+) failed login attempts in 24h$/)
if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
@@ -623,7 +685,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", "reboot_required",
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
])
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
@@ -634,6 +696,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
.replace(/\b\w/g, (c) => c.toUpperCase())
}
const translateHealthSummary = (snapshot: HealthDetails): string => {
if (snapshot.summary === "Updates") {
const reboot = snapshot.details?.updates?.checks?.reboot_required
if (reboot?.status === "INFO" && typeof reboot.detail === "string" && reboot.detail) {
return translateHealthText(reboot.detail)
}
return t("healthStatus.categories.updates")
}
return translateHealthText(snapshot.summary)
}
const renderChecks = (
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
categoryKey: string
@@ -645,6 +718,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{Object.entries(checks)
.filter(([, checkData]) => checkData.installed !== false)
.map(([checkKey, checkData]) => {
if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") {
return <RestartAfterUpdate key={checkKey} getApiUrl={getApiUrl}
unavailable={loading || snapshotUnavailable || !!error}
disabled={hostJobs.some(job => job.state !== "complete")} label={formatCheckLabel(checkKey)}
detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} />
}
const isDismissable = checkData.dismissable === true
const checkStatus = checkData.status?.toUpperCase() || "OK"
@@ -711,6 +790,14 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</DialogDescription>
</DialogHeader>
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
onResume={job => {
if (job.state === "complete") { setResultJobId(job.id); return }
setHostJob({ runId: job.id, resume: true, kind: "host" })
setShowUpdateTerminal(true)
}} />
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
@@ -724,8 +811,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
)}
{healthData && !loading && (
<div className="space-y-4">
{snapshotUnavailable && (
<div role="status" className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 text-xs text-amber-600 dark:text-amber-400">
<p>{t("healthStatus.snapshotUnavailable")}</p>
{healthData && <p className="mt-1">{t("healthStatus.lastKnownResults")}</p>}
</div>
)}
{healthData && (
<fieldset disabled={loading} className="space-y-4 min-w-0">
{/* Overall Stats Summary */}
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
<div className="text-center">
@@ -760,7 +854,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{healthData.summary && healthData.summary !== "All systems operational" && (
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthSummary(healthData)}</p>
</div>
)}
@@ -845,18 +939,30 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
pending prevents the operator from spawning a
terminal that would only report "System is
already up to date". */}
{key === "updates" && status?.toUpperCase() !== "OK" && (
<div className="flex justify-end px-3 py-2 pt-1">
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
<p className="px-2 sm:px-3 mt-2 text-[10px] sm:text-xs text-muted-foreground">
{t("healthStatus.packagePlanNote")}
</p>
)}
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
<div className="flex justify-end px-2 sm:px-3 pt-3 pb-1">
<Button
size="sm"
onClick={() => setShowUpdateTerminal(true)}
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
disabled={hostJobs.some(job => job.state !== "complete")}
onClick={() => {
const runId = window.crypto?.randomUUID?.()
|| `host-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
setHostJob({ runId, resume: false, kind: "host" })
setShowUpdateTerminal(true)
}}
className="h-10 sm:h-[34px] bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
>
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
{t("healthStatus.updateNow")}
{t("healthStatus.updateSystem")}
</Button>
</div>
)}
{key === "updates" && <ProxMenuxVersionCard getApiUrl={getApiUrl} />}
</div>
)}
</div>
@@ -964,11 +1070,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
</div>
)}
</div>
</fieldset>
)}
</DialogContent>
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
<ScriptTerminalModal
key={hostJob?.runId || "host-update"}
open={showUpdateTerminal}
backgroundUpdate={hostJob || undefined}
cancelledExitCode={130}
onComplete={() => { void fetchHealthDetails(true) }}
onClose={() => {
setShowUpdateTerminal(false)
// Force a fresh read (cache-busting via ?refresh=1) so the
+14 -1
View File
@@ -19,6 +19,7 @@ import {
FolderOpen,
Database,
Waves,
Monitor,
} from "lucide-react"
import { getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
@@ -85,6 +86,7 @@ interface ThresholdsTree {
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
vm_disk: { warning: ThresholdLeaf; critical: ThresholdLeaf }
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
disk_temperature: {
hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
@@ -202,6 +204,17 @@ const SECTIONS: SectionDef[] = [
{ path: ["lxc_rootfs", "critical"], label: "Critical" },
],
},
{
id: "vm_disk",
title: "Disk space — VMs",
icon: Monitor,
description:
"Filesystems of running VMs as the QEMU guest agent reports them, passthrough drives included. VMs without the guest agent are not evaluated.",
fields: [
{ path: ["vm_disk", "warning"], label: "Warning" },
{ path: ["vm_disk", "critical"], label: "Critical" },
],
},
{
id: "lxc_mount",
title: "LXC mount points",
@@ -285,7 +298,7 @@ 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"])
const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "vm_disk", "lxc_mount", "pve_storage"])
export function HealthThresholds() {
const t = useT()
+67
View File
@@ -0,0 +1,67 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "./ui/button"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
export type HostUpdateJob = {
id: string
state: "starting" | "running" | "attention" | "finalizing" | "complete"
exit_code: number | null
}
export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }: {
open: boolean
activeId?: string
onResume: (job: HostUpdateJob) => void
onJobs: (jobs: HostUpdateJob[]) => void
onSettled: () => void
}) {
const t = useT()
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
const [hidden, setHidden] = useState<Set<string>>(new Set())
const callbacks = useRef({ onJobs, onSettled })
callbacks.current = { onJobs, onSettled }
const observed = useRef(new Set<string>())
useEffect(() => {
if (!open) return
let closed = false
const read = async () => {
try {
const data = await fetchApi<{ jobs: HostUpdateJob[] }>("/api/host-update-jobs")
if (closed) return
const next = Array.isArray(data.jobs) ? data.jobs : []
setJobs(next)
callbacks.current.onJobs(next)
for (const job of next) {
if (job.state === "complete" && !observed.current.has(job.id)) {
observed.current.add(job.id)
callbacks.current.onSettled()
}
}
} catch {
// Authentication recovery belongs to fetchApi. Never retry a start.
}
}
void read()
const timer = setInterval(() => { void read() }, 5000)
return () => { closed = true; clearInterval(timer) }
}, [open])
const visible = jobs.filter(job => job.id !== activeId && !(job.state === "complete" && hidden.has(job.id)))
if (!visible.length) return null
return <div className="space-y-2" role="status">
{visible.map(job => <div key={job.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
<p className="text-sm">{t(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
{t(job.state === "complete" ? "healthStatus.hostUpdate.viewResult" : "healthStatus.hostUpdate.view")}
</Button>
{job.state === "complete" && <Button size="sm" variant="ghost" onClick={() => setHidden(previous => new Set([...previous, job.id]))}>
{t("healthStatus.hostUpdate.hideResult")}
</Button>}
</div>
</div>)}
</div>
}
@@ -0,0 +1,77 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { Button } from "./ui/button"
import { getAuthToken } from "../lib/api-config"
import { scriptCompletion } from "../lib/script-completion"
import { useT } from "../lib/i18n/provider"
import "xterm/css/xterm.css"
export function HostUpdateResult({ jobId, getApiUrl, onClose }: {
jobId: string | null
getApiUrl: (path: string) => string
onClose: () => void
}) {
const t = useT()
const container = useRef<HTMLDivElement>(null)
const [revision, setRevision] = useState(0)
const [exitCode, setExitCode] = useState<number | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!jobId) return
const controller = new AbortController()
let terminal: import("xterm").Terminal | undefined
let observer: ResizeObserver | undefined
let disposed = false
setExitCode(null); setFailed(false)
const timeout = setTimeout(() => controller.abort(), 10000)
void (async () => {
try {
const token = getAuthToken()
const response = await fetch(getApiUrl(`/api/host-update-jobs/${encodeURIComponent(jobId)}/result`), {
headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal,
})
if (!response.ok) throw Error("unavailable")
const value = await response.json()
if (value.state !== "complete" || !Number.isSafeInteger(value.exit_code)
|| typeof value.text !== "string" || value.text.length > 256 * 1024
|| typeof value.truncated !== "boolean") throw Error("invalid result")
const [{ Terminal }, { FitAddon }] = await Promise.all([import("xterm"), import("xterm-addon-fit")])
if (controller.signal.aborted || !container.current) return
terminal = new Terminal({ disableStdin: true, convertEol: true, fontSize: 12,
screenReaderMode: true, theme: { background: "#000000", foreground: "#dddddd" } })
const fit = new FitAddon()
terminal.loadAddon(fit); terminal.open(container.current); fit.fit()
if (value.truncated) terminal.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
terminal.write(value.text)
observer = new ResizeObserver(() => { if (!controller.signal.aborted) fit.fit() })
observer.observe(container.current)
setExitCode(value.exit_code)
} catch {
if (!disposed) setFailed(true)
} finally { clearTimeout(timeout) }
})()
return () => { disposed = true; controller.abort(); clearTimeout(timeout); observer?.disconnect(); terminal?.dispose() }
}, [jobId, getApiUrl, revision, t])
const kind = exitCode === null ? null : scriptCompletion(exitCode, 130).kind
return <Dialog open={jobId !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto p-4 sm:p-5">
<DialogHeader><DialogTitle>{t("healthStatus.hostUpdate.resultTitle")}</DialogTitle>
<DialogDescription>{t("healthStatus.hostUpdate.resultReadOnly")}</DialogDescription></DialogHeader>
{failed && <div role="alert" className="text-sm text-amber-500">
<p>{t("healthStatus.hostUpdate.resultUnavailable")}</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => setRevision(value => value + 1)}>{t("healthStatus.hostUpdate.retryResult")}</Button>
</div>}
<div ref={container} className="h-[45vh] min-h-[180px] bg-black rounded overflow-hidden" />
<div className="flex items-center justify-between gap-3">
<p role="status" className={`text-xs ${exitCode === 0 ? "text-green-500" : "text-muted-foreground"}`}>
{kind === null ? t(failed ? "healthStatus.hostUpdate.resultUnavailable" : "healthStatus.hostUpdate.loadingResult")
: kind === "cancelled" ? t("scriptTerminal.cancelled") : exitCode === 0
? t("scriptTerminal.completedSuccessfully") : t("scriptTerminal.completedWithError", { code: exitCode })}
</p>
<Button size="sm" variant="outline" onClick={onClose}>{t("healthStatus.hostUpdate.closeResult")}</Button>
</div>
</DialogContent>
</Dialog>
}
+177
View File
@@ -0,0 +1,177 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
import * as Confirm from "@radix-ui/react-alert-dialog"
import { Button } from "./ui/button"
import { getAuthToken } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
import { CheckCircle2, AlertTriangle, Info, ChevronRight } from "lucide-react"
function headers() {
const token = getAuthToken()
return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }
}
type Url = (path: string) => string
type Context = { hostname: string; boot_id: string; allowed: boolean; reason: string }
const compact = "h-10 sm:h-[34px] px-3 text-xs"
export function RestartAfterUpdate({ getApiUrl, disabled, unavailable = false, label, detail, icon }: {
getApiUrl: Url; disabled: boolean; unavailable?: boolean; label: string; detail: string; icon: ReactNode
}) {
const t = useT()
const [open, setOpen] = useState(false)
const [context, setContext] = useState<Context | null>(null)
const [submitted, setSubmitted] = useState(false)
const [pending, setPending] = useState(false)
const [accepted, setAccepted] = useState(false)
const [error, setError] = useState(false)
const sending = useRef(false)
const alive = useRef(true)
const postRequest = useRef<AbortController | null>(null)
useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, [])
useEffect(() => {
let disposed = false, reading = false
let active: AbortController | undefined
setContext(null); setSubmitted(false); setError(false); sending.current = false
const read = async () => {
if (reading) return
reading = true
const controller = new AbortController(); active = controller
const timeout = setTimeout(() => controller.abort(), 6000)
try {
const response = await fetch(getApiUrl("/api/health/reboot-context"), { headers: headers(), signal: controller.signal })
if (!response.ok) throw Error("unavailable")
const value: Context = await response.json()
if (!value || typeof value.hostname !== "string" || typeof value.allowed !== "boolean" || typeof value.boot_id !== "string" || !value.boot_id) throw Error("invalid")
if (!disposed) { setContext(value); setError(false) }
} catch { if (!disposed) { setContext(null); setError(true) } }
finally { clearTimeout(timeout); reading = false }
}
void read()
// A retained health snapshot must never grant permission after reconnect
// or remount. This cheap read does not run APT or a full health check.
const interval = setInterval(read, open ? 2000 : 30000)
return () => { disposed = true; clearInterval(interval); active?.abort() }
}, [open, getApiUrl])
useEffect(() => {
if (!accepted) return
let disposed = false, reading = false
const controller = new AbortController()
const interval = setInterval(async () => {
if (reading) return
reading = true
try {
const response = await fetch(getApiUrl("/api/health/reboot-after-update/status"), { headers: headers(), signal: controller.signal })
if (!response.ok) return
const value = await response.json()
if (!disposed && (value.state === "failed" || value.state === "cancelled")) { setAccepted(false); setError(true) }
} catch { /* Loss of connection does not prove reboot completion. */ }
finally { reading = false }
}, 2000)
const timeout = setTimeout(() => { clearInterval(interval); controller.abort() }, 10000)
return () => { disposed = true; controller.abort(); clearInterval(interval); clearTimeout(timeout) }
}, [accepted, getApiUrl])
const confirm = async () => {
if (disabled || unavailable || !context?.allowed || sending.current || submitted) return
sending.current = true; setSubmitted(true); setPending(true); setError(false)
const controller = new AbortController(); postRequest.current = controller
const timeout = setTimeout(() => controller.abort(), 15000)
try {
const response = await fetch(getApiUrl("/api/health/reboot-after-update"), {
method: "POST", headers: headers(), body: JSON.stringify({ confirm: true, hostname: context.hostname, boot_id: context.boot_id }), signal: controller.signal,
})
if (response.status !== 202 || (await response.json()).accepted !== true) throw Error("unconfirmed")
if (alive.current) { setAccepted(true); setOpen(false) }
} catch { if (alive.current) setError(true) }
finally { clearTimeout(timeout); postRequest.current = null; if (alive.current) setPending(false) }
}
return <section className="py-1.5 px-2 sm:px-3">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-start gap-2 flex-1 min-w-0 text-[10px] sm:text-xs"><span className="shrink-0">{icon}</span>
<div><span className="font-medium mr-2">{label}</span><span className="text-muted-foreground break-words">{detail}</span></div></div>
<Button variant="outline" className={`${compact} self-start sm:shrink-0 border-amber-500/40 text-amber-500`}
disabled={disabled || unavailable || !context?.allowed || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}</Button>
</div>
{disabled && <p className="mt-2 text-xs text-muted-foreground">{t("healthStatus.restart.blocked")}</p>}
{accepted && <p role="status" className="mt-2 text-xs text-blue-500">{t("healthStatus.restart.accepted")}</p>}
{error && !open && <p role="alert" className="mt-2 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
<Confirm.Root open={open} onOpenChange={setOpen}><Confirm.Portal>
<Confirm.Overlay className="fixed inset-0 bg-black/75 z-[70]" />
<Confirm.Content className="fixed z-[80] w-[92vw] max-w-[520px] rounded-lg border bg-card text-foreground p-5 shadow-lg" style={{ top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
<Confirm.Title className="text-base font-semibold">{t("healthStatus.restart.title", { server: context?.hostname ?? "…" })}</Confirm.Title>
<Confirm.Description className="mt-3 text-sm text-muted-foreground">{t("healthStatus.restart.impact")}</Confirm.Description>
{(disabled || context?.reason === "busy") && <p className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.blocked")}</p>}
{context?.reason === "current" && <p className="mt-3 text-xs text-muted-foreground">{t("healthStatus.hostUpdate.noReboot")}</p>}
{(error || context?.reason === "unknown") && <p role="alert" className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
<div className="mt-5 flex justify-end gap-2.5">
<Confirm.Cancel asChild><Button variant="outline" className={compact} disabled={pending}>{t("healthStatus.restart.cancel")}</Button></Confirm.Cancel>
<Confirm.Action asChild><Button variant="destructive" className={compact} disabled={disabled || unavailable || !context?.allowed || pending || submitted}
onClick={event => { event.preventDefault(); void confirm() }}>{t("healthStatus.restart.confirm")}</Button></Confirm.Action>
</div>
</Confirm.Content>
</Confirm.Portal></Confirm.Root>
</section>
}
type Versions = { installed: string | null; available: string | null; state: "available" | "current" | "unknown"; checked_at: string | null; channel: "stable" | "beta" }
export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) {
const t = useT()
const [value, setValue] = useState<Versions | null>(null)
useEffect(() => {
let disposed = false, reading = false
let active: AbortController | undefined
const read = async () => {
if (reading || document.visibilityState !== "visible") return
reading = true
const controller = new AbortController(); active = controller
const timeout = setTimeout(() => controller.abort(), 6000)
try {
const response = await fetch(getApiUrl("/api/health/proxmenux-version"), { headers: headers(), signal: controller.signal })
if (!response.ok) throw Error("unavailable")
const data: Versions = await response.json()
if (!["available", "current", "unknown"].includes(data?.state)) throw Error("invalid")
const validVersion = (version: unknown) => typeof version === "string" && /^\d+(?:\.\d+){1,5}$/.test(version)
if (!["stable", "beta"].includes(data.channel) ||
(data.installed !== null && !validVersion(data.installed)) ||
(data.available !== null && !validVersion(data.available)) ||
(data.state !== "unknown" && (!validVersion(data.installed) || !validVersion(data.available) || !data.checked_at)) ||
(data.checked_at !== null && !Number.isFinite(Date.parse(data.checked_at)))) throw Error("invalid")
if (!disposed) setValue(data)
} catch { if (!disposed) setValue(previous => previous ? { ...previous, state: "unknown" } : null) }
finally { clearTimeout(timeout); reading = false }
}
void read()
const interval = setInterval(read, 300000)
window.addEventListener("focus", read); document.addEventListener("visibilitychange", read)
return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) }
}, [getApiUrl])
const state = value?.state ?? "unknown"
const versions = <dl className="grid grid-cols-2 gap-4">
<div><dt className="text-muted-foreground mb-1">{t("healthStatus.versions.installed")}</dt><dd className="font-mono font-semibold">{value?.installed ?? t("healthStatus.versions.unverified")}</dd></div>
<div><dt className="text-muted-foreground mb-1">{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}</dt><dd className="font-mono font-semibold">{value?.available ?? t("healthStatus.versions.unverified")}</dd></div>
</dl>
const metadata = <>
{value && <p className="mt-3 text-muted-foreground">{t(`healthStatus.versions.${value.channel}`)}</p>}
{value?.checked_at && <p className="mt-1 text-muted-foreground">{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}</p>}
</>
if (state !== "available") return <details aria-label={t("healthStatus.versions.title")} className="group text-[10px] sm:text-xs">
<summary className="flex items-start gap-2 py-1.5 px-2 sm:px-3 rounded-md hover:bg-muted/40 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
{state === "current" ? <CheckCircle2 aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-green-500" /> : <AlertTriangle aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-amber-500" />}
<span className="font-medium shrink-0">ProxMenux</span>
<span className={`flex-1 min-w-0 break-words ${state === "current" ? "text-muted-foreground" : "text-amber-500"}`}>
{state === "current" ? t("healthStatus.versions.currentVersion", { version: value!.installed! }) : t("healthStatus.versions.unknown")}
</span>
<ChevronRight aria-hidden="true" className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
</summary>
<div className="ml-7 mr-2 sm:mr-3 pb-2">
{state === "unknown" && versions}
{metadata}
</div>
</details>
return <section aria-label={t("healthStatus.versions.title")} className="mt-3 mx-2 sm:mx-3 rounded-lg border bg-muted/10 p-3 text-[10px] sm:text-xs">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3"><h3 className="font-semibold">{t("healthStatus.versions.title")}</h3>
<span className="flex items-center gap-1.5 text-blue-500"><Info aria-hidden="true" className="h-3.5 w-3.5" />{t("healthStatus.versions.available")}</span></div>
{versions}
{metadata}
</section>
}
+18 -15
View File
@@ -256,21 +256,24 @@ export function Login({ onLogin }: LoginProps) {
</div>
<div className="space-y-2">
<Label htmlFor="totp-code" className="text-sm">
{t("login.authenticationCode")}
</Label>
<Input
id="totp-code"
type="text"
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 9))}
className="text-center text-lg tracking-widest font-mono text-base"
maxLength={9}
disabled={loading}
autoComplete="one-time-code"
autoFocus
/>
{/* Password managers read the field's siblings and skip it when they mention a backup code. */}
<div className="space-y-2">
<Label htmlFor="totp-code" className="text-sm">
{t("login.authenticationCode")}
</Label>
<Input
id="totp-code"
type="text"
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 9))}
className="text-center text-lg tracking-widest font-mono text-base"
maxLength={9}
disabled={loading}
autoComplete="one-time-code"
autoFocus
/>
</div>
<p className="text-xs text-muted-foreground text-center">
{t("login.backupCodeHint")}
</p>
+20 -1
View File
@@ -397,19 +397,38 @@ const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: strin
lightTheme: `${SELFHST_WEBP_BASE}/frigate-dark.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/frigate-light.webp`,
},
"hermes-agent": {
lightTheme: `${SELFHST_WEBP_BASE}/hermes-agent.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/hermes-agent-light.webp`,
},
"llama-cpp": {
lightTheme: `${SELFHST_WEBP_BASE}/llama-cpp.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/llama-cpp-light.webp`,
},
portainer: {
lightTheme: `${SELFHST_WEBP_BASE}/portainer-dark.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/portainer-light.webp`,
},
tailscale: {
lightTheme: `${SELFHST_WEBP_BASE}/tailscale.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/tailscale-light.webp`,
},
vaultwarden: {
lightTheme: `${SELFHST_WEBP_BASE}/vaultwarden.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/vaultwarden-light.webp`,
},
}
// Earlier catalog icons that now have a themed selfhst equivalent.
const THEMED_LOGO_ALIASES: Record<string, string> = {
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/webp/hermes.webp": "hermes-agent",
}
function selfhstThemeLogos(src: string) {
const alias = THEMED_LOGO_ALIASES[src.toLowerCase()]
if (alias) return SELFHST_THEME_LOGOS[alias]
if (!src.toLowerCase().includes("cdn.jsdelivr.net/gh/selfhst/icons")) return null
const match = src.toLowerCase().match(/\/(frigate|portainer|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
const match = src.toLowerCase().match(/\/(frigate|hermes-agent|llama-cpp|portainer|tailscale|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
return match ? SELFHST_THEME_LOGOS[match[1]] : null
}
+4 -1
View File
@@ -62,7 +62,7 @@ type Bridge = {
type Guest = {
id: string
label: string
kind: "lxc" | "vm" | "host"
kind: "lxc" | "vm" | "host" | "interface"
bridge: string
rx: number
tx: number
@@ -87,6 +87,8 @@ const ICONS: Record<string, string> = {
vm: `<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>`,
}
ICONS.interface = ICONS.bridge
const COLORS = {
host: "var(--amber-500, #f59e0b)",
lxc: "var(--cyan-500, #06b6d4)",
@@ -97,6 +99,7 @@ const COLORS = {
// just another NIC.
bond: "var(--orange-500, #f97316)",
bridge: "var(--cyan-500, #06b6d4)",
interface: "var(--cyan-500, #06b6d4)",
gray: "#525252",
}
+12 -4
View File
@@ -101,6 +101,9 @@ interface NetworkInterface {
// Master device resolved from /sys/class/net/<iface>/master — the
// bridge for a guest tap, the bond for a slave NIC.
bridge_owner?: string
network_backend?: "ovs"
ovs_tag?: number | null
ovs_trunks?: number[]
bridge_members?: string[]
bridge_physical_interface?: string
bridge_bond_slaves?: string[]
@@ -568,6 +571,7 @@ export function NetworkMetrics() {
...(networkData.bond_interfaces || []),
...(networkData.bridge_interfaces || []),
...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
]
const vmLxcInterfaces = (networkData.vm_lxc_interfaces || []).sort((a, b) => {
@@ -867,6 +871,7 @@ export function NetworkMetrics() {
...(networkData.bond_interfaces || []),
...(networkData.bridge_interfaces || []),
...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
]
const flowData: NetworkFlowData = {
nics: (networkData.physical_interfaces || []).map((p) => ({
@@ -916,7 +921,9 @@ export function NetworkMetrics() {
tx: runningGuests.reduce((a, v) => a + toMBps(v.tx_Bps), 0),
}
})(),
...(networkData.vm_lxc_interfaces || []).map((v) => {
...[...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
].map((v) => {
// Authoritative bridge from the kernel (read by the
// backend from /sys/class/net/<iface>/master). Fallback
// to bridge_members scan, then first bridge as last
@@ -929,8 +936,8 @@ export function NetworkMetrics() {
(networkData.bridge_interfaces?.[0]?.name || "")
return {
id: v.name,
label: v.vm_name || v.name,
kind: (v.vm_type === "vm" ? "vm" : "lxc") as "vm" | "lxc",
label: v.vm_name || (v.ovs_tag != null ? `${v.name} · VLAN ${v.ovs_tag}` : v.name),
kind: v.type === "vlan" ? "interface" as const : (v.vm_type === "vm" ? "vm" as const : "lxc" as const),
bridge: ownerName,
rx: toMBps(v.rx_Bps),
tx: toMBps(v.tx_Bps),
@@ -1213,6 +1220,7 @@ export function NetworkMetrics() {
...(modalNetworkData.bond_interfaces || []),
...(modalNetworkData.bridge_interfaces || []),
...(modalNetworkData.vm_lxc_interfaces || []),
...(modalNetworkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
].find((iface) => iface.name === selectedInterface.name)
: selectedInterface
@@ -1298,7 +1306,7 @@ export function NetworkMetrics() {
</div>
<div>
<div className="text-sm text-muted-foreground">MTU</div>
<div className="font-medium">{displayInterface.mtu}</div>
<div className="font-medium">{displayInterface.mtu || t("common.notAvailable")}</div>
</div>
{displayInterface.mac_address && (
<div className="col-span-2">
@@ -291,7 +291,7 @@ export function NetworkTrafficChart({
<>
<MetricsCacheNotice lastChecked={staleAt} />
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<AreaChart data={data} margin={{ bottom: 80, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis
dataKey="time"
+2 -2
View File
@@ -426,7 +426,7 @@ export function NodeMetricsCharts() {
</CardHeader>
<CardContent className="px-0 md:px-6">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
<AreaChart data={data} margin={{ bottom: 60, left: 10, right: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis
dataKey="time"
@@ -509,7 +509,7 @@ export function NodeMetricsCharts() {
</CardHeader>
<CardContent className="px-0 pr-2 md:px-6">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
<AreaChart data={data} margin={{ bottom: 60, left: 10, right: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis
dataKey="time"
+19 -6
View File
@@ -874,7 +874,7 @@ export function NotificationSettings() {
// Flatten per-provider API keys
if (cfg.ai_api_keys) {
for (const [provider, key] of Object.entries(cfg.ai_api_keys)) {
if (key) {
if (key || provider === "ollama") {
flat[`ai_api_key_${provider}`] = key
}
}
@@ -2305,6 +2305,12 @@ export function NotificationSettings() {
// Restore previously saved model for the new provider (if any)
const savedModel = config.ai_models?.[v] || ''
setShowSecrets(p => ({ ...p, ai_key: false }))
setRevealedSecrets(p => {
const next = { ...p }
delete next.ai_key
return next
})
updateConfig(p => ({
...p,
@@ -2364,11 +2370,13 @@ export function NotificationSettings() {
</div>
)}
{/* API Key (not shown for Ollama) */}
{config.ai_provider !== "ollama" && (
{/* Ollama gateways may require a key; native servers do not. */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
<Label htmlFor="notification-ai-api-key" className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
{config.ai_provider === "ollama" && (
<span className="text-xs text-muted-foreground">({t("settings.notifications.ui.optional")})</span>
)}
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl && (
<a
href={AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl}
@@ -2382,9 +2390,10 @@ export function NotificationSettings() {
</div>
<div className="flex items-center gap-2">
<Input
id="notification-ai-api-key"
type={showSecrets["ai_key"] ? "text" : "password"}
className="h-9 text-sm font-mono"
placeholder="sk-..."
placeholder={config.ai_provider === "ollama" ? "" : "sk-..."}
value={secretValue("ai_key", config.ai_api_keys?.[config.ai_provider] || "")}
onChange={e => updateConfig(p => ({
...p,
@@ -2402,8 +2411,12 @@ export function NotificationSettings() {
{showSecrets["ai_key"] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{config.ai_provider === "ollama" && (
<p className="text-xs text-muted-foreground">
{t("settings.notifications.ai.ollamaApiKeyHint")}
</p>
)}
</div>
)}
{/* Model - selector with Load button for all providers */}
<div className="space-y-2">
@@ -96,6 +96,8 @@ const slides: OnboardingSlide[] = [
},
]
export const ONBOARDING_CLOSED_EVENT = "proxmenux-onboarding-closed"
export function OnboardingCarousel() {
const t = useT()
const [open, setOpen] = useState(false)
@@ -110,6 +112,14 @@ export function OnboardingCarousel() {
}
}, [])
// The release notes wait for the tour: two identical dialogs on top of each
// other made closing the first one look as if nothing happened.
const [wasOpen, setWasOpen] = useState(false)
useEffect(() => {
if (open) setWasOpen(true)
else if (wasOpen) window.dispatchEvent(new Event(ONBOARDING_CLOSED_EVENT))
}, [open, wasOpen])
const handleNext = () => {
if (currentSlide < slides.length - 1) {
setDirection("next")
+32 -9
View File
@@ -1,6 +1,6 @@
"use client"
import React, { useState, useEffect, useMemo, useCallback } from "react"
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
import useSWR from "swr"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
@@ -185,10 +185,18 @@ function MonitorDashboard({ product }: { product: Product }) {
}
}, [])
const healthRequest = useRef<AbortController | null>(null)
// Fetch health info count independently (for initial load and refresh)
const fetchHealthInfoCount = useCallback(async () => {
const fetchHealthInfoCount = useCallback(async (cachedOnly = false) => {
if (cachedOnly && healthRequest.current) return
healthRequest.current?.abort()
const controller = new AbortController()
healthRequest.current = controller
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
try {
const response = await fetchApi("/api/health/full")
const response = await fetchApi(cachedOnly ? "/api/health/full?cached=1" : "/api/health/full", { signal: controller.signal })
if (healthRequest.current !== controller) return
if (!response?.health?.details) throw new Error("Invalid health snapshot")
let calculatedInfoCount = 0
if (response && response.health?.details) {
@@ -218,7 +226,10 @@ function MonitorDashboard({ product }: { product: Product }) {
setInfoCount(calculatedInfoCount)
} catch (error) {
// Silently fail - infoCount will remain at 0
// Preserve the last known count; a failed poll is not a healthy result.
} finally {
if (timeout !== undefined) clearTimeout(timeout)
if (healthRequest.current === controller) healthRequest.current = null
}
}, [])
@@ -269,20 +280,29 @@ function MonitorDashboard({ product }: { product: Product }) {
fetchHealthInfoCount()
fetchUpdateStatus()
// Overview polls health every 30 s; the other tabs every 60 s.
// Header reboot information stays current on every tab, without full audits.
const refreshHealth = () => {
if (document.visibilityState === "visible") void fetchHealthInfoCount(true)
}
let interval: ReturnType<typeof setInterval> | null = null
let healthInterval: ReturnType<typeof setInterval> | null = null
if (activeTab === "overview") {
interval = setInterval(fetchSystemData, 30000) // 30 segundos
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
} else {
interval = setInterval(fetchSystemData, 60000) // 60 segundos
healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count
}
healthInterval = setInterval(refreshHealth, 30000)
window.addEventListener("focus", refreshHealth)
document.addEventListener("visibilitychange", refreshHealth)
return () => {
if (interval) clearInterval(interval)
if (healthInterval) clearInterval(healthInterval)
window.removeEventListener("focus", refreshHealth)
document.removeEventListener("visibilitychange", refreshHealth)
const controller = healthRequest.current
healthRequest.current = null
controller?.abort()
}
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
@@ -391,6 +411,7 @@ function MonitorDashboard({ product }: { product: Product }) {
const refreshData = async () => {
setIsRefreshing(true)
await fetchSystemData()
await fetchHealthInfoCount()
setComponentKey((prev) => prev + 1)
await new Promise((resolve) => setTimeout(resolve, 500))
setIsRefreshing(false)
@@ -468,7 +489,9 @@ function MonitorDashboard({ product }: { product: Product }) {
)}
<header
className="border-b border-border bg-card sticky top-0 z-50 shadow-sm cursor-pointer hover:bg-accent/5 transition-colors"
// On a short screen (a phone turned sideways) the header and the bar below
// scroll with the page instead of covering half of it.
className="border-b border-border bg-card sticky [@media(max-height:500px)]:static top-0 z-50 shadow-sm cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => setShowHealthModal(true)}
>
<div className="container mx-auto px-4 md:px-6 py-4 md:py-4">
@@ -614,7 +637,7 @@ function MonitorDashboard({ product }: { product: Product }) {
</header>
<div
className={`sticky z-40 bg-background
className={`sticky [@media(max-height:500px)]:static z-40 bg-background
top-[120px] lg:top-[76px]
transition-all duration-700 ease-in-out
${showNavigation ? "translate-y-0 opacity-100" : "-translate-y-[120%] opacity-0 pointer-events-none"}
+58 -4
View File
@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { Plus, Share, X } from "lucide-react"
import { ExternalLink, Plus, Share, X } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// ==========================================================
@@ -30,6 +30,7 @@ import { useT } from "../lib/i18n/provider"
const DISMISSED_FOREVER_KEY = "proxmenux-install-dismissed"
const DISMISSED_UNTIL_KEY = "proxmenux-install-dismissed-until"
const NOT_NOW_DAYS = 30
const HTTPS_DOCS = "https://macrimi.github.io/ProxMenux/docs/monitor/access-auth#https"
function isMobileDevice(): boolean {
if (typeof window === "undefined") return false
@@ -62,6 +63,9 @@ export function PwaInstallPrompt() {
const t = useT()
const [open, setOpen] = useState(false)
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
// Chrome installs a web app only from a secure origin: over plain HTTP it
// can add a shortcut, never the app. Say so instead of steps that fail.
const [secure, setSecure] = useState(true)
useEffect(() => {
if (typeof window === "undefined") return
@@ -80,8 +84,36 @@ export function PwaInstallPrompt() {
// localStorage unavailable (private mode etc.) — treat as not dismissed.
}
setSecure(window.isSecureContext)
setPlatform(isIOS() ? "ios" : "android")
setOpen(true)
// A dialog of the dashboard (welcome tour, release notes) blocks pointer
// events on everything outside it, so this prompt would show on top but
// could not be tapped. Wait until no dialog is open, and confirm it a
// moment later, since one dialog can open right after another closes.
const dialogOpen = () => document.querySelector('[role="dialog"][data-state="open"]') !== null
let timer: ReturnType<typeof setTimeout> | undefined
// Dialogs mount in portals directly under <body> and announce their state
// in data-state; the rest of the page changing does not reset the wait.
const portals = new MutationObserver(() => schedule())
const states = new MutationObserver(() => schedule())
const stop = () => {
portals.disconnect()
states.disconnect()
clearTimeout(timer)
}
const schedule = () => {
clearTimeout(timer)
timer = setTimeout(() => {
if (dialogOpen()) return
stop()
setOpen(true)
}, 800)
}
portals.observe(document.body, { childList: true })
states.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["data-state"] })
schedule()
return stop
}, [])
const handleNotNow = useCallback(() => {
@@ -120,6 +152,8 @@ export function PwaInstallPrompt() {
aria-modal="true"
aria-labelledby="pwa-install-title"
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
// Stays usable even if another dialog opens and disables pointer events on the page.
style={{ pointerEvents: "auto" }}
onClick={(e) => {
if (e.target === e.currentTarget) handleClose()
}}
@@ -167,7 +201,9 @@ export function PwaInstallPrompt() {
<p className="mt-1 text-[13px] leading-snug text-muted-foreground">
{platform === "ios"
? t("pwaInstall.iosDescription")
: t("pwaInstall.androidDescription")}
: secure
? t("pwaInstall.androidDescription")
: t("pwaInstall.insecure.description")}
</p>
</div>
</div>
@@ -208,12 +244,30 @@ export function PwaInstallPrompt() {
</span>
</li>
</ol>
) : (
) : secure ? (
<div className="mb-4 rounded-lg border border-border bg-muted/50 px-3.5 py-3 text-[13px] leading-relaxed text-muted-foreground">
{t("pwaInstall.android.stepOpenMenu")} <b className="text-foreground">⋮</b> →{" "}
<b className="text-foreground">{t("pwaInstall.installApp")}</b> {t("pwaInstall.android.or")}{" "}
<b className="text-foreground">{t("pwaInstall.addToHomeScreen")}</b> → {t("pwaInstall.android.stepConfirm")}{" "}
<b className="text-foreground">{t("pwaInstall.android.install")}</b>.
</div>
) : (
<div className="mb-4 flex flex-col gap-2 rounded-lg border border-border bg-muted/50 px-3.5 py-3 text-[13px] leading-relaxed text-muted-foreground">
<p>{t("pwaInstall.insecure.howTo")}</p>
<a
href={HTTPS_DOCS}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
>
{t("pwaInstall.insecure.docs")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
<p>
{t("pwaInstall.insecure.shortcut")} <b className="text-foreground">⋮</b> →{" "}
<b className="text-foreground">{t("pwaInstall.addToHomeScreen")}</b>.
</p>
</div>
)}
<div className="mt-1 flex flex-col gap-1 border-t border-border pt-3">
+58 -49
View File
@@ -7,6 +7,7 @@ import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap,
import { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider"
import { APP_VERSION } from "../lib/version"
import { ONBOARDING_CLOSED_EVENT } from "./onboarding-carousel"
interface ReleaseNote {
date: string
@@ -18,6 +19,35 @@ interface ReleaseNote {
}
export const CHANGELOG: Record<string, ReleaseNote> = {
"1.2.8": {
date: "November 2, 2026",
changes: {
added: [
"Custom image installs Compose files with several services, one LXC per service on a private network (contributed by @jcastro).",
"OCI: a VLAN tag for the access interface (requested by @xistoso), and Ollama and llama.cpp on Intel and AMD integrated GPUs.",
"Seventeen new OCI applications, among them Authentik, TeslaMate, Zigbee2MQTT, Umami, Scrypted and NetAlertX (contributed by @jcastro) and Scanopy (suggested by @gabglus).",
"Proxmox updates run as a job on the server and carry on after signing out, and a pending reboot is shown after a new kernel (contributed by @Vaso73).",
"ZFS pool layout, scrub and device health, and the storage controller of each disk (contributed by @DrOwl).",
"VM disk thresholds in Settings (suggested by @byte21516), and an optional API key for local Ollama.",
],
changed: [
"Variables added or changed in Proxmox stay in an OCI container: they are saved in its oci-compose and the update carries on with them, manual or scheduled, on single applications and on every member of a stack.",
"OCI updates keep the rest of what is changed in Proxmox: start command, network options, DNS, hostname, features, protection, disk sizes and lxc.* lines.",
"Log2RAM 2.0: the journal and the task logs of Proxmox stay on disk with their history (reported by @shtefko).",
"Every disk is monitored unless it is excluded in Settings (reported by @ovikiss and @peterurbanec).",
"A service reloading or restarting counts as running, and nothing is concluded during a package update (reported by @Joshua1264).",
],
fixed: [
"Host backup jobs run on their schedule, and updating ProxMenux repairs saved jobs.",
"The Intel GPU view ends intel_gpu_top with each reading (reported by @FromTheCToTheG).",
"VM disk usage counts every filesystem of the guest (fix proposed by @TheFoxCraft).",
"The LVM check keeps the Proxmox filter (reported by @osipovdm-dot), and Secure Gateway installs on BTRFS hosts (reported by @78v5).",
"The Monitor installs as an app (PWA) from the browser, with its own web app manifest and service worker.",
"A disk I/O error is recorded on its disk.",
"The Y-axis titles stay inside the overview charts (contributed by @f3rs3n).",
],
},
},
"1.2.7": {
date: "October 26, 2026",
changes: {
@@ -333,65 +363,40 @@ 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.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.",
key: "releaseNotes.currentFeatures.composeStacks",
text: "Compose files with several services — Custom image installs one LXC per service on a private network, started in dependency order. Updates, backups and removal work on the whole application.",
},
{
icon: <ScrollText className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociInMonitor",
text: "OCI applications in the Monitor — the App tab recognises them, the Updates tab updates and modifies them as the menu does, and a Logs tab follows the console output of the application live.",
icon: <RefreshCw className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.keepProxmoxChanges",
text: "OCI updates keep what you change in Proxmox — variables, start command, network options, DNS, features, disk sizes and lxc.* lines stay on the rebuilt container, also in scheduled updates and on every member of a stack.",
},
{
icon: <Cpu className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociAcceleration",
text: "Hardware acceleration for OCI applications — the installer offers only the profiles the host can run: NVIDIA, Intel and AMD GPUs and, for Frigate, an Intel NPU or a Coral. The NVIDIA Container Toolkit installs with the driver.",
icon: <Globe className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociVlan",
text: "VLAN tag for OCI applications — the advanced installation asks for it next to the address, and Modify changes it on a single application. The host needs no access to that VLAN.",
},
{
icon: <DatabaseBackup className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociRecovery",
text: "An OCI application travels with its backup — restored on this host or on another one, it is registered and managed again, and in a cluster it starts on whichever node it moves to.",
icon: <Sparkles className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociNewApps",
text: "New in the OCI catalog — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX and ten more applications, and Ollama and llama.cpp run on Intel and AMD integrated GPUs.",
},
{
icon: <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: <ArrowUpCircle className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.hostUpdateJob",
text: "Proxmox updates that carry on after signing out — the update runs as a job on the server and signing back in reopens it. A pending reboot is shown after a new kernel, with a restart button that checks the host first.",
},
{
icon: <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: <HardDrive className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.storageDetails",
text: "Storage details — ZFS pools show their layout, scrub and device health, and each disk shows the storage controller it hangs off, also in the Hardware page. VM disk thresholds join Settings.",
},
{
icon: <Calendar className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.guestBackupJobs",
text: "Backup jobs of VMs and LXCs — the Backup tab lists the scheduled jobs of Proxmox and creates, edits, enables and runs them; they are the same ones as Datacenter → Backup (contributed by @MattiaC46).",
},
{
icon: <Languages className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.notificationLanguage",
text: "Notifications in your language — titles, bodies and daily digests are written in the language chosen for notifications (contributed by @Vaso73).",
icon: <Zap className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.log2ramV2",
text: "Log2RAM 2.0 — the journal and the task logs of Proxmox stay on disk with their history, and only the logs that change all the time go to RAM. Installed hosts update to it with ProxMenux.",
},
]
@@ -529,11 +534,15 @@ export function useVersionCheck() {
const [showReleaseNotes, setShowReleaseNotes] = useState(false)
useEffect(() => {
const lastSeenVersion = localStorage.getItem("proxmenux-last-seen-version")
if (lastSeenVersion !== APP_VERSION) {
setShowReleaseNotes(true)
if (localStorage.getItem("proxmenux-last-seen-version") === APP_VERSION) return
const show = () => setShowReleaseNotes(true)
if (localStorage.getItem("proxmenux-onboarding-seen")) {
show()
return
}
// The welcome tour opens first; the notes follow once it is closed.
window.addEventListener(ONBOARDING_CLOSED_EVENT, show, { once: true })
return () => window.removeEventListener(ONBOARDING_CLOSED_EVENT, show)
}, [])
return { showReleaseNotes, setShowReleaseNotes }
+17 -5
View File
@@ -54,8 +54,8 @@ interface ScriptTerminalModalProps {
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 }
// Explicit server-owned transports; ordinary /ws/script keeps its contract.
backgroundUpdate?: { runId: string; vmid?: number; resume: boolean; kind?: "host" }
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
@@ -178,7 +178,9 @@ export function ScriptTerminalModal({
}
}, 30000)
const initMessage = {
const initMessage = backgroundUpdate?.kind === "host" ? {
resume: true, offset: outputOffsetRef.current,
} : {
script_path: scriptPath,
params: paramsRef.current,
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
@@ -221,6 +223,10 @@ export function ScriptTerminalModal({
try {
const msg = JSON.parse(event.data)
if (msg.type === "host_update_state") {
if (msg.state === "attention") termRef.current?.writeln(t("healthStatus.hostUpdate.attention"))
return
}
if (msg.type === "job_output") {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
@@ -429,7 +435,9 @@ export function ScriptTerminalModal({
}
}, 30000)
const initMessage = {
const initMessage = backgroundUpdate?.kind === "host" ? {
resume: backgroundUpdate.resume, offset: outputOffsetRef.current,
} : {
script_path: scriptPath,
params: paramsRef.current,
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
@@ -478,6 +486,10 @@ export function ScriptTerminalModal({
try {
const msg = JSON.parse(event.data)
if (msg.type === "host_update_state") {
if (msg.state === "attention") term.writeln(t("healthStatus.hostUpdate.attention"))
return
}
if (msg.type === "job_output") {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
@@ -752,7 +764,7 @@ export function ScriptTerminalModal({
const getScriptWebSocketUrl = (sid: string): string => {
const route = backgroundUpdate
? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}`
? `/ws/${backgroundUpdate.kind === "host" ? "host-update" : "lxc-update"}/${encodeURIComponent(backgroundUpdate.runId)}`
: `/ws/script/${sid}`
if (typeof window === "undefined") {
return `ws://localhost:${API_PORT}${route}`
+4
View File
@@ -20,6 +20,7 @@ import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import { useIsPbs } from "../lib/use-platform"
import type { LanguageCode } from "../lib/i18n/languages"
import { NavTabOrderCard } from "./nav-tab-order-card"
import { StorageDisplayCard } from "./storage-display-card"
import { optimizationSourceError, optimizationSourceName, type OptimizationSourceError, type OptimizationSourceResponse } from "../lib/optimization-source-errors"
// GitHub Dark color palette for bash syntax highlighting
@@ -1233,6 +1234,9 @@ export function Settings() {
</CardContent>
</Card>
{/* Storage Display: group physical disks by controller */}
<StorageDisplayCard />
{/* Health Monitor Settings */}
<Card>
<CardHeader>
@@ -0,0 +1,90 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Switch } from "./ui/switch"
import { Check, HardDrive, Settings2 } from "lucide-react"
import { useT } from "../lib/i18n/provider"
import { useGroupDisksByController } from "../lib/storage-display"
export function StorageDisplayCard() {
const t = useT()
const [groupByController, setGroupByController] = useGroupDisksByController()
const [editMode, setEditMode] = useState(false)
const [pending, setPending] = useState<boolean | null>(null)
const [saved, setSaved] = useState(false)
const handleCancel = () => {
setPending(null)
setEditMode(false)
}
const handleSave = () => {
if (pending !== null && pending !== groupByController) {
setGroupByController(pending)
}
setPending(null)
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-blue-500" />
<CardTitle>{t("settings.storageDisplay.title")}</CardTitle>
</div>
<div className="flex items-center gap-2">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
>
{t("actions.cancel")}
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={pending === null || pending === groupByController}
>
<Check className="h-3 w-3" />
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
</div>
<CardDescription>{t("settings.storageDisplay.description")}</CardDescription>
</CardHeader>
<CardContent className={editMode ? "bg-accent" : undefined}>
<div className="text-foreground flex items-center justify-between gap-4">
<div className="flex items-center">{t("settings.storageDisplay.groupByController")}</div>
<Switch
checked={pending ?? groupByController}
disabled={!editMode}
onCheckedChange={(checked) => setPending(checked)}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`}
/>
</div>
</CardContent>
</Card>
)
}
+490 -51
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, Moon, EyeOff } from "lucide-react"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, EyeOff } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -14,6 +14,7 @@ import { DiskTemperatureCard } from "./disk-temperature-card"
import { getDiskType as resolveDiskType } from "../lib/disk-type"
import { APP_VERSION } from "../lib/version"
import { useT } from "@/lib/i18n/provider"
import { useGroupDisksByController } from "../lib/storage-display"
import {
useDiskTempThresholds,
loadDiskTempThresholds,
@@ -70,7 +71,6 @@ interface DiskInfo {
// badge AND to suppress the (stale) temperature value, so the
// operator understands the graph is frozen on purpose — issue #232.
standby?: boolean
idle?: boolean
excluded?: boolean
health: string
power_on_hours?: number
@@ -102,6 +102,7 @@ interface DiskInfo {
}
observations_count?: number
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
controller_slot?: string | null // PCI slot of the storage controller, key into StorageData.controllers
removable?: boolean
is_system_disk?: boolean
system_usage?: string[]
@@ -121,12 +122,65 @@ interface DiskObservation {
model: string
}
// Pool layout from `zpool status -j` (OpenZFS 2.3+). Only present when
// details_available is true; older ZFS gets the basic fields above.
interface ZFSDevice {
path: string
disk: string | null // base block device (e.g. "sdb"), null for file vdevs
state: string // ONLINE | DEGRADED | FAULTED | OFFLINE | UNAVAIL | REMOVED | AVAIL | INUSE
read_errors: number
write_errors: number
checksum_errors: number
slow_ios: number
via: string | null // "spare-0" / "replacing-0" while a device is being swapped
}
interface ZFSVdev {
name: string
type: string // mirror | raidz | draid | disk
parity: number | null
class: "normal" | "special" | "dedup" | "log" | "cache" | "spare"
state: string
read_errors: number
write_errors: number
checksum_errors: number
devices: ZFSDevice[]
}
interface ZFSScan {
function: "SCRUB" | "RESILVER" | string
state: "SCANNING" | "FINISHED" | "CANCELED" | string
start_time: number | null
end_time: number | null
errors: number
repaired_bytes: number
percent: number | null // only while SCANNING
}
interface ZFSPool {
name: string
size: string
allocated: string
free: string
health: string
fragmentation?: number | null
capacity?: number | null
dedup_ratio?: number | null
details_available?: boolean
status?: string | null
action?: string | null
error_count?: number
scan?: ZFSScan | null
vdevs?: ZFSVdev[]
}
interface StorageController {
slot: string
class: string
vendor: string
device: string
sdevice?: string | null
driver?: string | null
}
interface StorageData {
@@ -134,6 +188,7 @@ interface StorageData {
used: number
available: number
disks: DiskInfo[]
controllers?: Record<string, StorageController>
zfs_pools: ZFSPool[]
disk_count: number
healthy_disks: number
@@ -231,7 +286,43 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
return out.length ? out.join('; ') : null
}
// Bucket disks by controller PCI slot, ordered by slot so groups follow
// the bus layout. Disks with no resolvable controller go last.
function groupDisksBySlot(disks: DiskInfo[]): [string | null, DiskInfo[]][] {
const groups = new Map<string | null, DiskInfo[]>()
for (const disk of disks) {
const slot = disk.controller_slot ?? null
groups.set(slot, [...(groups.get(slot) ?? []), disk])
}
return [...groups.entries()].sort(([a], [b]) => {
if (a === null) return 1
if (b === null) return -1
return a.localeCompare(b)
})
}
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
// Same ring as the disk wear gauge, with a faint track so 0% still reads.
// Used for the fragmentation of a ZFS pool.
function PercentRing({ value, color, label }: { value: number; color: string; label: string }) {
const pct = Math.max(0, Math.min(100, value))
return (
<svg width="88" height="88" viewBox="0 0 88 88" className="flex-shrink-0">
<circle cx="44" cy="44" r="35" fill="none" stroke={color} strokeOpacity="0.15" strokeWidth="6" />
<circle cx="44" cy="44" r="35" fill="none" stroke={color} strokeWidth="6"
strokeDasharray={`${pct * 2.199} 219.9`}
strokeLinecap="round" transform="rotate(-90 44 44)" />
<text x="44" y="40" textAnchor="middle" fill={color} fontSize="20" fontWeight="700">{pct}%</text>
<text x="44" y="56" textAnchor="middle" fill="currentColor" fontSize="12" className="text-muted-foreground">{label}</text>
</svg>
)
}
// OpenZFS reports a pool without its newer features enabled, which needs no action.
const zfsStatusIsInformational = (status?: string | null) => /features are not enabled/i.test(status ?? "")
const zfsFragmentationColor = (pct: number) => (pct > 80 ? "#ef4444" : pct > 50 ? "#eab308" : "#06b6d4")
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
const t = useT()
@@ -240,6 +331,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
// sensible defaults from `lib/health-thresholds`, so first paint
// never blocks on the network.
const dtThresholds = useDiskTempThresholds()
const [groupDisksByController] = useGroupDisksByController()
const [storageData, setStorageData] = useState<StorageData | null>(null)
const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null)
@@ -250,6 +342,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
const [mountDetail, setMountDetail] = useState<RemoteMount | null>(null)
const [loading, setLoading] = useState(true)
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
const [selectedPool, setSelectedPool] = useState<ZFSPool | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
const [loadingObservations, setLoadingObservations] = useState(false)
@@ -259,6 +352,12 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
// 85 vh capped by the visual viewport otherwise). Keeps every
// detail modal at a matching size across the app.
const [isStandalone, setIsStandalone] = useState(false)
// Disk, ZFS pool and remote mount details share one size.
const detailDialogClass = `max-w-4xl flex flex-col p-0 overflow-hidden ${
isStandalone
? "h-[95vh] sm:h-[90vh]"
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
}`
useEffect(() => {
const checkStandalone = () => {
@@ -397,6 +496,57 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
}
}
// ZFS device/vdev states are shown verbatim, as `zpool status` prints them.
const getZfsStateBadge = (state: string) => {
const style =
state === "ONLINE"
? "bg-green-500/10 text-green-500 border-green-500/20"
: state === "AVAIL" || state === "INUSE"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: state === "DEGRADED"
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20"
return <Badge className={`font-mono text-xs ${style}`}>{state}</Badge>
}
const getZfsVdevLabel = (vdev: ZFSVdev) => {
if (vdev.type === "mirror") return t("storage.zfs.mirror")
if (vdev.type === "raidz") return t("storage.zfs.raidz", { parity: vdev.parity ?? 1 })
if (vdev.type === "draid") return t("storage.zfs.draid", { parity: vdev.parity ?? 1 })
return t("storage.zfs.singleDisk")
}
// One-line pool shape for the card, e.g. "RAIDZ2", "2 x Mirror", "Stripe".
const getZfsLayoutSummary = (pool: ZFSPool) => {
const data = (pool.vdevs ?? []).filter((v) => v.class === "normal")
if (data.length === 0) return null
if (data.every((v) => v.type === "disk")) {
return data.length === 1 ? t("storage.zfs.singleDisk") : t("storage.zfs.stripe")
}
const labels = [...new Set(data.map(getZfsVdevLabel))]
if (labels.length === 1 && data.length > 1) return `${data.length} x ${labels[0]}`
return labels.join(" + ")
}
const formatZfsTime = (epoch: number | null | undefined) =>
epoch ? new Date(epoch * 1000).toLocaleString() : t("common.notAvailable")
const getZfsScanSummary = (scan: ZFSScan | null | undefined) => {
if (!scan) return t("storage.zfs.neverScrubbed")
const kind = scan.function === "RESILVER" ? t("storage.zfs.resilver") : t("storage.zfs.scrub")
if (scan.state === "SCANNING") {
return t("storage.zfs.scanRunning", { kind, percent: scan.percent ?? 0 })
}
if (scan.state === "CANCELED") return t("storage.zfs.scanCanceled", { kind })
return t("storage.zfs.scanFinished", {
kind,
date: scan.end_time ? new Date(scan.end_time * 1000).toLocaleDateString() : "",
})
}
const zfsDeviceErrors = (d: { read_errors: number; write_errors: number; checksum_errors: number }) =>
d.read_errors + d.write_errors + d.checksum_errors
// Tiny coloured dot that prefixes status / counter values. Adds
// accessibility-friendly redundancy (colour + position) to fields
// that today rely on colour alone, so an "all OK" disk reads as
@@ -437,7 +587,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
// in 4 different disk-list views (system / data / pool / other) and we
// want them all to behave identically — issue #232 fix.
// Why a disk shows no live temperature, when it doesn't: excluded by the
// user, parked, or idle and deliberately not read. Shared by every view
// user, or parked. Shared by every view
// that paints a disk's temperature, so they cannot disagree.
const renderNoReadingBadge = (disk: DiskInfo) => {
if (disk.excluded) {
@@ -462,17 +612,6 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</Badge>
)
}
if (disk.idle) {
return (
<Badge
className="bg-muted text-muted-foreground border-border gap-1"
title={t("storage.idleTitle")}
>
<Moon className="h-3 w-3" />
{t("storage.idle")}
</Badge>
)
}
return null
}
@@ -665,7 +804,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</div>
)}
{/* Reallocated only meaningful on rotating disks. */}
{disk.reallocated_sectors !== undefined && (disk.rotation_rate ?? 0) > 0 && (
{disk.reallocated_sectors !== undefined && resolveDiskType(disk.name, disk.rotation_rate) === "HDD" && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
{t("storage.reallocatedShort")}
@@ -721,12 +860,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
// Resolve disk class → threshold pair from the user-configurable
// backend (single source of truth). The semantics: temp BELOW warn
// is green, between warn and hot is amber, hot or above is red.
let cls: keyof DiskTempMap = "HDD"
if (diskName?.startsWith("nvme")) {
cls = "NVMe"
} else if (!rotationRate || rotationRate === 0) {
cls = "SSD"
}
const cls: keyof DiskTempMap = resolveDiskType(diskName ?? "", rotationRate)
const t = dtThresholds[cls]
if (temp >= t.hot) return "text-red-500"
if (temp >= t.warn) return "text-yellow-500"
@@ -757,9 +891,9 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
return t("storage.duration.daysShort", { days: totalDays })
}
const formatRotationRate = (rpm: number | undefined) => {
if (!rpm || rpm === 0) return "SSD"
return `${rpm.toLocaleString()} RPM`
const formatRotationRate = (diskName: string, rpm: number | undefined) => {
if (getDiskType(diskName, rpm) !== "HDD") return "SSD"
return rpm && rpm > 0 ? `${rpm.toLocaleString()} RPM` : t("common.notAvailable")
}
// Thin wrapper over the shared classifier so the rest of the file
@@ -976,7 +1110,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
storageData.disks.forEach((disk) => {
// Match disk rows: policy-suppressed cached readings are not live temperatures.
if (disk.standby || disk.idle || disk.excluded || typeof disk.temperature !== "number" ||
if (disk.standby || disk.excluded || typeof disk.temperature !== "number" ||
!Number.isFinite(disk.temperature) || disk.temperature <= 0) {
unavailable++
return
@@ -1611,7 +1745,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
overlay with text-xs/text-[10px] all over and looked
shrunken next to the rest of the modals. */}
<Dialog open={!!mountDetail} onOpenChange={(open) => { if (!open) setMountDetail(null) }}>
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
<DialogContent className={detailDialogClass}>
{mountDetail && (() => {
const m = mountDetail
const isStale = m.status === "stale"
@@ -1658,7 +1792,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6 overflow-auto space-y-5">
<div className="flex-1 min-h-0 px-6 pb-6 overflow-y-auto space-y-5">
{/* Type + tags */}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getStorageTypeBadge(m.fstype)}>{m.fstype}</Badge>
@@ -1769,14 +1903,58 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
<CardContent>
<div className="space-y-4">
{storageData.zfs_pools.map((pool) => (
<div key={pool.name} className="border rounded-lg p-4">
<div
key={pool.name}
className="border border-white/10 rounded-lg p-4 cursor-pointer bg-card hover:bg-white/5 transition-colors"
onClick={() => setSelectedPool(pool)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-lg">{pool.name}</h3>
{getHealthBadge(pool.health)}
</div>
{getHealthIcon(pool.health)}
<div className="flex items-center gap-2">
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
{getHealthIcon(pool.health)}
</div>
</div>
{pool.details_available && (() => {
const dataDevices = (pool.vdevs ?? [])
.filter((v) => v.class === "normal")
.reduce((n, v) => n + v.devices.length, 0)
const problems = (pool.vdevs ?? []).some(
(v) => v.devices.some((d) => zfsDeviceErrors(d) > 0) || zfsDeviceErrors(v) > 0,
) || (pool.error_count ?? 0) > 0
return (
<p className="text-sm text-muted-foreground mb-3 flex flex-wrap items-center gap-x-2">
<span className="font-medium text-foreground">{getZfsLayoutSummary(pool)}</span>
<span>|</span>
<span>
{t(dataDevices === 1 ? "storage.zfs.diskCountOne" : "storage.zfs.diskCountMany", { count: dataDevices })}
</span>
<span>|</span>
<span>{getZfsScanSummary(pool.scan)}</span>
{problems && (
<span className="flex items-center gap-1 text-red-500">
<AlertTriangle className="h-3.5 w-3.5" />
{t("storage.zfs.errorsReported")}
</span>
)}
</p>
)
})()}
{pool.capacity != null && (
<Progress
value={pool.capacity}
className={`h-2 mb-2 ${
pool.capacity > 90
? "[&>div]:bg-red-500"
: pool.capacity > 75
? "[&>div]:bg-yellow-500"
: "[&>div]:bg-blue-500"
}`}
/>
)}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-sm text-muted-foreground">{t("storage.size")}</p>
@@ -1798,6 +1976,221 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</Card>
)}
{/* ZFS Pool Details Dialog: re-resolved from storageData so it follows
the background refresh while open (e.g. resilver progress). */}
<Dialog open={selectedPool !== null} onOpenChange={(open) => { if (!open) setSelectedPool(null) }}>
<DialogContent className={detailDialogClass}>
{selectedPool && (() => {
const pool = storageData.zfs_pools.find((p) => p.name === selectedPool.name) ?? selectedPool
const classOrder: ZFSVdev["class"][] = ["normal", "special", "dedup", "log", "cache", "spare"]
const vdevs = [...(pool.vdevs ?? [])].sort(
(a, b) => classOrder.indexOf(a.class) - classOrder.indexOf(b.class),
)
const showSlowIos = vdevs.some((v) => v.devices.some((d) => d.slow_ios > 0))
return (
<>
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle className="flex items-center gap-3">
<Database className="h-5 w-5" />
{pool.name}
{getHealthBadge(pool.health)}
</DialogTitle>
<DialogDescription>{t("storage.zfs.poolDetails")}</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 px-6 pb-6 overflow-y-auto flex flex-col gap-5">
{(pool.status || pool.action) && (
<div className={`rounded-lg border p-3 text-sm space-y-1 ${
zfsStatusIsInformational(pool.status)
? "border-blue-500/30 bg-blue-500/10"
: "border-yellow-500/30 bg-yellow-500/10"
}`}>
{pool.status && <p><span className="font-medium">{t("storage.zfs.status")}:</span> {pool.status}</p>}
{pool.action && <p><span className="font-medium">{t("storage.zfs.action")}:</span> {pool.action}</p>}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-border p-4 flex flex-col justify-center gap-3">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">{t("storage.free")}</p>
<p className="text-lg font-semibold">{pool.free}</p>
</div>
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
</div>
{pool.capacity != null && (
<Progress
value={pool.capacity}
className={`h-2 ${
pool.capacity > 90
? "[&>div]:bg-red-500"
: pool.capacity > 75
? "[&>div]:bg-yellow-500"
: "[&>div]:bg-blue-500"
}`}
/>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-muted-foreground">{t("storage.allocated")}</p>
<p className="font-medium">{pool.allocated}</p>
</div>
<div className="text-right">
<p className="text-muted-foreground">{t("storage.zfs.totalSize")}</p>
<p className="font-medium">{pool.size}</p>
</div>
</div>
</div>
{(pool.fragmentation != null || pool.dedup_ratio != null) && (
<div className="rounded-lg border border-border p-4 flex items-center gap-5">
{pool.fragmentation != null && (
<PercentRing value={pool.fragmentation} color={zfsFragmentationColor(pool.fragmentation)} label={t("storage.zfs.fragShort")} />
)}
<div className="min-w-0 flex-1 space-y-3 text-sm">
{pool.fragmentation != null && (
<div>
<p className="text-muted-foreground">{t("storage.zfs.fragmentation")}</p>
<p className="font-medium">{pool.fragmentation}%</p>
</div>
)}
{pool.dedup_ratio != null && (
<div>
<p className="text-muted-foreground">{t("storage.zfs.dedupRatio")}</p>
<p className="font-medium">{pool.dedup_ratio.toFixed(2)}x</p>
</div>
)}
</div>
</div>
)}
</div>
{!pool.details_available ? (
<p className="text-sm text-muted-foreground italic">{t("storage.zfs.detailsUnavailable")}</p>
) : (
<>
{/* Scrub / resilver */}
<div className="rounded-lg border border-border p-4 space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{t("storage.zfs.scanTitle")}
</h3>
<p className="text-sm">{getZfsScanSummary(pool.scan)}</p>
{pool.scan?.state === "SCANNING" && <Progress value={pool.scan.percent ?? 0} className="h-2" />}
{pool.scan && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div>
<p className="text-muted-foreground">{t("storage.zfs.started")}</p>
<p className="font-medium">{formatZfsTime(pool.scan.start_time)}</p>
</div>
{pool.scan.state !== "SCANNING" && (
<div>
<p className="text-muted-foreground">{t("storage.zfs.ended")}</p>
<p className="font-medium">{formatZfsTime(pool.scan.end_time)}</p>
</div>
)}
<div>
<p className="text-muted-foreground">{t("storage.zfs.repaired")}</p>
<p className="font-medium">{formatStorage(pool.scan.repaired_bytes / 1024 ** 3)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("storage.zfs.scanErrors")}</p>
<p className={`font-medium ${pool.scan.errors > 0 ? "text-red-500" : ""}`}>{pool.scan.errors}</p>
</div>
</div>
)}
<p className={`text-sm ${(pool.error_count ?? 0) > 0 ? "text-red-500" : "text-green-500"}`}>
{(pool.error_count ?? 0) > 0
? t("storage.zfs.dataErrors", { count: pool.error_count ?? 0 })
: t("storage.zfs.noDataErrors")}
</p>
</div>
{/* Layout: one block per top-level vdev; the last one
reaches the bottom of the dialog. */}
<div className="flex-1 flex flex-col gap-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{t("storage.zfs.layout")}
</h3>
{vdevs.map((vdev, i) => (
<div key={`${vdev.class}-${vdev.name}-${i}`} className={`border rounded-lg ${i === vdevs.length - 1 ? "flex-1" : ""}`}>
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b bg-muted/30">
<Badge variant="outline" className="text-xs">{t(`storage.zfs.classes.${vdev.class}`)}</Badge>
<span className="text-sm font-medium">{getZfsVdevLabel(vdev)}</span>
{vdev.type !== "disk" && (
<span className="font-mono text-xs text-muted-foreground">{vdev.name}</span>
)}
<span className="ml-auto">{getZfsStateBadge(vdev.state)}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground">
<th className="text-left font-normal px-3 py-1.5">{t("storage.zfs.device")}</th>
<th className="text-left font-normal px-2 py-1.5">{t("storage.zfs.disk")}</th>
<th className="text-left font-normal px-2 py-1.5">{t("storage.zfs.state")}</th>
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.readErrors")}>{t("storage.zfs.readShort")}</th>
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.writeErrors")}>{t("storage.zfs.writeShort")}</th>
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.checksumErrors")}>{t("storage.zfs.checksumShort")}</th>
{showSlowIos && (
<th className="text-right font-normal px-3 py-1.5">{t("storage.zfs.slowIos")}</th>
)}
</tr>
</thead>
<tbody>
{vdev.devices.map((dev) => {
const diskInfo = dev.disk ? storageData.disks.find((d) => d.name === dev.disk) : undefined
const errCls = (n: number) => `text-right px-2 py-1.5 font-mono ${n > 0 ? "text-red-500 font-semibold" : "text-muted-foreground"}`
return (
<tr key={`${dev.path}-${dev.via ?? ""}`} className="border-t border-border/50">
<td className="px-3 py-1.5 font-mono text-xs break-all" title={dev.path}>
{dev.path.split("/").pop()}
{dev.via && (
<span className="ml-2 text-muted-foreground">({t("storage.zfs.via", { name: dev.via })})</span>
)}
</td>
<td className="px-2 py-1.5">
{diskInfo ? (
<button
type="button"
title={diskInfo.model}
onClick={() => {
setSelectedPool(null)
handleDiskClick(diskInfo)
}}
>
<Badge variant="outline" className="font-mono hover:bg-accent">{diskInfo.name}</Badge>
</button>
) : (
<span className="font-mono text-xs text-muted-foreground">{dev.disk ?? "-"}</span>
)}
</td>
<td className="px-2 py-1.5">{getZfsStateBadge(dev.state)}</td>
<td className={errCls(dev.read_errors)}>{dev.read_errors}</td>
<td className={errCls(dev.write_errors)}>{dev.write_errors}</td>
<td className={errCls(dev.checksum_errors)}>{dev.checksum_errors}</td>
{showSlowIos && (
<td className={`text-right px-3 py-1.5 font-mono ${dev.slow_ios > 0 ? "text-yellow-500" : "text-muted-foreground"}`}>
{dev.slow_ios}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
))}
</div>
</>
)}
</div>
</>
)
})()}
</DialogContent>
</Dialog>
{/* Physical Disks (internal only) */}
<Card>
<CardHeader>
@@ -1807,11 +2200,43 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{storageData.disks
.filter((d) => d.connection_type !== 'usb')
.map((disk) => renderDiskCardV2(disk))}
</div>
{groupDisksByController ? (
<div className="space-y-6">
{groupDisksBySlot(storageData.disks.filter((d) => d.connection_type !== 'usb')).map(([slot, disks]) => {
const controller = slot ? storageData.controllers?.[slot] : undefined
return (
<div key={slot ?? "none"}>
<div className="flex flex-wrap items-center gap-2 mb-3 pb-2 border-b border-border/50">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{controller
? controller.sdevice || controller.device
: slot ?? t("storage.noController")}
</span>
{slot && <span className="font-mono text-xs text-muted-foreground">{slot}</span>}
{controller?.driver && (
<Badge variant="outline" className="font-mono text-xs text-green-500">
{controller.driver}
</Badge>
)}
<Badge variant="outline" className="ml-auto text-xs">
{t(disks.length === 1 ? "storage.diskCountOne" : "storage.diskCountMany", { count: disks.length })}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{disks.map((disk) => renderDiskCardV2(disk))}
</div>
</div>
)
})}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{storageData.disks
.filter((d) => d.connection_type !== 'usb')
.map((disk) => renderDiskCardV2(disk))}
</div>
)}
</CardContent>
</Card>
@@ -1843,13 +2268,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
setSmartJsonData(null)
}
}}>
<DialogContent
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
isStandalone
? "h-[95vh] sm:h-[90vh]"
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
}`}
>
<DialogContent className={detailDialogClass}>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle className="flex items-center gap-2">
{selectedDisk?.connection_type === 'usb' ? (
@@ -1966,6 +2385,28 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
)}
</div>
</div>
{selectedDisk.controller_slot && (() => {
const controller = storageData.controllers?.[selectedDisk.controller_slot]
return (
<div className="col-span-2">
<p className="text-sm text-muted-foreground">{t("storage.storageController")}</p>
<p className="font-medium">
{controller ? controller.sdevice || controller.device : selectedDisk.controller_slot}
{controller && (
<span className="ml-2 font-mono text-xs text-muted-foreground">{selectedDisk.controller_slot}</span>
)}
{controller?.driver && (
<Badge variant="outline" className="ml-2 font-mono text-xs text-green-500">
{controller.driver}
</Badge>
)}
</p>
{controller && controller.sdevice && (
<p className="text-xs text-muted-foreground">{controller.vendor} - {controller.device}</p>
)}
</div>
)
})()}
</div>
{/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */}
@@ -2180,7 +2621,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</div>
<div>
<p className="text-sm text-muted-foreground">{t("storage.rotationRate")}</p>
<p className="font-medium">{formatRotationRate(selectedDisk.rotation_rate)}</p>
<p className="font-medium">{formatRotationRate(selectedDisk.name, selectedDisk.rotation_rate)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("storage.powerCycles")}</p>
@@ -2425,14 +2866,12 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
// Determine disk type (SAS detected via backend flag or connection_type)
const isSasDisk = sd?.is_sas === true || disk.connection_type === 'sas'
let diskType = "HDD"
if (disk.name.startsWith("nvme")) {
diskType = "NVMe"
} else if (isSasDisk) {
let diskType: string = resolveDiskType(disk.name, disk.rotation_rate)
if (diskType !== "NVMe" && isSasDisk) {
diskType = "SAS"
} else if (!disk.rotation_rate || disk.rotation_rate === 0) {
diskType = "SSD"
}
const rpm = disk.rotation_rate && disk.rotation_rate > 0 ? disk.rotation_rate : 0
const known = (value?: string) => (value && value !== "Unknown" ? value : undefined)
// Health status styling
const healthStatus = String(testStatus.smart_status || (testStatus.smart_data?.smart_status) || 'unknown')
@@ -3378,11 +3817,11 @@ function pmxPrint(){
<div class="grid-4">
<div class="card">
<div class="card-label">${tSmart("labels.model")}</div>
<div class="card-value" style="font-size:11px;">${disk.model || sd?.model || t("app.unknown")}</div>
<div class="card-value" style="font-size:11px;">${known(disk.model) || known(sd?.model) || t("app.unknown")}</div>
</div>
<div class="card">
<div class="card-label">${tSmart("labels.serial")}</div>
<div class="card-value" style="font-size:11px;font-family:monospace;">${disk.serial || sd?.serial || t("app.unknown")}</div>
<div class="card-value" style="font-size:11px;font-family:monospace;">${known(disk.serial) || known(sd?.serial) || t("app.unknown")}</div>
</div>
<div class="card">
<div class="card-label">${tSmart("labels.capacity")}</div>
@@ -3390,7 +3829,7 @@ function pmxPrint(){
</div>
<div class="card">
<div class="card-label">${tSmart("labels.type")}</div>
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (disk.rotation_rate ? `SAS ${disk.rotation_rate} RPM` : 'SAS SSD') : diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}</div>
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (rpm ? `SAS ${rpm} RPM` : disk.rotation_rate === -1 ? 'SAS HDD' : 'SAS SSD') : diskType === 'HDD' && rpm ? `HDD ${rpm} RPM` : diskType}</div>
</div>
</div>
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
+77 -6
View File
@@ -19,6 +19,7 @@ import {
ChevronDown,
Copy,
Clipboard,
RotateCw,
} from "lucide-react"
import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard"
import {
@@ -97,6 +98,10 @@ const proxmoxCommands = [
"scp file user@host:/path", "tail -f /var/log/syslog", "history", "clear",
]
const ROTATE_HINT_KEY = "proxmenux-terminal-rotate-hint-dismissed"
// Toolbar above the terminal and key bar below it, in CSS pixels.
const LANDSCAPE_CHROME_PX = 120
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
const t = useT()
const localizedCommands = proxmoxCommands.map((cmd, index) => ({
@@ -109,6 +114,17 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
const [viewportHeight, setViewportHeight] = useState(0)
const [portrait, setPortrait] = useState(false)
// ProxMenux menus need about 80 columns; a phone held upright gives ~50.
const [rotateHintHidden, setRotateHintHidden] = useState(true)
const panelRef = useRef<HTMLDivElement | null>(null)
// A phone turned sideways uses the tablet layout with little height: keep the
// terminal, its toolbar and its key bar inside the screen.
const fittedHeight = isTablet && viewportHeight
? Math.min(terminalHeight, Math.max(200, viewportHeight - LANDSCAPE_CHROME_PX))
: terminalHeight
const [searchModalOpen, setSearchModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(localizedCommands)
@@ -129,6 +145,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
setIsMobile(width < 768)
setIsTablet(isTouchDevice && isTabletSize)
setViewportHeight(window.innerHeight)
setPortrait(window.innerHeight > width)
}
updateDeviceType()
@@ -139,6 +157,11 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
if (savedHeight) {
setTerminalHeight(Number.parseInt(savedHeight, 10))
}
try {
setRotateHintHidden(localStorage.getItem(ROTATE_HINT_KEY) === "1")
} catch {
setRotateHintHidden(false)
}
return () => {
window.removeEventListener("resize", handleResize)
@@ -417,12 +440,25 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const container = containerRefs.current[terminal.id]
if (!terminal.term && container) {
initializeTerminal(terminal, container)
} else if (terminal.term?.element && container && terminal.term.element.parentElement !== container) {
// Turning the device switches between the phone and tablet layouts,
// which render new containers: move the open terminal into its new
// place instead of leaving it in the one React removed.
container.appendChild(terminal.term.element)
try {
terminal.fitAddon?.fit()
if (terminal.ws?.readyState === WebSocket.OPEN) {
terminal.ws.send(JSON.stringify({ type: "resize", cols: terminal.term.cols, rows: terminal.term.rows }))
}
} catch (err) {
console.warn("[Terminal] resize after layout change failed:", err)
}
}
})
}, 50)
return () => clearTimeout(timer)
}, [terminals, isMobile])
}, [terminals, isMobile, isTablet])
useEffect(() => {
if (window.innerWidth < 640) return
@@ -449,7 +485,15 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}
}
})
}, [terminalHeight, layout, terminals, isMobile])
}, [terminalHeight, fittedHeight, layout, terminals, isMobile])
useEffect(() => {
// Turned sideways, the dashboard header fills most of a phone screen:
// scroll so the whole terminal is in view.
if (isTablet && !portrait && viewportHeight && viewportHeight < terminalHeight + LANDSCAPE_CHROME_PX) {
panelRef.current?.scrollIntoView({ block: "start", behavior: "smooth" })
}
}, [isTablet, portrait])
const initializeTerminal = async (terminal: TerminalInstance, container: HTMLDivElement) => {
const [TerminalClass, FitAddonClass] = await Promise.all([
@@ -766,8 +810,17 @@ const handleClose = () => {
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
const dismissRotateHint = () => {
setRotateHintHidden(true)
try {
localStorage.setItem(ROTATE_HINT_KEY, "1")
} catch {
// Best effort: the hint shows again on the next visit.
}
}
return (
<div className="flex flex-col h-full bg-zinc-950 rounded-md overflow-hidden">
<div ref={panelRef} className="flex flex-col h-full bg-zinc-950 rounded-md overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-500" />
@@ -843,13 +896,30 @@ const handleClose = () => {
</div>
</div>
{isMobile && portrait && !rotateHintHidden && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border-b border-zinc-800 text-xs text-zinc-400">
<RotateCw className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="flex-1">{t("terminal.rotateHint")}</span>
<button
type="button"
onClick={dismissRotateHint}
aria-label={t("actions.close")}
className="-my-1.5 -mr-2 flex h-8 w-8 shrink-0 items-center justify-center rounded-full hover:bg-zinc-800 touch-manipulation"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
<div
data-terminal-container
ref={(el) => {
containerRefs.current["main"] = el
}}
className={`overflow-hidden flex flex-col ${isMobile ? "flex-1 h-[60vh]" : "overflow-hidden"} w-full max-w-full`}
style={!isMobile || isTablet ? { height: `${terminalHeight}px`, flexShrink: 0 } : undefined}
// In a column flex parent, flex-1 (basis 0) would override the height
// and the terminal would shrink to xterm's default 24 rows.
className={`overflow-hidden flex flex-col ${isMobile ? `${portrait && !rotateHintHidden ? "h-[calc(60dvh-36px)]" : "h-[60dvh]"} shrink-0` : "overflow-hidden"} w-full max-w-full`}
style={!isMobile || isTablet ? { height: `${fittedHeight}px`, flexShrink: 0 } : undefined}
>
{isMobile ? (
<Tabs value={activeTerminalId} onValueChange={setActiveTerminalId} className="h-full flex flex-col">
@@ -876,7 +946,8 @@ const handleClose = () => {
key={terminal.id}
value={terminal.id}
forceMount
className={`flex-1 h-full mt-0 ${activeTerminalId === terminal.id ? "block" : "hidden"}`}
// Below the tab list: h-full would take the whole container and push the tabs out.
className={`flex-1 min-h-0 mt-0 ${activeTerminalId === terminal.id ? "block" : "hidden"}`}
>
<div
ref={(el) => (containerRefs.current[terminal.id] = el)}
+2 -4
View File
@@ -110,12 +110,10 @@
"openai/gpt-oss-120b",
"meta-llama/llama-3.1-8b-instruct",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"nvidia/nemotron-3-super-120b-a12b:free",
"google/gemma-4-26b-a4b-it:free",
"poolside/laguna-s-2.1:free"
"google/gemma-4-26b-a4b-it: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. Reviewed 2026-10-26: three `:free` variants no longer served by OpenRouter were dropped."
"_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. Availability reviewed 2026-11-04 against the public models endpoint: expired poolside/laguna-s-2.1:free and unlisted nvidia/nemotron-3-super-120b-a12b:free removed. This availability check is not a new inference verification; no untested models were added."
},
"ollama": {
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState, useCallback } from "react"
// Per-browser display preference for the Storage view: whether the
// "Physical Disks & SMART Status" card groups disks under the storage
// controller they hang off. Off by default so the layout is unchanged
// until the user opts in from Settings.
const STORAGE_KEY = "proxmenux-group-disks-by-controller"
const CHANGE_EVENT = "proxmenux-group-disks-by-controller-changed"
export function readGroupDisksByController(): boolean {
if (typeof window === "undefined") return false
try {
return window.localStorage.getItem(STORAGE_KEY) === "true"
} catch {
return false
}
}
export function writeGroupDisksByController(enabled: boolean): void {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, String(enabled))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
// Storage full / disabled; the in-memory state still updates.
}
}
// Hook that keeps the Settings card and the Storage view in sync in the
// same tab (custom event) and across tabs (storage event).
export function useGroupDisksByController(): [boolean, (enabled: boolean) => void] {
const [enabled, setEnabledState] = useState(false)
useEffect(() => {
setEnabledState(readGroupDisksByController())
const onChange = () => setEnabledState(readGroupDisksByController())
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) onChange()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", onStorage)
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
window.removeEventListener("storage", onStorage)
}
}, [])
const setEnabled = useCallback((next: boolean) => {
writeGroupDisksByController(next)
setEnabledState(next)
}, [])
return [enabled, setEnabled]
}
+1 -1
View File
@@ -8,4 +8,4 @@
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
//
// Keep the three in sync on every bump.
export const APP_VERSION = "1.2.7"
export const APP_VERSION = "1.2.8"
+146 -11
View File
@@ -827,7 +827,63 @@
"temperatureCriticalCount": "Kritische Temperatur: {count}",
"temperatureUnavailable": "Temperatur nicht verfügbar",
"temperatureIncomplete": "Temperaturdaten unvollständig",
"temperatureUnavailableCount": "Nicht verfügbar: {count}"
"temperatureUnavailableCount": "Nicht verfügbar: {count}",
"zfs": {
"classes": {
"normal": "Daten",
"special": "Speziell",
"dedup": "Dedup",
"log": "Protokoll",
"cache": "Cache",
"spare": "Ersatz"
},
"poolDetails": "Pool-Layout, Scrub-Status und Gerätezustand",
"detailsUnavailable": "Detailliertes Poollayout erfordert OpenZFS 2.3 oder neuer.",
"status": "Status",
"action": "Aktion",
"capacity": "Kapazität",
"fragmentation": "Fragmentierung",
"dedupRatio": "Dedup-Verhältnis",
"layout": "Layout",
"singleDisk": "Einzelne Festplatte",
"stripe": "Streifen",
"mirror": "Spiegel",
"raidz": "RAIDZ{parity}",
"draid": "dRAID{parity}",
"diskCountOne": "{count}-Datenträger",
"diskCountMany": "{count}-Festplatten",
"errorsReported": "Fehler gemeldet",
"scanTitle": "Scrub / Resilver",
"scrub": "Scrub",
"resilver": "Resilver",
"neverScrubbed": "Nie geschrubbt",
"scanRunning": "{kind} in Bearbeitung ({percent} %)",
"scanCanceled": "{kind} abgebrochen",
"scanFinished": "{kind} fertig {date}",
"started": "Gestartet",
"ended": "Fertig",
"repaired": "Repariert",
"scanErrors": "Scanfehler",
"dataErrors": "{count}-Datenfehler",
"noDataErrors": "Keine bekannten Datenfehler",
"device": "Gerät",
"disk": "Festplatte",
"state": "Staat",
"readErrors": "Lesefehler",
"writeErrors": "Schreibfehler",
"checksumErrors": "Prüfsummenfehler",
"readShort": "R",
"writeShort": "W",
"checksumShort": "CK",
"slowIos": "Langsame E/As",
"via": "über {name}",
"totalSize": "Gesamtgröße",
"fragShort": "Frag."
},
"storageController": "Speichercontroller",
"noController": "Kein Controller erkannt",
"diskCountOne": "{count}-Datenträger",
"diskCountMany": "{count}-Festplatten"
},
"details": {
"temperature": {
@@ -2312,6 +2368,7 @@
"testing": "Testen…",
"testConnection": "Testverbindung",
"ollamaUrl": "Ollama-URL",
"ollamaApiKeyHint": "Bei einem normalen Ollama-Server leer lassen. Wenn dein Proxy oder Gateway einen Schlüssel verlangt, wird er als Authorization: Bearer gesendet. Dieses Feld aktiviert keine Authentifizierung auf dem Server.",
"getKey": "Holen Sie sich den Schlüssel",
"loadModelsPlaceholder": "Klicken Sie auf „Laden“, um Modelle abzurufen",
"selectModel": "Modell auswählen",
@@ -2575,6 +2632,10 @@
"title": "Speicherplatz – LXC rootfs",
"description": "Root-Laufwerk pro Container, bewertet anhand der Rootfs-Größe von PVE."
},
"vm_disk": {
"title": "Speicherplatz - VMs",
"description": "Dateisysteme laufender VMs, wie der QEMU-Gastagent sie meldet, einschließlich durchgereichter Laufwerke. VMs ohne Gastagent werden nicht ausgewertet."
},
"lxc_mount": {
"title": "LXC-Montagepunkte",
"description": "Kapazität der Mountpunkte innerhalb laufender CTs (mp0, mp1, NFS, Bind-Mounts). Schließt die Rootfs aus; das steht oben."
@@ -2609,8 +2670,12 @@
"excluded": "Ausgeschlossen",
"notConnected": "nicht verbunden",
"saveFailed": "Konnte die Laufwerksausschlüsse nicht retten.",
"help": "Für ein ausgeschlossenes Laufwerk werden weder Temperatur noch SMART-Werte planmäßig ausgelesen, nicht einmal der Energiestatus wird abgefragt, sodass es völlig in Ruhe gelassen wird. Beim Aufrufen der SMART-Details werden diese immer noch gelsesn.",
"idleHelp": "Mechanische Laufwerke ohne Aktivität werden bereits automatisch in Ruhe gelassen, bis sie wieder verwendet werden."
"help": "Für ein ausgeschlossenes Laufwerk werden weder Temperatur noch SMART-Werte planmäßig ausgelesen, nicht einmal der Energiestatus wird abgefragt, sodass es völlig in Ruhe gelassen wird. Beim Aufrufen der SMART-Details werden diese immer noch gelsesn."
},
"storageDisplay": {
"title": "Speicheranzeige",
"description": "Ändern Sie die Anordnung der Festplatten auf der Seite „Speicher“.",
"groupByController": "Physische Festplatten nach Speichercontroller gruppieren"
}
},
"login": {
@@ -3732,7 +3797,14 @@
"auditReviewDate": "Audit & Report – ein akzeptierter Befund kann ein Überprüfungsdatum tragen, das ihn wieder vorlegt, ohne die Entscheidung zurückzuziehen, und der Vergleich gibt an, ob sich ein Befund verschlechtert, verbessert oder gehalten hat (mit @Vaso73).",
"ociCatalog": "OCI manager Apps (Technology Preview) – ein neuer Eintrag im ProxMenux-Menü installiert Anwendungen aus ihren offiziellen Container-Images als native Proxmox-LXC, ohne Docker-Engine. Der Katalog umfasst 514 Anwendungen.",
"pbsSupport": "ProxMenux auf Proxmox Backup Server – es wird mit demselben Befehl installiert und öffnet ein eigenes Menü, mit Post-Install und einem Monitor, der Zustand, Datastores und Aufgaben des Servers zeigt (beigetragen von @jcastro).",
"menuSearch": "Suche im Hauptmenü – ein Name oder ein Teil davon findet eine ProxMenux-Option, eine OCI-Anwendung oder ein Helper-Script und öffnet es auf seinem ersten Bildschirm."
"menuSearch": "Suche im Hauptmenü – ein Name oder ein Teil davon findet eine ProxMenux-Option, eine OCI-Anwendung oder ein Helper-Script und öffnet es auf seinem ersten Bildschirm.",
"composeStacks": "Compose-Dateien mit mehreren Diensten — Custom image installiert je Dienst einen LXC in einem privaten Netzwerk, gestartet in der Reihenfolge der Abhängigkeiten. Updates, Backups und Entfernen gelten für die gesamte Anwendung.",
"keepProxmoxChanges": "OCI-Updates behalten, was du in Proxmox änderst — Variablen, Startbefehl, Netzwerkoptionen, DNS, Features, Festplattengrößen und lxc.*-Zeilen bleiben im neu erstellten Container erhalten, auch bei geplanten Updates und für jedes Mitglied eines Stacks.",
"ociVlan": "VLAN-Tag für OCI-Anwendungen — die erweiterte Installation fragt ihn neben der Adresse ab, und Ändern passt ihn bei einer Einzelanwendung an. Der Host braucht keinen Zugriff auf dieses VLAN.",
"ociNewApps": "Neu im OCI-Katalog — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX und zehn weitere Anwendungen, und Ollama und llama.cpp laufen auf integrierten GPUs von Intel und AMD.",
"hostUpdateJob": "Proxmox-Updates, die nach dem Abmelden weiterlaufen — das Update läuft als Auftrag auf dem Server und öffnet sich bei der erneuten Anmeldung wieder. Nach einem neuen Kernel wird ein ausstehender Neustart angezeigt, mit einer Schaltfläche, die zuerst den Host prüft.",
"storageDetails": "Speicherdetails — ZFS-Pools zeigen Aufbau, Scrub und Gerätezustand, und jede Festplatte zeigt ihren Speichercontroller, auch auf der Hardware-Seite. Die Schwellenwerte für VM-Festplatten kommen in die Einstellungen.",
"log2ramV2": "Log2RAM 2.0 — das Journal und die Aufgabenprotokolle von Proxmox bleiben mit ihrem Verlauf auf der Festplatte, und nur ständig wechselnde Protokolle kommen in den RAM. Bestehende Installationen werden mit ProxMenux aktualisiert."
}
},
"network": {
@@ -4089,7 +4161,9 @@
"interface": "Schnittstelle",
"rotationRate": "Rotationsrate",
"classCode": "Klassencode",
"serial": "Seriell"
"serial": "Seriell",
"storageController": "Speichercontroller",
"attachedDisks": "Angehängte Datenträger"
},
"values": {
"cores": "{count} Kerne",
@@ -5237,7 +5311,8 @@
"certificates": "Zertifikate",
"login_attempts": "Anmeldeversuche",
"fail2ban": "Fail2Ban",
"proxmox_storages": "Proxmox-Speicher"
"proxmox_storages": "Proxmox-Speicher",
"reboot_required": "Neustart erforderlich"
},
"details": {
"allOperational": "Alle Systeme betriebsbereit",
@@ -5316,7 +5391,8 @@
"securityCheckUnavailable": "Sicherheitsüberprüfung nicht verfügbar: {error}",
"certificateExpired": "Zertifikat abgelaufen",
"certificateExpiresDays": "Zertifikatsablauf (Tage): {days}",
"certificateInconclusive": "Zertifikatsprüfung nicht schlüssig"
"certificateInconclusive": "Zertifikatsprüfung nicht schlüssig",
"noPendingUpdates": "Keine ausstehenden Aktualisierungen"
},
"time": {
"justNow": "soeben",
@@ -5353,7 +5429,53 @@
"years": "{count} Jahre",
"24hours": "24 Stunden",
"7days": "7 Tage"
}
},
"restart": {
"button": "Server neu starten",
"title": "Server {server} neu starten?",
"impact": "Durch einen Neustart werden der Server, die virtuellen Maschinen, Container und deren Dienste vorübergehend unterbrochen.",
"cancel": "Abbrechen",
"confirm": "Neustart bestätigen",
"blocked": "Neustart ist verfügbar, nachdem das Host-Update abgeschlossen ist.",
"accepted": "Neustartanforderung angenommen.Überprüfen Sie, ob der Server wieder online ist.",
"unconfirmed": "Die Neustartanforderung konnte nicht überprüft werden.Überprüfen Sie den Server, bevor Sie es erneut versuchen."
},
"versions": {
"title": "ProxMenux-Update",
"installed": "Installierte Version",
"latest": "Verfügbare Version",
"lastKnown": "Letzte bekannte verfügbare Version",
"available": "Update verfügbar",
"current": "ProxMenux ist aktuell",
"currentVersion": "Version {version} ist aktuell",
"unknown": "Die verfügbare Version konnte nicht überprüft werden",
"unverified": "Nicht bestätigt",
"stable": "Stabiler Kanal",
"beta": "Beta-Kanal",
"checked": "Letzte erfolgreiche Prüfung: {time}"
},
"hostUpdate": {
"running": "Das Host-Update wird auch dann fortgesetzt, wenn Sie sich abmelden.Öffnen Sie es nach der Anmeldung erneut.",
"finished": "Das letzte Host-Update ist abgeschlossen.Das Ergebnis liegt vor.",
"attention": "Das Host-Update erfordert Aufmerksamkeit.Es wurde nicht gestoppt;Stellen Sie die Verbindung erneut her, um es zu überprüfen.",
"view": "Update anzeigen",
"viewResult": "Ergebnis anzeigen",
"hideResult": "Ergebnis ausblenden",
"resultTitle": "Host-Update-Ergebnis",
"resultReadOnly": "Gespeicherte Ausgabe eines abgeschlossenen Updates.Beim Öffnen wird kein weiteres Update ausgeführt.",
"resultUnavailable": "Das gespeicherte Ergebnis konnte nicht geladen werden.Es wurde kein Update gestartet.",
"retryResult": "Ergebnis neu laden",
"loadingResult": "Gespeichertes Ergebnis wird geladen…",
"closeResult": "Ergebnis schließen",
"noReboot": "Kein Neustart erforderlich",
"rebootMarker": "Das Betriebssystem erfordert einen Neustart.",
"rebootUnknown": "Es konnte nicht festgestellt werden, ob ein Neustart erforderlich ist.",
"rebootKernel": "Zum Aktivieren von {installed} ist ein Neustart erforderlich.Derzeit läuft: {running}."
},
"updateSystem": "System aktualisieren",
"packagePlanNote": "Der Plan umfasst auch neue Pakete, etwa einen neuen Kernel.Die Übersicht zählt nur Aktualisierungen bestehender Pakete.",
"snapshotUnavailable": "Es sind noch keine neuen Gesundheitsergebnisse verfügbar.",
"lastKnownResults": "Zeigt die letzten verfügbaren Ergebnisse mit ihrer ursprünglichen Prüfzeit an."
},
"profilePage": {
"loading": "Profil wird geladen…",
@@ -5394,6 +5516,7 @@
"iosDescription": "Fügen Sie den Monitor für einen schnellen Zugriff zu Ihrem Startbildschirm hinzu.",
"androidDescription": "Fügen Sie den Monitor als App hinzu, damit er wie eine normale Anwendung geöffnet wird.",
"addToHomeScreen": "Zum Startbildschirm hinzufügen",
"installApp": "App installieren",
"ios": {
"stepShareBefore": "Tippen Sie auf",
"share": "Aktie",
@@ -5406,10 +5529,17 @@
"android": {
"stepOpenMenu": "Öffnen Sie das Browsermenü",
"stepConfirm": "durch Antippen bestätigen",
"install": "Installieren"
"install": "Installieren",
"or": "oder"
},
"notNow": "Nicht jetzt",
"neverAgain": "Nicht mehr anzeigen"
"neverAgain": "Nicht mehr anzeigen",
"insecure": {
"description": "Über unverschlüsseltes HTTP kann Chrome nur eine Verknüpfung auf dem Startbildschirm hinzufügen.",
"howTo": "Um den Monitor als App zu installieren, öffnen Sie ihn über HTTPS mit einem gültigen Zertifikat: ein ACME-Zertifikat von Proxmox unter Admin → Sicherheit, ein Reverse-Proxy oder Tailscale.",
"docs": "HTTPS einrichten",
"shortcut": "Bis dahin können Sie eine Verknüpfung hinzufügen:"
}
},
"vmMetrics": {
"title": "Metriken – {name}",
@@ -5559,6 +5689,7 @@
}
},
"terminal": {
"rotateHint": "Drehen Sie das Telefon quer, um die ProxMenux-Menüs vollständig zu sehen.",
"connected": "Verbunden",
"disconnected": "Getrennt",
"terminalCount": "{count} / 4 Terminals",
@@ -7647,7 +7778,11 @@
"unconfirmedBody": "Das Backup-Ergebnis ist nicht bestätigt.",
"warningTitle": "{hostname}: Sicherung mit Warnungen abgeschlossen",
"warningBody": "Sicherung mit Warnungen abgeschlossen.",
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten."
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten.",
"quotaCause": "Ein Speicherkontingent verhinderte den Abschluss der Sicherung.",
"quotaAffected": "Sicherungen {ids}: ein Speicherkontingent verhinderte den Abschluss.",
"quotaCheck": "Prüfe das Kontingent des Sicherungsspeichers {storage}, bevor du es erneut versuchst.",
"causeMissing": "Sicherungen {ids}: die Zusammenfassung nennt keine Ursache; prüfe die vollständigen Aufgabenprotokolle."
}
}
},
File diff suppressed because one or more lines are too long
+148 -13
View File
@@ -369,8 +369,8 @@
"notApplicable": "n / A",
"error": "Error",
"system": "Sistema",
"standby": "En reposo",
"standbyTitle": "La unidad está en espera: se omitió smartctl para mantenerla apagada",
"standby": "En espera",
"standbyTitle": "El disco está parado: no se le envía nada para no despertarlo",
"filesystemCorruption": "Se detectó corrupción en el sistema de archivos",
"ioErrorOne": "{count} Error de E/S en 5 minutos",
"ioErrorMany": "{count} Errores de E/S en 5 minutos",
@@ -980,7 +980,63 @@
"temperatureCriticalCount": "Temperaturas críticas: {count}",
"temperatureUnavailable": "Temperaturas no disponibles",
"temperatureIncomplete": "datos de temperatura incompletos",
"temperatureUnavailableCount": "No disponible: {count}"
"temperatureUnavailableCount": "No disponible: {count}",
"zfs": {
"classes": {
"normal": "Datos",
"special": "Especial",
"dedup": "Dedup",
"log": "Log",
"cache": "Caché",
"spare": "Repuesto"
},
"poolDetails": "Estructura del pool, estado del scrub y salud de los dispositivos",
"detailsUnavailable": "La estructura detallada del pool requiere OpenZFS 2.3 o posterior.",
"status": "Estado",
"action": "Acción",
"capacity": "Capacidad",
"fragmentation": "Fragmentación",
"dedupRatio": "Ratio de deduplicación",
"layout": "Estructura",
"totalSize": "Tamaño total",
"fragShort": "Frag.",
"singleDisk": "Disco único",
"stripe": "Stripe",
"mirror": "Espejo",
"raidz": "RAIDZ{parity}",
"draid": "dRAID{parity}",
"diskCountOne": "{count} disco",
"diskCountMany": "{count} discos",
"errorsReported": "Errores detectados",
"scanTitle": "Scrub / Resilver",
"scrub": "Scrub",
"resilver": "Resilver",
"neverScrubbed": "Sin scrub todavía",
"scanRunning": "{kind} en curso ({percent}%)",
"scanCanceled": "{kind} cancelado",
"scanFinished": "{kind} terminado {date}",
"started": "Inicio",
"ended": "Fin",
"repaired": "Reparado",
"scanErrors": "Errores del análisis",
"dataErrors": "{count} errores de datos",
"noDataErrors": "No hay errores de datos conocidos",
"device": "Dispositivo",
"disk": "Disco",
"state": "Estado",
"readErrors": "Errores de lectura",
"writeErrors": "Errores de escritura",
"checksumErrors": "Errores de checksum",
"readShort": "R",
"writeShort": "W",
"checksumShort": "CK",
"slowIos": "E/S lentas",
"via": "a través de {name}"
},
"storageController": "Controlador de almacenamiento",
"noController": "No se detectó ningún controlador",
"diskCountOne": "disco {count}",
"diskCountMany": "discos {count}"
},
"details": {
"temperature": {
@@ -2465,6 +2521,7 @@
"testing": "Pruebas…",
"testConnection": "Conexión de prueba",
"ollamaUrl": "URL de Ollama",
"ollamaApiKeyHint": "Déjala vacía para un servidor Ollama estándar. Si tu proxy o pasarela requiere una clave, se envía como Authorization: Bearer. Este campo no activa la autenticación en el servidor.",
"getKey": "Obtener clave",
"loadModelsPlaceholder": "Haz clic en Cargar para buscar modelos",
"selectModel": "Seleccionar modelo",
@@ -2728,6 +2785,10 @@
"title": "Espacio en disco - rootfs LXC",
"description": "Disco raíz por contenedor, evaluado con respecto al tamaño de rootfs de PVE."
},
"vm_disk": {
"title": "Espacio en disco - VMs",
"description": "Sistemas de archivos de las VM en marcha según los informa el agente invitado de QEMU, incluidos los discos en passthrough. Las VM sin agente invitado no se evalúan."
},
"lxc_mount": {
"title": "Puntos de montaje LXC",
"description": "Capacidad de puntos de montaje dentro de CT en ejecución (mp0, mp1, NFS, montajes vinculados). Excluye los rootfs; que se trata arriba."
@@ -2762,8 +2823,12 @@
"excluded": "Excluido",
"notConnected": "No conectado",
"saveFailed": "No se pudieron guardar las exclusiones de discos.",
"help": "Un disco excluido no recibe lecturas periódicas de temperatura ni de SMART —ni siquiera la consulta de su estado de energía—, así que el Monitor no lo consulta en absoluto. Abrir sus detalles SMART sí lo lee.",
"idleHelp": "Los discos mecánicos sin actividad ya no se consultan automáticamente, para no sacarlos del reposo, hasta que algo vuelve a usarlos."
"help": "Un disco excluido no recibe lecturas periódicas de temperatura ni de SMART —ni siquiera la consulta de su estado de energía—, así que el Monitor no lo consulta en absoluto. Abrir sus detalles SMART sí lo lee."
},
"storageDisplay": {
"title": "Pantalla de almacenamiento",
"description": "cambie la distribución de los discos en la página Almacenamiento",
"groupByController": "agrupar discos físicos por controlador de almacenamiento"
}
},
"login": {
@@ -3885,7 +3950,14 @@
"auditReviewDate": "Auditoría e Informes — un hallazgo aceptado puede llevar una fecha de revisión que lo trae de vuelta sin retirar la decisión, y la comparación indica si un hallazgo empeoró, mejoró o se mantuvo (con @Vaso73).",
"ociCatalog": "OCI manager Apps (Technology Preview) — una nueva entrada del menú de ProxMenux instala aplicaciones desde sus imágenes de contenedor oficiales como LXC nativos de Proxmox, sin motor Docker. El catálogo reúne 514 aplicaciones.",
"pbsSupport": "ProxMenux en Proxmox Backup Server — se instala con el mismo comando y abre un menú propio, con post-install y un Monitor que muestra la salud, los datastores y las tareas del servidor (aportado por @jcastro).",
"menuSearch": "Búsqueda en el menú principal — un nombre o parte de él encuentra una opción de ProxMenux, una aplicación OCI o un Helper-Script, y lo abre en su primera pantalla."
"menuSearch": "Búsqueda en el menú principal — un nombre o parte de él encuentra una opción de ProxMenux, una aplicación OCI o un Helper-Script, y lo abre en su primera pantalla.",
"composeStacks": "Archivos Compose con varios servicios — Custom image instala un LXC por servicio en una red privada, arrancados por orden de dependencias. Las actualizaciones, los backups y la eliminación funcionan sobre toda la aplicación.",
"keepProxmoxChanges": "Las actualizaciones OCI conservan lo que cambias en Proxmox — variables, comando de arranque, opciones de red, DNS, features, tamaños de disco y líneas lxc.* siguen en el contenedor reconstruido, también en las actualizaciones programadas y en cada miembro de una pila.",
"ociVlan": "Etiqueta VLAN para aplicaciones OCI — la instalación avanzada la pregunta junto a la dirección, y Modificar la cambia en una aplicación de un contenedor. El host no necesita acceso a esa VLAN.",
"ociNewApps": "Novedades en el catálogo OCI — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX y diez aplicaciones más, y Ollama y llama.cpp funcionan en GPU integradas Intel y AMD.",
"hostUpdateJob": "Actualizaciones de Proxmox que continúan al cerrar la sesión — la actualización se ejecuta como una tarea en el servidor y se vuelve a abrir al entrar. Tras un kernel nuevo se muestra el reinicio pendiente, con un botón que comprueba antes el host.",
"storageDetails": "Detalles de almacenamiento — los pools ZFS muestran su estructura, scrub y el estado de sus dispositivos, y cada disco muestra su controladora, también en la página de Hardware. Los umbrales de disco de VM llegan a Ajustes.",
"log2ramV2": "Log2RAM 2.0 — el journal y los registros de tareas de Proxmox se quedan en disco con su historial, y solo los registros que cambian constantemente van a la RAM. Los hosts que ya lo usan se actualizan con ProxMenux."
}
},
"network": {
@@ -4242,7 +4314,9 @@
"interface": "Interfaz",
"rotationRate": "Velocidad de rotación",
"classCode": "Código de clase",
"serial": "De serie"
"serial": "De serie",
"storageController": "Controlador de almacenamiento",
"attachedDisks": "Discos adjuntos"
},
"values": {
"cores": "{count} núcleos",
@@ -5390,7 +5464,8 @@
"certificates": "Certificados",
"login_attempts": "Intentos de inicio de sesión",
"fail2ban": "Fail2Ban",
"proxmox_storages": "Almacenamiento Proxmox"
"proxmox_storages": "Almacenamiento Proxmox",
"reboot_required": "Es necesario reiniciar"
},
"details": {
"allOperational": "Todos los sistemas operativos.",
@@ -5469,7 +5544,8 @@
"securityCheckUnavailable": "Comprobación de seguridad no disponible: {error}",
"certificateExpired": "Certificado caducado",
"certificateExpiresDays": "Caducidad del certificado (días): {days}",
"certificateInconclusive": "Comprobación del certificado no concluyente"
"certificateInconclusive": "Comprobación del certificado no concluyente",
"noPendingUpdates": "No hay actualizaciones pendientes"
},
"time": {
"justNow": "En este momento",
@@ -5506,7 +5582,53 @@
"years": "{count} años",
"24hours": "24 horas",
"7days": "7 dias"
}
},
"restart": {
"button": "reiniciar el servidor",
"title": "¿Reiniciar el servidor {server}?",
"impact": "El reinicio interrumpe temporalmente el servidor, las máquinas virtuales, los contenedores y sus servicios.",
"cancel": "Cancelar",
"confirm": "confirmar reinicio",
"blocked": "el reinicio estará disponible una vez que finalice la actualización del host.",
"accepted": "Solicitud de reinicio aceptada.Compruebe que el servidor vuelva a estar en línea.",
"unconfirmed": "No se pudo verificar la solicitud de reinicio.Verifique el servidor antes de volver a intentarlo."
},
"versions": {
"title": "actualización ProxMenux",
"installed": "versión instalada",
"latest": "Versión disponible",
"lastKnown": "última versión disponible conocida",
"available": "Actualización disponible",
"current": "ProxMenux está actualizado",
"currentVersion": "La versión {version} está actualizada",
"unknown": "No se pudo verificar la versión disponible",
"unverified": "No verificado",
"stable": "canal estable",
"beta": "canal Beta",
"checked": "Última verificación exitosa: {time}"
},
"hostUpdate": {
"running": "la actualización del host continúa incluso si cierra sesión.Vuelva a abrirlo después de iniciar sesión.",
"finished": "La última actualización del host ha finalizado.Su resultado está disponible.",
"attention": "La actualización del host necesita atención.No ha sido detenido;Vuelva a conectarlo para inspeccionarlo.",
"view": "Ver actualización",
"viewResult": "Ver resultado",
"hideResult": "Ocultar resultado",
"resultTitle": "resultado de la actualización del host",
"resultReadOnly": "resultado guardado de una actualización completa.Al abrirlo no se ejecuta otra actualización.",
"resultUnavailable": "No se pudo cargar el resultado guardado.No se inició ninguna actualización.",
"retryResult": "recargar resultado",
"loadingResult": "Cargando resultado guardado…",
"closeResult": "Cerrar resultado",
"noReboot": "No es necesario reiniciar",
"rebootMarker": "El sistema operativo requiere un reinicio.",
"rebootUnknown": "No se pudo determinar si es necesario reiniciar.",
"rebootKernel": "Es necesario reiniciar para activar {installed}.Actualmente en ejecución: {running}."
},
"updateSystem": "sistema de actualización",
"packagePlanNote": "el plan también incluye nuevos paquetes, como un nuevo kernel.La descripción general cuenta únicamente las actualizaciones de los paquetes existentes.",
"snapshotUnavailable": "Los nuevos resultados de salud aún no están disponibles.",
"lastKnownResults": "muestra los últimos resultados disponibles con su hora de verificación original."
},
"profilePage": {
"loading": "Cargando perfil…",
@@ -5547,6 +5669,7 @@
"iosDescription": "Añade el monitor a tu pantalla de inicio para un acceso rápido.",
"androidDescription": "Añade el Monitor como una aplicación para que se abra como una aplicación normal.",
"addToHomeScreen": "Añadir a la pantalla de inicio",
"installApp": "Instalar aplicación",
"ios": {
"stepShareBefore": "Toca el",
"share": "Compartir",
@@ -5559,10 +5682,17 @@
"android": {
"stepOpenMenu": "Abre el menú del navegador",
"stepConfirm": "confirmar tocando",
"install": "Instalar"
"install": "Instalar",
"or": "o"
},
"notNow": "Ahora no",
"neverAgain": "No volver a mostrar"
"neverAgain": "No volver a mostrar",
"insecure": {
"description": "Por HTTP sin cifrar, Chrome solo puede añadir un acceso directo a la pantalla de inicio.",
"howTo": "Para instalar el Monitor como aplicación, ábrelo por HTTPS con un certificado válido: un certificado ACME de Proxmox en Administración → Seguridad, un proxy inverso o Tailscale.",
"docs": "Cómo configurar HTTPS",
"shortcut": "Mientras tanto, puedes añadir un acceso directo:"
}
},
"vmMetrics": {
"title": "Métricas: {name}",
@@ -5712,6 +5842,7 @@
}
},
"terminal": {
"rotateHint": "Gira el móvil para ver completos los menús de ProxMenux.",
"connected": "Conectado",
"disconnected": "Desconectado",
"terminalCount": "{count} / 4 terminales",
@@ -7800,7 +7931,11 @@
"unconfirmedBody": "El resultado del backup no está confirmado.",
"warningTitle": "{hostname}: Backup completado con advertencias",
"warningBody": "Backup completado con advertencias.",
"diagnosticsOmitted": "Líneas o texto de diagnóstico omitidos: {count}. Se conserva el informe original."
"diagnosticsOmitted": "Líneas o texto de diagnóstico omitidos: {count}. Se conserva el informe original.",
"quotaCause": "Una cuota de almacenamiento impidió completar la copia.",
"quotaAffected": "Copias {ids}: una cuota de almacenamiento impidió completarlas.",
"quotaCheck": "Revisa la cuota del almacenamiento de backup {storage} antes de repetir las copias.",
"causeMissing": "Copias {ids}: el resumen no indica la causa; revisa el log completo de esas tareas."
}
}
},
+146 -11
View File
@@ -827,7 +827,63 @@
"temperatureCriticalCount": "Températures critiques : {count}",
"temperatureUnavailable": "Températures indisponibles",
"temperatureIncomplete": "Données de température incomplètes",
"temperatureUnavailableCount": "Indisponible : {count}"
"temperatureUnavailableCount": "Indisponible : {count}",
"zfs": {
"classes": {
"normal": "Données",
"special": "Spécial",
"dedup": "Déduplication",
"log": "Journal",
"cache": "Cache",
"spare": "Pièce de rechange"
},
"poolDetails": "Disposition du pool, état du nettoyage et santé de l'appareil",
"detailsUnavailable": "La disposition détaillée du pool nécessite OpenZFS 2.3 ou une version ultérieure.",
"status": "Statut",
"action": "Action",
"capacity": "Capacité",
"fragmentation": "Fragmentation",
"dedupRatio": "taux de déduplication",
"layout": "Mise en page",
"singleDisk": "Disque unique",
"stripe": "Rayure",
"mirror": "Miroir",
"raidz": "RAIDZ{parity}",
"draid": "dRAID{parity}",
"diskCountOne": "disque {count}",
"diskCountMany": "disques {count}",
"errorsReported": "erreurs signalées",
"scanTitle": "Gommage / Réargent",
"scrub": "Gommage",
"resilver": "Réargent",
"neverScrubbed": "Jamais nettoyé",
"scanRunning": "{kind} en cours ({percent}%)",
"scanCanceled": "{kind} annulé",
"scanFinished": "{kind} terminé {date}",
"started": "commencé",
"ended": "Terminé",
"repaired": "Réparé",
"scanErrors": "erreurs d'analyse",
"dataErrors": "erreurs de données {count}",
"noDataErrors": "aucune erreur de données connue",
"device": "Appareil",
"disk": "Disque",
"state": "État",
"readErrors": "lire les erreurs",
"writeErrors": "Ecrire des erreurs",
"checksumErrors": "Erreurs de somme de contrôle",
"readShort": "R",
"writeShort": "W",
"checksumShort": "CK",
"slowIos": "E/S lentes",
"via": "via {name}",
"totalSize": "Taille totale",
"fragShort": "Frag."
},
"storageController": "Contrôleur de stockage",
"noController": "Aucun contrôleur détecté",
"diskCountOne": "disque {count}",
"diskCountMany": "disques {count}"
},
"details": {
"temperature": {
@@ -2312,6 +2368,7 @@
"testing": "Essai…",
"testConnection": "Tester la connexion",
"ollamaUrl": "URL d'Ollama",
"ollamaApiKeyHint": "Laissez ce champ vide pour un serveur Ollama standard. Si votre proxy ou passerelle exige une clé, elle est envoyée dans Authorization: Bearer. Ce champ n'active pas l'authentification sur le serveur.",
"getKey": "Obtenir la clé",
"loadModelsPlaceholder": "Cliquez sur Charger pour récupérer les modèles",
"selectModel": "Sélectionnez le modèle",
@@ -2575,6 +2632,10 @@
"title": "Espace disque - rootfs LXC",
"description": "Disque racine par conteneur, évalué par rapport à la taille rootfs de PVE."
},
"vm_disk": {
"title": "Espace disque - VM",
"description": "Systèmes de fichiers des VM en cours d'exécution, tels que les signale l'agent invité QEMU, disques en passthrough compris. Les VM sans agent invité ne sont pas évaluées."
},
"lxc_mount": {
"title": "Points de montage LXC",
"description": "Capacité des points de montage à l’intérieur des CT en cours d’exécution (mp0, mp1, NFS, montages de liaison). Exclut les rootfs ; qui est couvert ci-dessus."
@@ -2609,8 +2670,12 @@
"excluded": "Exclusion",
"notConnected": "Non connecté",
"saveFailed": "Impossible d'enregistrer les exclusions du disque.",
"help": "Un disque exclu n'obtient aucune température programmée ou SMART lit — pas même une requête en mode de puissance — donc il est laissé tout seul. Ouverture de ses détails SMART le lit encore.",
"idleHelp": "Les disques mécaniques sans activité sont déjà laissés seuls automatiquement jusqu'à ce que quelque chose les utilise à nouveau."
"help": "Un disque exclu n'obtient aucune température programmée ou SMART lit — pas même une requête en mode de puissance — donc il est laissé tout seul. Ouverture de ses détails SMART le lit encore."
},
"storageDisplay": {
"title": "Affichage du stockage",
"description": "modifier la disposition des disques sur la page Stockage",
"groupByController": "Regrouper les disques physiques par contrôleur de stockage"
}
},
"login": {
@@ -3732,7 +3797,14 @@
"auditReviewDate": "Audit & Report — une constatation acceptée peut porter une date de révision qui la fait réapparaître sans retirer la décision, et la comparaison indique si une constatation s'est aggravée, améliorée ou maintenue (avec @Vaso73).",
"ociCatalog": "OCI manager Apps (Technology Preview) — une nouvelle entrée du menu ProxMenux installe les applications à partir de leurs images de conteneur officielles en tant que LXC Proxmox natifs, sans moteur Docker. Le catalogue réunit 514 applications.",
"pbsSupport": "ProxMenux sur Proxmox Backup Server — il s'installe avec la même commande et ouvre son propre menu, avec post-installation et un Monitor qui affiche la santé, les datastores et les tâches du serveur (contribution de @jcastro).",
"menuSearch": "Recherche dans le menu principal — un nom ou une partie du nom trouve une option de ProxMenux, une application OCI ou un Helper-Script, et l'ouvre sur son premier écran."
"menuSearch": "Recherche dans le menu principal — un nom ou une partie du nom trouve une option de ProxMenux, une application OCI ou un Helper-Script, et l'ouvre sur son premier écran.",
"composeStacks": "Fichiers Compose à plusieurs services — Custom image installe un LXC par service sur un réseau privé, démarrés selon leurs dépendances. Les mises à jour, les sauvegardes et la suppression s’appliquent à toute l’application.",
"keepProxmoxChanges": "Les mises à jour OCI conservent ce que vous modifiez dans Proxmox — variables, commande de démarrage, options réseau, DNS, features, tailles de disque et lignes lxc.* restent sur le conteneur reconstruit, y compris lors des mises à jour planifiées et sur chaque membre d’une pile.",
"ociVlan": "Étiquette VLAN pour les applications OCI — l’installation avancée la demande à côté de l’adresse, et Modifier la change sur une application à conteneur unique. L’hôte n’a pas besoin d’accéder à ce VLAN.",
"ociNewApps": "Nouveautés du catalogue OCI — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX et dix autres applications, et Ollama et llama.cpp fonctionnent sur les GPU intégrés Intel et AMD.",
"hostUpdateJob": "Des mises à jour Proxmox qui continuent après la déconnexion — la mise à jour s’exécute comme une tâche sur le serveur et se rouvre à la reconnexion. Un redémarrage en attente s’affiche après un nouveau noyau, avec un bouton qui vérifie d’abord l’hôte.",
"storageDetails": "Détails du stockage — les pools ZFS affichent leur structure, leur scrub et l’état de leurs périphériques, et chaque disque indique son contrôleur, y compris sur la page Matériel. Les seuils de disque des VM arrivent dans les Paramètres.",
"log2ramV2": "Log2RAM 2.0 — le journal et les journaux de tâches de Proxmox restent sur disque avec leur historique, et seuls les journaux qui changent sans cesse vont en RAM. Les hôtes qui l’utilisent déjà se mettent à jour avec ProxMenux."
}
},
"network": {
@@ -4089,7 +4161,9 @@
"interface": "Interface",
"rotationRate": "Taux de rotation",
"classCode": "Code de classe",
"serial": "En série"
"serial": "En série",
"storageController": "Contrôleur de stockage",
"attachedDisks": "disques connectés"
},
"values": {
"cores": "{count} cœurs",
@@ -5237,7 +5311,8 @@
"certificates": "Certificats",
"login_attempts": "Tentatives de connexion",
"fail2ban": "Échec2Ban",
"proxmox_storages": "Stockage Proxmox"
"proxmox_storages": "Stockage Proxmox",
"reboot_required": "Redémarrage requis"
},
"details": {
"allOperational": "Tous les systèmes opérationnels",
@@ -5316,7 +5391,8 @@
"securityCheckUnavailable": "Contrôle de sécurité indisponible : {error}",
"certificateExpired": "Certificat expiré",
"certificateExpiresDays": "Expiration du certificat (jours) : {days}",
"certificateInconclusive": "Vérification du certificat non concluante"
"certificateInconclusive": "Vérification du certificat non concluante",
"noPendingUpdates": "aucune mise à jour en attente"
},
"time": {
"justNow": "tout à l' heure",
@@ -5353,7 +5429,53 @@
"years": "{count} ans",
"24hours": "24 heures",
"7days": "7 jours"
}
},
"restart": {
"button": "Redémarrer le serveur",
"title": "Redémarrer le serveur {server} ?",
"impact": "Le redémarrage interrompt temporairement le serveur, les machines virtuelles, les conteneurs et leurs services.",
"cancel": "Annuler",
"confirm": "Confirmer le redémarrage",
"blocked": "Le redémarrage sera disponible une fois la mise à jour de l'hôte terminée.",
"accepted": "demande de redémarrage acceptée.Vérifiez que le serveur revient en ligne.",
"unconfirmed": "La demande de redémarrage n'a pas pu être vérifiée.Vérifiez le serveur avant de réessayer."
},
"versions": {
"title": "mise à jour ProxMenux",
"installed": "version installée",
"latest": "version disponible",
"lastKnown": "Dernière version disponible connue",
"available": "mise à jour disponible",
"current": "ProxMenux est à jour",
"currentVersion": "La version {version} est à jour",
"unknown": "Impossible de vérifier la version disponible",
"unverified": "non vérifié",
"stable": "Canal stable",
"beta": "canal Beta",
"checked": "Dernière vérification réussie : {time}"
},
"hostUpdate": {
"running": "La mise à jour de l'hôte se poursuit même si vous vous déconnectez.Rouvrez-le après vous être connecté.",
"finished": "La dernière mise à jour de l'hôte est terminée.Son résultat est disponible.",
"attention": "La mise à jour de l'hôte nécessite votre attention.Cela n’a pas été arrêté ;reconnectez-vous pour l’inspecter.",
"view": "Afficher la mise à jour",
"viewResult": "Afficher le résultat",
"hideResult": "Masquer le résultat",
"resultTitle": "Résultat de la mise à jour de l'hôte",
"resultReadOnly": "sortie enregistrée d’une mise à jour terminée.L'ouvrir n'exécute pas une autre mise à jour.",
"resultUnavailable": "Le résultat enregistré n'a pas pu être chargé.Aucune mise à jour n'a été lancée.",
"retryResult": "Recharger le résultat",
"loadingResult": "Chargement du résultat enregistré…",
"closeResult": "Fermer le résultat",
"noReboot": "Aucun redémarrage requis",
"rebootMarker": "Le système d’exploitation nécessite un redémarrage.",
"rebootUnknown": "impossible de déterminer si un redémarrage est requis.",
"rebootKernel": "Un redémarrage est nécessaire pour activer {installed}.En cours d'exécution : {running}."
},
"updateSystem": "Mettre à jour le système",
"packagePlanNote": "Le plan comprend également de nouveaux packages, tels qu'un nouveau noyau.L'aperçu ne compte que les mises à jour des packages existants.",
"snapshotUnavailable": "Les nouveaux résultats de santé ne sont pas encore disponibles.",
"lastKnownResults": "Affichage des derniers résultats disponibles avec leur heure de vérification d'origine."
},
"profilePage": {
"loading": "Chargement du profil…",
@@ -5394,6 +5516,7 @@
"iosDescription": "Ajoutez le moniteur à votre écran d'accueil pour un accès rapide.",
"androidDescription": "Ajoutez le moniteur en tant qu'application pour qu'il s'ouvre comme une application normale.",
"addToHomeScreen": "Ajouter à l'écran d'accueil",
"installApp": "Installer l'application",
"ios": {
"stepShareBefore": "Appuyez sur le",
"share": "Partager",
@@ -5406,10 +5529,17 @@
"android": {
"stepOpenMenu": "Ouvrez le menu du navigateur",
"stepConfirm": "confirmer en appuyant sur",
"install": "Installer"
"install": "Installer",
"or": "ou"
},
"notNow": "Pas maintenant",
"neverAgain": "Ne plus montrer"
"neverAgain": "Ne plus montrer",
"insecure": {
"description": "En HTTP non chiffré, Chrome peut seulement ajouter un raccourci à l'écran d'accueil.",
"howTo": "Pour installer le moniteur comme application, ouvrez-le en HTTPS avec un certificat valide : un certificat ACME de Proxmox dans Administrateur → Sécurité, un proxy inverse ou Tailscale.",
"docs": "Configurer HTTPS",
"shortcut": "En attendant, vous pouvez ajouter un raccourci :"
}
},
"vmMetrics": {
"title": "Métriques — {name}",
@@ -5559,6 +5689,7 @@
}
},
"terminal": {
"rotateHint": "Tournez le téléphone à l'horizontale pour voir les menus de ProxMenux en entier.",
"connected": "Connecté",
"disconnected": "Déconnecté",
"terminalCount": "{count} / 4 bornes",
@@ -7647,7 +7778,11 @@
"unconfirmedBody": "Le résultat de la sauvegarde n’est pas confirmé.",
"warningTitle": "{hostname}: Sauvegarde terminée avec avertissements",
"warningBody": "Sauvegarde terminée avec avertissements.",
"diagnosticsOmitted": "Lignes ou texte de diagnostic omis : {count}. Rapport original conservé."
"diagnosticsOmitted": "Lignes ou texte de diagnostic omis : {count}. Rapport original conservé.",
"quotaCause": "Un quota de stockage a empêché la sauvegarde de se terminer.",
"quotaAffected": "Sauvegardes {ids} : un quota de stockage a empêché leur achèvement.",
"quotaCheck": "Vérifiez le quota du stockage de sauvegarde {storage} avant de réessayer.",
"causeMissing": "Sauvegardes {ids} : le résumé ne précise pas la cause ; consultez les journaux complets de ces tâches."
}
}
},
+147 -12
View File
@@ -827,7 +827,63 @@
"temperatureCriticalCount": "Temperature critiche: {count}",
"temperatureUnavailable": "Temperature non disponibili",
"temperatureIncomplete": "Dati temperatura incompleti",
"temperatureUnavailableCount": "Non disponibili: {count}"
"temperatureUnavailableCount": "Non disponibili: {count}",
"zfs": {
"classes": {
"normal": "Dati",
"special": "Speciale",
"dedup": "Dedup",
"log": "Log",
"cache": "Cache",
"spare": "Riserva"
},
"poolDetails": "Struttura del pool, stato dello scrub e stato dei dispositivi",
"detailsUnavailable": "La struttura dettagliata del pool richiede OpenZFS 2.3 o versione successiva.",
"status": "Stato",
"action": "Azione",
"capacity": "Capacità",
"fragmentation": "Frammentazione",
"dedupRatio": "Rapporto di deduplicazione",
"layout": "Struttura",
"singleDisk": "Disco singolo",
"stripe": "Stripe",
"mirror": "Mirror",
"raidz": "RAIDZ{parity}",
"draid": "dRAID{parity}",
"diskCountOne": "{count} disco",
"diskCountMany": "{count} dischi",
"errorsReported": "Errori segnalati",
"scanTitle": "Scrub / Resilver",
"scrub": "Scrub",
"resilver": "Resilver",
"neverScrubbed": "Scrub mai eseguito",
"scanRunning": "{kind} in corso ({percent}%)",
"scanCanceled": "{kind} annullato",
"scanFinished": "{kind} terminato {date}",
"started": "Inizio",
"ended": "Fine",
"repaired": "Dati riparati",
"scanErrors": "Errori di scansione",
"dataErrors": "{count} errori nei dati",
"noDataErrors": "Nessun errore noto nei dati",
"device": "Dispositivo",
"disk": "Disco",
"state": "Stato",
"readErrors": "Errori di lettura",
"writeErrors": "Errori di scrittura",
"checksumErrors": "Errori di checksum",
"readShort": "R",
"writeShort": "W",
"checksumShort": "CK",
"slowIos": "I/O lenti",
"via": "tramite {name}",
"totalSize": "Dimensione totale",
"fragShort": "Frag."
},
"storageController": "Controller storage",
"noController": "Nessun controller rilevato",
"diskCountOne": "{count} disco",
"diskCountMany": "{count} dischi"
},
"details": {
"temperature": {
@@ -2312,6 +2368,7 @@
"testing": "Test in corso…",
"testConnection": "Prova connessione",
"ollamaUrl": "URL Ollama",
"ollamaApiKeyHint": "Lascia vuoto per un server Ollama standard. Se il proxy o gateway richiede una chiave, viene inviata come Authorization: Bearer. Questo campo non abilita l'autenticazione sul server.",
"getKey": "Ottieni chiave",
"loadModelsPlaceholder": "Fai clic su Carica per recuperare i modelli",
"selectModel": "Seleziona modello",
@@ -2575,6 +2632,10 @@
"title": "Spazio disco: rootfs LXC",
"description": "Disco root di ciascun container, valutato rispetto alla dimensione rootfs indicata da PVE."
},
"vm_disk": {
"title": "Spazio su disco - VM",
"description": "File system delle VM in esecuzione come li riporta l'agente guest QEMU, inclusi i dischi in passthrough. Le VM senza agente guest non vengono valutate."
},
"lxc_mount": {
"title": "Punti di montaggio LXC",
"description": "Capacità dei punti di montaggio nei CT in esecuzione (mp0, mp1, NFS, bind mount). Esclude il rootfs, trattato sopra."
@@ -2609,8 +2670,12 @@
"excluded": "Escluso",
"notConnected": "Non collegato",
"saveFailed": "Impossibile salvare le esclusioni dei dischi.",
"help": "Un disco escluso non viene interrogato durante i controlli pianificati, nemmeno per temperatura, SMART o modalità di alimentazione. L’apertura dei suoi dettagli SMART comporta comunque una lettura.",
"idleHelp": "I dischi meccanici inattivi vengono già lasciati a riposo automaticamente finché non vengono nuovamente utilizzati."
"help": "Un disco escluso non viene interrogato durante i controlli pianificati, nemmeno per temperatura, SMART o modalità di alimentazione. L’apertura dei suoi dettagli SMART comporta comunque una lettura."
},
"storageDisplay": {
"title": "Visualizzazione storage",
"description": "Modifica la disposizione dei dischi nella pagina Storage",
"groupByController": "Raggruppa i dischi fisici per controller storage"
}
},
"login": {
@@ -3732,7 +3797,14 @@
"auditReviewDate": "Audit & Report: un risultato accettato può avere una data di revisione che lo ripresenta senza ritirare la decisione, e il confronto indica se un risultato è peggiorato, migliorato o rimasto invariato (con @Vaso73).",
"ociCatalog": "OCI manager Apps (Technology Preview): una nuova voce nel menu di ProxMenux installa le applicazioni dalle loro immagini container ufficiali come LXC nativi di Proxmox, senza motore Docker. Il catalogo contiene 514 applicazioni.",
"pbsSupport": "ProxMenux su Proxmox Backup Server: si installa con lo stesso comando e apre un menu proprio, con post-installazione e un Monitor che mostra lo stato, i datastore e le attività del server (contributo di @jcastro).",
"menuSearch": "Ricerca nel menu principale: basta un nome o parte di esso per trovare un'opzione di ProxMenux, un'applicazione OCI o un Helper-Script e aprirne la prima schermata."
"menuSearch": "Ricerca nel menu principale: basta un nome o parte di esso per trovare un'opzione di ProxMenux, un'applicazione OCI o un Helper-Script e aprirne la prima schermata.",
"composeStacks": "File Compose con più servizi — Custom image installa un LXC per servizio su una rete privata, avviati in ordine di dipendenza. Aggiornamenti, backup e rimozione valgono per l’intera applicazione.",
"keepProxmoxChanges": "Gli aggiornamenti OCI mantengono ciò che modifichi in Proxmox — variabili, comando di avvio, opzioni di rete, DNS, features, dimensioni dei dischi e righe lxc.* restano nel container ricostruito, anche negli aggiornamenti pianificati e in ogni membro di uno stack.",
"ociVlan": "Tag VLAN per le applicazioni OCI — l’installazione avanzata lo chiede accanto all’indirizzo, e Modifica lo cambia in un’applicazione a contenitore singolo. L’host non ha bisogno di accedere a quella VLAN.",
"ociNewApps": "Novità nel catalogo OCI — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX e altre dieci applicazioni, e Ollama e llama.cpp funzionano sulle GPU integrate Intel e AMD.",
"hostUpdateJob": "Aggiornamenti di Proxmox che proseguono dopo la disconnessione — l’aggiornamento viene eseguito come job sul server e si riapre al nuovo accesso. Dopo un nuovo kernel viene mostrato il riavvio in sospeso, con un pulsante che controlla prima l’host.",
"storageDetails": "Dettagli dello storage — i pool ZFS mostrano struttura, scrub e stato dei dispositivi, e ogni disco mostra il suo controller, anche nella pagina Hardware. Le soglie dei dischi delle VM arrivano nelle Impostazioni.",
"log2ramV2": "Log2RAM 2.0 — il journal e i log delle attività di Proxmox restano su disco con la loro cronologia, e solo i log che cambiano di continuo vanno in RAM. Gli host che lo usano già si aggiornano con ProxMenux."
}
},
"network": {
@@ -4089,7 +4161,9 @@
"interface": "Interfaccia",
"rotationRate": "Velocità di rotazione",
"classCode": "Codice classe",
"serial": "Seriale"
"serial": "Seriale",
"storageController": "Controller storage",
"attachedDisks": "Dischi collegati"
},
"values": {
"cores": "{count} core",
@@ -5237,7 +5311,8 @@
"certificates": "Certificati",
"login_attempts": "Tentativi di accesso",
"fail2ban": "Fail2Ban",
"proxmox_storages": "Storage Proxmox"
"proxmox_storages": "Storage Proxmox",
"reboot_required": "Riavvio necessario"
},
"details": {
"noTemperatureSensor": "Nessun sensore di temperatura rilevato. Installa lm-sensors se l’hardware lo supporta.",
@@ -5303,7 +5378,7 @@
"failedLogins": "Tentativi di accesso non riusciti in 24 ore: {count}",
"fail2banBannedIps": "Indirizzi IP bloccati da Fail2Ban: {count} (jail: {jails})",
"uptimeDays": "Tempo di attività: {count} giorni",
"pendingPackages": "{count} pacchetti in sospeso",
"pendingPackages": "Pacchetti previsti: {count} (inclusi i nuovi pacchetti)",
"updatedDaysAgo": "Ultimo aggiornamento {count} giorni fa",
"storageAvailable": "{type} disponibile",
"storageUsage": "{percent}% utilizzato",
@@ -5316,7 +5391,8 @@
"startupStorageChecking": "Controllo storage Proxmox dopo l’avvio: {storage}",
"startupStorageNotFound": "Controllo storage Proxmox dopo l’avvio: {storage} non è ancora visibile sul server",
"startupStorageUnavailable": "Controllo storage Proxmox dopo l’avvio: {storage} non è ancora pronto",
"startupStorageStatus": "Controllo storage Proxmox dopo l’avvio: {storage} (stato corrente: {status})"
"startupStorageStatus": "Controllo storage Proxmox dopo l’avvio: {storage} (stato corrente: {status})",
"noPendingUpdates": "Nessun aggiornamento in sospeso"
},
"time": {
"justNow": "proprio adesso",
@@ -5353,7 +5429,53 @@
"years": "{count} anni",
"24hours": "24 ore",
"7days": "7 giorni"
}
},
"restart": {
"button": "Riavvia server",
"title": "Riavviare il server {server}?",
"impact": "Il riavvio interrompe temporaneamente il server, le macchine virtuali, i container e i relativi servizi.",
"cancel": "Annulla",
"confirm": "Conferma riavvio",
"blocked": "Il riavvio sarà disponibile al termine dell’aggiornamento dell’host.",
"accepted": "Richiesta di riavvio accettata. Controlla che il server torni online.",
"unconfirmed": "Non è stato possibile verificare la richiesta di riavvio. Controlla il server prima di riprovare."
},
"versions": {
"title": "Aggiornamento ProxMenux",
"installed": "Versione installata",
"latest": "Versione disponibile",
"lastKnown": "Ultima versione disponibile nota",
"available": "Aggiornamento disponibile",
"current": "ProxMenux è aggiornato",
"currentVersion": "La versione {version} è aggiornata",
"unknown": "Impossibile verificare la versione disponibile",
"unverified": "Non verificata",
"stable": "Canale stabile",
"beta": "Canale beta",
"checked": "Ultimo controllo riuscito: {time}"
},
"hostUpdate": {
"running": "L’aggiornamento dell’host continua anche se ti disconnetti. Riaprilo dopo aver effettuato l’accesso.",
"finished": "L’ultimo aggiornamento dell’host è terminato. Il risultato è disponibile.",
"attention": "L’aggiornamento dell’host richiede attenzione. Non è stato interrotto; riconnettiti per verificarne lo stato.",
"view": "Visualizza aggiornamento",
"viewResult": "Visualizza il risultato",
"hideResult": "Nascondi il risultato",
"resultTitle": "Risultato dell’aggiornamento dell’host",
"resultReadOnly": "Output salvato di un aggiornamento completato. Aprirlo non avvia un altro aggiornamento.",
"resultUnavailable": "Non è stato possibile caricare il risultato salvato. Non è stato avviato alcun aggiornamento.",
"retryResult": "Ricarica il risultato",
"loadingResult": "Caricamento del risultato salvato…",
"closeResult": "Chiudi risultato",
"noReboot": "Nessun riavvio necessario",
"rebootMarker": "Il sistema operativo richiede un riavvio.",
"rebootUnknown": "Impossibile determinare se è necessario un riavvio.",
"rebootKernel": "È necessario un riavvio per attivare {installed}. Attualmente in esecuzione: {running}."
},
"updateSystem": "Aggiorna sistema",
"packagePlanNote": "Il piano include anche nuovi pacchetti, come un nuovo kernel. La panoramica conta solo gli aggiornamenti ai pacchetti esistenti.",
"snapshotUnavailable": "I nuovi risultati dei controlli di integrità non sono ancora disponibili.",
"lastKnownResults": "Sono mostrati gli ultimi risultati disponibili con l’ora originale del controllo."
},
"profilePage": {
"loading": "Caricamento profilo…",
@@ -5394,6 +5516,7 @@
"iosDescription": "Aggiungi il Monitor alla schermata iniziale per un accesso rapido.",
"androidDescription": "Aggiungi il Monitor come app in modo che si apra come una normale applicazione.",
"addToHomeScreen": "Aggiungi alla schermata Home",
"installApp": "Installa app",
"ios": {
"stepShareBefore": "Tocca il pulsante",
"share": "Condividi",
@@ -5406,10 +5529,17 @@
"android": {
"stepOpenMenu": "Apri il menu del browser",
"stepConfirm": "conferma toccando",
"install": "Installa"
"install": "Installa",
"or": "oppure"
},
"notNow": "Non adesso",
"neverAgain": "Non mostrare più"
"neverAgain": "Non mostrare più",
"insecure": {
"description": "Tramite HTTP non cifrato, Chrome può solo aggiungere una scorciatoia alla schermata Home.",
"howTo": "Per installare il Monitor come app, aprilo tramite HTTPS con un certificato valido: un certificato ACME di Proxmox in Amministratore → Sicurezza, un reverse proxy o Tailscale.",
"docs": "Come configurare HTTPS",
"shortcut": "Nel frattempo, puoi aggiungere una scorciatoia:"
}
},
"vmMetrics": {
"title": "Metriche — {name}",
@@ -5559,6 +5689,7 @@
}
},
"terminal": {
"rotateHint": "Ruota il telefono in orizzontale per vedere per intero i menu di ProxMenux.",
"connected": "Collegato",
"disconnected": "Disconnesso",
"terminalCount": "{count} / 4 terminali",
@@ -7647,7 +7778,11 @@
"unconfirmedBody": "L’esito del backup non è confermato.",
"warningTitle": "{hostname}: Backup completato con avvisi",
"warningBody": "Backup completato con avvisi.",
"diagnosticsOmitted": "Righe o testo diagnostico omessi: {count}. Il report originale è conservato."
"diagnosticsOmitted": "Righe o testo diagnostico omessi: {count}. Il report originale è conservato.",
"quotaCause": "Una quota di archiviazione ha impedito il completamento del backup.",
"quotaAffected": "Backup {ids}: una quota di archiviazione ne ha impedito il completamento.",
"quotaCheck": "Controlla la quota dello storage di backup {storage} prima di riprovare.",
"causeMissing": "Backup {ids}: il riepilogo non indica la causa; controlla i log completi di queste attività."
}
}
},
+146 -11
View File
@@ -827,7 +827,63 @@
"temperatureCriticalCount": "Critical temperatures: {count}",
"temperatureUnavailable": "Temperaturas indisponíveis",
"temperatureIncomplete": "Dados de temperatura incompletos",
"temperatureUnavailableCount": "Indisponível: {count}"
"temperatureUnavailableCount": "Indisponível: {count}",
"zfs": {
"classes": {
"normal": "Dados",
"special": "Especial",
"dedup": "Dedup",
"log": "Registro",
"cache": "Cache",
"spare": "sobressalente"
},
"poolDetails": "layout do pool, status de limpeza e integridade do dispositivo",
"detailsUnavailable": "O layout detalhado do pool requer OpenZFS 2.3 ou mais recente.",
"status": "Status",
"action": "Ação",
"capacity": "Capacidade",
"fragmentation": "Fragmentação",
"dedupRatio": "Taxa de desduplicação",
"layout": "Layout",
"singleDisk": "disco único",
"stripe": "listra",
"mirror": "Espelho",
"raidz": "RAIDZ{parity}",
"draid": "dRAID{parity}",
"diskCountOne": "disco {count}",
"diskCountMany": "discos {count}",
"errorsReported": "Erros relatados",
"scanTitle": "Scrub / Resilver",
"scrub": "esfregar",
"resilver": "Resilver",
"neverScrubbed": "Nunca esfreguei",
"scanRunning": "{kind} em andamento ({percent}%)",
"scanCanceled": "{kind} cancelado",
"scanFinished": "{kind} terminou {date}",
"started": "Iniciado",
"ended": "Concluído",
"repaired": "Reparado",
"scanErrors": "erros de verificação",
"dataErrors": "erros de dados {count}",
"noDataErrors": "Nenhum erro de dados conhecido",
"device": "Dispositivo",
"disk": "Disco",
"state": "Estado",
"readErrors": "Erros de leitura",
"writeErrors": "erros de gravação",
"checksumErrors": "erros de soma de verificação",
"readShort": "R",
"writeShort": "W",
"checksumShort": "CK",
"slowIos": "E/S lentas",
"via": "via {name}",
"totalSize": "Tamanho total",
"fragShort": "Frag."
},
"storageController": "Controlador de armazenamento",
"noController": "Nenhum controlador detectado",
"diskCountOne": "disco {count}",
"diskCountMany": "discos {count}"
},
"details": {
"temperature": {
@@ -2312,6 +2368,7 @@
"testing": "Testando…",
"testConnection": "Conexão de teste",
"ollamaUrl": "URL de Ollama",
"ollamaApiKeyHint": "Deixa vazio para um servidor Ollama padrão. Se o proxy ou gateway exigir uma chave, esta é enviada como Authorization: Bearer. Este campo não ativa a autenticação no servidor.",
"getKey": "Obter chave",
"loadModelsPlaceholder": "Clique em Carregar para buscar modelos",
"selectModel": "Selecione o modelo",
@@ -2575,6 +2632,10 @@
"title": "Espaço em disco - rootfs LXC",
"description": "Disco raiz por contêiner, avaliado em relação ao tamanho do rootfs do PVE."
},
"vm_disk": {
"title": "Espaço em disco - VMs",
"description": "Sistemas de ficheiros das VMs em execução, conforme os reporta o agente convidado do QEMU, incluindo discos em passthrough. As VMs sem agente convidado não são avaliadas."
},
"lxc_mount": {
"title": "Pontos de montagem LXC",
"description": "Capacidade de pontos de montagem dentro de CTs em execução (mp0, mp1, NFS, montagens de ligação). Exclui os rootfs; que é abordado acima."
@@ -2609,8 +2670,12 @@
"excluded": "Excluido",
"notConnected": "Não conectado",
"saveFailed": "Não foi possível salvar as exclusões do disco.",
"help": "Um disco excluído não recebe temperatura programada ou SMART lê — nem mesmo uma consulta de modo de potência — por isso é deixado completamente sozinho. A abertura dos seus detalhes SMART ainda o lê.",
"idleHelp": "Discos mecânicos sem atividade já são deixados sozinhos automaticamente até que algo os use novamente."
"help": "Um disco excluído não recebe temperatura programada ou SMART lê — nem mesmo uma consulta de modo de potência — por isso é deixado completamente sozinho. A abertura dos seus detalhes SMART ainda o lê."
},
"storageDisplay": {
"title": "Exibição de armazenamento",
"description": "alterar a disposição dos discos na página Armazenamento",
"groupByController": "Agrupar discos físicos por controlador de armazenamento"
}
},
"login": {
@@ -3732,7 +3797,14 @@
"auditReviewDate": "Auditoria & Relatório — uma constatação aceite pode ter uma data de revisão que a traz de volta sem retirar a decisão, e a comparação indica se uma constatação piorou, melhorou ou se manteve (com @Vaso73).",
"ociCatalog": "OCI manager Apps (Technology Preview) — uma nova entrada no menu do ProxMenux instala aplicações a partir das suas imagens de contentor oficiais como LXC nativos do Proxmox, sem motor Docker. O catálogo reúne 514 aplicações.",
"pbsSupport": "ProxMenux no Proxmox Backup Server — instala-se com o mesmo comando e abre um menu próprio, com pós-instalação e um Monitor que mostra o estado, os datastores e as tarefas do servidor (contribuição de @jcastro).",
"menuSearch": "Pesquisa no menu principal — um nome ou parte dele encontra uma opção do ProxMenux, uma aplicação OCI ou um Helper-Script e abre-o no seu primeiro ecrã."
"menuSearch": "Pesquisa no menu principal — um nome ou parte dele encontra uma opção do ProxMenux, uma aplicação OCI ou um Helper-Script e abre-o no seu primeiro ecrã.",
"composeStacks": "Ficheiros Compose com vários serviços — Custom image instala um LXC por serviço numa rede privada, iniciados pela ordem das dependências. As atualizações, os backups e a remoção funcionam em toda a aplicação.",
"keepProxmoxChanges": "As atualizações OCI conservam o que altera no Proxmox — variáveis, comando de arranque, opções de rede, DNS, features, tamanhos de disco e linhas lxc.* mantêm-se no contentor reconstruído, também nas atualizações agendadas e em cada membro de uma pilha.",
"ociVlan": "Etiqueta VLAN para aplicações OCI — a instalação avançada pede-a junto ao endereço, e Modificar altera-a numa aplicação de um só contentor. O anfitrião não precisa de acesso a essa VLAN.",
"ociNewApps": "Novidades no catálogo OCI — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX e mais dez aplicações, e o Ollama e o llama.cpp funcionam em GPU integradas Intel e AMD.",
"hostUpdateJob": "Atualizações do Proxmox que continuam após terminar a sessão — a atualização corre como uma tarefa no servidor e reabre ao voltar a entrar. Após um novo kernel é mostrado o reinício pendente, com um botão que verifica primeiro o anfitrião.",
"storageDetails": "Detalhes do armazenamento — os pools ZFS mostram a sua estrutura, scrub e estado dos dispositivos, e cada disco mostra a sua controladora, também na página de Hardware. Os limites de disco das VM chegam às Definições.",
"log2ramV2": "Log2RAM 2.0 — o journal e os registos de tarefas do Proxmox ficam em disco com o seu histórico, e só os registos que mudam constantemente vão para a RAM. Os anfitriões que já o usam atualizam-se com o ProxMenux."
}
},
"network": {
@@ -4089,7 +4161,9 @@
"interface": "Interface",
"rotationRate": "Taxa de rotação",
"classCode": "Código de classe",
"serial": "Serial"
"serial": "Serial",
"storageController": "Controlador de armazenamento",
"attachedDisks": "Discos anexados"
},
"values": {
"cores": "{count} núcleos",
@@ -5237,7 +5311,8 @@
"certificates": "Certificados",
"login_attempts": "Tentativas de login",
"fail2ban": "Fail2Ban",
"proxmox_storages": "Armazenamento Proxmox"
"proxmox_storages": "Armazenamento Proxmox",
"reboot_required": "reinicialização necessária"
},
"details": {
"allOperational": "Todos os sistemas operacionais",
@@ -5316,7 +5391,8 @@
"securityCheckUnavailable": "Verificação de segurança indisponível: {error}",
"certificateExpired": "Certificado expirou",
"certificateExpiresDays": "Validade do certificado (dias): {days}",
"certificateInconclusive": "Verificação do certificado inconclusiva"
"certificateInconclusive": "Verificação do certificado inconclusiva",
"noPendingUpdates": "nenhuma atualização pendente"
},
"time": {
"justNow": "agora mesmo",
@@ -5353,7 +5429,53 @@
"years": "{count} anos",
"24hours": "24 horas",
"7days": "7 dias"
}
},
"restart": {
"button": "reiniciar servidor",
"title": "Reiniciar o servidor {server}?",
"impact": "A reinicialização interrompe temporariamente o servidor, as máquinas virtuais, os contêineres e seus serviços.",
"cancel": "Cancelar",
"confirm": "Confirmar reinicialização",
"blocked": "A reinicialização estará disponível após a conclusão da atualização do host.",
"accepted": "solicitação de reinicialização aceita.Verifique se o servidor retorna online.",
"unconfirmed": "A solicitação de reinicialização não pôde ser verificada.Verifique o servidor antes de tentar novamente."
},
"versions": {
"title": "atualização ProxMenux",
"installed": "versão instalada",
"latest": "versão disponível",
"lastKnown": "Última versão disponível conhecida",
"available": "atualização disponível",
"current": "ProxMenux está atualizado",
"currentVersion": "A versão {version} está atualizada",
"unknown": "não foi possível verificar a versão disponível",
"unverified": "Não verificado",
"stable": "canal estável",
"beta": "canal Beta",
"checked": "Última verificação bem-sucedida: {time}"
},
"hostUpdate": {
"running": "A atualização do host continua mesmo se você sair.Reabra-o após fazer login.",
"finished": "A última atualização do host foi concluída.Seu resultado está disponível.",
"attention": "A atualização do host precisa de atenção.Não foi interrompido;reconecte para inspecioná-lo.",
"view": "Ver atualização",
"viewResult": "Ver resultado",
"hideResult": "ocultar resultado",
"resultTitle": "resultado da atualização do host",
"resultReadOnly": "Saída salva de uma atualização concluída.Abri-lo não executa outra atualização.",
"resultUnavailable": "O resultado salvo não pôde ser carregado.Nenhuma atualização foi iniciada.",
"retryResult": "Recarregar resultado",
"loadingResult": "Carregando resultado salvo…",
"closeResult": "Fechar resultado",
"noReboot": "Não é necessária reinicialização",
"rebootMarker": "O sistema operacional requer uma reinicialização.",
"rebootUnknown": "não foi possível determinar se uma reinicialização é necessária.",
"rebootKernel": "É necessária uma reinicialização para ativar o {installed}.Atualmente em execução: {running}."
},
"updateSystem": "Sistema de atualização",
"packagePlanNote": "O plano também inclui novos pacotes, como um novo kernel.A visão geral conta atualizações apenas para pacotes existentes.",
"snapshotUnavailable": "Novos resultados de saúde ainda não estão disponíveis.",
"lastKnownResults": "Mostra os últimos resultados disponíveis com o horário de verificação original."
},
"profilePage": {
"loading": "Carregando perfil…",
@@ -5394,6 +5516,7 @@
"iosDescription": "Adicione o monitor à sua tela inicial para acesso rápido.",
"androidDescription": "Adicione o Monitor como um aplicativo para que ele abra como um aplicativo normal.",
"addToHomeScreen": "Adicionar à tela inicial",
"installApp": "Instalar app",
"ios": {
"stepShareBefore": "Toque no",
"share": "Compartilhar",
@@ -5406,10 +5529,17 @@
"android": {
"stepOpenMenu": "Abra o menu do navegador",
"stepConfirm": "confirme tocando",
"install": "Instalar"
"install": "Instalar",
"or": "ou"
},
"notNow": "Agora não",
"neverAgain": "Não mostre novamente"
"neverAgain": "Não mostre novamente",
"insecure": {
"description": "Por HTTP sem criptografia, o Chrome só pode adicionar um atalho à tela inicial.",
"howTo": "Para instalar o Monitor como aplicativo, abra-o por HTTPS com um certificado válido: um certificado ACME do Proxmox em Administrador → Segurança, um proxy reverso ou o Tailscale.",
"docs": "Como configurar o HTTPS",
"shortcut": "Enquanto isso, você pode adicionar um atalho:"
}
},
"vmMetrics": {
"title": "Métricas — {name}",
@@ -5559,6 +5689,7 @@
}
},
"terminal": {
"rotateHint": "Gire o celular na horizontal para ver os menus do ProxMenux por completo.",
"connected": "Conectado",
"disconnected": "Desconectado",
"terminalCount": "{count} / 4 terminais",
@@ -7647,7 +7778,11 @@
"unconfirmedBody": "O resultado do backup não está confirmado.",
"warningTitle": "{hostname}: Backup concluído com avisos",
"warningBody": "Backup concluído com avisos.",
"diagnosticsOmitted": "Linhas ou texto de diagnóstico omitidos: {count}. Relatório original preservado."
"diagnosticsOmitted": "Linhas ou texto de diagnóstico omitidos: {count}. Relatório original preservado.",
"quotaCause": "Uma quota de armazenamento impediu a conclusão do backup.",
"quotaAffected": "Backups {ids}: uma quota de armazenamento impediu a conclusão.",
"quotaCheck": "Verifica a quota do armazenamento de backup {storage} antes de tentar novamente.",
"causeMissing": "Backups {ids}: o resumo não indica a causa; consulta os logs completos dessas tarefas."
}
}
},
+205 -45
View File
@@ -826,7 +826,63 @@
"temperatureCriticalCount": "Kritické teploty: {count}",
"temperatureUnavailable": "Teploty nie sú k dispozícii",
"temperatureIncomplete": "Údaje o teplote nie sú úplné",
"temperatureUnavailableCount": "Nedostupné: {count}"
"temperatureUnavailableCount": "Nedostupné: {count}",
"zfs": {
"classes": {
"normal": "Údaje",
"special": "Špeciálne",
"dedup": "Dedup",
"log": "Log",
"cache": "Cache",
"spare": "Náhradné"
},
"poolDetails": "Rozloženie ZFS poolu, priebeh kontroly integrity a stav zariadení",
"detailsUnavailable": "Podrobné rozloženie poolu vyžaduje OpenZFS 2.3 alebo novší.",
"status": "Stav",
"action": "Akcia",
"capacity": "Kapacita",
"fragmentation": "Fragmentácia",
"dedupRatio": "Pomer deduplikácie",
"layout": "Rozloženie",
"singleDisk": "Jeden disk",
"stripe": "Stripe",
"mirror": "Zrkadlo",
"raidz": "RAIDZ{parity}",
"draid": "dRAID{parity}",
"diskCountOne": "{count} disk",
"diskCountMany": "Disky: {count}",
"errorsReported": "Boli nahlásené chyby",
"scanTitle": "Kontrola integrity a obnova redundancie",
"scrub": "Kontrola integrity",
"resilver": "Obnova redundancie",
"neverScrubbed": "Kontrola integrity ešte neprebehla",
"scanRunning": "{kind}: prebieha ({percent} %)",
"scanCanceled": "{kind}: zrušené",
"scanFinished": "{kind}: dokončené {date}",
"started": "Spustené",
"ended": "Dokončené",
"repaired": "Opravené",
"scanErrors": "Chyby pri kontrole",
"dataErrors": "Chyby údajov: {count}",
"noDataErrors": "Žiadne známe chyby údajov",
"device": "Zariadenie",
"disk": "Disk",
"state": "Stav",
"readErrors": "Chyby čítania",
"writeErrors": "Chyby zápisu",
"checksumErrors": "Chyby kontrolného súčtu",
"readShort": "R",
"writeShort": "W",
"checksumShort": "CK",
"slowIos": "Pomalé I/O",
"via": "cez {name}",
"totalSize": "Celková veľkosť",
"fragShort": "Frag."
},
"storageController": "Radič úložiska",
"noController": "Radič sa nezistil",
"diskCountOne": "{count} disk",
"diskCountMany": "Disky: {count}"
},
"details": {
"temperature": {
@@ -1771,7 +1827,7 @@
"dockerTagPreviewFailed": "Tagy Docker Hub sa nepodarilo zobraziť",
"dockerTagPreviewEmpty": "Tomuto filtru nezodpovedá žiadny skutočný tag.",
"dockerMovingTag": "pohyblivý tag",
"dockerMovingTagHelp": "Pohyblivé tagy neobsahujú verziu. Sledujte ich podľa digestu v aktualizáciách Docker obrazov.",
"dockerMovingTagHelp": "Značky, ktorých obsah sa priebežne mení, neobsahujú číslo verzie. V aktualizáciách obrazov Docker ich sleduj podľa jednoznačného identifikátora obrazu (digestu).",
"tagRegexLabel": "Regex tagu (so zachytávacou skupinou)",
"tagRegexPlaceholder": "napr. v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1801,7 +1857,7 @@
"detectMethodCommand": "príkaz",
"binaryVersionHint": "program (výstup --version)",
"systemctlHint": "systemctl show <service> -p ExecStart",
"whichHint": "ktorá <aplikácia>",
"whichHint": "which <app>",
"apkInfoHint": "\" : \"informácie o apk | grep",
"grafanaNote": ". Grafana potrebuje",
"hiddenDetectionsHelpSingular": "Máte {count} skrytú nájdenú aplikáciu. Obnovte ju alebo pridajte vlastnú aplikáciu ručne.",
@@ -2311,6 +2367,7 @@
"testing": "Testujem…",
"testConnection": "Otestovať pripojenie",
"ollamaUrl": "URL služby Ollama",
"ollamaApiKeyHint": "Pre bežný server Ollama nechajte prázdne. Ak proxy alebo brána vyžaduje kľúč, odosiela sa ako Authorization: Bearer. Toto pole nezapína autentifikáciu na serveri.",
"getKey": "Získať kľúč",
"loadModelsPlaceholder": "Kliknutím na Načítať získate modely",
"selectModel": "Vyberte model",
@@ -2574,6 +2631,10 @@
"title": "Miesto na disku - LXC rootfs",
"description": "Koreňový disk každého kontajnera, hodnotený podľa veľkosti rootfs z PVE."
},
"vm_disk": {
"title": "Miesto na disku - VM",
"description": "Súborové systémy spustených VM podľa hlásenia hosťovského agenta QEMU vrátane diskov v passthrough. VM bez hosťovského agenta sa nevyhodnocujú."
},
"lxc_mount": {
"title": "Prípojné body LXC",
"description": "Kapacita prípojných bodov v bežiacich CT (mp0, mp1, NFS, bind mounty). Rootfs je riešený vyššie."
@@ -2608,8 +2669,12 @@
"excluded": "Vylúčené",
"notConnected": "Nepripojený",
"saveFailed": "Nepodarilo sa uložiť vylúčenie disku.",
"help": "Vylúčený disk nedostane žiadnu plánovanú teplotu alebo SMART číta Otvorenie jeho SMART detaily stále číta.",
"idleHelp": "Mechanické disky bez aktivity sú už automaticky ponechané samé, kým ich niečo znova nepoužíva."
"help": "Z vylúčeného disku sa plánovane nečíta teplota ani údaje SMART. Pri otvorení podrobností SMART sa však disk načíta."
},
"storageDisplay": {
"title": "Zobrazenie úložiska",
"description": "Nastavenie zobrazenia diskov na stránke Úložisko",
"groupByController": "Zoskupiť fyzické disky podľa radiča úložiska"
}
},
"login": {
@@ -3702,7 +3767,7 @@
"multilingual": "Monitor teraz hovorí 8 jazykmi: angličtina, španielčina, nemčina, francúzština, taliančina, portugalčina, švédčina a slovenčina. Veľká vďaka patrí @vaso73 za vybudovanie i18n základov.",
"nvidiaMultiGpu": "Správa NVIDIA ovládačov sa teraz riadi presným BDF, takže server s viacerými GPU môže jednu kartu odovzdať VM a druhú ponechať pre server alebo LXC. Výber verzie zohľadní kernel, vetvu aj GPU (#298).",
"aiCustomEndpoint": "V AI asistentovi si teraz môžete nastaviť vlastný endpoint kompatibilný s OpenAI. Pri načítaní zoznamu modelov rozpozná LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute aj vlastné proxy na súkromných IP adresách, lokálnom rozhraní či v Docker sieťach. Prípadná chyba servera alebo siete sa zobrazí priamo pod tlačidlom „Načítať“ (#325, nahlásil @jorgeffonte).",
"secureGatewayArch": "Sprievodca Secure Gateway teraz prispôsobí stiahnutie Alpine šablóny, výber lokálnej šablóny aj vytvorenie kontajnera cez pct skutočnej architektúre servera. Server x86_64 tak dostane kontajner amd64 a server arm64 kontajner arm64 (#324, nahlásil @N0X4DD0).",
"secureGatewayArch": "Sprievodca Secure Gateway teraz prispôsobí stiahnutie Alpine šablóny, výber lokálnej šablóny aj vytvorenie kontajnera cez pct create skutočnej architektúre servera. Server x86_64 tak dostane kontajner amd64 a server arm64 kontajner arm64 (#324, nahlásil @N0X4DD0).",
"atomicNotifications": "Udalosti upozornení si ešte pred spracovaním AI a odoslaním do kanála atómovo rezervujú jedinečný odtlačok proti duplicitám. Súbežné kontroly, dokončovacie volania ani paralelné procesy Monitoru tak neodošlú tú istú udalosť dvakrát. Ak neodošle úspešne žiadny kanál, rezervácia sa uvoľní, aby mohli fungovať opakované pokusy.",
"borgSshPort": "Cieľ Borg na vzdialenom serveri — dialóg „Pridať cieľ Borg“ aj textové rozhranie podporujú vlastný SSH port. Rešpektujú ho BORG_RSH, automatická inštalácia kľúča aj kontrola kapacity. Existujúce ciele bez uvedeného portu naďalej fungujú bez zmeny (navrhol @songochain v diskusii #236).",
"githubToken": "V časti Nastavenia → GitHub API môžete zadať voliteľný osobný prístupový token, ak pri kontrole vydaní a značiek vyčerpáte anonymný limit. Token je v úložisku zašifrovaný a nikdy sa neposiela do prehliadača; hlásenie o prekročenom limite je preložené do každého jazyka Monitoru (navrhol @SystemIdleProcess v diskusii #306).",
@@ -3731,7 +3796,14 @@
"auditReviewDate": "Audit a správy — akceptované zistenie môže mať dátum kontroly, ktorý ho znova pripomenie bez zrušenia rozhodnutia, a porovnanie ukáže, či sa zistenie zhoršilo, zlepšilo alebo zostalo rovnaké (s @Vaso73).",
"ociCatalog": "OCI manager Apps (Technology Preview) — nová položka v ponuke ProxMenux inštaluje aplikácie z ich oficiálnych kontajnerových obrazov ako natívne kontajnery Proxmox LXC, bez Dockeru. Katalóg obsahuje 514 aplikácií.",
"pbsSupport": "ProxMenux na Proxmox Backup Server — inštaluje sa rovnakým príkazom a otvára vlastnú ponuku s poinštaláciou a Monitorom, ktorý zobrazuje stav, úložiská dát a úlohy servera (prispel @jcastro).",
"menuSearch": "Vyhľadávanie v hlavnej ponuke — názov alebo jeho časť nájde možnosť ProxMenux, aplikáciu OCI alebo Helper-Script a otvorí ju na jej prvej obrazovke."
"menuSearch": "Vyhľadávanie v hlavnej ponuke — názov alebo jeho časť nájde možnosť ProxMenux, aplikáciu OCI alebo Helper-Script a otvorí ju na jej prvej obrazovke.",
"composeStacks": "Súbory Compose s viacerými službami — voľba Vlastný obraz vytvorí pre každú službu samostatný LXC v súkromnej sieti. Služby sa spúšťajú v poradí podľa závislostí. Aktualizácie, zálohovanie a odstránenie sa vykonávajú pre celú aplikáciu.",
"keepProxmoxChanges": "Aktualizácie OCI zachovávajú zmeny vykonané v Proxmoxe — premenné, príkaz spustenia, nastavenia siete, DNS, funkcie kontajnera (features), veľkosti diskov a riadky lxc.* zostávajú aj v znovu vytvorenom kontajneri. Platí to aj pre plánované aktualizácie a všetky kontajnery aplikácie.",
"ociVlan": "Značka VLAN pre aplikácie OCI — pokročilá inštalácia ju ponúkne pri nastavení adresy. Pri aplikácii s jedným kontajnerom ju možno zmeniť cez Upraviť. Server nemusí mať prístup do tejto VLAN.",
"ociNewApps": "Novinky v katalógu OCI — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX a ďalších desať aplikácií. Ollama a llama.cpp teraz fungujú aj s integrovanými GPU Intel a AMD.",
"hostUpdateJob": "Aktualizácie Proxmoxu pokračujú aj po odhlásení — bežia ako úloha na serveri a po opätovnom prihlásení sa ich priebeh znova otvorí. Po inštalácii nového jadra sa zobrazí požadovaný reštart s tlačidlom, ktoré najprv skontroluje server.",
"storageDetails": "Podrobnosti úložiska — pri ZFS pooloch sa zobrazuje ich rozloženie, kontrola integrity a stav zariadení. Každý disk má uvedený svoj radič, aj na stránke Hardvér. V Nastaveniach pribudli prahové hodnoty zaplnenia diskov VM.",
"log2ramV2": "Log2RAM 2.0 — žurnál a záznamy úloh Proxmoxu zostávajú na disku aj so svojou históriou. Do RAM sa ukladajú iba priebežne sa meniace záznamy. Na serveroch, ktoré už Log2RAM používajú, sa nová verzia nainštaluje pri aktualizácii ProxMenuxu."
}
},
"network": {
@@ -4088,7 +4160,9 @@
"interface": "Rozhranie",
"rotationRate": "Otáčky",
"classCode": "Kód triedy",
"serial": "Sériové číslo"
"serial": "Sériové číslo",
"storageController": "Radič úložiska",
"attachedDisks": "Pripojené disky"
},
"values": {
"cores": "{count} jadier",
@@ -5176,6 +5250,48 @@
}
},
"healthStatus": {
"restart": {
"button": "Reštartovať server",
"title": "Reštartovať server {server}?",
"impact": "Reštart dočasne preruší dostupnosť servera, virtuálnych strojov, kontajnerov a ich služieb.",
"cancel": "Zrušiť",
"confirm": "Potvrdiť reštart",
"blocked": "Reštart bude dostupný po dokončení aktualizácie servera.",
"accepted": "Požiadavka na reštart bola prijatá. Over, že sa server znova sprístupní.",
"unconfirmed": "Požiadavku na reštart sa nepodarilo overiť. Pred ďalším pokusom skontroluj server."
},
"versions": {
"title": "Aktualizácia ProxMenuxu",
"installed": "Nainštalovaná verzia",
"latest": "Dostupná verzia",
"lastKnown": "Posledná známa dostupná verzia",
"available": "Dostupná aktualizácia",
"current": "ProxMenux je aktuálny",
"currentVersion": "Verzia {version} je aktuálna",
"unknown": "Dostupnú verziu sa nepodarilo overiť",
"unverified": "Neoverené",
"stable": "Stabilný kanál",
"beta": "Beta kanál",
"checked": "Posledná úspešná kontrola: {time}"
},
"hostUpdate": {
"running": "Aktualizácia servera pokračuje aj po odhlásení. Po prihlásení ju môžeš znova otvoriť.",
"finished": "Posledná aktualizácia servera sa skončila. Jej výsledok je k dispozícii.",
"attention": "Aktualizácia servera potrebuje kontrolu. Nebola zastavená; otvor ju a skontroluj priebeh.",
"view": "Zobraziť aktualizáciu",
"viewResult": "Zobraziť výsledok",
"hideResult": "Skryť výsledok",
"resultTitle": "Výsledok aktualizácie servera",
"resultReadOnly": "Uložený výpis dokončenej aktualizácie. Jeho otvorenie nespustí ďalšiu aktualizáciu.",
"resultUnavailable": "Uložený výsledok sa nepodarilo načítať. Žiadna aktualizácia sa nespustila.",
"retryResult": "Znova načítať výsledok",
"loadingResult": "Načítava sa uložený výsledok…",
"closeResult": "Zavrieť výsledok",
"noReboot": "Reštart nie je potrebný",
"rebootMarker": "Operačný systém vyžaduje reštart.",
"rebootUnknown": "Nepodarilo sa zistiť, či je potrebný reštart.",
"rebootKernel": "Na aktiváciu jadra {installed} je potrebný reštart. Teraz beží: {running}."
},
"title": "Stav systému",
"description": "Podrobná kontrola všetkých častí systému",
"status": {
@@ -5232,6 +5348,7 @@
"system_age": "Aktuálnosť systému",
"pending_updates": "Čakajúce aktualizácie",
"kernel_pve": "Jadro / PVE",
"reboot_required": "Potrebný reštart",
"uptime": "Doba prevádzky",
"certificates": "Certifikáty",
"login_attempts": "Pokusy o prihlásenie",
@@ -5264,7 +5381,7 @@
"failedLogins": "Neúspešné prihlásenia za 24 h: {count}",
"fail2banBannedIps": "Fail2Ban momentálne blokuje IP adresy: {count} (pravidlá: {jails})",
"uptimeDays": "Beží {count} dní",
"pendingPackages": "Čakajúce balíky: {count}",
"pendingPackages": "Plánované balíky: {count} (vrátane nových)",
"updatedDaysAgo": "Naposledy aktualizované pred {count} dňami",
"startupStoragesChecking": "Po štarte sa ešte overuje stav Proxmox úložísk: {storages}",
"startupStorageChecking": "Po štarte sa ešte overuje stav Proxmox úložiska: {storage}",
@@ -5295,9 +5412,10 @@
"pendingSecurityUpdatesDays": "Čakajúce aktualizácie zabezpečenia: {count} (dni čakania: {days})",
"systemNotUpdatedMonths": "Systém nebol aktualizovaný za {days} dní (>18 mesiacov)",
"systemNotUpdatedYear": "Systém nebol aktualizovaný za {days} dní (>1 rok)",
"runningKernelUpdate": "K dispozícii je aktualizácia jadra pre spustenie {kernel}",
"runningKernelUpdate": "K dispozícii je aktualizácia pre bežiace jadro {kernel}",
"kernelPveUpdates": "Dostupné aktualizácie jadra: {kernelCount}; dostupné aktualizácie Proxmox: {pveCount}",
"pendingPackageUpdates": "Čakajúce aktualizácie balíka: {count}",
"noPendingUpdates": "Žiadne čakajúce aktualizácie",
"updatesAptError": "Nepodarilo sa skontrolovať aktualizácie (chyba apt-get)",
"updatesAptTimeout": "Časový limit apt-get vypršal - úložisko môže byť nedostupné",
"kernelPackagesMore": "{technicalIdentity} (+{count} viac)",
@@ -5330,6 +5448,10 @@
},
"noIssues": "Neboli zistené žiadne problémy",
"updateNow": "Aktualizovať teraz",
"updateSystem": "Aktualizovať systém",
"packagePlanNote": "Plán zahŕňa aj nové balíky, napríklad nové jadro. Prehľad počíta iba aktualizácie existujúcich balíkov.",
"snapshotUnavailable": "Nový výsledok kontroly zatiaľ nie je k dispozícii.",
"lastKnownResults": "Zobrazené sú posledné dostupné údaje s pôvodným časom kontroly.",
"dismissedItems": "Skryté upozornenia ({count})",
"permanent": "Natrvalo",
"dismissed": "Skryté",
@@ -5393,6 +5515,7 @@
"iosDescription": "Pridajte Monitor na plochu, aby bol rýchlo poruke.",
"androidDescription": "Pridajte Monitor ako aplikáciu, aby sa otváral ako bežná appka.",
"addToHomeScreen": "Pridať na plochu",
"installApp": "Inštalovať aplikáciu",
"ios": {
"stepShareBefore": "Ťuknite na",
"share": "Zdieľať",
@@ -5405,10 +5528,17 @@
"android": {
"stepOpenMenu": "Otvorte menu prehliadača",
"stepConfirm": "potom potvrďte ťuknutím na",
"install": "Inštalovať"
"install": "Inštalovať",
"or": "alebo"
},
"notNow": "Teraz nie",
"neverAgain": "Už nezobrazovať"
"neverAgain": "Už nezobrazovať",
"insecure": {
"description": "Cez nešifrované HTTP môže Chrome pridať na plochu iba odkaz.",
"howTo": "Ak chcete Monitor nainštalovať ako aplikáciu, otvorte ho cez HTTPS s platným certifikátom: certifikát ACME z Proxmoxu v Správa → Bezpečnosť, reverzný proxy alebo Tailscale.",
"docs": "Ako nastaviť HTTPS",
"shortcut": "Zatiaľ môžete pridať odkaz:"
}
},
"vmMetrics": {
"title": "Metriky — {name}",
@@ -5558,6 +5688,7 @@
}
},
"terminal": {
"rotateHint": "Otočte telefón na šírku, aby ste videli celé ponuky ProxMenux.",
"connected": "Pripojené",
"disconnected": "Odpojené",
"terminalCount": "{count} zo 4 terminálov",
@@ -5959,12 +6090,12 @@
"evaluationFailed": "Kontrolu sa nepodarilo vyhodnotiť"
},
"nextStep": {
"noJobs": "Vytvorte záložnú úlohu na tomto uzle a vyberte hostí, ktorí majú dáta, ktoré by ste nechceli prestavať ručne. Práca, ktorá existuje, ale je zdravotne postihnutá, nič nevyberá.",
"uncovered": "Rozhodnite sa pre každého z týchto hostí, či má niečo, čo stojí za to udržať. Pridajte tie, ktoré robia prácu; pre tie, ktoré nie, deklarujte, že v politike, takže sa prestanú počítať tu.",
"excludedData": "Otvorte zoznam vylúčených pracovných miest a potvrďte, že vynecháva údaje, ktoré si môžete dovoliť stratiť. Vylúčenie pridané, aby sa práca rýchlejšie chráni nič, čo preskočí.",
"uncoveredExpected": "Politika vyhlasuje, že títo hostia vyžadujú zálohu a žiadna povolená práca si ich vyberie. Buď ich pridajte do práce, alebo zmeňte, čo politika vyhlasuje, takže obaja súhlasia."
"noJobs": "Vytvor zálohovaciu úlohu na tomto uzle a vyber VM alebo kontajnery s údajmi, ktoré by sa ťažko obnovovali ručne. Vypnutá úloha nezahŕňa do zálohovania žiadne VM ani kontajnery.",
"uncovered": "Pre každého z týchto hostí rozhodnite, či obsahuje niečo, čo stojí za to ponechať. Tých, ktorí to obsahujú, pridajte do úlohy; v prípade tých, ktorí to neobsahujú, uveďte to v pravidle, aby sa tu už nezapočítavali.",
"excludedData": "Otvorte zoznam vylúčených položiek každej úlohy a overte si, či sa medzi vylúčenými údajmi nachádzajú len tie, ktorých stratu si môžete dovoliť. Vylúčenie pridané s cieľom urýchliť úlohu nijako nechráni údaje, ktoré sa tým vynechajú.",
"uncoveredExpected": "V rámci tejto politiky sú títo hostia označení ako vyžadujúci zálohovanie, avšak žiadna zapnutá úloha ich nevyberá. Pridajte ich do úlohy alebo upravte nastavenia politiky tak, aby sa tieto dve veci zhodovali."
},
"explanation": "Záloha chráni len to, čo si práca skutočne vyberie. Táto kontrola spáruje hostí na tomto uzle s pracovnými miestami, ktoré tu bežia, takže hosť, ktorý sa nikto neobjaví ako taký, namiesto toho, aby bol považovaný za pokrytý niečím iným."
"explanation": "Záloha chráni len to, čo úloha skutočne vyberie. Táto kontrola spáruje hostí na tomto uzle s úlohami, ktoré sa tu spúšťajú, takže hosť, ktorého nikto nezálohuje, sa zobrazí ako taký, namiesto toho, aby sa predpokladalo, že je zahrnutý v niečom inom."
},
"last_backup_age": {
"title": "Vek uložených záloh",
@@ -5977,11 +6108,11 @@
"evaluationFailed": "Kontrolu sa nepodarilo vyhodnotiť"
},
"nextStep": {
"stale": "Zistite, prečo práca prestala pre týchto hostí vyrábať výtlačky: mohla byť zdravotne postihnutá, jej rozvrh možno nikdy nevystrelí, alebo môže zlyhať. Spustiť výsledky kontroly hlási, ako každá práca skončila.",
"noBackups": "Žiadna uložená kópia sa nezhoduje s hosťom v tomto uzle. Ak práce napísať do cieľa, tento uzol nemôže čítať, to sa očakáva, inak sú produkovať nič.",
"attention": "Prezrite si uvedených hostí. Každá z nich nemá žiadnu kópiu alebo je staršia ako vek, ktorý sa používa."
"stale": "Zistite, prečo táto úloha prestala vytvárať kópie pre týchto hostí: mohla byť deaktivovaná, jej plán sa možno nikdy nespustil alebo jej behy zlyhávajú. Kontrola výsledkov behov uvádza, ako sa každá úloha naposledy ukončila.",
"noBackups": "Žiadna uložená kópia nezodpovedá žiadnemu hosťovi na tomto uzle. Ak úlohy zapisujú do cieľového umiestnenia, z ktorého tento uzol nemôže čítať, je to očakávané; inak by totiž nevytvárali žiadne výstupy.",
"attention": "Prezrite si zoznam hostí. Každý z nich buď nemá žiadnu aktuálnu kópiu, alebo má kópiu staršiu, než je požadovaný vek."
},
"explanation": "Práca, ktorá existuje, nie je to isté ako kópia, ktorá existuje. Tento šek číta najnovšie uložené kópie každého hosťa a ako dávno to bolo napísané, čo je, ako ďaleko by ste mali ísť, ak ste museli obnoviť dnes."
"explanation": "Existencia úlohy nie je to isté ako existencia jej kópie. Táto kontrola zisťuje najnovšiu uloženú kópiu každého hosťa a to, kedy bola napísaná – teda ako ďaleko by ste museli siahnuť, keby ste dnes museli vykonať obnovenie."
},
"retention_defined": {
"title": "Retencia záloh",
@@ -5994,11 +6125,11 @@
"evaluationFailed": "Kontrolu sa nepodarilo vyhodnotiť"
},
"nextStep": {
"missing": "Nastavte si držanie na týchto pracovných miestach, alebo na sklade, na ktorý píšu. Proxmox berie nastavenie úlohy ako prvý, potom úložisko je, potom uzol predvolený.",
"notDeclared": "Tieto práce uchovávajú všetky kópie, ktoré robia. To je zámerná voľba pre niektoré destinácie, ale potvrďte, že cieľ má priestor na rast.",
"onServer": "Tieto úlohy ukladajú zálohy na zálohovací server. Ten odstraňuje staré zálohy podľa vlastných pravidiel. Z tohto uzla sa nedá zistiť, ktoré zálohy uchováva; skontroluj ich priamo na zálohovacom serveri."
"missing": "Nastavte dobu uchovávania pre tieto úlohy alebo pre úložisko, do ktorého sa zapisujú. Proxmox uprednostňuje najskôr nastavenie úlohy, potom nastavenie úložiska a nakoniec predvolené nastavenie uzla.",
"notDeclared": "Tieto úlohy uchovávajú každú vytvorenú kópiu. V prípade niektorých cieľových umiestnení ide o zámernú voľbu, avšak je potrebné overiť, či má cieľové umiestnenie dostatok miesta na ďalší rast.",
"onServer": "Tieto úlohy zapisujú údaje na záložný server, ktorý ich odstraňuje podľa vlastných pravidiel. Pravidlá uchovávania sa z tohto uzla nedajú zistiť; skontroluj ich priamo na zálohovacom serveri."
},
"explanation": "O tom, koľko výtlačkov sa uchováva a ako dlho sa uchováva. Bez nej sa cieľ naplní, kým neprestane prijímať nové kópie, a záloha, ktorú nemožno napísať, je tá, o ktorej sa dozviete v deň, keď ju potrebujete."
"explanation": "Doba uchovávania určuje, koľko kópií sa uchováva a ako dlho. Bez nej sa cieľové úložisko zaplní do tej miery, že prestane prijímať nové kópie, a o zálohe, ktorú nie je možné zapísať, sa dozviete práve v deň, keď ju budete potrebovať."
},
"verification_state": {
"title": "Overenie záloh",
@@ -6010,10 +6141,10 @@
"evaluationFailed": "Stav overenia sa nepodarilo prečítať"
},
"nextStep": {
"failed": "Kópiu, ktorá nie je overená, nemožno použiť. Skontrolujte, či skoršia kópia rovnakého hosťa overené, a pozrite sa na skladovanie neúspešné kópie naživo.",
"notVerified": "Nič nepotvrdilo, že tieto kópie sa dajú prečítať späť. Záložný server môže overiť svoj vlastný rozvrh, oddelene od úlohy, ktorá ich napísala."
"failed": "Na kópiu, ktorá neprešla overením, sa nedá spoľahnúť. Skontrolujte, či bola overená skoršia kópia toho istého hosťa, a pozrite sa na úložisko, na ktorom sa nachádzajú neúspešné kópie.",
"notVerified": "Nič nepotvrdilo, že tieto kópie je možné opäť prečítať. Záložný server môže overovať podľa vlastného harmonogramu, nezávisle od úlohy, ktorá ich vytvorila."
},
"explanation": "Overenie číta uložené kópie späť a potvrdzuje, že je neporušený. Je to rozdiel medzi kópiou, ktorá existuje, a kópiou, ktorá môže byť prečítaná, rozdiel, ktorý záleží len na dni, kedy ho potrebujete."
"explanation": "Pri overovaní sa načíta uložená kópia a potvrdí sa, že je neporušená. Ide o rozdiel medzi kópiou, ktorá existuje, a kópiou, ktorú je možné prečítať – toto rozlíšenie má význam až v deň, keď ju budete potrebovať."
},
"job_results": {
"title": "Výsledky zálohovacích behov",
@@ -6025,10 +6156,10 @@
"recovered": "{count} z {total} VM a LXC zlyhalo pri skoršom spustení a odvtedy uspelo"
},
"nextStep": {
"recovered": "Títo hostia zlyhali a odvtedy sa im to podarilo. Predchádzajúca chyba stále stojí za čítanie: zlyhanie, ktoré vyrieši sám často vracia.",
"recovered": "Týmto hosťom sa predchádzajúci pokus nepodaril, ale neskôr uspeli. Stojí za to si prečítať popis tej predchádzajúcej chyby: chyba, ktorá sa sama vyriešila, sa často vráti.",
"someFailed": "Pri týchto behoch si pozrite chybu v zázname úloh. Beh, ktorý skončil chybou, považujte za neúspešný. Skôr než sa na zálohu spoľahnete, overte dostupnú kópiu pre príslušné VM a LXC."
},
"explanation": "Ako sa posledná práca skončila, ako to nahral tento uzol. Práca môže byť nakonfigurovaná dokonale a stále zlyhá každú noc, a to je miesto, kde to ukazuje."
"explanation": "Ako skončilo posledné spustenie každej úlohy, tak ako to zaznamenal tento uzol. Úloha môže byť dokonale nakonfigurovaná a napriek tomu zlyháva každú noc, a práve tu sa to prejaví."
},
"host_recovery": {
"title": "Obnova servera",
@@ -6040,11 +6171,11 @@
"scheduledOnly": "Zálohy nastavení servera sú naplánované cez {count} časovačov, lokálne však nie je uložený žiadny archív"
},
"nextStep": {
"attention": "Prehľadajte uvedené záznamy konfigurácie hostiteľa. Každý má niečo, čo stojí za to pozrieť sa na: beh, ktorý zlyhal, cieľ, ktorý je preč, alebo výtlačok starší ako vek v používaní.",
"scheduledOnly": "Časovač bude produkovať zálohovanie konfigurácie hostiteľa, ale žiadny ešte nie je uložený. Kým prvý beží, uzol vlastné nastavenie nie je chránené.",
"attention": "Prezrite si uvedené konfiguračné záznamy hostiteľov. V každom z nich sa nachádza niečo, čo stojí za pozornosť: neúspešné spustenie, cieľ, ktorý už neexistuje, alebo kópia staršia, než je povolená doba používania.",
"scheduledOnly": "Časovač bude vytvárať zálohy konfigurácie hostiteľa, zatiaľ však nie je uložená žiadna. Pokiaľ sa nespustí prvá záloha, vlastná konfigurácia uzla nie je chránená.",
"noHostBackup": "ProxMenux tu nemá zaznamenanú uloženú zálohu nastavení servera. Ak neexistuje iná záloha, ktorú tento uzol nevidí, pri obnove servera by bolo potrebné úložiská, sieť a používateľské účty nastaviť ručne podľa dostupných poznámok."
},
"explanation": "Zálohovanie hostí neobnoví uzol. Táto kontrola sa pozerá na to, či uzol vlastné konfigurácia"
"explanation": "Zálohovanie hostí nevedie k obnoveniu uzla. Táto kontrola overuje, či je vlastná konfigurácia uzla – jeho definície úložísk, sieťové nastavenia a používatelia – uložená niekde, od čoho závisí samotná obnova hostiteľa."
}
},
"system": {
@@ -6855,8 +6986,8 @@
"withoutLynis": "Spustiť bez Lynisu",
"cancel": "Zrušiť"
},
"noActionNeeded": "Nemám čo robiť. Tento šek našiel to, čo očakáva.",
"couldNotEvaluate": "Tento šek nemohol byť vyhodnotený, takže nič nehlási. Dôkazy zaznamenávajú, čo nebolo možné prečítať."
"noActionNeeded": "Nie je čo robiť. Táto kontrola potvrdila to, čo sa očakávalo.",
"couldNotEvaluate": "Túto kontrolu nebolo možné vyhodnotiť, preto sa v správe neuvádza žiadny výsledok. V zázname je uvedené, čo sa nepodarilo prečítať."
},
"runtime": {
"notifications": {
@@ -7327,8 +7458,8 @@
"label": "Úložisko PVE je takmer plné"
},
"zfs_pool_full": {
"title": "{hostname}: Fond ZFS {pool_name} je zaplnený na {usage_percent}%",
"body": "Fond ZFS „{pool_name}“ je zaplnený na {usage_percent}%.\n\nVýkon a spoľahlivosť zápisu ZFS sa nad približne 80 % výrazne zhoršujú, pretože CoW potrebuje voľné miesto pre nové bloky. Uvoľnite snímky, odstráňte staré datasety alebo pridajte do fondu ďalšie vdevy.",
"title": "{hostname}: ZFS pool {pool_name} je zaplnený na {usage_percent} %",
"body": "ZFS pool „{pool_name}“ (spoločné úložisko diskov) je zaplnený na {usage_percent} %.\n\nVýkon a spoľahlivosť zápisu ZFS sa nad približne 80 % výrazne zhoršujú, pretože CoW potrebuje voľné miesto pre nové bloky. Uvoľni miesto odstránením nepotrebných snímok a datasetov alebo rozšír pool o ďalšie vdevy.",
"label": "ZFS pool je takmer plný"
},
"post_install_update": {
@@ -7646,7 +7777,11 @@
"unconfirmedBody": "Výsledok zálohovania nie je potvrdený.",
"warningTitle": "{hostname}: Zálohovanie dokončené s upozorneniami",
"warningBody": "Zálohovanie bolo dokončené s upozorneniami.",
"diagnosticsOmitted": "Ďalšie diagnostické riadky alebo text vynechaný: {count}. Pôvodná správa sa zachovala."
"diagnosticsOmitted": "Ďalšie diagnostické riadky alebo text vynechaný: {count}. Pôvodná správa sa zachovala.",
"quotaCause": "Kvóta úložiska zabránila dokončeniu zálohy.",
"quotaAffected": "Zálohy {ids}: kvóta úložiska zabránila ich dokončeniu.",
"quotaCheck": "Pred opakovaním skontrolujte kvótu zálohovacieho úložiska {storage}.",
"causeMissing": "Zálohy {ids}: súhrn neuvádza príčinu; skontrolujte úplné záznamy týchto úloh."
}
}
},
@@ -7768,15 +7903,40 @@
"datastoreUnavailable": "Súborový systém dátového úložiska nie je dostupný."
},
"taskTypes": {
"backup": "Zálohovanie", "restore": "Obnova", "verify": "Overovanie",
"verifyGroup": "Overovanie skupiny záloh", "verifySnapshot": "Overovanie snímky",
"prune": "Odstraňovanie starých záloh", "garbageCollection": "Čistenie nepoužívaných dát", "sync": "Synchronizácia",
"updateCheck": "Kontrola aktualizácií", "terminal": "Terminál", "createDatastore": "Vytvorenie dátového úložiska",
"backup": "Zálohovanie",
"restore": "Obnova",
"verify": "Overovanie",
"verifyGroup": "Overovanie skupiny záloh",
"verifySnapshot": "Overovanie snímky",
"prune": "Odstraňovanie starých záloh",
"garbageCollection": "Čistenie nepoužívaných dát",
"sync": "Synchronizácia",
"updateCheck": "Kontrola aktualizácií",
"terminal": "Terminál",
"createDatastore": "Vytvorenie dátového úložiska",
"other": "Iná úloha ({type})"
},
"states": { "ok": "V poriadku", "unknown": "Neznámy", "warnings": "Upozornenia: {count}", "warning": "Upozornenie", "error": "Chyba" },
"serviceStates": { "active": "Aktívna", "inactive": "Neaktívna", "failed": "Zlyhala", "activating": "Spúšťa sa", "deactivating": "Zastavuje sa", "unknown": "Neznámy stav" },
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
"states": {
"ok": "V poriadku",
"unknown": "Neznámy",
"warnings": "Upozornenia: {count}",
"warning": "Upozornenie",
"error": "Chyba"
},
"serviceStates": {
"active": "Aktívna",
"inactive": "Neaktívna",
"failed": "Zlyhala",
"activating": "Spúšťa sa",
"deactivating": "Zastavuje sa",
"unknown": "Neznámy stav"
},
"durationUnits": {
"seconds": "{count} s",
"minutes": "{count} min",
"hours": "{count} h",
"days": "{count} d"
}
},
"monitorLinks": {
"groups": {
+146 -11
View File
@@ -827,7 +827,63 @@
"temperatureCriticalCount": "Critical temperatures: {count}",
"temperatureUnavailable": "Temperaturer otillgängliga",
"temperatureIncomplete": "Temperaturdata är ofullständig",
"temperatureUnavailableCount": "Ej tillgänglig: {count}"
"temperatureUnavailableCount": "Ej tillgänglig: {count}",
"zfs": {
"classes": {
"normal": "Data",
"special": "Special",
"dedup": "Dedup",
"log": "Logga",
"cache": "Cache",
"spare": "Spare"
},
"poolDetails": "Poollayout, skrubbstatus och enhetens hälsa",
"detailsUnavailable": "Detaljerad poollayout kräver OpenZFS 2.3 eller senare.",
"status": "Status",
"action": "Action",
"capacity": "Kapacitet",
"fragmentation": "Fragmentering",
"dedupRatio": "Dedup Ratio",
"layout": "Layout",
"singleDisk": "Enkel disk",
"stripe": "Stripe",
"mirror": "Spegel",
"raidz": "RAIDZ{parity}",
"draid": "dRAID{parity}",
"diskCountOne": "{count} disk",
"diskCountMany": "{count} diskar",
"errorsReported": "Fel rapporterade",
"scanTitle": "Scrub / Resilver",
"scrub": "Skrubba",
"resilver": "Resilver",
"neverScrubbed": "Aldrig skrubbad",
"scanRunning": "{kind} pågår ({percent}%)",
"scanCanceled": "{kind} avbruten",
"scanFinished": "{kind} färdig {date}",
"started": "Startat",
"ended": "Färdig",
"repaired": "Reparerad",
"scanErrors": "Skanningsfel",
"dataErrors": "{count} datafel",
"noDataErrors": "Inga kända datafel",
"device": "Enhet",
"disk": "Disk",
"state": "Stat",
"readErrors": "Läsfel",
"writeErrors": "Skrivfel",
"checksumErrors": "Kontrollsummefel",
"readShort": "R",
"writeShort": "W",
"checksumShort": "CK",
"slowIos": "Långsamma I/O",
"via": "via {name}",
"totalSize": "Total storlek",
"fragShort": "Frag."
},
"storageController": "Storage Controller",
"noController": "Ingen kontroller upptäckt",
"diskCountOne": "{count} disk",
"diskCountMany": "{count} diskar"
},
"details": {
"temperature": {
@@ -2312,6 +2368,7 @@
"testing": "Testning…",
"testConnection": "Testa anslutningen",
"ollamaUrl": "Ollama URL",
"ollamaApiKeyHint": "Lämna tomt för en vanlig Ollama-server. Om din proxy eller gateway kräver en nyckel skickas den som Authorization: Bearer. Fältet aktiverar inte autentisering på servern.",
"getKey": "Få nyckel",
"loadModelsPlaceholder": "Klicka på Ladda för att hämta modeller",
"selectModel": "Välj modell",
@@ -2575,6 +2632,10 @@
"title": "Diskutrymme - LXC rootfs",
"description": "Rotskiva per behållare, utvärderad mot rootfs-storleken från PVE."
},
"vm_disk": {
"title": "Diskutrymme - VM",
"description": "Filsystem i körande VM så som QEMU-gästagenten rapporterar dem, inklusive genomsläppta diskar. VM utan gästagent utvärderas inte."
},
"lxc_mount": {
"title": "LXC fästpunkter",
"description": "Kapacitet för monteringspunkter inuti körda CT: er (mp0, mp1, NFS, bind mounts). Utesluter rootfs; som täcks ovan."
@@ -2609,8 +2670,12 @@
"excluded": "Exkluderad",
"notConnected": "Inte ansluten",
"saveFailed": "Kunde inte spara diskutslag.",
"help": "En exkluderad disk får ingen schemalagd temperatur eller SMART läser - inte ens en power-mode fråga - så det lämnas helt ensam. Öppna SMART-detaljerna läser det fortfarande.",
"idleHelp": "Mekaniska diskar utan aktivitet lämnas redan ensam automatiskt tills något använder dem igen."
"help": "En exkluderad disk får ingen schemalagd temperatur eller SMART läser - inte ens en power-mode fråga - så det lämnas helt ensam. Öppna SMART-detaljerna läser det fortfarande."
},
"storageDisplay": {
"title": "Storage Display",
"description": "Ändra hur diskar läggs ut på sidan Lagring",
"groupByController": "Group physical disks by storage controller"
}
},
"login": {
@@ -3732,7 +3797,14 @@
"auditReviewDate": "Audit & Report — ett accepterat fynd kan ha ett granskningsdatum som tar upp det igen utan att beslutet dras tillbaka, och jämförelsen visar om ett fynd blev värre, blev bättre eller låg kvar (med @Vaso73).",
"ociCatalog": "OCI manager Apps (Technology Preview) — en ny post i ProxMenux-menyn installerar applikationer från deras officiella containeravbilder som inbyggda Proxmox LXC, utan Docker-motor. Katalogen innehåller 514 applikationer.",
"pbsSupport": "ProxMenux på Proxmox Backup Server — installeras med samma kommando och öppnar en egen meny, med efterinstallation och en Monitor som visar serverns hälsa, datalager och uppgifter (bidrag från @jcastro).",
"menuSearch": "Sökning i huvudmenyn — ett namn eller en del av det hittar ett ProxMenux-alternativ, en OCI-applikation eller ett Helper-Script och öppnar det på dess första skärm."
"menuSearch": "Sökning i huvudmenyn — ett namn eller en del av det hittar ett ProxMenux-alternativ, en OCI-applikation eller ett Helper-Script och öppnar det på dess första skärm.",
"composeStacks": "Compose-filer med flera tjänster — Custom image installerar en LXC per tjänst på ett privat nätverk, startade i beroendeordning. Uppdateringar, säkerhetskopior och borttagning gäller hela programmet.",
"keepProxmoxChanges": "OCI-uppdateringar behåller det du ändrar i Proxmox — variabler, startkommando, nätverksalternativ, DNS, features, diskstorlekar och lxc.*-rader finns kvar i den återskapade containern, även vid schemalagda uppdateringar och för varje medlem i en stack.",
"ociVlan": "VLAN-tagg för OCI-program — den avancerade installationen frågar efter den bredvid adressen, och Ändra byter den för ett enskilt program. Värden behöver ingen åtkomst till det VLAN:et.",
"ociNewApps": "Nytt i OCI-katalogen — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX och tio program till, och Ollama och llama.cpp körs på integrerade GPU:er från Intel och AMD.",
"hostUpdateJob": "Proxmox-uppdateringar som fortsätter efter utloggning — uppdateringen körs som ett jobb på servern och öppnas igen när du loggar in. Efter en ny kärna visas en väntande omstart, med en knapp som först kontrollerar värden.",
"storageDetails": "Lagringsdetaljer — ZFS-pooler visar sin struktur, scrub och enheternas hälsa, och varje disk visar sin lagringskontroller, även på sidan Hårdvara. Tröskelvärden för VM-diskar finns nu i Inställningar.",
"log2ramV2": "Log2RAM 2.0 — journalen och Proxmox uppgiftsloggar stannar på disken med sin historik, och bara loggar som ändras hela tiden går till RAM. Värdar som redan använder det uppdateras med ProxMenux."
}
},
"network": {
@@ -4089,7 +4161,9 @@
"interface": "Gränssnitt",
"rotationRate": "Rotationshastighet",
"classCode": "Klasskod",
"serial": "Serie"
"serial": "Serie",
"storageController": "Storage Controller",
"attachedDisks": "Attached Disks"
},
"values": {
"cores": "{count} kärnor",
@@ -5237,7 +5311,8 @@
"certificates": "Certifikat",
"login_attempts": "Inloggningsförsök",
"fail2ban": "Fail2Ban",
"proxmox_storages": "Proxmox lagring"
"proxmox_storages": "Proxmox lagring",
"reboot_required": "Omstart krävs"
},
"details": {
"allOperational": "Alla system i drift",
@@ -5316,7 +5391,8 @@
"securityCheckUnavailable": "Säkerhetskontroll inte tillgänglig: {error}",
"certificateExpired": "Certifikatet har gått ut",
"certificateExpiresDays": "Certifikats utgång (dagar): {days}",
"certificateInconclusive": "Certifikatkontrollen gav inget säkert resultat"
"certificateInconclusive": "Certifikatkontrollen gav inget säkert resultat",
"noPendingUpdates": "Inga väntande uppdateringar"
},
"time": {
"justNow": "just nu",
@@ -5353,7 +5429,53 @@
"years": "{count} år",
"24hours": "24 timmar",
"7days": "7 dagar"
}
},
"restart": {
"button": "Starta om servern",
"title": "Starta om servern {server}?",
"impact": "Omstart avbryts tillfälligt servern, virtuella maskiner, behållare och deras tjänster.",
"cancel": "Avbryt",
"confirm": "Bekräfta omstart",
"blocked": "Omstart kommer att vara tillgänglig när värduppdateringen är klar.",
"accepted": "Begäran om omstart accepteras.Kontrollera att servern återgår online.",
"unconfirmed": "Omstartsbegäran kunde inte verifieras.Kontrollera servern innan du försöker igen."
},
"versions": {
"title": "ProxMenux uppdatering",
"installed": "Installerad version",
"latest": "Tillgänglig version",
"lastKnown": "Senast kända tillgängliga version",
"available": "Uppdatering tillgänglig",
"current": "ProxMenux är uppdaterad",
"currentVersion": "Version {version} är uppdaterad",
"unknown": "Det gick inte att verifiera den tillgängliga versionen",
"unverified": "Ej verifierad",
"stable": "Stabil kanal",
"beta": "Beta kanal",
"checked": "Senaste lyckade kontroll: {time}"
},
"hostUpdate": {
"running": "Värduppdateringen fortsätter även om du loggar ut.Öppna den igen efter att ha loggat in.",
"finished": "Den senaste värduppdateringen har slutförts.Resultatet är tillgängligt.",
"attention": "Värduppdateringen behöver uppmärksamhet.Det har inte stoppats;återanslut för att inspektera den.",
"view": "Visa uppdatering",
"viewResult": "Visa resultat",
"hideResult": "Dölj resultatet",
"resultTitle": "Värduppdateringsresultat",
"resultReadOnly": "Sparad utdata från en slutförd uppdatering.Att öppna den kör inte en annan uppdatering.",
"resultUnavailable": "Det sparade resultatet kunde inte laddas.Ingen uppdatering startades.",
"retryResult": "Ladda om resultatet",
"loadingResult": "Laddar sparat resultat...",
"closeResult": "Stäng resultat",
"noReboot": "Ingen omstart krävs",
"rebootMarker": "Operativsystemet kräver en omstart.",
"rebootUnknown": "Kunde inte avgöra om en omstart krävs.",
"rebootKernel": "En omstart krävs för att aktivera {installed}.Körs för närvarande: {running}."
},
"updateSystem": "Uppdatera systemet",
"packagePlanNote": "Planen innehåller också nya paket, till exempel en ny kärna.Översikten räknar endast uppdateringar av befintliga paket.",
"snapshotUnavailable": "Nya hälsoresultat är inte tillgängliga ännu.",
"lastKnownResults": "Visar de senaste tillgängliga resultaten med deras ursprungliga kontrolltid."
},
"profilePage": {
"loading": "Laddar profil...",
@@ -5394,6 +5516,7 @@
"iosDescription": "Lägg till monitorn på din startskärm för snabb åtkomst.",
"androidDescription": "Lägg till Monitor som en app så att den öppnas som en vanlig applikation.",
"addToHomeScreen": "Lägg till på startskärmen",
"installApp": "Installera app",
"ios": {
"stepShareBefore": "Tryck på",
"share": "Dela",
@@ -5406,10 +5529,17 @@
"android": {
"stepOpenMenu": "Öppna webbläsarmenyn",
"stepConfirm": "bekräfta genom att trycka på",
"install": "Installera"
"install": "Installera",
"or": "eller"
},
"notNow": "Inte nu",
"neverAgain": "Visa inte igen"
"neverAgain": "Visa inte igen",
"insecure": {
"description": "Över okrypterad HTTP kan Chrome bara lägga till en genväg på startskärmen.",
"howTo": "För att installera Monitor som en app, öppna den över HTTPS med ett giltigt certifikat: ett ACME-certifikat från Proxmox under Administration → Säkerhet, en omvänd proxy eller Tailscale.",
"docs": "Så konfigurerar du HTTPS",
"shortcut": "Under tiden kan du lägga till en genväg:"
}
},
"vmMetrics": {
"title": "Mätvärden — {name}",
@@ -5559,6 +5689,7 @@
}
},
"terminal": {
"rotateHint": "Vrid telefonen till liggande läge för att se ProxMenux-menyerna i sin helhet.",
"connected": "Ansluten",
"disconnected": "Frånkopplad",
"terminalCount": "{count} / 4 terminaler",
@@ -7647,7 +7778,11 @@
"unconfirmedBody": "Säkerhetskopieringens resultat är inte bekräftat.",
"warningTitle": "{hostname}: Säkerhetskopiering klar med varningar",
"warningBody": "Säkerhetskopiering klar med varningar.",
"diagnosticsOmitted": "Utelämnade diagnosrader eller text: {count}. Originalrapporten bevaras."
"diagnosticsOmitted": "Utelämnade diagnosrader eller text: {count}. Originalrapporten bevaras.",
"quotaCause": "En lagringskvot hindrade säkerhetskopieringen från att slutföras.",
"quotaAffected": "Säkerhetskopior {ids}: en lagringskvot hindrade dem från att slutföras.",
"quotaCheck": "Kontrollera kvoten för säkerhetskopieringslagringen {storage} innan du försöker igen.",
"causeMissing": "Säkerhetskopior {ids}: sammanfattningen anger inte orsaken; kontrollera de fullständiga loggarna för dessa uppgifter."
}
}
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.7",
"version": "1.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ProxMenux-Monitor",
"version": "1.2.7",
"version": "1.2.8",
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.7",
"version": "1.2.8",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
+4 -1
View File
@@ -6,8 +6,11 @@
# Get the directory where this AppImage is mounted
APPDIR="$(dirname "$(readlink -f "${0}")")"
source "${APPDIR}/usr/bin/appimage_arch.sh" || exit 1
pmx_appimage_arch "$(uname -m)" || exit 1
export PATH="${APPDIR}/usr/bin:${PATH}"
export LD_LIBRARY_PATH="${APPDIR}/usr/lib/x86_64-linux-gnu:${APPDIR}/usr/lib:${APPDIR}/lib/x86_64-linux-gnu:${APPDIR}/lib:${LD_LIBRARY_PATH}"
export LD_LIBRARY_PATH="${APPDIR}/usr/lib/${PMX_APPIMAGE_TRIPLET}:${APPDIR}/usr/lib:${APPDIR}/lib/${PMX_APPIMAGE_TRIPLET}:${APPDIR}/lib:${LD_LIBRARY_PATH}"
# Select the native dependency tree matching the host interpreter. PVE 8
# uses CPython 3.11 and PVE 9 uses CPython 3.13; both runtimes are bundled in
+55 -11
View File
@@ -56,6 +56,56 @@ def _get_freq_conn():
return None
def get_backup_error_context(pve_message: str,
log_directory: Path = Path('/var/log/vzdump')) -> str:
"""Read low-level errors only from the exact failed run in a native report.
Per-guest vzdump logs are reused. A matching VMID alone is not sufficient:
require the report's complete, timestamped failure line in the local log.
Shell commands and paths from the message are never executed or followed.
"""
if not isinstance(pve_message, str):
return ''
context = []
seen = set()
for line in pve_message.splitlines():
reference = re.sub(r'^\s*\d+:\s*', '', line).strip()
match = re.match(
r'\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+ERROR:\s+Backup of VM (\d+) failed\b',
reference)
if not match:
continue
vmid = match.group(1)
if vmid in seen or len(seen) >= 20:
continue
seen.add(vmid)
for kind, label in (('lxc', 'CT'), ('qemu', 'VM')):
try:
with (log_directory / f'{kind}-{vmid}.log').open('rb') as handle:
handle.seek(0, os.SEEK_END)
handle.seek(max(0, handle.tell() - 131072))
lines = handle.read(131072).decode('utf-8', 'replace').splitlines()
except OSError:
continue
if reference not in lines:
continue
end = lines.index(reference)
starts = [i for i, item in enumerate(lines[:end])
if f'Starting Backup of VM {vmid} (' in item]
start = starts[-1] if starts else 0
evidence = [item for item in lines[start:end]
if re.search(r'\b(?:ERROR|Error):|EDQUOT|quota exceeded|no space left on device',
item, re.IGNORECASE)]
for item in list(dict.fromkeys(evidence))[-6:]:
if len(item) > 700:
item = item[:349] + '…' + item[-350:]
context.append(f'{label} {vmid}: {item}')
break
if sum(map(len, context)) >= 10000:
break
return '\n'.join(context)
def get_system_uptime() -> str:
"""Get system uptime in human-readable format.
@@ -115,22 +165,16 @@ def get_event_frequency(error_id: str = None, error_key: str = None,
try:
cursor = conn.cursor()
# Try to find the error
if error_id:
# Try to find the error. The errors table keys a row by error_key.
if error_id or error_key:
cursor.execute('''
SELECT first_seen, last_seen, occurrences, category
FROM errors WHERE error_key = ? OR error_id = ?
ORDER BY last_seen DESC LIMIT 1
''', (error_id, error_id))
elif error_key:
cursor.execute('''
SELECT first_seen, last_seen, occurrences, category
SELECT first_seen, last_seen, occurrence_count, category
FROM errors WHERE error_key = ?
ORDER BY last_seen DESC LIMIT 1
''', (error_key,))
''', (error_id or error_key,))
elif category:
cursor.execute('''
SELECT first_seen, last_seen, occurrences, category
SELECT first_seen, last_seen, occurrence_count, category
FROM errors WHERE category = ? AND resolved_at IS NULL
ORDER BY last_seen DESC LIMIT 1
''', (category,))
+12 -1
View File
@@ -212,10 +212,21 @@ class AIProvider(ABC):
except urllib.error.HTTPError as e:
# Drain the body so we can decide whether to retry, but NEVER
# include it in the raised exception (PII / API key in echo).
quota_exhausted = False
try:
e.read()
error = json.loads(e.read(65536)).get('error', {})
if isinstance(error, dict):
quota_exhausted = any(error.get(key) in (
'insufficient_quota', 'billing_hard_limit_reached', 'billing_not_active',
'credit_balance_exhausted', 'organization_spend_limit_exceeded',
'project_spend_limit_exceeded', 'organization_usage_limit_exceeded',
) for key in ('code', 'type'))
except Exception:
pass
if e.code == 429 and quota_exhausted:
raise AIProviderError(
'HTTP 429: AI provider quota exhausted. Check API usage and billing limits.'
)
# Retry on 429 (rate limit) and 5xx (server error).
retryable = e.code == 429 or 500 <= e.code < 600
last_error = AIProviderError(f"HTTP {e.code}: {e.reason}")
@@ -21,8 +21,13 @@ class GroqProvider(AIProvider):
# Exclude non-chat models
EXCLUDED_PATTERNS = ['whisper', 'tts', 'guard', 'tool-use']
# Recommended models (in priority order - versatile/large models first)
RECOMMENDED_PREFIXES = ['llama-3.3', 'llama-3.1-70b', 'llama-3.1-8b', 'mixtral', 'gemma']
# Prefer the verified default and current GPT-OSS models. Llama remains
# available for accounts with access; retired Mixtral/Gemma families have
# no recommendation priority. Sorting never adds IDs to the API response.
RECOMMENDED_PREFIXES = [
'openai/gpt-oss-20b', 'openai/gpt-oss-120b', 'qwen/qwen3.8-27b',
'llama-3.3', 'llama-3.1-8b',
]
def list_models(self) -> List[str]:
"""List available Groq models for chat completions.
@@ -7,6 +7,16 @@ from typing import Optional
from .base import AIProvider, AIProviderError
def ollama_headers(api_key: str = "") -> dict:
"""Authenticate protected Ollama gateways without requiring a key locally."""
if not isinstance(api_key, str) or len(api_key) > 512 or '\r' in api_key or '\n' in api_key:
raise AIProviderError("Invalid Ollama API key")
headers = {'Content-Type': 'application/json'}
if api_key.strip():
headers['Authorization'] = f'Bearer {api_key.strip()}'
return headers
class OllamaProvider(AIProvider):
"""Ollama provider for local AI execution."""
@@ -18,7 +28,7 @@ class OllamaProvider(AIProvider):
"""Initialize Ollama provider.
Args:
api_key: Not used for Ollama (local execution)
api_key: Optional Bearer token for an authenticated Ollama gateway
model: Model name (user must select from loaded models)
base_url: Ollama server URL (default: http://localhost:11434)
"""
@@ -57,9 +67,7 @@ class OllamaProvider(AIProvider):
}
}
headers = {
'Content-Type': 'application/json',
}
headers = ollama_headers(self.api_key)
# Cloud models (e.g., kimi-k2.5:cloud, minimax-m2.7:cloud) need longer timeout
# because requests go through: ProxMenux -> Ollama -> Cloud Provider -> back
@@ -96,7 +104,8 @@ class OllamaProvider(AIProvider):
# First check if server is running
try:
url = f"{self.base_url.rstrip('/')}/api/tags"
req = urllib.request.Request(url, method='GET', headers={'User-Agent': 'ProxMenux/1.0'})
headers = {'User-Agent': 'ProxMenux/1.0', **ollama_headers(self.api_key)}
req = urllib.request.Request(url, method='GET', headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
@@ -122,6 +131,13 @@ class OllamaProvider(AIProvider):
'message': f"Model '{self.model}' not found. Available: {', '.join(display_models)}{'...' if len(full_model_names) > 5 else ''}",
'model': self.model
}
except urllib.error.HTTPError as error:
message = (
f"HTTP {error.code}: Ollama authentication failed. Check the optional API key."
if error.code in (401, 403)
else f"HTTP {error.code}: Ollama request failed"
)
return {'success': False, 'message': message, 'model': self.model}
except urllib.error.URLError:
return {
'success': False,
@@ -24,6 +24,9 @@ class OpenAIProvider(AIProvider):
"""
NAME = "openai"
# The Monitor gives any rewrite 25 s; a local model behind an
# OpenAI-compatible endpoint may need all of it.
REQUEST_TIMEOUT_SECONDS = 25
REQUIRES_API_KEY = True
DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions"
DEFAULT_MODELS_URL = "https://api.openai.com/v1/models"
@@ -226,7 +229,7 @@ class OpenAIProvider(AIProvider):
}
api_url = self._get_api_url()
result = self._make_request(api_url, payload, headers)
result = self._make_request(api_url, payload, headers, timeout=self.REQUEST_TIMEOUT_SECONDS)
try:
return result['choices'][0]['message']['content'].strip()
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Shared native build/runtime architecture names. No emulated cross-builds.
pmx_appimage_arch() {
case "$1" in
x86_64|amd64)
PMX_APPIMAGE_ARCH=x86_64
PMX_APPIMAGE_TRIPLET=x86_64-linux-gnu
PMX_APPIMAGE_SUFFIX=""
;;
aarch64|arm64)
PMX_APPIMAGE_ARCH=aarch64
PMX_APPIMAGE_TRIPLET=aarch64-linux-gnu
PMX_APPIMAGE_SUFFIX=-aarch64
;;
*)
echo "Unsupported Monitor AppImage architecture: $1" >&2
return 1
;;
esac
}
+7 -1
View File
@@ -27,6 +27,8 @@ import sys
import time
from typing import Any, Optional
import disk_identity
SCHEMA_VERSION = 2
# Disk entries in a guest configuration: rootfs and mpN for containers,
@@ -630,12 +632,16 @@ def _physical_disks(ctx) -> list[dict[str, Any]]:
size = row.get("SIZE", "")
name = row.get("NAME", "")
health = smart.get(name) or {}
# The Monitor's rotation when it read the disk, so both agree.
rate = health.get("rotation_rate")
if not isinstance(rate, int):
rate = -1 if row.get("ROTA") == "1" else 0
disks.append({
"name": name,
"model": (row.get("MODEL") or "").strip(),
"serial": (row.get("SERIAL") or "").strip(),
"size_bytes": int(size) if size.isdigit() else None,
"rotational": row.get("ROTA") == "1",
"rotational": disk_identity.media_type(name, rate) == "HDD",
"bus": (row.get("TRAN") or "").strip(),
"health": health.get("smart_status"),
"temperature": health.get("temperature"),
+6 -2
View File
@@ -394,10 +394,12 @@ def verify_token_full(token):
API JWT 365 días sin scope.
"""
if not JWT_AVAILABLE or not token:
_log_auth_failure_throttled("Session rejected: missing token or verifier unavailable")
return None, None
try:
token_hash = hashlib.sha256(token.encode()).hexdigest()
if token_hash in _get_revoked_tokens_cached():
_log_auth_failure_throttled("Session rejected: revoked")
return None, None
try:
payload = jwt.decode(
@@ -409,8 +411,10 @@ def verify_token_full(token):
payload = jwt.decode(token, _get_jwt_secret(), algorithms=[JWT_ALGORITHM])
return payload.get('username'), payload.get('scope', 'full_admin')
except jwt.ExpiredSignatureError:
_log_auth_failure_throttled("Session rejected: expired")
return None, None
except jwt.InvalidTokenError:
_log_auth_failure_throttled("Session rejected: invalid")
return None, None
@@ -437,9 +441,9 @@ def _log_auth_failure_throttled(msg):
if _AUTH_LOG_RATE['suppressed']:
print(f"[auth] {_AUTH_LOG_RATE['last_msg']} "
f"(+{_AUTH_LOG_RATE['suppressed']} more in last "
f"{int(elapsed)}s)")
f"{int(elapsed)}s)", flush=True)
else:
print(f"[auth] {msg}")
print(f"[auth] {msg}", flush=True)
_AUTH_LOG_RATE['last_ts'] = now
_AUTH_LOG_RATE['suppressed'] = 0
_AUTH_LOG_RATE['last_msg'] = msg
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Borg 1.2.8 has no official ARM64 standalone release. Never ship or run
# its AMD64 executable on ARM; use the host's native distro package instead.
if [ -x /usr/bin/borg ]; then
# The distro package must use its own Python modules and shared libraries.
unset PYTHONPATH PYTHONHOME LD_LIBRARY_PATH
exec /usr/bin/borg "$@"
fi
echo 'Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.' >&2
exit 127
+43 -16
View File
@@ -8,6 +8,8 @@ set -e
WORK_DIR="/tmp/proxmenux_build"
APP_DIR="$WORK_DIR/ProxMenux.AppDir"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/appimage_arch.sh"
pmx_appimage_arch "$(uname -m)" || exit 1
DIST_DIR="$SCRIPT_DIR/../dist"
APPIMAGE_ROOT="$SCRIPT_DIR/.."
@@ -24,7 +26,7 @@ ZOPE_EVENT_VERSION="6.2"
GEVENT_WEBSOCKET_VERSION="0.10.1"
VERSION=$(node -p "require('$APPIMAGE_ROOT/package.json').version")
APPIMAGE_NAME="ProxMenux-${VERSION}.AppImage"
APPIMAGE_NAME="ProxMenux-${VERSION}${PMX_APPIMAGE_SUFFIX}.AppImage"
echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitoring tools..."
@@ -33,17 +35,18 @@ echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitor
# under the non-root GitHub Actions runner — previously hardcoded to
# /var/cache/, which the CI user can't write to (mkdir Permission
# denied → build aborted before doing any actual work).
APPIMAGETOOL_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/appimagetool"
APPIMAGETOOL_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/appimagetool-${PMX_APPIMAGE_ARCH}"
# Preserve a cached copy of appimagetool across builds. wget -q has bitten
# us repeatedly when GitHub momentarily rate-limits or the runner has no
# network — the result is a 0-byte file that passes the `[ -f ]` check on
# the next run and breaks the build silently.
if [ -f "$WORK_DIR/appimagetool" ] && [ -s "$WORK_DIR/appimagetool" ]; then
mkdir -p "$(dirname "$APPIMAGETOOL_CACHE")"
cp -f "$WORK_DIR/appimagetool" "$APPIMAGETOOL_CACHE"
APPIMAGETOOL_URL="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${PMX_APPIMAGE_ARCH}.AppImage"
if [ "$PMX_APPIMAGE_ARCH" = aarch64 ]; then
# The old AppImageKit ARM build does not accept ARCH=aarch64 reliably.
# Use a versioned tool; leave the existing AMD64 tool unchanged.
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-aarch64.AppImage"
APPIMAGETOOL_CACHE="${APPIMAGETOOL_CACHE}-1.9.1"
fi
# Architecture-specific caches never reuse an executable from a different CPU.
# Clean and create work directory
rm -rf "$WORK_DIR"
mkdir -p "$APP_DIR"
@@ -57,7 +60,7 @@ if [ -s "$APPIMAGETOOL_CACHE" ]; then
fi
if [ ! -s "$WORK_DIR/appimagetool" ]; then
echo "📥 Downloading appimagetool..."
wget --tries=3 --timeout=60 "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" -O "$WORK_DIR/appimagetool" || true
wget --tries=3 --timeout=60 "$APPIMAGETOOL_URL" -O "$WORK_DIR/appimagetool" || true
if [ ! -s "$WORK_DIR/appimagetool" ]; then
echo "❌ Failed to download appimagetool" >&2
exit 1
@@ -67,6 +70,17 @@ if [ ! -s "$WORK_DIR/appimagetool" ]; then
cp -f "$WORK_DIR/appimagetool" "$APPIMAGETOOL_CACHE"
fi
APPIMAGETOOL_ARGS=()
if [ "$PMX_APPIMAGE_ARCH" = aarch64 ]; then
echo "f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 $WORK_DIR/appimagetool" | sha256sum -c -
# Without --runtime-file, appimagetool 1.9.1 embeds the unpinned
# `continuous` runtime. Use a released runtime checked by its digest.
RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-aarch64"
wget --tries=3 --timeout=60 "$RUNTIME_URL" -O "$WORK_DIR/runtime-aarch64"
echo "00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 $WORK_DIR/runtime-aarch64" | sha256sum -c -
APPIMAGETOOL_ARGS=(--runtime-file "$WORK_DIR/runtime-aarch64")
fi
# Create directory structure
mkdir -p "$APP_DIR/usr/bin"
mkdir -p "$APP_DIR/usr/lib/python3/dist-packages"
@@ -126,6 +140,7 @@ cd "$SCRIPT_DIR"
# Copy Flask server
echo "📋 Copying Flask server..."
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/ovs_topology.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/zram_metrics.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
@@ -149,6 +164,10 @@ cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_jobs.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/host_update_jobs.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/kernel_reboot.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/host_update_controls.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/proxmenux_version_status.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/"
@@ -241,6 +260,7 @@ fi
# hb_ensure_borg used to download on demand — kept in lockstep so both
# code paths see the same version semantics. SHA256 is the upstream
# release checksum; bump both together.
if [ "$PMX_APPIMAGE_ARCH" = x86_64 ]; then
BORG_VERSION="1.2.8"
BORG_URL="https://github.com/borgbackup/borg/releases/download/${BORG_VERSION}/borg-linux64"
BORG_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
@@ -276,6 +296,12 @@ else
exit 1
fi
else
echo "⚠️ No official Borg 1.2.8 ARM64 standalone binary; using native-host adapter"
cp "$SCRIPT_DIR/borg_native_arm.sh" "$APP_DIR/usr/bin/borg"
chmod +x "$APP_DIR/usr/bin/borg"
fi
# Copy Next.js build
echo "📋 Copying web dashboard..."
if [ -d "$APPIMAGE_ROOT/out" ]; then
@@ -306,6 +332,7 @@ fi
echo "📋 Copying AppRun script..."
if [ -f "$SCRIPT_DIR/AppRun" ]; then
cp "$SCRIPT_DIR/AppRun" "$APP_DIR/AppRun"
cp "$SCRIPT_DIR/appimage_arch.sh" "$APP_DIR/usr/bin/appimage_arch.sh"
chmod +x "$APP_DIR/AppRun"
echo "✅ AppRun script copied successfully"
else
@@ -375,7 +402,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
# Phase 3b: Install the complete gevent/WSS runtime separately for every
# supported host ABI. Keeping each dependency tree isolated avoids mixing
# CPython-specific modules from the build runner with those loaded by PVE.
# The two --platform values allow gevent's manylinux_2_28 wheels and the
# The three --platform values allow gevent's manylinux_2_28 wheels and the
# backwards-compatible manylinux2014 zope.interface wheels to be resolved in
# one deterministic installation.
install_gevent_runtime() {
@@ -390,9 +417,9 @@ install_gevent_runtime() {
--target "$target" \
--upgrade \
--only-binary=:all: \
--platform manylinux_2_28_x86_64 \
--platform manylinux_2_27_x86_64 \
--platform manylinux2014_x86_64 \
--platform "manylinux_2_28_${PMX_APPIMAGE_ARCH}" \
--platform "manylinux_2_27_${PMX_APPIMAGE_ARCH}" \
--platform "manylinux2014_${PMX_APPIMAGE_ARCH}" \
--implementation cp \
--python-version "$python_version" \
--abi "$abi" \
@@ -543,7 +570,7 @@ if [ -d "$APP_DIR/bin" ]; then
fi
echo "🔍 Sanity check (ldd + presence of libfreeipmi)"
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu"
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/${PMX_APPIMAGE_TRIPLET}:$APP_DIR/usr/lib:$APP_DIR/usr/lib/${PMX_APPIMAGE_TRIPLET}"
if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then
echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)"
@@ -604,7 +631,7 @@ echo "🔨 Building unified AppImage v${VERSION}..."
cd "$WORK_DIR"
export NO_CLEANUP=1
export APPIMAGE_EXTRACT_AND_RUN=1
ARCH=x86_64 ./appimagetool --no-appstream --verbose "$APP_DIR" "$APPIMAGE_NAME"
ARCH="$PMX_APPIMAGE_ARCH" ./appimagetool --no-appstream --verbose "${APPIMAGETOOL_ARGS[@]}" "$APP_DIR" "$APPIMAGE_NAME"
# Move to dist directory
mv "$APPIMAGE_NAME" "$DIST_DIR/"
+22
View File
@@ -15,6 +15,7 @@ import subprocess
from typing import Any, Dict, List
_LSBLK_TIMEOUT = 5
_SYS_BLOCK = "/sys/block"
_FIELD_RE = re.compile(r'(\w+)="([^"]*)"')
_SKIP_PREFIXES = ("loop", "zd", "nbd", "ram", "sr")
@@ -69,6 +70,27 @@ def list_physical_disks() -> List[Dict[str, Any]]:
return disks
def rotation_rate(name: str, reported: Any = None) -> int:
"""The rotation every view of a disk shows: the RPM smartctl reported,
0 meaning solid state, or else the kernel's flag, where -1 is a spinning
disk of unknown speed. What smartctl reported wins, because USB-SATA
bridges mark every disk behind them as rotational."""
if isinstance(reported, int) and not isinstance(reported, bool) and reported >= 0:
return reported
try:
with open(f"{_SYS_BLOCK}/{name}/queue/rotational") as flag:
return -1 if flag.read().strip() == "1" else 0
except OSError:
return 0
def media_type(name: str, rate: int) -> str:
"""NVMe, SSD or HDD, from a rotation as ``rotation_rate`` returns it."""
if name.startswith("nvme"):
return "NVMe"
return "HDD" if rate else "SSD"
def names_for_keys(keys) -> set:
"""Current kernel names of the disks whose identity is in ``keys``."""
if not keys:
+12 -86
View File
@@ -410,60 +410,16 @@ def _extract_temperature(data: dict[str, Any]) -> Optional[float]:
# ---------------------------------------------------------------------------
# ── Leaving idle and excluded disks alone ──────────────────────────
# ── Leaving excluded disks alone ─────────────────────────────────
#
# `-n standby` keeps a periodic reader from waking a disk that is asleep.
# It does not let an awake one fall asleep: a drive's spin-down timer
# counts time without commands, and a read every minute resets it, so an
# unused drive whose timer is longer than that never spins down — and a
# drive that parks its heads when idle loads them again on every read.
#
# So a rotational disk with no I/O since it was last looked at is not read
# at all, not even asked for its power mode. The counters come from
# /proc/diskstats, which costs no disk access, and a SMART query does not
# move them — passthrough commands are not accounted as reads or writes —
# so any change means something else used the disk. A disk in use is
# already awake, and reading it then costs nothing. Solid-state disks have
# no spindle and no heads, and keep their reading.
#
# A disk seen for the first time is read once, so a Monitor that has just
# started still has values to show; after that it is left alone for as
# long as nothing uses it.
# A disk the user excludes in Settings receives nothing from a periodic
# reader, not even the power-mode question, so it can spin down on its own
# timer. Every other disk is read on schedule: `-n standby` still keeps a
# reader from waking one that is already asleep.
READ = "read"
IDLE = "idle"
EXCLUDED = "excluded"
_last_io: dict[str, tuple[int, int]] = {}
_idle_state: dict[str, float] = {}
_IDLE_TTL = 600 # same horizon as the standby badge
def _read_diskstats() -> dict[str, tuple[int, int]]:
"""Reads and writes completed per device, from /proc/diskstats."""
out: dict[str, tuple[int, int]] = {}
try:
with open("/proc/diskstats") as f:
for line in f:
parts = line.split()
if len(parts) < 8:
continue
try:
out[parts[2]] = (int(parts[3]), int(parts[7]))
except ValueError:
continue
except OSError:
pass
return out
def _is_rotational(disk_name: str) -> bool:
try:
with open(f"/sys/block/{disk_name}/queue/rotational") as f:
return f.read().strip() == "1"
except OSError:
return False
_EXCLUDED_TTL = 15
_excluded_cache: Optional[tuple[float, set]] = None
@@ -503,50 +459,20 @@ _excluded_disk_names = excluded_disk_names
def disk_read_policy(disk_name: str, excluded: Optional[set] = None) -> str:
"""Whether a periodic reader may touch this disk now: READ, IDLE or
EXCLUDED. Shared by every reader that runs on its own, so the
temperature poller and the storage view cannot disagree about a disk.
``excluded`` lets a caller that checks many disks pass the list once."""
"""Whether a periodic reader may touch this disk: READ or EXCLUDED.
Shared by every reader that runs on its own, so the temperature poller
and the storage view cannot disagree about a disk. ``excluded`` lets a
caller that checks many disks pass the list once."""
if excluded is None:
excluded = _excluded_disk_names()
if disk_name in excluded:
_idle_state.pop(disk_name, None)
return EXCLUDED
if not _is_rotational(disk_name):
return READ
current = _read_diskstats().get(disk_name)
with _cache_lock:
previous = _last_io.get(disk_name)
if current is not None:
_last_io[disk_name] = current
if current is None or previous is None or current != previous:
_idle_state.pop(disk_name, None)
return READ
_idle_state[disk_name] = time.time()
return IDLE
def settle_own_reads(disk_names) -> None:
"""A reader that has just read these disks itself takes their counters as
the new baseline, so its own I/O never counts as activity."""
stats = _read_diskstats()
with _cache_lock:
for name in disk_names:
if name in _last_io and name in stats:
_last_io[name] = stats[name]
def is_disk_idle(disk_name: str) -> bool:
"""True while the disk is being left alone for having no I/O."""
ts = _idle_state.get(disk_name)
return ts is not None and (time.time() - ts) < _IDLE_TTL
return EXCLUDED if disk_name in excluded else READ
def record_all_disk_temperatures() -> int:
"""Sample the disks that may be read now and persist their temperature.
USB disks are included. A disk the user excluded, or a rotational one
with no I/O since the last cycle, is skipped — see ``disk_read_policy``.
USB disks are included. A disk the user excluded is skipped — see
``disk_read_policy``.
Sampling fans out across a thread pool so a host with N disks pays
roughly the time of the slowest single ``smartctl`` call instead of
+83 -6
View File
@@ -2,7 +2,7 @@
Flask routes for health monitoring with persistence support
"""
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, current_app
from health_monitor import health_monitor
from health_persistence import health_persistence
from jwt_middleware import require_auth, require_admin_scope
@@ -17,6 +17,53 @@ except ImportError:
health_bp = Blueprint('health', __name__)
@health_bp.route('/api/health/proxmenux-version', methods=['GET'])
@require_auth
def get_proxmenux_version():
from proxmenux_version_status import version_status
return jsonify(version_status())
@health_bp.route('/api/health/reboot-context', methods=['GET'])
@require_admin_scope
def get_reboot_context():
from host_update_controls import reboot_context
return jsonify(reboot_context())
@health_bp.route('/api/health/reboot-after-update', methods=['POST'])
@require_admin_scope
def reboot_after_update():
import socket
data = request.get_json(silent=True)
if not isinstance(data, dict) or data.get('confirm') is not True or data.get('hostname') != socket.gethostname() or not isinstance(data.get('boot_id'), str) or not data['boot_id']:
return jsonify({'reason': 'confirmation'}), 400
power = current_app.extensions.get('host_update_power')
if not power:
return jsonify({'reason': 'unavailable'}), 503
from host_update_controls import reboot_context, COMMAND, UNIT
context = reboot_context()
if context.get('boot_id') != data['boot_id']:
return jsonify({'reason': 'confirmation'}), 409
if not context['allowed']:
return jsonify({'reason': context['reason']}), 409
ok, error = power['run'](UNIT, list(COMMAND), description='ProxMenux: reboot after host update',
extra_properties=['RuntimeMaxSec=75'])
if not ok:
return jsonify({'reason': 'unavailable'}), error.get('status', 500)
return jsonify({'accepted': True}), 202
@health_bp.route('/api/health/reboot-after-update/status', methods=['GET'])
@require_admin_scope
def reboot_after_update_status():
power = current_app.extensions.get('host_update_power')
if not power:
return jsonify({'state': 'unknown'}), 503
from host_update_controls import UNIT
return jsonify(power['state'](UNIT))
@health_bp.route('/api/health/status', methods=['GET'])
@require_auth
def get_health_status():
@@ -243,10 +290,14 @@ def get_full_health():
security before returning, so an event that just changed underlying state
(Update Now finished, dismiss action) sees the new value immediately
instead of waiting for the next polling tick.
?cached=1 is a strictly read-only poll: no full checks or cache clearing,
even with refresh=1. Unavailable/stale snapshots return 503. Every valid
response refreshes reboot evidence, while keeping the full audit's date.
"""
import time as _time
try:
if request.args.get('refresh') == '1':
cached_only = request.args.get('cached') == '1'
if not cached_only and request.args.get('refresh') == '1':
for ck in ('updates_check', 'pve_services', 'security_check',
'_bg_detailed', '_bg_overall', 'overall_health'):
health_monitor.last_check_times.pop(ck, None)
@@ -255,14 +306,42 @@ def get_full_health():
# Try to use the background-cached detailed result for instant response
bg_key = '_bg_detailed'
bg_last = health_monitor.last_check_times.get(bg_key, 0)
bg_age = _time.time() - bg_last
bg_age = _time.time() - bg_last if type(bg_last) in (int, float) else float('inf')
publish_live = False
if bg_age < 360 and bg_key in health_monitor.cached_results:
if 0 <= bg_age < 360 and bg_key in health_monitor.cached_results:
# Use cached result (at most ~5 min old)
details = health_monitor.cached_results[bg_key]
elif cached_only:
# Automatic polling must never start APT or a full audit, even
# during startup, collector failure, expiry or a conflicting flag.
return jsonify({'error': 'Health snapshot is not available yet'}), 503
else:
# No fresh cache, run live (first load or cache expired)
details = health_monitor.get_detailed_status()
if (not isinstance(details, dict) or not details.get('timestamp')
or details.get('overall') not in ('OK', 'INFO', 'WARNING', 'CRITICAL', 'UNKNOWN')
or not isinstance(details.get('summary'), str)
or not isinstance(details.get('details'), dict)
or not isinstance(details['details'].get('updates'), dict)):
raise ValueError('Health snapshot is incomplete')
# Publish an explicit check too: its caller may have invalidated
# the background cache after an update. A following read-only poll
# must see this result instead of waiting for another collector.
publish_live = True
# Installed/running kernel state changes outside Monitor jobs too.
# Overlay fresh read-only evidence without refreshing other checks or
# rewriting the background collector's snapshot/timestamp.
from kernel_reboot import refresh_health_reboot
details = refresh_health_reboot(details)
if publish_live:
checked_at = _time.time()
health_monitor.cached_results[bg_key] = details
health_monitor.cached_results['_bg_overall'] = {
'status': details['overall'], 'summary': details['summary']}
health_monitor.last_check_times[bg_key] = checked_at
health_monitor.last_check_times['_bg_overall'] = checked_at
active_errors = health_persistence.get_active_errors()
dismissed = health_persistence.get_dismissed_errors()
@@ -728,7 +807,6 @@ def get_disks_for_exclusion():
**disk,
'excluded': entry is not None,
'excluded_at': entry.get('excluded_at') if entry else None,
'idle': _dth.is_disk_idle(disk['name']),
'present': True,
})
# An excluded disk that is not connected right now — an unplugged USB
@@ -747,7 +825,6 @@ def get_disks_for_exclusion():
'rotational': False,
'excluded': True,
'excluded_at': entry.get('excluded_at'),
'idle': False,
'present': False,
})
result.sort(key=lambda d: (not d['present'], d['name']))
@@ -446,7 +446,7 @@ def get_provider_models():
Request body:
{
"provider": "gemini|groq|openai|openrouter|ollama|anthropic",
"api_key": "your-api-key", // Not needed for ollama
"api_key": "your-api-key", // Optional for protected Ollama gateways
"ollama_url": "http://localhost:11434", // Only for ollama
"openai_base_url": "https://custom.endpoint/v1" // Optional for openai
}
@@ -498,9 +498,10 @@ def get_provider_models():
if provider == 'ollama':
import urllib.request
import urllib.error
from ai_providers.ollama_provider import ollama_headers
url = f"{ollama_url.rstrip('/')}/api/tags"
req = urllib.request.Request(url, method='GET')
req = urllib.request.Request(url, method='GET', headers=ollama_headers(api_key))
req.add_header('User-Agent', 'ProxMenux-Monitor/1.1')
with urllib.request.urlopen(req, timeout=10) as resp:
+396 -138
View File
@@ -52,6 +52,8 @@ import time
import threading
import urllib.parse
import uuid
import disk_identity
import ovs_topology
import hardware_monitor
from health_persistence import health_persistence
import xml.etree.ElementTree as ET
@@ -98,6 +100,7 @@ from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
from flask_audit_routes import audit_bp # noqa: E402
from flask_vm_backup_routes import vm_backup_bp # noqa: E402
from flask_vm_backup_routes import active_vzdump # noqa: E402
from guest_backup_archives import (BackupArchiveError, delete_guest_backups,
guest_backup_deletion_status) # noqa: E402
from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402
@@ -1752,6 +1755,7 @@ def get_uptime():
_system_info_cache = {
'proxmox_version': None,
'proxmox_version_time': 0,
'proxmox_version_stamp': 0.0,
'available_updates': 0,
'available_updates_time': 0,
'available_updates_stamp': 0.0,
@@ -2391,8 +2395,10 @@ def _compute_vm_disk_from_fsinfo(fsinfo):
# real C:\ mount, and marking their signature eagerly would
# suppress C:\ and yield None for the entire VM (bug reported
# for a Windows 11 VM at 166 GB used / 1965 GB total).
# Some agents omit `dev`, so partitions of one disk share the
# disk fields; the filesystem name keeps /boot and / apart.
d = disks[0]
sig = (d.get('bus'), d.get('target'), d.get('unit'), d.get('dev'))
sig = (d.get('bus'), d.get('target'), d.get('unit'), d.get('dev'), fs.get('name'))
if sig in seen_disks:
continue
seen_disks.add(sig)
@@ -2748,6 +2754,9 @@ def _action_state(unit_name):
return result_dict
app.extensions['host_update_power'] = {'run': _action_run, 'state': _action_state}
def _action_cancel(unit_name):
"""Stop a running transient action unit. Idempotent — succeeds on
units that are not running (or don't exist).
@@ -3504,14 +3513,16 @@ def get_coral_info():
def get_proxmox_version():
"""Get Proxmox version if available. Cached for 6 hours."""
"""Cache the installed PVE version until package state changes."""
global _system_info_cache
if is_pbs_host():
return get_platform().get('version')
now = time.time()
if _system_info_cache['proxmox_version'] is not None and \
now - _system_info_cache['proxmox_version_time'] < _SYSTEM_INFO_CACHE_TTL:
stamp = _apt_state_stamp()
age = now - _system_info_cache['proxmox_version_time']
if _system_info_cache['proxmox_version'] is not None and (0 <= age < _AVAILABLE_UPDATES_MIN_INTERVAL or
(stamp == _system_info_cache.get('proxmox_version_stamp') and 0 <= age < _SYSTEM_INFO_CACHE_TTL)):
return _system_info_cache['proxmox_version']
proxmox_version = None
@@ -3527,8 +3538,11 @@ def get_proxmox_version():
except Exception as e:
pass
if proxmox_version is None:
return None # Keep last-known cache intact, but do not present it as fresh.
_system_info_cache['proxmox_version'] = proxmox_version
_system_info_cache['proxmox_version_time'] = now
_system_info_cache['proxmox_version_stamp'] = stamp
return proxmox_version
def _apt_state_stamp():
@@ -3823,69 +3837,6 @@ def serve_dashboard():
pass
return jsonify({'error': f'Dashboard not available: {str(e)}'}), 500
@app.route('/manifest.json')
def serve_manifest():
"""Serve PWA manifest"""
try:
manifest_paths = [
os.path.join(os.path.dirname(__file__), '..', 'web', 'public', 'manifest.json'),
os.path.join(os.path.dirname(__file__), '..', 'public', 'manifest.json')
]
for manifest_path in manifest_paths:
if os.path.exists(manifest_path):
return send_file(manifest_path)
# Return default manifest if not found
return jsonify({
"name": "ProxMenux Monitor",
"short_name": "ProxMenux",
"description": "Proxmox System Monitoring Dashboard",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#4f46e5",
"icons": [
{
"src": "/images/proxmenux-logo.png",
"sizes": "256x256",
"type": "image/png"
}
]
})
except Exception as e:
# print(f"Error serving manifest: {e}")
pass
return jsonify({}), 404
@app.route('/sw.js')
def serve_sw():
"""Serve service worker"""
return '''
const CACHE_NAME = 'proxmenux-v1';
const urlsToCache = [
'/',
'/api/system',
'/api/storage',
'/api/network',
'/api/health'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
''', 200, {'Content-Type': 'application/javascript'}
@app.route('/_next/<path:filename>')
def serve_next_static(filename):
"""Serve Next.js static files"""
@@ -3899,14 +3850,8 @@ def serve_next_static(filename):
appimage_root = os.path.dirname(base_dir)
static_dir = os.path.join(appimage_root, 'web', '_next')
file_path = os.path.join(static_dir, filename)
if os.path.exists(file_path):
return send_file(file_path)
# print(f"[v0] ❌ Next.js static file not found: {file_path}")
pass
return '', 404
# send_from_directory refuses any path that leaves static_dir.
return send_from_directory(static_dir, filename)
except Exception as e:
# print(f"Error serving Next.js static file {filename}: {e}")
pass
@@ -4019,6 +3964,106 @@ def is_disk_removable(disk_name):
return False
_PCI_ADDR_RE = re.compile(r'^([0-9a-f]{4,}):([0-9a-f]{2}:[0-9a-f]{2}\.[0-7])$')
def _last_pci_slot(sysfs_path):
"""Return the deepest PCI address in a resolved sysfs path, in lspci format.
lspci omits the PCI domain when it is 0000, so we do too; that keeps the
result directly comparable with pci_devices[].slot. Non-zero domains (e.g.
Intel VMD's 10000:) are kept, matching what lspci prints for them.
"""
slot = None
for part in sysfs_path.split('/'):
m = _PCI_ADDR_RE.match(part)
if m:
domain, bdf = m.groups()
slot = bdf if domain == '0000' else f'{domain}:{bdf}'
return slot
def get_disk_controller_slot(disk_name):
"""Return the PCI slot of the controller a disk hangs off, or None.
The disk's sysfs device path runs from the PCI root down through any
bridges to the controller, then into the controller's own topology:
SAS HBA .../0000:01:00.0/host6/port-6:1/end_device-6:1/... -> 01:00.0
SATA .../0000:02:00.1/ata1/host0/... -> 02:00.1
USB .../0000:0c:00.3/usb3/3-2/3-2.4/.../host7/... -> 0c:00.3
NVMe .../0000:03:04.0/0000:08:00.0/nvme/nvme0 -> 08:00.0
Bridges come earlier in the path, so the last PCI address is always the
controller. zvols, loop and other virtual devices have no device link.
"""
try:
device_path = f'/sys/block/{disk_name}/device'
if os.path.exists(device_path):
slot = _last_pci_slot(os.path.realpath(device_path))
if slot:
return slot
# NVMe native multipath can point the namespace at an nvme-subsystem
# with no PCI parent; fall back to the controller's own device link.
match = re.match(r'(nvme\d+)n\d+', disk_name)
if match:
ctrl_path = f'/sys/class/nvme/{match.group(1)}/device'
if os.path.exists(ctrl_path):
return _last_pci_slot(os.path.realpath(ctrl_path))
except Exception:
pass
return None
def get_storage_controllers(slots):
"""Describe the PCI controllers at the given slots, keyed by slot.
Built from the cached `lspci -vmm` output rather than the full
/api/hardware enumeration, so the Storage view can label its
controller groups without paying for get_hardware_info(). No class
filter: whatever a disk resolved to is described, including SATA
controllers left in BIOS IDE mode.
"""
wanted = {s for s in slots if s}
controllers = {}
if not wanted:
return controllers
for block in get_cached_lspci_vmm().split('\n\n'):
fields = {}
for line in block.split('\n'):
if ':' in line:
key, value = line.split(':', 1)
fields[key.strip()] = value.strip()
slot = fields.get('Slot')
if slot not in wanted:
continue
driver = None
try:
sysfs_addr = slot if slot.count(':') == 2 else f'0000:{slot}'
driver_link = f'/sys/bus/pci/devices/{sysfs_addr}/driver'
if os.path.exists(driver_link):
driver = os.path.basename(os.readlink(driver_link))
except Exception:
pass
# lspci prints "Device 84ca" when it has no name for the subsystem;
# drop that so the UI falls back to the real chip name.
sdevice = fields.get('SDevice') or None
if sdevice and re.fullmatch(r'Device [0-9a-f]{4}', sdevice):
sdevice = None
controllers[slot] = {
'slot': slot,
'class': fields.get('Class', ''),
'vendor': fields.get('Vendor', ''),
'device': fields.get('Device', ''),
'sdevice': sdevice,
'driver': driver,
}
return controllers
def is_disk_usb(disk_name):
"""Return True when sysfs places the disk behind a USB bus."""
return resolver_is_usb_disk(disk_name)
@@ -4083,6 +4128,149 @@ def _resolve_zfs_entry(entry):
return None
def get_zpool_status_details():
"""Return `zpool status` for every pool as parsed JSON, keyed by pool name.
Needs OpenZFS 2.3+ for `-j`. Returns None when the JSON output isn't
available (older ZFS, no zpool binary) so callers can tell "no details"
apart from "no pools". --json-int gives raw byte counts and epoch
timestamps; -P gives full device paths.
"""
try:
result = subprocess.run(
['zpool', 'status', '-j', '--json-int', '-P'],
capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
return None
return json.loads(result.stdout).get('pools') or {}
except Exception:
return None
def _zfs_member_disk(path):
"""Map a pool member path (by-id, by-partlabel, /dev/sdX3...) to its base disk.
Pure sysfs (no lsblk subprocess) since /api/storage is polled and a
pool can have dozens of members. Non-/dev members (file vdevs) give None.
"""
if not path.startswith('/dev/'):
return None
try:
name = os.path.basename(os.path.realpath(path))
if os.path.exists(f'/sys/class/block/{name}/partition'):
return os.path.basename(os.path.dirname(os.path.realpath(f'/sys/class/block/{name}')))
if os.path.exists(f'/sys/class/block/{name}'):
return name
except Exception:
pass
return None
def _zfs_errors(node):
return {
'read_errors': node.get('read_errors', 0),
'write_errors': node.get('write_errors', 0),
'checksum_errors': node.get('checksum_errors', 0),
}
def _zfs_device(node, via=None):
path = node.get('path') or node.get('name', '')
return {
'path': path,
'disk': _zfs_member_disk(path),
'state': node.get('state', 'UNKNOWN'),
'slow_ios': node.get('slow_ios', 0),
'via': via,
**_zfs_errors(node),
}
_ZFS_PARITY_RE = re.compile(r'^(?:raidz|draid)(\d)')
def _zfs_vdev(node, vdev_class):
"""Flatten one top-level vdev into {type, parity, devices[...]}.
A leaf at the top level (single disk, stripe member, log/cache/spare
device) becomes a vdev of type 'disk' holding just itself. spare-N and
replacing-N wrappers inside a mirror/raidz are unwrapped, with each
device tagged `via` so the UI can show what's being swapped.
"""
children = node.get('vdevs') or {}
if not children:
device = _zfs_device(node)
return {
'name': device['path'],
'type': 'disk',
'parity': None,
'class': vdev_class,
'state': device['state'],
'devices': [device],
**_zfs_errors(node),
}
devices = []
for child in children.values():
if child.get('vdevs'):
devices.extend(_zfs_device(leaf, via=child.get('name')) for leaf in child['vdevs'].values())
else:
devices.append(_zfs_device(child))
parity = _ZFS_PARITY_RE.match(node.get('name', ''))
return {
'name': node.get('name', ''),
'type': node.get('vdev_type', ''),
'parity': int(parity.group(1)) if parity else None,
'class': vdev_class,
'state': node.get('state', 'UNKNOWN'),
'devices': devices,
**_zfs_errors(node),
}
def _zfs_pool_details(pool):
"""Turn one pool from `zpool status -j` into the shape the Storage view uses.
Data/special/dedup vdevs hang off the root vdev; log, cache and spare
devices live in separate top-level maps. GUIDs are dropped; they don't
fit in a JS number and the UI doesn't need them.
"""
root = (pool.get('vdevs') or {}).get(pool.get('name'), {})
vdevs = [_zfs_vdev(child, child.get('class', 'normal')) for child in (root.get('vdevs') or {}).values()]
for key, vdev_class in (('logs', 'log'), ('l2cache', 'cache'), ('spares', 'spare')):
vdevs.extend(_zfs_vdev(child, vdev_class) for child in (pool.get(key) or {}).values())
scan = None
stats = pool.get('scan_stats')
if stats:
# issued/to_examine drifts either side of 100 once finished (skipped
# blocks), so progress is only meaningful while the scan is running.
to_examine = stats.get('to_examine') or 0
running = stats.get('state') == 'SCANNING' and to_examine
scan = {
'function': stats.get('function'), # SCRUB | RESILVER
'state': stats.get('state'), # SCANNING | FINISHED | CANCELED
'start_time': stats.get('start_time'),
'end_time': stats.get('end_time'),
'errors': stats.get('errors', 0),
'repaired_bytes': stats.get('processed', 0),
'percent': min(100.0, round(100 * (stats.get('issued') or 0) / to_examine, 1)) if running else None,
}
def _tidy(text):
return ' '.join(text.split()) if text else None
return {
'status': _tidy(pool.get('status')),
'action': _tidy(pool.get('action')),
'error_count': pool.get('error_count', 0),
'scan': scan,
'vdevs': vdevs,
}
def get_system_disks():
"""
Detect which physical disks are used by the system (Proxmox).
@@ -4227,9 +4415,6 @@ def get_system_disks():
['pvs', *dev_args, '--noheadings', '-o', 'pv_name,vg_name'],
capture_output=True, text=True, timeout=5
) if dev_args is not None else None
if result is not None:
from lvm_device_scope import settle_scan_reads
settle_scan_reads()
if result is not None and result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if line.strip():
@@ -4367,12 +4552,10 @@ def get_storage_info():
# temperature graph isn't a monitor bug — the
# disk is parked. See issue #232.
in_standby = False
in_idle = False
in_excluded = False
try:
import disk_temperature_history as _dth
in_standby = _dth.is_disk_in_standby(disk_name)
in_idle = _dth.is_disk_idle(disk_name)
in_excluded = disk_name in _dth.excluded_disk_names()
except Exception:
pass
@@ -4383,7 +4566,6 @@ def get_storage_info():
'size_bytes': disk_size_bytes,
'temperature': smart_data.get('temperature', 0),
'standby': in_standby,
'idle': in_idle,
'excluded': in_excluded,
'health': smart_data.get('health', 'unknown'),
'power_on_hours': smart_data.get('power_on_hours', 0),
@@ -4401,6 +4583,7 @@ def get_storage_info():
'total_lbas_written': smart_data.get('total_lbas_written'),
'ssd_life_left': smart_data.get('ssd_life_left'),
'connection_type': conn_type,
'controller_slot': get_disk_controller_slot(disk_name),
'removable': removable,
'is_system_disk': is_system_disk,
'system_usage': system_usage,
@@ -4536,8 +4719,6 @@ def get_storage_info():
total_used = 0
total_available = 0
zfs_disks = set()
for partition in disk_partitions:
try:
# Skip special filesystems
@@ -4583,9 +4764,11 @@ def get_storage_info():
continue
try:
result = subprocess.run(['zpool', 'list', '-H', '-p', '-o', 'name,size,alloc,free,health'],
result = subprocess.run(['zpool', 'list', '-H', '-p', '-o', 'name,size,alloc,free,health,frag,cap,dedup'],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
# One `zpool status` for all pools; None on ZFS < 2.3 (no -j)
zpool_details = get_zpool_status_details()
for line in result.stdout.strip().split('\n'):
if line:
parts = line.split('\t')
@@ -4595,6 +4778,14 @@ def get_storage_info():
pool_alloc_bytes = int(parts[2])
pool_free_bytes = int(parts[3])
pool_health = parts[4]
# frag is '-' on pools without spacemap histograms
extra = parts[5:8] + ['-'] * (8 - len(parts))
fragmentation = int(extra[0]) if extra[0].isdigit() else None
capacity = int(extra[1]) if extra[1].isdigit() else None
try:
dedup_ratio = float(extra[2])
except ValueError:
dedup_ratio = None
total_used += pool_alloc_bytes
total_available += pool_free_bytes
@@ -4615,22 +4806,20 @@ def get_storage_info():
'size': format_zfs_size(pool_size_bytes),
'allocated': format_zfs_size(pool_alloc_bytes),
'free': format_zfs_size(pool_free_bytes),
'health': pool_health
'health': pool_health,
'fragmentation': fragmentation,
'capacity': capacity,
'dedup_ratio': dedup_ratio,
'details_available': False,
}
if zpool_details and pool_name in zpool_details:
try:
pool_info.update(_zfs_pool_details(zpool_details[pool_name]))
pool_info['details_available'] = True
except Exception:
pass
storage_data['zfs_pools'].append(pool_info)
try:
pool_status = subprocess.run(['zpool', 'status', pool_name],
capture_output=True, text=True, timeout=5)
if pool_status.returncode == 0:
for status_line in pool_status.stdout.split('\n'):
for disk_name in physical_disks.keys():
if disk_name in status_line:
zfs_disks.add(disk_name)
except Exception as e:
# print(f"Error getting ZFS pool status for {pool_name}: {e}")
pass
except FileNotFoundError:
# print("[v0] Note: ZFS not installed")
pass
@@ -4678,6 +4867,9 @@ def get_storage_info():
pass
storage_data['disks'] = list(physical_disks.values())
storage_data['controllers'] = get_storage_controllers(
d.get('controller_slot') for d in physical_disks.values()
)
return storage_data
@@ -5048,6 +5240,30 @@ def _lvm_device_args():
return lvm_device_args(_hdd_in_standby)
def _smart_identity_payload(disk_name):
"""The empty payload with what the host knows of the disk without sending
it any command: its model, its serial and whether it spins."""
base = _smart_default_payload()
try:
model = Path(f'/sys/block/{disk_name}/device/model').read_text().strip()
if model:
base['model'] = model
except OSError:
pass
try:
device = Path(f'/sys/block/{disk_name}/dev').read_text().strip()
for line in Path(f'/run/udev/data/b{device}').read_text().splitlines():
key, _, value = line.partition('=')
if key == 'E:ID_MODEL' and value.strip():
base['model'] = value.strip().replace('_', ' ')
elif key == 'E:ID_SERIAL_SHORT' and value.strip():
base['serial'] = value.strip()
except OSError:
pass
base['rotation_rate'] = disk_identity.rotation_rate(disk_name)
return base
def get_smart_data(disk_name):
"""Cached wrapper around the underlying smartctl probe.
@@ -5076,20 +5292,19 @@ def get_smart_data(disk_name):
if cached and now - cached[0] < _SMART_RESULT_TTL:
return dict(cached[1])
# Excluded, or rotational with no I/O since it was last looked at: send
# it nothing — not even the power-mode question below, which is still a
# command. Serve what is known, without a temperature that would only be
# stale. Same rule as the temperature poller, so the two agree.
# Excluded in Settings: send it nothing — not even the power-mode
# question below, which is still a command. Serve what is known, without
# a temperature that would only be stale. Same rule as the temperature
# poller, so the two agree.
try:
import disk_temperature_history as _dth
policy = _dth.disk_read_policy(disk_name)
except Exception:
policy = 'read'
if policy != 'read':
base = dict(cached[1]) if cached else _smart_default_payload()
base = dict(cached[1]) if cached else _smart_identity_payload(disk_name)
base['temperature'] = 0
base['excluded'] = policy == 'excluded'
base['idle'] = policy == 'idle'
base['excluded'] = True
return base
if _hdd_in_standby(disk_name):
@@ -5098,14 +5313,15 @@ def get_smart_data(disk_name):
# the disk sleeps. The Storage view paints its own Standby badge
# from the temperature poller's flag. Don't refresh the cache
# timestamp: the moment the disk is active again we re-probe.
base = dict(cached[1]) if cached else _smart_default_payload()
base = dict(cached[1]) if cached else _smart_identity_payload(disk_name)
base['standby'] = True
base['temperature'] = 0
return base
retry_at = _smart_fail_backoff.get(disk_name, 0)
if retry_at > now:
return dict(cached[1]) if cached else _smart_default_payload()
# smartctl keeps failing: show what the host knows of the disk.
return dict(cached[1]) if cached else _smart_identity_payload(disk_name)
result = _get_smart_data_uncached(disk_name)
@@ -5579,6 +5795,11 @@ def _get_smart_data_uncached(disk_name):
# print(f"[v0] Health: CRITICAL (SMART failed)")
pass
# Without an answer from smartctl, the kernel's flag; the temperature
# class below depends on it.
if not smart_data['_rotation_known']:
smart_data['rotation_rate'] = disk_identity.rotation_rate(disk_name)
# Temperature-based health (only if we have a valid temperature)
# Thresholds differ by disk class (HDD/SSD/NVMe/SAS) and are
# user-configurable via health_thresholds; the values below are
@@ -5609,27 +5830,6 @@ def _get_smart_data_uncached(disk_name):
smart_data['health'] = 'critical'
elif temp > warn:
smart_data['health'] = 'warning'
# Fallback: ask the kernel only when smartctl had no opinion.
# USB-SATA enclosures (ASM105x family etc.) advertise
# rotational=1 to the kernel even when there's an SSD behind
# them, so trusting /sys here without first checking what
# smartctl said would flip a correctly-identified SSD back to
# HDD. The previous condition (`rotation_rate == 0`) couldn't
# tell "smartctl confirmed SSD" from "smartctl never spoke",
# which is what bit the OCZ-SOLID2 behind an ASM105x dock on
# the lab host (sdd showed as HDD in the UI while smartctl
# was reporting it as SSD).
if not smart_data['_rotation_known']:
try:
rotational_path = f"/sys/block/{disk_name}/queue/rotational"
if os.path.exists(rotational_path):
with open(rotational_path, 'r') as f:
rotational = int(f.read().strip())
if rotational == 1:
smart_data['rotation_rate'] = -1 # HDD, RPM unknown
except Exception:
pass
except FileNotFoundError:
# print(f"[v0] ERROR: smartctl not found - install smartmontools for disk monitoring.")
pass
@@ -6053,17 +6253,24 @@ def _iface_master(iface_name):
# Chain: veth/tap → fwbr (intermediate) → fwpr (peer) → vmbr (real).
def _resolve_bridge_owner(iface_name):
import re
ovs_owners = ovs_topology.get_topology().get('owners', {})
if iface_name in ovs_owners:
return ovs_owners[iface_name]
master = _iface_master(iface_name)
if not master:
return None
if master.startswith('fwbr'):
fwpr = re.sub(r'^fwbr', 'fwpr', master)
fwpr = re.sub(r'i(\d+)$', r'p\1', fwpr)
# A Linux bridge reaches the firewall bridge through fwprNpM; an OVS
# bridge through its own internal port fwlnNoM.
suffix = master[len('fwbr'):]
links = (re.sub(r'i(\d+)$', r'p\1', 'fwpr' + suffix),
re.sub(r'i(\d+)$', r'o\1', 'fwln' + suffix))
try:
if os.path.exists(f'/sys/class/net/{fwpr}'):
real = _iface_master(fwpr)
if real:
return real
for link in links:
if os.path.exists(f'/sys/class/net/{link}'):
real = ovs_owners.get(link) or _iface_master(link)
if real:
return real
except Exception:
pass
return master
@@ -6146,6 +6353,10 @@ def get_interface_type(interface_name):
if interface_name.startswith(('tun', 'vnet', 'docker', 'virbr')):
return 'skip'
ovs_type = ovs_topology.get_topology().get('types', {}).get(interface_name)
if ovs_type:
return ovs_type
# Check if it's a bond — sysfs first (name-agnostic), then the
# "bondN" convention as fallback when /sys isn't readable.
if _is_bond_master(interface_name) or interface_name.startswith('bond'):
@@ -6385,6 +6596,7 @@ def get_network_info():
pass
vm_lxc_map = get_vm_lxc_names()
ovs = ovs_topology.get_topology()
# Get network interfaces
net_if_addrs = psutil.net_if_addrs()
@@ -6519,8 +6731,10 @@ def get_network_info():
# vmbr so a guest behind the Proxmox firewall still lands
# under its true bridge in the Network Flow diagram.
master = _resolve_bridge_owner(interface_name)
master = ovs.get('owners', {}).get(interface_name) or master
if master:
interface_info['bridge_owner'] = master
interface_info.update(ovs.get('ports', {}).get(interface_name, {}))
if interface_type == 'bond':
bond_info = get_bond_info(interface_name)
@@ -6533,6 +6747,12 @@ def get_network_info():
if interface_type == 'bridge':
bridge_info = get_bridge_info(interface_name)
if interface_name in ovs.get('bridges', {}):
bridge_info['members'] = ovs['bridges'][interface_name]
bridge_info['physical_interface'] = next((member for member in bridge_info['members']
if member in ovs.get('bonds', {}) or _is_physical_dev(member)), None)
bridge_info['bond_slaves'] = ovs.get('bonds', {}).get(bridge_info['physical_interface'], {}).get('bond_slaves', [])
interface_info['network_backend'] = 'ovs'
interface_info['bridge_members'] = bridge_info['members']
interface_info['bridge_physical_interface'] = bridge_info['physical_interface']
interface_info['bridge_physical_duplex'] = bridge_info['physical_duplex']
@@ -6553,6 +6773,22 @@ def get_network_info():
if interface_type == 'bond':
network_data['bond_interfaces'].append(interface_info)
# An OVS bond is an OVSDB port, not a kernel network interface.
for name, detail in ovs.get('bonds', {}).items():
slaves = [item for item in network_data['physical_interfaces']
if item['name'] in detail['bond_slaves']]
network_data['bond_interfaces'] = [item for item in network_data['bond_interfaces'] if item['name'] != name]
bond_interface = {
'name': name, 'type': 'bond', 'addresses': [],
'status': 'up' if any(item['status'] == 'up' for item in slaves) else 'down',
# OVSDB membership does not reveal active-backup capacity.
'speed': 0, 'duplex': 'unknown', 'mtu': 0, 'mac_address': None,
'rx_Bps': sum(item.get('rx_Bps', 0) for item in slaves),
'tx_Bps': sum(item.get('tx_Bps', 0) for item in slaves), **detail}
network_data['bond_interfaces'].append(bond_interface)
network_data['interfaces'] = [item for item in network_data['interfaces'] if item['name'] != name]
network_data['interfaces'].append(bond_interface)
network_data['physical_active_count'] = physical_active_count
network_data['physical_total_count'] = physical_total_count
network_data['bridge_active_count'] = bridge_active_count
@@ -6603,6 +6839,11 @@ def get_network_info():
bridges_per_nic.setdefault(slave, []).append(bridge['name'])
else:
bridges_per_nic.setdefault(phys, []).append(bridge['name'])
for name, members in ovs.get('bridges', {}).items():
for member in members:
for nic in ovs.get('bonds', {}).get(member, {}).get('bond_slaves', [member]):
if name not in bridges_per_nic.setdefault(nic, []):
bridges_per_nic[nic].append(name)
for phy in network_data['physical_interfaces']:
if phy['name'] in bridges_per_nic:
phy['used_by_bridges'] = bridges_per_nic[phy['name']]
@@ -7776,7 +8017,8 @@ def get_detailed_gpu_info(gpu):
env = os.environ.copy()
env['TERM'] = 'xterm' # Ensure terminal type is set
cmd = f'{intel_gpu_top_path} -J' # Use the found path
# Without a shell: the signal has to reach intel_gpu_top itself.
cmd = [intel_gpu_top_path, '-J']
# print(f"[v0] Executing command: {cmd}", flush=True)
pass
@@ -7786,7 +8028,6 @@ def get_detailed_gpu_info(gpu):
stderr=subprocess.PIPE,
text=True,
bufsize=1,
shell=True,
env=env,
cwd='/' # Ejecutar desde root en lugar de dentro del AppImage
)
@@ -9446,6 +9687,7 @@ def _get_hardware_info_uncached():
'rotation_rate': smart_data.get('rotation_rate', 0),
'form_factor': form_factor,
'sata_version': sata_version,
'controller_slot': get_disk_controller_slot(disk_name),
}
if pcie_info:
@@ -9590,8 +9832,9 @@ def _get_hardware_info_uncached():
if any(keyword in device_class for keyword in ['VGA', 'Display', '3D']):
device_type = 'Graphics Card'
include_device = True
# Storage controllers (including SCSI/SAS HBA cards like LSI 9400-16i)
elif any(keyword in device_class for keyword in ['SATA', 'RAID', 'Mass storage', 'Non-Volatile memory', 'SCSI', 'Serial Attached SCSI', 'SAS']):
# Storage controllers (including SCSI/SAS HBA cards like LSI 9400-16i,
# and SATA controllers left in BIOS IDE mode, which report "IDE interface")
elif any(keyword in device_class for keyword in ['SATA', 'RAID', 'Mass storage', 'Non-Volatile memory', 'SCSI', 'Serial Attached SCSI', 'SAS', 'IDE']):
device_type = 'Storage Controller'
include_device = True
# Network controllers
@@ -11299,7 +11542,7 @@ def api_smart_status(disk_name):
'temperature': temp,
'power_on_hours': poh,
'power_cycles': cycles,
'rotation_rate': data.get('rotation_rate', 0),
'rotation_rate': disk_identity.rotation_rate(disk_name, data.get('rotation_rate')),
'form_factor': form_factor,
'physical_block_size': physical_block_size,
'trim_supported': trim_supported,
@@ -13205,6 +13448,7 @@ def api_create_backup(vmid):
pass
unit = f'proxmenux-vzdump-{vmid}-{uuid.uuid4().hex[:8]}'
out_path = os.path.join(run_dir, f'vzdump-{unit}.out')
since = int(time.time()) - 1
launch = subprocess.run(
['systemd-run', f'--unit={unit}', '--collect', '--quiet',
f'--description=ProxMenux backup of {vm_type.upper()} {vmid}',
@@ -13232,7 +13476,21 @@ def api_create_backup(vmid):
capture_output=True, text=True).stdout.strip()
if state not in ('active', 'activating'):
break
# pvesh prints the UPID only once the backup has ended.
upid = active_vzdump(node, since)
if upid:
break
time.sleep(0.3)
if upid is None:
# A short backup can finish between two reads.
try:
with open(out_path, encoding='utf-8', errors='replace') as handle:
buf = handle.read()
except OSError:
pass
m = upid_re.search(buf)
if m:
upid = m.group(0).rstrip(':')
if upid is None:
tail = buf.strip() or 'pvesh exited without producing a UPID'
return jsonify({
+84
View File
@@ -200,6 +200,90 @@ def lxc_update_viewer(ws, run_id):
# No process termination, PTY close or completion callback here. The job's
# worker alone owns those, even while no viewer is authenticated/connected.
def _host_update_completed(script_path, params, exit_code, duration_seconds):
"""Invalidate health even when no viewer is left to close its modal."""
from health_monitor import health_monitor
for key in ('updates_check', 'overall_health', '_bg_overall', '_bg_detailed'):
health_monitor.last_check_times.pop(key, None)
health_monitor.cached_results.pop(key, None)
@terminal_bp.route('/api/host-update-jobs', methods=['GET'])
@require_admin_scope
def host_update_jobs_status():
from host_update_jobs import list_jobs
return jsonify({'jobs': list_jobs()})
@terminal_bp.route('/api/host-update-jobs/<run_id>/result', methods=['GET'])
@require_admin_scope
def host_update_result(run_id):
"""Read a completed owner's retained output; never create or resume a job."""
from host_update_jobs import get
try:
view = get(run_id).read(0)
except (ValueError, KeyError):
return jsonify({'error': 'Host update result is unavailable'}), 404
if view['state'] != 'complete' or type(view['exit_code']) is not int:
return jsonify({'error': 'Host update has not completed'}), 409
response = jsonify({key: view[key] for key in ('state', 'exit_code', 'text', 'truncated')})
response.headers['Cache-Control'] = 'no-store'
return response
@sock.route('/ws/host-update/<run_id>')
def host_update_viewer(ws, run_id):
"""New fixed-script contract; existing script/LXC routes stay intact."""
if not _ws_auth_check():
ws.close(reason=1008, message="Authentication required")
return
from host_update_jobs import get, start
try:
init = json.loads(ws.receive(timeout=10))
if (not isinstance(init, dict) or set(init) - {'resume', 'offset'}
or type(init.get('resume')) is not bool):
raise ValueError("Invalid host update request")
offset = init.get('offset', 0)
if type(offset) is not int or offset < 0:
raise ValueError("Invalid host update cursor")
job = get(run_id) if init['resume'] else start(run_id, _host_update_completed)
revision = -1
while True:
view = job.read(offset)
if view['text'] or view['offset'] != offset:
ws.send(json.dumps({'type': 'job_output', 'text': view['text'],
'offset': view['offset'], 'truncated': view['truncated']}))
offset = view['offset']
if view['revision'] != revision:
revision = view['revision']
ws.send(json.dumps({'type': 'web_interaction', 'interaction': view['interaction']}))
ws.send(json.dumps({'type': 'host_update_state', 'state': view['state'],
'attention_reason': view['attention_reason']}))
if view['state'] == 'complete':
ws.send(json.dumps({'type': 'script_complete', 'exit_code': view['exit_code']}))
break
data = ws.receive(timeout=.2)
if data is None:
if getattr(ws, 'connected', True) is False or getattr(ws, 'closed', False) is True:
break
continue
try:
message = json.loads(data)
except (TypeError, ValueError):
message = data
if isinstance(message, dict) and message.get('type') == 'ping':
ws.send('{"type":"pong"}')
else:
job.input(message)
except (ValueError, KeyError, OSError, TypeError) as exc:
try:
ws.send(json.dumps({'type': 'job_unavailable', 'message': str(exc)}))
except Exception:
pass
except Exception:
pass # An observer disconnect never signals the owner or its PTY.
@terminal_bp.route('/api/terminal/health', methods=['GET'])
def terminal_health():
"""Health check for terminal service"""
+87 -5
View File
@@ -24,7 +24,10 @@ policy only changes the native storage configuration; it never runs pruning.
import json
import os
import re
import shutil
import subprocess
import time
import uuid
from flask import Blueprint, jsonify, request
@@ -395,6 +398,86 @@ def _run_arguments(job):
return args
# A job of several guests leaves the id of its task empty.
UPID_RE = re.compile(r"UPID:[^:\s]+:[^:\s]+:[^:\s]+:[^:\s]+:vzdump:[^:\s]*:[^:\s]+:")
RUN_DIR = "/run/proxmenux"
def active_vzdump(node, since):
"""The UPID of the first backup task the node started at or after `since`."""
ok, tasks = _pvesh_json(["get", f"/nodes/{node}/tasks", "--source", "active", "--typefilter", "vzdump"])
if not ok or not isinstance(tasks, list):
return None
started = sorted((task for task in tasks if isinstance(task, dict)
and UPID_RE.match(str(task.get("upid") or ""))
and isinstance(task.get("starttime"), int) and task["starttime"] >= since),
key=lambda task: task["starttime"])
return started[0]["upid"].rstrip(":") if started else None
def _start_vzdump(node, arguments, job_id):
"""Start the part of a job that runs on a node and return its task UPID.
pvesh waits for the whole backup and prints the UPID only at the end, and
a child of the Monitor is killed with its service, so vzdump runs in its
own transient unit and its UPID is read from the node's active tasks. The
task then runs to the end on its own. Returns (upid, error)."""
if not shutil.which("systemd-run"):
return None, "systemd-run not found"
run_dir = RUN_DIR
try:
os.makedirs(run_dir, mode=0o700, exist_ok=True)
now = time.time()
for old in os.listdir(run_dir):
old_path = os.path.join(run_dir, old)
if old.startswith("vzdump-") and now - os.path.getmtime(old_path) > 86400:
os.remove(old_path)
except OSError:
pass
unit = f"proxmenux-vzdump-job-{uuid.uuid4().hex[:8]}"
out_path = os.path.join(run_dir, f"vzdump-{unit}.out")
since = int(time.time()) - 1
try:
launch = subprocess.run(
["systemd-run", f"--unit={unit}", "--collect", "--quiet",
f"--description=ProxMenux backup job {job_id}",
f"--property=StandardOutput=file:{out_path}",
"pvesh", "create", f"/nodes/{node}/vzdump", *arguments],
capture_output=True, text=True, timeout=15)
except subprocess.TimeoutExpired:
return None, "systemd-run timed out"
if launch.returncode != 0:
return None, (launch.stderr or launch.stdout).strip()
buf = ""
deadline = time.time() + 10.0
while time.time() < deadline:
try:
with open(out_path, encoding="utf-8", errors="replace") as handle:
buf = handle.read()
except OSError:
buf = ""
found = UPID_RE.search(buf)
if found:
return found.group(0).rstrip(":"), None
state = subprocess.run(["systemctl", "is-active", unit], capture_output=True, text=True).stdout.strip()
if state not in ("active", "activating"):
break
upid = active_vzdump(node, since)
if upid:
return upid, None
time.sleep(0.5)
# A short backup can finish between two reads: its UPID is the last line.
try:
with open(out_path, encoding="utf-8", errors="replace") as handle:
buf = handle.read()
except OSError:
pass
found = UPID_RE.search(buf)
if found:
return found.group(0).rstrip(":"), None
return None, (buf.strip() or "pvesh exited without producing a UPID")[-800:]
@vm_backup_bp.route("/api/vm-backup-jobs/<job_id>/run", methods=["POST"])
@require_admin_scope
def run_job(job_id):
@@ -418,12 +501,11 @@ def run_job(job_id):
for node in nodes:
if not NODE_RE.match(node):
continue
ok, out = _pvesh(["create", f"/nodes/{node}/vzdump", *arguments])
if ok:
found = re.search(r"UPID:[^\s\"']+", out)
tasks.append({"node": node, "upid": found.group(0) if found else ""})
upid, error = _start_vzdump(node, arguments, job_id)
if upid:
tasks.append({"node": node, "upid": upid})
else:
failures.append({"node": node, "error": out})
failures.append({"node": node, "error": error})
if not tasks:
return _err(failures[0]["error"] if failures else "The backup could not be started", 500)
return jsonify({"success": True, "tasks": tasks, "failures": failures})

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