Compare commits

..
Author SHA1 Message Date
MacRimiandGitHub 4b384e64ee Merge pull request #437 from f3rs3n/fix/italian-oci-prompts
fix(i18n): refine Italian OCI prompts and menu messages
2026-10-11 19:32:43 +02:00
martino 5155c0c0c8 fix(i18n): refine Italian OCI prompts and menu messages 2026-10-11 19:29:31 +02:00
MacRimiandGitHub e633ecadcb Merge pull request #436 from Vaso73/i18n/sk-oci-installer
i18n: refine Slovak OCI installer texts
2026-10-11 19:14:18 +02:00
VAIO73 85794a8989 i18n: refine Slovak OCI installer texts 2026-10-11 18:46:20 +02:00
github-actions[bot] 11449239cb Update AppImage beta build (2026-10-11 14:00:21) 2026-10-11 14:00:21 +00:00
MacRimi 69b76116fd fix(oci): services show their address instead of a web link, and applications are not asked for their own 2026-10-11 15:57:05 +02:00
MacRimi 873a3cd8e2 fix(i18n): translate the search texts and review the new translations 2026-10-11 14:21:59 +02:00
ProxMenuxBot dd16682603 chore(lang): auto-rebuild translation cache
Source: cf830ef
Triggered by: push
2026-10-11 12:14:08 +00:00
MacRimiandGitHub cf830ef143 Merge pull request #433 from MacRimi/oci-catalog-review
OCI catalog review, search in the main menu and Helper-Scripts addons in LXC
2026-10-11 14:02:42 +02:00
MacRimi bbec8d34d1 fix(menu): the search asks for the name or part of the option or of the application 2026-10-11 13:27:47 +02:00
MacRimi d33b131c4d fix(oci): a new volume starts as the image has it
A container volume is filled with what the image holds at that path, with its owner and permissions, as Docker does with a new volume. It was only done for the recipes that asked for it. Images that run as a user of their own and do not take PUID, such as Node-RED or Grafana, find their data directory writable and start.
2026-10-11 13:26:23 +02:00
MacRimi 0fd3be15c1 feat(menu): search options and applications from the main menu
The main menu opens with a search that finds an option of ProxMenux, an application of the OCI catalog or a Helper-Script by its name or by related words, in that order, and by category after the names.

Every result opens what its name says and nothing is run by the search itself: a script starts at its first screen, an application opens its page, an option of the post-install or a system utility opens its list with it marked, and an option that lives inside a menu is opened by that menu, which stays behind it.

The menus take the option to open as an argument and behave as before without one. The Helper-Scripts catalog is kept on the host and refreshed once a day. The main menu, the lists of OCI manager and the Helper-Scripts menu share the same height.
2026-10-11 13:26:23 +02:00
MacRimi 8b8fed2564 feat(helper-scripts): an addon runs on the host or inside an LXC, as its catalog says
An addon is run where the catalog lists it: on the Proxmox host, inside an LXC chosen from a list, or wherever is chosen when it can be both. The LXC is started when it is stopped, and OCI containers are left out. Until now every addon ran on the host.

A script is downloaded whole before anything runs, so a failed or empty download runs nothing. Descriptions and notes the catalog writes in HTML are shown as plain text.

The menu opens one script given as argument, and is as tall as the main menu.
2026-10-11 13:26:23 +02:00
MacRimi b581d4c498 fix(oci): keep the name the page width of an application is read by
The width of the description keeps the name the script tests give it; the width of the window has its own.
2026-10-11 12:05:31 +02:00
MacRimi 5be6c14221 feat(oci): add Duplicati Official and hide three applications
The image published by the Duplicati project joins the catalog next to the LinuxServer one.

Bitcoin Knots is hidden until its image closes the application cleanly when it stops; EmulatorJS and SickChill, until their images are published again.
2026-10-11 12:02:04 +02:00
MacRimi 04cfa2c587 feat(oci): recipes that say what they ask for
Manyfold, BookStack and NetBox declare the services they need. Lychee and Manyfold install with their own SQLite database.

Folding@home, Healthchecks, NetBird, Speedtest Tracker, Twingate Connector, HA Bridge, ChatGPT Next Web, DDNS Updater and Minisatip explain each value, propose one or leave it to the advanced installation.

Pinchflat gets 1024 MB.
2026-10-11 12:02:04 +02:00
MacRimi a552da6f91 fix(oci): a Compose reservation is not the memory limit of the container
What a Compose file reserves is the least the application is promised, not the most it may use. 81 recipes had taken it as the memory of the container, as low as 128 MB, and the kernel stopped applications such as Home Assistant Official and UniFi Network.

Without a limit in the file the container gets the standard 1024 MB, or the reservation when it is larger. A limit the file sets is kept.
2026-10-11 12:02:04 +02:00
MacRimi 56d7a1839b feat(oci): content folders belong to the application, and an application says first what it needs
A path offered as a container volume or as a host directory is created for the user the application runs as, also as a volume, and is always mounted: the option of leaving it out is gone for those folders.

A container volume is never read-only, which is kept for a host directory that already holds the content, and the time files of the host are not turned into volumes.

An application that expects a database or another service that is already running shows it, with the address of its image documentation, before anything is asked, and its page lists it under Needs.

The installer windows share one width; the page of an application and the final summary are a little wider.
2026-10-11 12:02:04 +02:00
MacRimi 155cafcb73 fix(oci): a shared host directory shows what the host has mounted inside it 2026-10-10 20:52:52 +02:00
github-actions[bot] a1bac7aeec Update AppImage beta build (2026-10-10 08:35:33) 2026-10-10 08:35:33 +00:00
MacRimi 716b9ae871 fix(monitor): the OCI terminal window says it runs the same process as the menu 2026-10-10 10:16:09 +02:00
MacRimi b46cc4bd6d ix(monitor): the watchdog news says when an application is not restarted 2026-10-10 10:03:52 +02:00
MacRimiandGitHub 0460db741e Merge pull request #432 from f3rs3n/fix/italian-beta-release-notes
fix(i18n): refine Italian beta release notes
2026-10-09 23:23:28 +02:00
martino 539f91189e fix(i18n): refine Italian beta release notes 2026-10-09 23:18:30 +02:00
MacRimiandGitHub 522860e352 Merge pull request #431 from Vaso73/i18n/sk-beta-news
i18n: clarify Slovak beta watchdog release note
2026-10-09 22:37:30 +02:00
VAIO73 216298377a i18n: clarify Slovak beta watchdog release note 2026-10-09 21:52:51 +02:00
github-actions[bot] 07321333ca Update AppImage beta build (2026-10-09 19:30:25) 2026-10-09 19:30:25 +00:00
MacRimi f319ba4b2b fix(i18n): correct the machine translations of the 1.2.6.2-beta news 2026-10-09 21:20:09 +02:00
ProxMenuxBot 47ef6a96c5 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 10e42c3
Triggered by: push
2026-10-09 19:14:40 +00:00
MacRimi 10e42c3c9a feat(monitor): what's new in 1.2.6.2-beta 2026-10-09 21:12:36 +02:00
MacRimi c2936d4587 fix(oci): a shared host directory is recognised by its filesystem, not its device number 2026-10-09 21:00:33 +02:00
MacRimiandGitHub 9f09272889 Merge pull request #430 from Vaso73/i18n/sk-oci-manage
i18n: refine Slovak OCI manage messages
2026-10-09 20:14:06 +02:00
VAIO73 51851136ab i18n: refine Slovak OCI manage messages 2026-10-09 12:03:50 +02:00
MacRimi 9ef621e049 fix(i18n): French wording of the record copy notice 2026-10-09 09:44:08 +02:00
ProxMenuxBot 9a3f254b10 chore(lang): auto-rebuild translation cache
Source: 815d98a
Triggered by: push
2026-10-09 07:38:50 +00:00
MacRimi 815d98ab9f feat(oci): the menu shows the saved records being brought up to date. 2026-10-09 09:37:51 +02:00
MacRimi 5a892db7c9 ix(i18n): correct the machine translations of the new access messages 2026-10-09 00:47:30 +02:00
ProxMenuxBot 82f69be859 chore(lang): auto-rebuild translation cache
Source: 16ab980
Triggered by: push
2026-10-08 22:44:58 +00:00
MacRimi 16ab9808ac fix(oci): the access prompt says what it grants, and two checks name what differs 2026-10-09 00:43:32 +02:00
MacRimiandGitHub 316eb2f104 Merge pull request #429 from f3rs3n/fix/italian-oci-maintenance
fix(i18n): refine Italian OCI backup and sharing messages
2026-10-09 00:39:22 +02:00
martino 11479bcfe1 fix(i18n): refine Italian OCI backup and sharing messages 2026-10-09 00:09:36 +02:00
github-actions[bot] 3bc45535e4 Update AppImage beta build (2026-10-08 21:50:45) 2026-10-08 21:50:45 +00:00
MacRimi 66c621fe7a fix(oci): the access rule covers the container's root, and a shared volume is writable by its group 2026-10-08 22:57:37 +02:00
ProxMenuxBot 5604094907 chore(lang): auto-rebuild translation cache
Source: abf436c
Triggered by: push
2026-10-08 20:32:33 +00:00
MacRimi abf436c352 feat(oci): offer access to an existing host directory and gather the file-sharing applications
feat(oci): offer access to a host directory that already exists
feat(oci): gather the file-sharing applications in Files & Sharing
fix(oci): ShareCoveX keeps its settings in a 1 GB volume, and the new translations are mended
2026-10-08 22:28:53 +02:00
ProxMenuxBot 1a5fec13ec chore(lang): auto-rebuild translation cache
Source: 8879abf
Triggered by: push
2026-10-08 20:07:23 +00:00
MacRimiandGitHub 8879abf7bb Merge pull request #428 from MacRimi/oci-lifecycle-dhcp-and-monitor-fixes
OCI: update and modify every application; Monitor fixes for /etc/pve, DHCP and disk observations
2026-10-08 21:58:16 +02:00
MacRimi 73bca5f130 feat(oci): ShareCoveX is recorded as tested in the lab
Installed for real on a lab host through the default and the advanced
installation, with a host directory and a container volume to share, and
removed again. The recipe states what was exercised: clean install and
service health; the rest of its validation fields stay pending.
2026-10-08 21:52:56 +02:00
MacRimi eb5105f582 docs(oci): count ShareCoveX in the validation record 2026-10-08 21:45:52 +02:00
MacRimi c7efdd2e59 feat(oci): add ShareCoveX to the catalog and ask for the directories it shares
ShareCoveX is a file server with a web panel that publishes directories
over SMB, NFS or both. Its recipe declares that it shares directories, so
the installation asks for them one after another, in the default mode as
well: each is a container volume or a directory of the host, mounted under
/shares with the name given to it. Modify offers to add more.

The advanced installation also offers the privileged profile NFSv4 needs;
the default one never asks and stays unprivileged. A secret can declare a
minimum length, and the recipe opens the Files & Sharing category.
2026-10-08 21:43:49 +02:00
MacRimi 693e6ae481 fix(monitor): the observation history of a disk is permanent
- An observation is never left out for its age and cannot be dismissed:
  the rule that hid it after 30 days without a repetition, and the
  clean-up that marked the ones of a missing device, are removed.
- The ones earlier versions left out are shown again; an error that came
  back while hidden appears with its first date and its full count.
- The audit attributes recorded events to a disk by its serial number.
2026-10-08 17:32:47 +02:00
MacRimi 07713d5365 fix(monitor): write Proxmox's notification settings only while /etc/pve is mounted
- Nothing is written under /etc/pve unless it is the mounted cluster
  filesystem; a Monitor that starts while pve-cluster is being restarted
  waits for it and registers its webhook afterwards.
- The Monitor service is ordered after pve-cluster.service.
- The settings are written only when they have to change. The copies
  taken before a change are kept in
  /usr/local/share/proxmenux/backups/pve-notifications, and the ones left
  in /etc/pve by earlier versions are moved there.
- Uninstalling the Monitor removes its target and matcher from Proxmox.

Reported in #427.
2026-10-08 17:14:36 +02:00
MacRimi 053b6c81f8 fix(monitor): disk observations follow the disk, and OCI containers keep their address
- The observations of a disk belong to its serial number, not to its
  connector: a replaced disk starts clean, and the badge of the list
  counts what the detail of the disk lists.
- An OCI container is recognised while it is still being installed, so
  an image that carries Docker inside is not offered Docker.
- Links use the IPv4 address of a container and write IPv6 in brackets;
  an answer without an address of its own is read again.
- The Monitor restores, when it starts, the DHCP clients of containers
  that were started from it.
2026-10-08 17:14:36 +02:00
MacRimi f5ad4072b5 feat(oci): update and modify every application, and keep its address
- The backup taken before an update or a modification is made on another
  storage when it does not fit on the system disk; an operation without
  room says so before anything is stopped.
- Privileged containers, relaxed AppArmor or seccomp, process limits, the
  host monitors Glances and Netdata and single-file host mounts are
  rebuilt and recovered like any other application.
- HAOS One installs the docker-haos image: port 8123, host time zone,
  unprivileged, watchdog proposed by default; it is updated and modified
  with Home Assistant's own check on the rebuilt container.
- A container that reboots itself gets its DHCP client back.
- The installer takes the address of the container's own interface, and
  the notes of a suite or stack container show its LAN address.
- Seerr installs: its volume belongs to the image user and
  no-new-privileges goes through the include of the container.
- Removing an application deletes its seccomp profile.
2026-10-08 17:14:36 +02:00
MacRimiandGitHub b629938cf1 Merge pull request #426 from f3rs3n/fix/italian-followup-backup-oci-watchdog
Italian follow-up: backup jobs, OCI prompts and watchdog
2026-10-07 16:51:08 +02:00
martino 957fef8c62 fix(i18n): refine Italian backup, OCI and watchdog wording 2026-10-07 10:15:34 +02:00
MacRimi b927db8708 fix(lang): mend the translations of the reworded messages 2026-10-07 07:39:23 +02:00
ProxMenuxBot 8ff0d68c89 chore(lang): auto-rebuild translation cache
Source: f0d7d4f
Triggered by: push
2026-10-07 05:36:54 +00:00
MacRimi f0d7d4f8f7 fix: more messages say only what is certain
- Host restore notification: reports its tasks and no longer calls the node fully ready.
- Watchdog notification: covers an application that did not start as well as one that stopped again.
- OCI: Modify summary, Immich recognition preview, private network kept by another guest, empty USB list.
- gzip diagnostic no longer says that nothing changed.

Source questions from the Italian review by @f3rs3n.
2026-10-07 07:35:10 +02:00
MacRimi fc14ff5c33 fix(lang): one name for the watchdog in each language 2026-10-06 23:23:13 +02:00
ProxMenuxBot feb13949e1 chore(lang): auto-rebuild translation cache
Source: 41ae752
Triggered by: push
2026-10-06 21:21:04 +00:00
MacRimi 41ae752da1 feat(oci): watchdog that restarts an application when it stops on its own
- New service that starts again an OCI application that stopped on its own, such as Frigate after Save & Restart. A stop or shutdown asked by the user, a backup, a migration and a ProxMenux operation are never undone.
- The installer asks it in both modes and proposes what the recipe declares; it is changed from the management menu or with the Watchdog switch of the container modal in the Monitor.
- Notifications when the watchdog restarts an application and when it keeps stopping.
- The service is recorded in the change journal, and the feature is documented.
2026-10-06 23:18:26 +02:00
MacRimi 6b70c3934e fix(lang): mend the translations of the reworded previews and warnings 2026-10-06 21:42:40 +02:00
ProxMenuxBot 1e00eeac4f chore(lang): auto-rebuild translation cache
Source: 19a7068
Triggered by: push
2026-10-06 19:38:21 +00:00
MacRimi 19a7068fee fix: previews and warnings promise only what always happens 2026-10-06 21:36:02 +02:00
MacRimi f6274e360b ci(lang): run the translation cache when an OCI engine script or a .func file changes 2026-10-06 21:25:24 +02:00
MacRimi 42c927ecfd feat(oci): a notification for Modify, and the documentation under the same name 2026-10-06 20:04:46 +02:00
github-actions[bot] a60e065d33 Update AppImage beta build (2026-10-06 17:51:48) 2026-10-06 17:51:48 +00:00
ProxMenuxBot 74df2969dc chore(lang): auto-rebuild translation cache
Source: 3f75b94
Triggered by: push
2026-10-06 17:49:07 +00:00
MacRimi 3f75b94ea1 fix(oci): call the editing of an application Modify everywhere
- The option that edits an installed application is Modify for one container and for a multi-container application, in the OCI menu and on the Monitor button. Recreate is only the rebuild of a multi-container application as it is.
- A stack recreation says "Recreating" and "Recreated" in its steps instead of "Updating".
- The notes of the GPU, Coral, disk and share menus no longer name the option.
- Spanish for the new texts; the English copies of the stack texts are removed from de, fr, pt and sv so the bot translates them.
2026-10-06 19:42:30 +02:00
ProxMenuxBot 747b32489f chore(lang): auto-rebuild translation cache
Source: 4a3dd8c
Triggered by: push
2026-10-06 17:13:33 +00:00
MacRimiandGitHub 4a3dd8cc43 Merge pull request #423 from Vaso73/fix/oci-stack-recreate
fix(oci): recreate coordinated stacks from saved state
2026-10-06 19:12:28 +02:00
ProxMenuxBot c343b5ade1 chore(lang): auto-rebuild translation cache
Source: 69c9984
Triggered by: push
2026-10-06 17:06:53 +00:00
MacRimi 69c9984ff7 fix(i18n): translate texts with line breaks; drop the experimental label of OCI stacks 2026-10-06 19:04:49 +02:00
ProxMenuxBot be396e0054 chore(lang): auto-rebuild translation cache
Source: f788f20
Triggered by: push
2026-10-06 16:48:25 +00:00
MacRimiandMattiaC46 f788f205b8 feat(backup): manage the VM and CT backup jobs of Proxmox
Co-authored-by: MattiaC46 <52442052+MattiaC46@users.noreply.github.com>
2026-10-06 18:29:12 +02:00
VAIO73 f464b62190 fix(oci): complete Monitor modify wrapper contract 2026-10-06 18:22:42 +02:00
VAIO73 ebb25e5a70 fix(oci): preserve Monitor stack editor action 2026-10-06 18:22:42 +02:00
VAIO73 cff386053b fix(oci): gate stack recreate by replay support 2026-10-06 18:22:42 +02:00
VAIO73 0cf62149fd fix(oci): preserve multi-arch digest during recreate 2026-10-06 18:22:42 +02:00
VAIO73 3b6615194f feat(oci): recreate coordinated stacks from saved state 2026-10-06 18:22:42 +02:00
MacRimi b4f4d98e85 fix(backup): send host backup notifications over HTTPS when the Monitor uses it 2026-10-06 17:26:55 +02:00
MacRimi c6b2b74bf0 fix(lang): correct the generated strings of the reworded messages 2026-10-06 17:13:48 +02:00
ProxMenuxBot f04b84cb89 chore(lang): auto-rebuild translation cache
Source: 85b88f7
Triggered by: push
2026-10-06 15:11:43 +00:00
MacRimi 85b88f709a fix: make messages say what was verified (source questions from the Italian review) 2026-10-06 17:08:18 +02:00
MacRimi 12632c027e fix(i18n): correct the generated health popup strings 2026-10-06 16:47:47 +02:00
ProxMenuxBot 8639ed1525 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 374d959
Triggered by: push
2026-10-06 14:41:50 +00:00
MacRimiandGitHub 374d95921c Merge pull request #424 from f3rs3n/feature/health-popup-i18n-it
fix(i18n): translate health popup details and add Italian messages
2026-10-06 16:34:37 +02:00
martino dd28d2d341 fix(i18n): translate health popup details and add Italian messages 2026-10-06 11:41:57 +02:00
github-actions[bot] b2398a1cae Update AppImage beta build (2026-10-05 22:04:09) 2026-10-05 22:04:09 +00:00
MacRimiandGitHub b36e5292ff Merge pull request #422 from f3rs3n/l10n/italian-tui-monitor
Improve Italian localization of the TUI and Monitor
2026-10-05 23:58:26 +02:00
martino c7256042e4 fix(i18n): improve Italian TUI and Monitor wording 2026-10-05 22:31:44 +02:00
github-actions[bot] 65f406f13e Update AppImage beta build (2026-10-05 16:46:26) 2026-10-05 16:46:26 +00:00
MacRimi 8d456f7ca1 fix(lang): correct the generated strings of the OCI menu notes 2026-10-05 18:43:11 +02:00
MacRimi 37acfbc369 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-05 18:19:29 +02:00
MacRimi 32e67ba54b Update test_tui_network_storage_wording.py 2026-10-05 18:19:18 +02:00
ProxMenuxBot 75c528e5c2 chore(lang): auto-rebuild translation cache
Source: 6b50763
Triggered by: push
2026-10-05 16:12:32 +00:00
MacRimi 6b50763e54 fix(i18n): natural Spanish for the menus and the OCI catalogue. 2026-10-05 18:10:17 +02:00
github-actions[bot] 7a4a6e114d Update AppImage beta build (2026-10-04 22:06:03) 2026-10-04 22:06:03 +00:00
MacRimi 55de7d1cbb fix(monitor): keep the last image check when a registry does not answer 2026-10-04 23:58:28 +02:00
MacRimi 73f484834a fix(installer): keep apt quiet when only the enterprise repositories are refused 2026-10-04 22:06:52 +02:00
github-actions[bot] f82d5d1fb0 Update AppImage beta build (2026-10-04 20:00:06) 2026-10-04 20:00:06 +00:00
MacRimi c7dba81f8f ix(i18n): correct the new OCI strings in six locales. 2026-10-04 21:53:04 +02:00
ProxMenuxBot ee6d53acc5 chore(lang): auto-rebuild translation cache
Source: 951acfb
Triggered by: push
2026-10-04 19:31:19 +00:00
MacRimiandGitHub 951acfb14e Merge pull request #420 from MacRimi/oci-restore-recovery-amd-gpu
OCI: recover applications after a Proxmox reinstall, cluster records and AMD GPU profiles
2026-10-04 21:10:16 +02:00
MacRimi f6eccfb25c test(oci): give the extracted menu functions the recovery helpers
The menu registers restored containers and refreshes the carried record
before it lists or manages an instance. The tests that run those menu
functions in isolation provide the new helpers they call.
2026-10-04 21:03:22 +02:00
MacRimi c739699ec7 fix(monitor): read an OCI image saved from a Docker-format manifest
An image published with a Docker-format manifest is saved for Proxmox
under another digest than the one its registry serves. The App tab reads
the installed version through the digest the registry served the image
under, and decides an update by comparing the platform manifest that
digest resolves to with the one the tag points at.
2026-10-04 20:43:15 +02:00
MacRimi 1f9d2acef5 feat(oci): recover applications after a Proxmox reinstall, cluster records and AMD GPU profiles
The installation record of an OCI application travels with its container:
a copy inside the container and another in /etc/pve, written together
with the one kept on the host. A container restored on a newly installed
Proxmox, restored with another ID or moved to another node of a cluster
is recognised and registered again, with its private network, hookscript,
Rclone mount, host firewall rule and NVIDIA runtime. The Monitor offers
the same recovery from the Updates tab.

AMD GPUs are offered by generation. A GPU the ROCm image supports takes
the profile as it is; one of a supported family (Radeon 680M, 780M) is an
experimental option that asks for confirmation and is never proposed; an
older one is not offered. The GPU is checked with a real inference before
the installation accepts it. Recreate changes what runs recognition in an
installed Immich, between the CPU and a GPU of the host.

Updates:
- A failed update that is restored and checked removes its temporary
  container and the disks of the failed attempt.
- Every container volume is part of the backups, so Jellyfin, Plex and
  Hugo update with their default installation.
- An image published with a Docker-format manifest is recognised by its
  layers and build time and updates.
- The Proxmox notes of a multi-container application link to its LAN
  address.
2026-10-04 20:32:17 +02:00
github-actions[bot] 7e9f16fdb3 Update AppImage beta build (2026-10-02 20:11:57) 2026-10-02 20:11:57 +00:00
MacRimi cb07f34626 fix(i18n): correct the new OCI strings in six locales 2026-10-02 22:08:16 +02:00
ProxMenuxBot a40ad157dd chore(lang): auto-rebuild translation cache
Source: 9b5cefb
Triggered by: push
2026-10-02 19:50:26 +00:00
ProxMenuxBot badd0f8771 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 9b5cefb
Triggered by: push
2026-10-02 19:46:44 +00:00
MacRimi 9b5cefb81a feat(oci): GPU selection per host and per image, one notification per update, and App tab for stack containers
- Immich asks what runs its video and its recognition in one menu, in both
  modes, and gives the GPU to the server and to Machine learning; AMD uses ROCm
- Frigate, Ollama, llama.cpp, Faster Whisper and Piper take the image built
  for the chosen GPU
- The acceleration menu offers only what the host can run
- An update or a recreation sends one notification with its result instead of
  the stop, backup and start of each container
- A private bridge with nothing connected is not reported as down
- Secondary containers of a stack appear in the App tab with their version and
  logo; Secure Gateway shows the same update state in both views
- A mistyped value in the wizard asks the same question again
2026-10-02 21:45:38 +02:00
github-actions[bot] 20ee21c08f Update AppImage beta build (2026-10-02 08:43:00) 2026-10-02 08:43:00 +00:00
MacRimiandGitHub 284851627e Merge pull request #418 from pbr85/fix/post-install-test-journal
fix(tests): load journal helpers in post-install test
2026-10-01 23:08:51 +02:00
MacRimi 96f3d82e15 chore: follow-ups to #406, #407 and #415 — locale cleanup, journal entry, pigz 1.1 and docs 2026-10-01 23:06:49 +02:00
MacRimiandGitHub b7e053b88a Merge pull request #417 from pbr85/fix/pmx-write-temp-check
fix(journal): fail write/append when the temp copy can't be written
2026-10-01 23:01:28 +02:00
pbr f80e6c3e39 fix(tests): load journal helpers in post-install test
The test points LOCAL_SCRIPTS at a missing directory, so pmx_journal.sh
was never sourced and it stopped at the first pmx_journal_context call.
2026-10-01 21:43:41 +02:00
pbr e5fc9c7188 fix(journal): fail write/append when the temp copy can't be written
pmx_write_file and pmx_append_file didn't check the first cat into
the temp file, so a full /tmp returned 0 and left a truncated target.
2026-10-01 21:43:40 +02:00
ProxMenuxBot 37a461c5e5 chore(lang): auto-rebuild translation cache
Source: 4078885
Triggered by: push
2026-10-01 19:28:13 +00:00
MacRimiandGitHub 40788855db Merge pull request #407 from f3rs3n/fix/preserve-enterprise-repositories
fix: preserve configured PVE repositories during dependency checks
2026-10-01 21:24:49 +02:00
ProxMenuxBot c91e7ecbac chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 8e5920f
Triggered by: push
2026-10-01 19:24:23 +00:00
ProxMenuxBot 6d40cfc8f9 chore(lang): auto-rebuild translation cache
Source: 872d90e
Triggered by: push
2026-10-01 19:23:11 +00:00
MacRimiandGitHub 8e5920faf0 Merge pull request #406 from f3rs3n/fix/notification-outcome-reporting
Clarify backup outcomes and diagnostic presentation
2026-10-01 21:23:01 +02:00
MacRimiandGitHub 872d90e75a Merge pull request #415 from pbr85/fix/pigz-dpkg-divert
fix(post-install): divert gzip instead of replacing it for pigz
2026-10-01 21:22:14 +02:00
MacRimi 9cbcd0d764 test(oci): a stack that cannot be updated still offers Recreate and Remove 2026-10-01 21:19:46 +02:00
MacRimi 064b169ae9 feat(monitor): offer Recreate for multi-container OCI applications 2026-10-01 20:53:04 +02:00
ProxMenuxBot c609fa0e12 chore(lang): auto-rebuild translation cache
Source: a4cb993
Triggered by: push
2026-10-01 17:44:57 +00:00
MacRimi a4cb9936a5 feat(oci): Recreate for multi-container apps and CPU/memory in every advanced install 2026-10-01 19:39:01 +02:00
pbr 07f88659ea fix(post-install): verify gzip before diverting or restoring it
Only use a file as the real gzip if it is a working binary, otherwise
reinstall the gzip package; bail out without touching gzip if that
fails. Check the .distrib and wrapper writes, and never purge pigz
while gzip still depends on it.
2026-10-01 19:38:06 +02:00
martino ea5fc041b8 fix(notifications): scope outcome improvements to backups 2026-10-01 18:04:08 +02:00
martino 21babcc14d test(notifications): allow default temporary directory in CI 2026-10-01 11:18:26 +02:00
martino aac75a0753 fix(notifications): ship recovery helper and reject superseded proofs 2026-10-01 09:03:27 +02:00
martino 54eef11334 Load bounded recovery predicate only for recovery presentation 2026-10-01 08:38:32 +02:00
martino 3be15211b1 Fix principal cause caps and bind measured recovery to incident policy 2026-10-01 08:38:32 +02:00
martino 8e396f957f fix(notifications): avoid repeating subject causes already in error diagnostics 2026-10-01 08:38:32 +02:00
martino 08598d2863 fix(notifications): preserve evidenced outcomes in backup and health notices 2026-10-01 08:38:32 +02:00
martino 147c401864 fix(notifications): retain diagnostic context across final consumers 2026-10-01 08:38:32 +02:00
martino e24308c017 Correct backup outcome diagnostics and email presentation chains 2026-10-01 08:38:32 +02:00
martino e52f52388a fix(monitor): parse PVE 9.2 backup reports and remove Slovak overrides 2026-10-01 08:38:32 +02:00
martino 2ea261dca3 test(notifications): keep label checks independent of git history 2026-10-01 08:38:32 +02:00
martino f80e0b6784 fix(monitor): honor guest terminology and leaf-scoped Slovak fallback 2026-10-01 08:38:32 +02:00
martino 5faf55a746 fix(monitor): reconcile backup outcome reports with maintainer review 2026-10-01 08:38:32 +02:00
martino dcf00cc6b9 fix(monitor): distinguish notification recovery and backup outcomes 2026-10-01 08:38:32 +02:00
pbr 4f651ffed5 fix(post-install): divert gzip instead of replacing it for pigz
A gzip package update overwrote the pigz wrapper, and uninstall could
restore a stale gzip.original over a newer binary. Use dpkg-divert
(--no-rename, gzip is Essential), migrate existing installs, and also
divert the /usr path on bookworm so a PVE 8 -> 9 upgrade keeps working.
2026-09-30 22:55:05 +02:00
MacRimi d137bb3d31 fix(oci): accept the console start hook in the dynamic NVIDIA profile check 2026-09-30 22:41:51 +02:00
ProxMenuxBot 4e6d43f6a5 chore(lang): auto-rebuild translation cache
Source: c83f843
Triggered by: push
2026-09-30 20:29:21 +00:00
MacRimi c83f84398b fix(oci): keep every declared image data path on a volume so installs can be updated 2026-09-30 22:28:20 +02:00
MacRimiandGitHub d2ac3b7638 Merge pull request #414 from pbr85/fix/pigz-wrapper-export-gzip
fix(post-install): export GZIP in pigz wrapper
2026-09-30 22:27:01 +02:00
martino 028026a9a0 fix: normalize Proxmox repository state and refresh consented switches 2026-09-30 22:05:46 +02:00
pbr e9957415ed fix(post-install): export GZIP in pigz wrapper so level -1 takes effect
The wrapper assigned GZIP="-1" without exporting it, so pigz never saw
it and compressed at its default level 6. Export it, and refresh
/bin/gzip on re-runs when gzip.original already exists so existing
installs pick up the corrected wrapper.
2026-09-30 21:57:38 +02:00
MacRimi 22e8bf2532 fix(lang): Spanish wording for the container filesystem unmount message 2026-09-30 21:41:09 +02:00
ProxMenuxBot c1012356f2 chore(lang): auto-rebuild translation cache
Source: fda584f
Triggered by: push
2026-09-30 19:38:52 +00:00
MacRimiandGitHub fda584f65b Merge pull request #413 from Vaso73/fix/oci-runtime-home
fix(oci): set HOME for native OCI runtime
2026-09-30 21:37:44 +02:00
VAIO73 4fd987fdd5 fix(oci): set HOME for native OCI runtime 2026-09-30 10:46:34 +02:00
github-actions[bot] 0fa112de56 Update AppImage beta build (2026-09-29 19:04:14) 2026-09-29 19:04:14 +00:00
MacRimi 2398b605b2 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-09-29 20:56:52 +02:00
MacRimi f421ac1cb1 fix(monitor): honour storage exclusions in the PVE storage capacity alert 2026-09-29 20:56:02 +02:00
ProxMenuxBot 889fc4daf4 chore(lang): auto-rebuild translation cache
Source: 438cb93
Triggered by: push
2026-09-29 18:52:18 +00:00
MacRimi 438cb935d0 feat(oci): explain optional image variables and show their example value as a hint 2026-09-29 20:49:45 +02:00
github-actions[bot] c40a335301 Update AppImage beta build (2026-09-29 18:27:29) 2026-09-29 18:27:29 +00:00
ProxMenuxBot 72c4dcd0be chore(lang): auto-rebuild translation cache
Source: 135df4b
Triggered by: push
2026-09-29 18:23:44 +00:00
MacRimi 135df4b2c5 feat(oci): coherent advanced installer flow, container creation progress and USB device list 2026-09-29 20:20:36 +02:00
MacRimiandGitHub db4ef3f29b Merge pull request #412 from Vaso73/fix/oci-uptime-kuma-v2
fix(oci): use Uptime Kuma v2 image
2026-09-29 20:14:51 +02:00
MacRimiandGitHub 41099960d6 Merge pull request #411 from Vaso73/fix/oci-stremio-nginx-runtime
fix(oci): add nginx runtime contract for Stremio
2026-09-29 20:13:41 +02:00
ProxMenuxBot ad24cd9c15 chore(lang): auto-rebuild translation cache
Source: b7154ff
Triggered by: push
2026-09-29 18:12:32 +00:00
MacRimiandGitHub b7154ffee9 Merge pull request #410 from Vaso73/fix/oci-managed-volume-preparation-refresh
fix(oci): prepare managed volumes across stack installs
2026-09-29 20:11:34 +02:00
VAIO73 e18ff82eea fix(oci): prepare managed volumes across stack installs 2026-09-29 19:44:42 +02:00
VAIO73 18bf0c3e7e fix(oci): use Uptime Kuma v2 image 2026-09-29 19:41:15 +02:00
VAIO73 c93c0dbcef fix(oci): add nginx runtime contract for Stremio 2026-09-29 19:40:29 +02:00
martino e57a2f3aae fix: require subscription-aware consent for PVE repository switches 2026-09-29 18:04:12 +02:00
MacRimi eb7cc548fa fix(monitor): refresh the VM/LXC modal after edits made outside the Monitor 2026-09-29 17:27:41 +02:00
MacRimi a17f4120e3 fix(monitor): round the temperature in alerts and tidy the #408 follow-ups 2026-09-29 17:10:27 +02:00
MacRimiandGitHub 57a69e1f92 Merge pull request #408 from f3rs3n/fix/active-notification-payloads
Fix active health and temperature notification payloads
2026-09-29 17:06:07 +02:00
martino 1f1a2176fd fix(notifications): wrap unbroken temperature email details 2026-09-29 11:59:56 +02:00
martino 25cb81d740 fix(notifications): localize sensor alerts and preserve email context 2026-09-29 11:31:16 +02:00
martino 4c08ef6ea0 fix(notifications): align active health and temperature payloads
Local source candidate; non-English temperature migration and rendered layout validation remain pending.
2026-09-29 10:45:17 +02:00
MacRimiandGitHub d14b968930 Merge pull request #404 from f3rs3n/fix/lynis-removal-threshold-guidance
Clarify Lynis removal outcomes and printable/threshold guidance
2026-09-29 07:25:49 +02:00
martino d27012806b fix(monitor): clarify Lynis removal outcomes and threshold guidance 2026-09-29 00:42:44 +02:00
MacRimi 72f77069ca fix(oci): limit Frigate's CPU by time instead of pinning it to threads 2026-09-28 23:08:45 +02:00
github-actions[bot] eb869d1250 Update AppImage beta build (2026-09-28 20:37:04) 2026-09-28 20:37:04 +00:00
MacRimi 0b82e91dd7 i18n: fix spacing and capitalisation of the new removal messages 2026-09-28 22:34:02 +02:00
ProxMenuxBot 6e303e35ce chore(lang): auto-rebuild translation cache
Source: 0ae601f
Triggered by: push
2026-09-28 20:32:12 +00:00
MacRimi 0ae601f5e7 fix(oci): remove everything an OCI installation leaves, and name cluster events 2026-09-28 22:31:00 +02:00
MacRimiandGitHub 4a5dd8a94e Merge pull request #403 from f3rs3n/fix/monitor-cluster-event-wording
Describe cluster notifications without asserting split-brain or quorum loss
2026-09-28 22:19:56 +02:00
ProxMenuxBot 55999da58a chore(lang): auto-rebuild translation cache
Source: 328a690
Triggered by: push
2026-09-28 20:19:17 +00:00
MacRimiandGitHub 328a690712 Merge pull request #402 from f3rs3n/fix/oci-removal-firewall-wording
Clarify OCI removal outcomes and host firewall diagnostics
2026-09-28 22:15:50 +02:00
martino 46af79eb51 fix(monitor): report cluster events without unsupported diagnoses 2026-09-28 21:02:04 +02:00
martino 118a757ac7 fix(oci): distinguish clean removal from incomplete cleanup 2026-09-28 19:48:34 +02:00
MacRimi 2922fc235d fix: quiet apt in the installers and fix the duplicate repository cleanup 2026-09-28 19:14:05 +02:00
github-actions[bot] 58fb192b29 Update AppImage beta build (2026-09-28 17:11:49) 2026-09-28 17:11:49 +00:00
MacRimi 7ec6d06dc3 i18n(es): drop an unused catalog tip translation 2026-09-28 19:09:30 +02:00
ProxMenuxBot 488b9b6521 chore(lang): auto-rebuild translation cache
Source: 79547de
Triggered by: push
2026-09-28 17:07:17 +00:00
MacRimi 79547dec35 fix(oci): start OCI containers on any node of a cluster 2026-09-28 19:06:07 +02:00
MacRimi 297c026c95 fix: Health Monitor storage and disk notices, SELinux-safe host backup, Frigate detector keys 2026-09-28 18:18:09 +02:00
martino 0c754bda60 fix(oci): clarify removal and host firewall diagnostics 2026-09-28 18:07:21 +02:00
ProxMenuxBot 33245a423a chore(lang): auto-rebuild translation cache
Source: bf07f0a
Triggered by: push
2026-09-28 15:51:01 +00:00
MacRimi bf07f0a8a9 feat(oci): offer an Intel NPU or a Coral as Frigate's object detector 2026-09-28 17:48:47 +02:00
MacRimi adc42caae3 feat(oci): community validation record, report form and generated list 2026-09-28 17:30:29 +02:00
MacRimi 3d2aff6c66 i18n(es): curate the new OCI messages and the dry-run wording 2026-09-28 17:19:28 +02:00
ProxMenuxBot a3f07e643e chore(lang): auto-rebuild translation cache
Source: b181fbd
Triggered by: push
2026-09-28 15:12:30 +00:00
ProxMenuxBot 642f1207c7 chore(lang): auto-rebuild translation cache
Source: 82e3791
Triggered by: push
2026-09-28 15:11:04 +00:00
MacRimiandGitHub b181fbd1e8 Merge pull request #401 from f3rs3n/fix/oci-update-preservation-wording
Clarify OCI template refresh and update backup guidance
2026-09-28 17:09:47 +02:00
MacRimiandGitHub 82e3791b03 Merge pull request #400 from f3rs3n/fix/oci-rclone-hedgedoc-wording
Clarify Rclone dry-run messages and preserve HedgeDoc description on catalog sync
2026-09-28 17:08:59 +02:00
MacRimiandGitHub 2756af0b33 Merge pull request #399 from f3rs3n/fix/monitor-narrow-report-layout
fix(monitor): prevent narrow-screen overflow in audit and printable reports
2026-09-28 17:07:48 +02:00
MacRimi 4a77d11bc4 fix(monitor): register every OCI service port and read versions from the guest 2026-09-28 17:05:52 +02:00
martino 8e3ef83c19 fix(oci): clarify template refresh and update backup guidance 2026-09-28 16:33:43 +02:00
martino e071fdf4a1 test: raise cancellation sentinel without generator semantics 2026-09-28 16:17:49 +02:00
martino 3e7ebc4be0 fix(oci): clarify Rclone dry runs and preserve HedgeDoc description 2026-09-28 16:15:59 +02:00
martino fa48be17d1 fix(monitor): prevent narrow-screen report overflow 2026-09-28 14:26:58 +02:00
MacRimi 8aade0e55c feat(oci): show a single Verified status for tested applications 2026-09-28 10:26:18 +02:00
MacRimi 115295acb6 fix(oci): reuse the existing HTTP check message 2026-09-28 09:56:29 +02:00
ProxMenuxBot 2a17ea84ac chore(lang): auto-rebuild translation cache
Source: 8e321e6
Triggered by: push
2026-09-28 07:51:23 +00:00
MacRimiandGitHub 8e321e6d72 Merge pull request #397 from f3rs3n/fix/oci-selection-setup-diagnostics
Clarify OCI selection and stack setup diagnostics
2026-09-28 09:49:58 +02:00
martino c026ae59fe fix(oci): clarify selection and stack setup diagnostics 2026-09-28 02:59:47 +02:00
MacRimi a0ff593241 i18n: fix Yes/No labels in German, Italian and Swedish 2026-09-27 23:01:47 +02:00
MacRimiandGitHub dcf0052039 Merge pull request #396 from Vaso73/i18n/oci-manager-slovak-wording
i18n(oci): refine Slovak manager wording
2026-09-27 22:59:06 +02:00
VAIO73 291b657218 i18n(oci): refine Slovak manager wording 2026-09-27 22:30:23 +02:00
MacRimi cdedf3ed24 i18n(es): clarify the managed host firewall rule message 2026-09-27 22:12:46 +02:00
ProxMenuxBot cb7609bb05 chore(lang): auto-rebuild translation cache
Source: 5a4fab8
Triggered by: push
2026-09-27 20:07:32 +00:00
MacRimiandGitHub 5a4fab8abd Merge pull request #395 from Vaso73/fix/oci-firewall-lifecycle
fix(oci): complete host firewall lifecycle follow-ups
2026-09-27 22:06:47 +02:00
VAIO73 f5627afe27 test(i18n): stub OCI firewall cleanup in wording test 2026-09-27 22:04:52 +02:00
VAIO73 ec391246a1 fix(oci): clean up owned host firewall rules 2026-09-27 21:44:47 +02:00
VAIO73 d45da0742d test(oci): run Glances port contract in isolation 2026-09-27 21:44:47 +02:00
ProxMenuxBot b1e11e7666 chore(lang): auto-rebuild translation cache
Source: 52bb982
Triggered by: push
2026-09-27 19:18:59 +00:00
MacRimi 52bb982721 Clarify the host-monitor restore notice 2026-09-27 21:18:15 +02:00
github-actions[bot] 1a9136514d Update AppImage beta build (2026-09-27 19:15:16) 2026-09-27 19:15:16 +00:00
MacRimi 687f446f00 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-09-27 21:05:04 +02:00
MacRimi 0948d7b078 Declare oci_console in the OCI removal wording test 2026-09-27 21:04:54 +02:00
ProxMenuxBot 72d3e6aefe chore(lang): auto-rebuild translation cache
Source: f3c4959
Triggered by: push
2026-09-27 19:03:01 +00:00
MacRimi f3c4959fa4 OCI catalog verification, console start marks and log cleanup 2026-09-27 21:02:16 +02:00
MacRimi 24617f9924 fix(lang): curate the Spanish host-monitor firewall strings 2026-09-27 20:18:18 +02:00
ProxMenuxBot 11b97d1aea chore(lang): auto-rebuild translation cache
Source: c4c654b
Triggered by: push
2026-09-27 18:15:37 +00:00
MacRimiandGitHub c4c654bfc6 Merge pull request #394 from Vaso73/fix/oci-persistence-input-safety
fix(oci): preserve persistent data and caller-owned plans
2026-09-27 20:14:19 +02:00
ProxMenuxBot 73edc51f1a chore(lang): auto-rebuild translation cache
Source: 4d370d3
Triggered by: push
2026-09-27 18:13:58 +00:00
MacRimiandGitHub 4d370d3edc Merge pull request #393 from Vaso73/fix/oci-glances-host-firewall
fix(oci): scope Glances host-monitor firewall access
2026-09-27 20:06:15 +02:00
VAIO73 884817f05c fix(oci): confirm scoped host-monitor firewall access 2026-09-27 13:28:53 +02:00
VAIO73 ca9dbba65b fix(oci): expose only Glances web port 2026-09-27 13:28:53 +02:00
VAIO73 1367bcb7d4 fix(oci): preserve caller-owned install plans 2026-09-27 13:28:39 +02:00
VAIO73 6e7be4c029 fix(oci): preserve PocketBase initial data on first install 2026-09-27 13:28:39 +02:00
MacRimiandGitHub 15692fe031 Merge pull request #391 from Vaso73/i18n/sk-oci-review
i18n: polish Slovak OCI and terminal descriptions
2026-09-26 21:43:48 +02:00
VAIO73 1d445f7064 i18n: polish Slovak OCI and terminal descriptions 2026-09-26 20:21:15 +02:00
MacRimi 8ab72b8bcc fix(lang): curate the OCI diagnostic strings from #389 2026-09-26 18:39:30 +02:00
MacRimiandGitHub e72bfcf8be Merge pull request #390 from f3rs3n/fix/tui-literal-placeholder-replacements
fix(tui): preserve literal text in translated placeholder replacements
2026-09-26 18:33:43 +02:00
ProxMenuxBot 8610f66fe1 chore(lang): auto-rebuild translation cache
Source: 8d9f38e
Triggered by: push
2026-09-26 16:32:20 +00:00
MacRimiandGitHub 8d9f38e7c2 Merge pull request #389 from f3rs3n/fix/oci-diagnostic-wording
Clarify OCI import, adoption, recovery and removal diagnostics
2026-09-26 18:27:57 +02:00
martino 3fa5a379f3 fix(tui): preserve literal translated placeholder replacements 2026-09-26 18:23:07 +02:00
martino c2298a09be ci: install PyYAML for OCI wording regressions 2026-09-26 18:05:44 +02:00
martino 6d53035936 fix(oci): qualify import adoption and recovery diagnostics 2026-09-26 18:04:35 +02:00
github-actions[bot] 9998a937d5 Update AppImage beta build (2026-09-26 15:33:59) 2026-09-26 15:33:59 +00:00
MacRimi b69aeea360 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-09-26 17:20:22 +02:00
MacRimi 02c4206d49 Keep Monitor-started backups running across a Monitor restart 2026-09-26 17:20:09 +02:00
ProxMenuxBot c7b052c424 chore(lang): auto-rebuild translation cache
Source: dd4bcfa
Triggered by: push
2026-09-26 14:59:51 +00:00
MacRimi dd4bcfa867 Fix the script terminal, Arr suite updates and OCI app tracking 2026-09-26 16:57:35 +02:00
MacRimi 93862abdb0 Update cli.py 2026-09-26 08:49:34 +02:00
MacRimi e14a9911fd Stop asking to confirm host directories before an OCI update 2026-09-26 02:21:35 +02:00
github-actions[bot] 1e6b8b1c20 Update AppImage beta build (2026-09-26 00:14:32) 2026-09-26 00:14:32 +00:00
MacRimi 00bc122611 Update a multi-container OCI application from its main container 2026-09-26 02:11:22 +02:00
MacRimi 756851350a Update es.json 2026-09-26 01:32:15 +02:00
MacRimi cb5da6ce76 Update extra_devices.py 2026-09-26 01:28:29 +02:00
ProxMenuxBot 1bf792adce chore(lang): auto-rebuild translation cache
Source: f7266e7
Triggered by: push
2026-09-25 23:27:40 +00:00
MacRimi f7266e7b44 Generalize OCI device setup and remove unused catalog hashes 2026-09-26 01:22:37 +02:00
MacRimi 88acceb8ef fix(installer): surface apt errors and prevent false success messages 2026-09-25 23:05:55 +02:00
MacRimi 14583ad9f6 Back up /var/lib/proxmenux whole in the default host profile 2026-09-25 22:53:35 +02:00
github-actions[bot] 557c34e1f6 Update AppImage beta build (2026-09-25 20:41:24) 2026-09-25 20:41:24 +00:00
MacRimi d1bb843f67 Delete ProxMenux-1.2.6.AppImage 2026-09-25 22:37:22 +02:00
ProxMenuxBot 174f178f5b chore(lang): auto-rebuild translation cache
Source: ace445b
Triggered by: push
2026-09-25 20:31:19 +00:00
MacRimiandGitHub ace445b2dd Merge pull request #388 from MacRimi/beta/1.2.6.2
ProxMenux pre 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
2026-09-25 22:23:58 +02:00
MacRimiandClaude Opus 5.5 c0fa75f404 Keep the Mounts tab in place when an LXC modal opens
- Apply the static mount list as soon as it arrives instead of waiting
  for the df/stat runtime probe.
- Refill a guest's mount seed after a lifecycle event rather than
  leaving it empty until the next modal open.
- Retry the bulk modal cache while the server is still warming guests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:11:23 +02:00
MacRimiandClaude Opus 5.5 53b415b127 Drop catalog snapshot assertion from the TUI upgrade/GPU wording test
The translation workflow fills new keys in the shipped catalogs after
merge, so the fixture no longer asserts that lang/it.json lacks them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:03:47 +02:00
MacRimiandClaude Opus 5.5 91e62b16e0 Fix offline i18n tests: refresh semantics and complete Monitor catalogs
- Web docs test no longer passes the removed refresh argument.
- i18n messages tests assert that --refresh keeps existing values and
  that only keys listed in --refresh-keys are re-translated.
- Add the new Monitor keys to de, fr, it, pt, sk and sv.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:01:22 +02:00
MacRimi e8a7ba6f47 Merge remote-tracking branch 'origin/develop' into beta/1.2.6.2 2026-09-25 21:51:23 +02:00
MacRimiandClaude Opus 5.5 4437a671d2 ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
OCI manager Apps
- App tab: containers installed from an OCI image are identified from their
  installation record; the application and image versions are shown and an
  update is detected by image digest; repository link; Refresh data.
- Updates tab for OCI containers: Update and Recreate run the same flow as the
  OCI menu in the Monitor terminal; the pre-update backup can be kept in a
  backup storage; scheduled image updates with an optional minimum age.
- Logs tab: console output of the application, kept on the host
  (lxc.console.logfile + logrotate) and followed live.
- The Proxmox console opens a shell (cmode: shell) when the image has one.
- A damaged image download is fetched again before failing.
- Multi-container applications open at their LAN address; volume mount
  points on block storage report their usage.

Monitor
- Proxmox notifications are delivered to a loopback-only HTTP listener when
  HTTPS is enabled, so they no longer fail certificate verification.
- Log persistence counts recurring patterns only; an ended burst is not
  reported as persistent and its warning clears on its own (#386).
- Proxmox notification config backups are deduplicated and capped at three.
- The update icon on the Apps page opens the container on its Updates tab.
- Version 1.2.6.2-beta and its release notes in every Monitor language.

Docs
- OCI manager Apps and Audit & Report rebuilt as per-page message files,
  with a new page for OCI containers in the Monitor.
- Seven pages fixed where rich-text tags were missing from t.rich.

Translations
- Spanish fixes across the OCI engine, the Monitor and the TUI menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:51:12 +02:00
ProxMenuxBot 77080367bb chore(lang): auto-rebuild translation cache
Source: d468b54
Triggered by: push
2026-09-25 19:43:54 +00:00
MacRimiandGitHub d468b54226 Merge pull request #384 from f3rs3n/fix/tui-independent-wording-combined
fix(tui): correct network, storage, upgrade, and GPU guidance
2026-09-25 21:39:33 +02:00
MacRimiandGitHub 29fe30e2f4 Merge pull request #385 from Vaso73/fix/lxc-ip-search
fix(monitor): include all LXC IPs in VM search
2026-09-25 21:36:24 +02:00
VAIO73 42bb91868f fix: include all LXC IPs in VM search 2026-09-25 09:36:08 +02:00
martino 583e45f568 fix(tui): correct network, storage, upgrade, and GPU guidance 2026-09-24 22:27:26 +02:00
ProxMenuxBot 386d33df6e chore(lang): auto-rebuild translation cache
Source: 2124b97
Triggered by: push
2026-09-23 20:07:55 +00:00
MacRimiandGitHub 2124b97347 Merge pull request #382 from MacRimi/fix/oci-labels-and-literal-protection
Protect the literals a reader copies, and name the OCI category
2026-09-23 22:06:59 +02:00
MacRimiandGitHub 8333a7ba1f Merge pull request #383 from f3rs3n/ci/qualified-offline-tests
ci: gate qualified offline Python and Node tests separately
2026-09-23 22:02:25 +02:00
MacRimiandClaude Opus 5 32ffa63879 fix(i18n): put back the sk key the previous commit removed by accident
Staging AppImage/messages/sk/common.json for the Slovak wording also
carried an unrelated deletion that was in the working tree at the time.
The offline suite caught it: with the key gone the generator had work to
do on a clean checkout, and writes are forbidden there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 21:57:47 +02:00
martino 4b24ab9681 ci: gate qualified offline Python and Node tests separately 2026-09-23 18:46:47 +02:00
MacRimiandClaude Opus 5 72a38dbf38 fix(i18n): drop the string that named the removed script, take @Vaso73's Slovak
The argos run read a copy of lang/ from before #379, so the key naming
proxmenux-oci.sh came back with it — in all seven locales, pointing at a
file that no longer exists. Removed.

The Monitor category takes the Slovak @Vaso73 supplied: Správy a
upozornenia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:17:16 +02:00
MacRimiandClaude Opus 5 fe46d3d4bc fix(i18n): protect the literals a reader copies, and name the OCI category
The glossary only holds what someone listed, and what reaches the reader
as a broken command was never on it. Paths, long options and Proxmox
subcommands are now recognised by shape:

    /dev/apex_0        came back as  /dev/apex 0
    --auto-uninstall                 desinstalación automática
    pct config 110                   configuration PCT 110   (fr)
    pct enter 101                    pct inserire 101        (it)

OCI joins the glossary as well. Read as a word it became "BEC" in French
across thirty-one strings — "Vérification du BEC" named nothing — and the
menu entry read "Gestionnaire du BEC Apps". The entry itself and "beta"
are protected too, so the name stays the same in every language.

A provider can also alter a token rather than carry it through: argos
returned MPXTERM000 for PMXTERM000, the restore found nothing, and the
token shipped. "MPXTERM000 configuré" is in the French catalogue today.
The restore now verifies its own tokens and fails the string instead,
which leaves the key absent for the next run to retry.

Spanish category labels: twenty-six were wrong. Seven collapsed two
categories into one — "Backup & Recovery" read only "Recuperación",
"Network & Firewall" read "Red de cortafuegos" — six were half
translated, and the connector alternated between "&", "y" and "e". They
now use "y" or "e" throughout. "IoT & Smart Home" and
"AI / Coding & Dev-Tools" stay in English by choice.

"Messaging & Queues" becomes "Messaging & Notifications": its only
application is Apprise, a notification gateway, not a queue. The
Helper-Scripts import still maps their name, so nothing breaks upstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 17:49:59 +02:00
MacRimiandGitHub 4f79144c5a Merge pull request #381 from f3rs3n/test/offline-audit-backup-fixtures
test: align Audit comparison expectations and backup i18n fixtures
2026-09-23 17:04:17 +02:00
MacRimiandGitHub 11d1d125dc Merge pull request #380 from f3rs3n/ci/scheduler-multilingual-coverage
test: exercise every shipped scheduler locale and cover its CI inputs
2026-09-23 17:04:14 +02:00
martino 6cf3f4a520 test: align Audit comparison expectations and backup i18n fixtures 2026-09-23 10:42:15 +02:00
martino 8297a5c935 test: exercise every shipped scheduler locale and cover its CI inputs 2026-09-23 03:16:44 +02:00
ProxMenuxBot dee18df711 chore(lang): auto-rebuild translation cache
Source: d415c55
Triggered by: push
2026-09-22 21:26:18 +00:00
MacRimiandGitHub d415c55187 Merge pull request #379 from MacRimi/remove/oci-shell-entrypoint
Remove the OCI shell entry point nothing runs
2026-09-22 23:25:13 +02:00
MacRimiandClaude Opus 5 f3bda28338 refactor(oci): remove the shell entry point nothing runs
`oci/proxmenux-oci.sh` was installed on every Proxmox host and used on
none of them. The menu entry goes straight to the orchestrator:
scripts/oci/oci_manager_apps.sh runs `python3 -m proxmenux_oci` with
PYTHONPATH pointing at the engine.

Its only remaining caller was a message telling the reader to open it on
the Proxmox host — which is not how anyone gets in, and is what sent one
there to run it. That message now names the menu entry.

It could not be fetched and run either: it needs requirements.txt and
src/ beside it, so `wget | bash` resolved its own directory to the
working directory and failed on a path nobody chose. Making that work
would mean writing a second installer next to the one that already
ships the engine.

What it did offer was a virtualenv for a contributor generating the
catalog. The README now gives the direct invocation and names the two
distribution packages it needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:22:51 +02:00
MacRimiandGitHub 3bda7851a1 Merge pull request #378 from MacRimi/fix/oci-entrypoint-guard
Say where the OCI converter entry point has to run
2026-09-22 23:17:19 +02:00
MacRimiandClaude Opus 5 41b999ffeb fix(oci): say where the converter entry point has to run
Fetched with wget and piped to bash, $0 is "bash" and the script resolves
its own directory to wherever the shell happens to be. It then reports
"shasum: /root/requirements.txt: No such file or directory", which names
a path the reader never chose and says nothing about the real problem.

It now checks for the files it needs and names both places it runs from:
the installed engine directory on a Proxmox host, or oci/ in a clone.

PACKAGE-CONTENTS.md called it the "installer entry point", which is what
invites fetching it over the network in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:13:28 +02:00
MacRimiandGitHub 13ad61e8b0 Merge pull request #377 from MacRimi/fix/tui-catalog-damage
Repair strings the translator damaged in the TUI catalogue
2026-09-22 22:14:17 +02:00
MacRimiandClaude Opus 5 7e39aa550c fix(i18n): repair strings the translator damaged in the TUI catalogue
Three faults, all user-visible in the released catalogue.

The context prompt survived into 13 translations. A reader of the
Italian TUI was told "Messaggio tecnico per Proxmox e IT.Traduzione:
impossibile aggiornare initramfs." — the instruction given to the
provider, printed as if it were the message. One in French, eight in
Italian, four in Portuguese. The text after the prefix is correct, so
only the prefix goes.

Fifteen strings carried HTML entities where the source had a plain
character: "Adblock &quot; DNS", "Affari &amp; ERP".

Three product names had been translated as words — "Vaultwarden Courbe
Web" in French, where courbe means curve — and now keep their published
spelling. Two German strings said Kunden, a commercial customer, for an
OAuth client. Existing translations of the category labels and the
descriptive taglines are left as they are.

Twelve strings had no value in some locale and were retried on every
run, about thirty-five minutes a time. A fresh extraction now reports
nothing pending in any of the seven.

The Monitor catalogues are clean: they send no context prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 22:08:34 +02:00
MacRimiandGitHub 852f14e5b4 Merge pull request #376 from MacRimi/fix/tui-placeholder-protection
Keep {placeholders} out of the translator in the TUI cache
2026-09-22 21:54:09 +02:00
MacRimiandClaude Opus 5 75f571ecdb i18n(sk): match the wording already used for the sibling message
Every other Slovak string renders "Completed" as "Dokončené", including
the device message these two sit beside. They came back with "Hotovo" and
"k" instead of "do", which reads as a different hand in a list of four
lines that differ only by the noun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:48:42 +02:00
MacRimiandClaude Opus 5 eafc472279 fix(i18n): keep {placeholders} out of the translator in the TUI cache
The Monitor's generator protects them; this one did not, so a provider
read the word inside the braces and translated it. "{count}" came back
as "{conta}" in Portuguese and "{počet}" in Slovak, and the consumer —
finding no placeholder to substitute — fell back to English, which is
every message the recent batches introduced.

The token carries no underscores: argos splits on them and hands back
"PMX PH 0".

Two further fixes this depends on:

- protect_catalog_titles rebuilt the glossary pattern without the word
  boundaries the module-level one has, so a short term matched inside
  longer words once the catalogue was loaded.
- CT and VM join the glossary. Read as initials they get reordered —
  "CT" comes back as "TC" in Spanish — naming something Proxmox does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:45:54 +02:00
MacRimiandGitHub f8fade3c0a Merge pull request #375 from f3rs3n/fix/disk-passthrough-completion-messages
fix(storage): translate disk passthrough completion messages as a whole
2026-09-22 21:23:52 +02:00
MacRimiandGitHub 2a8f220462 Merge pull request #374 from MacRimi/fix/audit-backup-wording
State what the backup checks observed, not more
2026-09-22 21:23:03 +02:00
MacRimiandClaude Opus 5 44db612d01 i18n(sk): use the Slovak wording supplied by @Vaso73
The regenerated Slovak read as machine output. These carry the same
evidence boundary as the English and name VM and LXC the way a Slovak
reader expects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 20:17:54 +02:00
ProxMenuxBot 9c9bc7d15b chore(lang): auto-rebuild translation cache
Source: 24a2557
Triggered by: push
2026-09-22 18:04:01 +00:00
MacRimiandClaude Opus 5 5f09af0162 fix(audit): state what the backup checks observed, not more
Two next steps in the backup area claimed more than the evidence behind
them supports, reported by @Vaso73 while reviewing the pattern.

A failed run is now treated as unsuccessful with a copy to confirm,
rather than asserted to have produced nothing usable — the task log does
not establish that. And a missing host-configuration record says no
backup is recorded here, leaving room for one this node cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:59:39 +02:00
martino 4322ae517b fix(storage): translate disk passthrough completion messages as a whole 2026-09-22 19:59:31 +02:00
MacRimiandGitHub f3a2b70219 Merge pull request #373 from MacRimi/fix/oci-entrypoint-messages
Write the OCI converter entry point messages in English
2026-09-22 19:40:51 +02:00
MacRimiandClaude Opus 5 1858fd5c60 fix(oci): write the converter entry point messages in English
The four setup messages were in Spanish and one told the reader Python 3
was needed "en el Mac", which is the wrong machine for a script the
README documents as running on the Proxmox host.

They stay outside translate(): this script runs before the Python
environment it is preparing exists, so no catalogue is available yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:29:06 +02:00
ProxMenuxBot 3f3e3f05d9 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 6fc893d
Triggered by: push
2026-09-22 17:28:25 +00:00
ProxMenuxBot f6bc0ea969 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ce60060
Triggered by: push
2026-09-22 17:25:47 +00:00
MacRimiandGitHub 6fc893d3f4 Merge pull request #372 from f3rs3n/fix/storage-temperature-summary
Distinguish unavailable disk temperatures in the storage summary
2026-09-22 19:24:43 +02:00
MacRimiandGitHub ce6006094e Merge pull request #369 from f3rs3n/fix/borg-ssh-guidance
Clarify Borg SSH account and key setup guidance
2026-09-22 19:23:55 +02:00
MacRimiandClaude Opus 5 ee12d421f0 Merge develop: combine the i18n test fixture with #367
Both batches seed the same block for their English-only additions. One
read and one write now cover the PBS key guidance and the Borg SSH keys
together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:23:10 +02:00
ProxMenuxBot 4594177035 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 60505a7
Triggered by: push
2026-09-22 17:23:06 +00:00
martino b06e0e8d13 fix: honor idle and excluded temperature availability 2026-09-22 19:22:40 +02:00
martino 7ad3e629c8 fix: distinguish unavailable disk temperatures in summary 2026-09-22 19:22:40 +02:00
MacRimiandGitHub 60505a74a9 Merge pull request #367 from f3rs3n/fix/pbs-key-guidance
Clarify PBS encryption key guidance and PVE key locations
2026-09-22 19:22:03 +02:00
MacRimiandGitHub 246393d3d6 Merge pull request #371 from f3rs3n/fix/samba-guest-outcome-messages
Describe Samba guest listing and access-test outcomes accurately
2026-09-22 19:21:49 +02:00
MacRimiandGitHub e6d3f2e3cb Merge pull request #370 from f3rs3n/fix/vm-storage-selection-messages
Clarify VM storage selection and fix translation lookups
2026-09-22 19:21:45 +02:00
MacRimiandGitHub 24a2557d8e Merge pull request #368 from f3rs3n/fix/scheduler-empty-messages
Use complete messages for empty scheduler job lists
2026-09-22 19:21:41 +02:00
ProxMenuxBot 90a925f88c chore(lang): auto-rebuild translation cache
Source: 9769988
Triggered by: push
2026-09-22 17:13:03 +00:00
martino 56d51dd461 fix(monitor): clarify BORG key guidance 2026-09-22 19:00:29 +02:00
martino 65cb75a6b7 fix(monitor): clarify PBS key guidance 2026-09-22 19:00:29 +02:00
martino c2f7bb4efc fix(samba): distinguish guest listing and access outcomes 2026-09-22 19:00:05 +02:00
martino eb13ca620e fix(i18n): clarify VM storage selection messages 2026-09-22 19:00:04 +02:00
martino 14f0831b05 fix: translate complete scheduler empty-job messages 2026-09-22 18:59:42 +02:00
MacRimiandGitHub 976998806c Merge pull request #366 from MacRimi/oci-manager
OCI manager: run official container images as native LXC containers
2026-09-22 18:32:36 +02:00
MacRimiandClaude Opus 5 dda8deb6e4 fix(i18n): complete the Monitor catalogs so a rerun writes nothing
The 41 keys added for the audit explanations, disk exclusions and idle
disk state existed only in English, so build_i18n_messages had work to do
on a clean checkout and the offline suite rejected it.

Fills them in the six remaining locales with argos, which joins the
provider list here as it already had in build_translation_cache: it runs
locally with no quota, and this script stores the English on a provider
failure, where a remote quota refusal would have been recorded as a
translation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:29:23 +02:00
MacRimiandClaude Opus 5 bcabcb618c feat(oci): run official container images as native LXC containers
Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:24:59 +02:00
ProxMenuxBot b36498f215 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 94c3da0
Triggered by: push
2026-09-22 14:58:47 +00:00
ProxMenuxBot 4f1d70775f chore(lang): auto-rebuild translation cache
Source: 94c3da0
Triggered by: push
2026-09-22 14:57:53 +00:00
MacRimiandGitHub 94c3da003d Merge pull request #365 from f3rs3n/storage-message-batch
fix(storage): qualify SMART status and report explanations
2026-09-22 16:56:42 +02:00
MacRimiandClaude Opus 5 2d25585faf Merge develop: combine the i18n test fixture with #363
Both message batches seeded locale copies at the same point of
test_command_descriptions.py. The two blocks are independent — one
seeds the backup messages, the other the storage ones — so they are
merged into a single block that reads and writes the fixture once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:46:04 +02:00
ProxMenuxBot f63362b460 chore(lang): auto-rebuild translation cache
Source: a6ca3d4
Triggered by: push
2026-09-22 14:41:17 +00:00
ProxMenuxBot 4d4828e7c8 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 3b18e3e
Triggered by: push
2026-09-22 14:40:45 +00:00
MacRimiandGitHub a6ca3d4222 Merge pull request #364 from f3rs3n/fix/tui-count-messages
fix(tui): use complete messages for device, path and package counts
2026-09-22 16:39:57 +02:00
MacRimiandGitHub 3b18e3e664 Merge pull request #363 from f3rs3n/fix/backup-message-batch
fix(backup): use complete messages for archives and destinations
2026-09-22 16:39:22 +02:00
martino 402278ecfd fix(storage): qualify SMART status and report explanations 2026-09-21 21:54:04 +02:00
martino db8cc2267a fix(tui): use complete messages for device, path and package counts 2026-09-21 20:37:01 +02:00
martino 6a7ffad9b8 fix(backup): use complete messages for archives and destinations 2026-09-21 19:49:43 +02:00
MacRimiandGitHub 5dffb5cb88 Merge pull request #362 from Vaso73/i18n/sk-terminal-description-alignment
i18n: align Slovak terminal command descriptions
2026-09-21 17:58:15 +02:00
VAIO73 22c569dc41 i18n: align Slovak terminal command descriptions 2026-09-21 17:31:13 +02:00
ProxMenuxBot 704eb3f9fb chore(lang): auto-rebuild translation cache
Source: e318914
Triggered by: push
2026-09-21 08:51:05 +00:00
MacRimiandGitHub bc736926c5 Merge pull request #356 from f3rs3n/fix/backup-recovered-guest-count
fix: count distinct guests in backup recovery summary
2026-09-21 10:51:04 +02:00
MacRimiandGitHub e318914d01 Merge pull request #355 from f3rs3n/fix/fastfetch-config-generation
fix: restore Fastfetch system modules on current releases
2026-09-21 10:50:11 +02:00
MacRimiandGitHub 32355ded88 Merge pull request #354 from f3rs3n/fix/terminal-command-descriptions
fix: align terminal command descriptions with their commands
2026-09-21 10:49:14 +02:00
martino 6cd2b516b1 fix: count distinct guests in backup recovery summary 2026-09-19 17:58:39 +02:00
martino f5e4427903 fix: restore Fastfetch config generation 2026-09-19 12:05:10 +02:00
martino e19927af48 fix: align terminal command descriptions with their commands 2026-09-19 11:41:01 +02:00
MacRimi 44e91c4710 orrect the machine-translated notification catalogs 2026-09-18 21:13:18 +02:00
ProxMenuxBot f6e3738c1e chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: d5bce37
Triggered by: workflow_dispatch
2026-09-18 18:07:38 +00:00
MacRimi d5bce37ed2 Leave untranslated notification keys for the i18n bot to fill 2026-09-18 18:55:14 +02:00
MacRimiandGitHub d8b1716c0a Merge pull request #352 from f3rs3n/fix/audit-safety-wording
fix: clarify audit side effects and boot-check scope
2026-09-18 18:47:23 +02:00
MacRimiandGitHub daf711328b Merge pull request #351 from f3rs3n/feature/f05-i18n-arrays
fix: preserve i18n arrays and restore command descriptions
2026-09-18 18:46:15 +02:00
MacRimiandGitHub a727cf0118 Merge pull request #348 from Vaso73/fix/notification-runtime-i18n
i18n: localize runtime notification delivery
2026-09-18 18:42:37 +02:00
ProxMenuxBot 86d6ae4dec chore(lang): auto-rebuild translation cache
Source: 702c774
Triggered by: push
2026-09-18 16:40:32 +00:00
MacRimiandGitHub 702c774d31 Merge pull request #349 from f3rs3n/fix/pve8-to-9-apt-safety
fix: preserve APT diagnostics and enforce PVE upgrade safety checks
2026-09-18 18:39:30 +02:00
martino 60f71c0c8a fix: clarify audit side effects and boot-check scope 2026-09-17 18:23:56 +02:00
martino 12684f483d fix: preserve i18n arrays and restore command descriptions 2026-09-17 17:33:32 +02:00
martino c7f58079b9 fix: preserve APT diagnostics and enforce PVE upgrade safety checks 2026-09-17 16:20:18 +02:00
VAIO73 603fb0e03a fix: deduplicate repeated LXC digest results 2026-09-17 14:48:52 +02:00
VAIO73 63e72abb27 fix: deduplicate identical digest events 2026-09-17 14:26:18 +02:00
VAIO73 40cfe78aa7 fix: add icons to update notification details 2026-09-17 14:10:22 +02:00
VAIO73 1151928b3e fix: honor future digest schedule changes 2026-09-17 13:47:24 +02:00
VAIO73 10a69947dc i18n: localize health degradation alerts 2026-09-17 13:31:58 +02:00
VAIO73 2f8197eb97 fix: add icons to notification digests 2026-09-17 13:23:37 +02:00
VAIO73 82c3a49f4c fix: localize empty important package lists 2026-09-17 13:17:08 +02:00
VAIO73 661b944e0c fix: localize batched app update notifications 2026-09-17 13:12:02 +02:00
VAIO73 d61719dcaf fix: preserve notification icons across locales 2026-09-17 12:57:46 +02:00
VAIO73 25b71efab0 fix: use display name in runtime notifications 2026-09-17 12:49:41 +02:00
VAIO73 ad23a1167f i18n: localize LXC update notification results 2026-09-17 12:40:54 +02:00
VAIO73 dc283bed9a i18n: localize runtime notification delivery 2026-09-17 11:59:48 +02:00
Vaso73andVAIO73 da7bf37c07 fix: enforce Telegram chunk limits 2026-09-17 11:46:29 +02:00
Vaso73andVAIO73 38ac46460e fix: add notification language UI labels 2026-09-17 11:46:29 +02:00
Vaso73andVAIO73 f11aba3492 Fix notification language and event group parity 2026-09-17 11:45:48 +02:00
Vaso73andVAIO73 376b9504f8 Fix runtime language migration and email localization 2026-09-17 11:45:38 +02:00
Vaso73andVAIO73 584c4c31a3 Localize runtime notifications in Slovak 2026-09-17 11:45:10 +02:00
Vaso73andVAIO73 ca163cd2bb test: cover runtime notification localization on v1.2.6 2026-09-17 11:44:49 +02:00
Vaso73andVAIO73 7587b2ad6a Preserve localized backup status with vzdump inventory 2026-09-17 11:44:49 +02:00
Vaso73andVAIO73 c83c68b4ee Preserve vzdump inventory when AI is enabled 2026-09-17 11:44:49 +02:00
Vaso73andVAIO73 49cb8133ed test: cover backup notification integrity on v1.2.6 2026-09-17 11:44:49 +02:00
ProxMenuxBot dede876f0b chore(lang): auto-rebuild translation cache
Source: 256f323
Triggered by: push
2026-09-16 21:25:52 +00:00
MacRimi 256f3232e4 Offer only NVIDIA drivers that can drive the GPU, and prove it before removing the working one 2026-09-16 23:19:39 +02:00
MacRimi 78971f4ed7 New version 1.2.6.1-beta 2026-09-14 18:34:53 +02:00
github-actions[bot] c89c19deb2 Update AppImage beta build (2026-09-14 16:26:25) 2026-09-14 16:26:25 +00:00
MacRimi 2308b4ddc1 Translate the network interface counters as statistics, not sentences 2026-09-12 18:39:10 +02:00
MacRimi cc7b38a141 Name host and cluster correctly in every language 2026-09-12 12:01:25 +02:00
MacRimiandGitHub dc05b1125b Merge pull request #345 from Vaso73/i18n/sk-monitor-copy-polish
i18n: refine Slovak copy across Monitor pages
2026-09-12 11:52:53 +02:00
VAIO73 36bca5908b i18n: upravený sprievodca systémovými záznamami 2026-09-12 11:36:49 +02:00
VAIO73 cbc5b9d953 i18n: upravené systémové záznamy 2026-09-12 11:36:49 +02:00
VAIO73 e61fdbf84f i18n: upravené texty karty Sieť 2026-09-12 11:36:49 +02:00
VAIO73 e67d5f1617 i18n: upravené texty úložiska 2026-09-12 11:36:49 +02:00
VAIO73 991cf732e5 i18n: upravené texty na Prehľade 2026-09-12 11:36:49 +02:00
VAIO73 8918675393 i18n: lokalizované kategórie aplikácií 2026-09-12 11:36:49 +02:00
VAIO73 e0c5740a47 i18n: zjednotené VM a LXC v Aplikáciách 2026-09-12 11:36:49 +02:00
VAIO73 9ff1fa1bda i18n: spresnené uzly v Termináli 2026-09-12 11:36:49 +02:00
VAIO73 7b06c90040 i18n: zjednotené pojmy na karte Záloha 2026-09-12 11:36:49 +02:00
VAIO73 f6a3dc2f24 i18n: zjednotené pojmy na karte Hardvér 2026-09-12 11:36:49 +02:00
VAIO73 9bdd0cb9fc i18n: zjednotené pojmy v Nastaveniach 2026-09-12 11:36:49 +02:00
VAIO73 6f7e94d095 i18n: zjednotené pojmy na karte Bezpečnosť 2026-09-12 11:36:49 +02:00
VAIO73 8c67f883c9 i18n: zjednotené pojmy na karte VM a LXC 2026-09-12 11:36:49 +02:00
MacRimiandGitHub 9d3285d0df Merge pull request #344 from Vaso73/i18n/sk-audit-report-pr
i18n: add Slovak Audit & Report copy
2026-09-12 11:05:49 +02:00
MacRimi b5fb747271 Section icons in the change journal, and admin scope on secret reveal 2026-09-12 10:54:36 +02:00
VAIO73 1e6efe37a0 i18n: doplnené chýbajúce kľúče záloh 2026-09-12 10:22:45 +02:00
VAIO73 abef0c0303 i18n: zjednotené označenie kontroly auditu 2026-09-12 10:18:25 +02:00
VAIO73 bd6d1e323e i18n: zjednotené stavy auditu 2026-09-12 10:18:25 +02:00
VAIO73 9db1696d5a i18n: zjednotené názvy v úvodných novinkách 2026-09-12 10:18:25 +02:00
VAIO73 c6cb14c651 i18n: rozlíšené VM a LXC v audite 2026-09-12 10:18:25 +02:00
VAIO73 57b77eb212 i18n: spresnené pravidlá auditu 2026-09-12 10:18:25 +02:00
VAIO73 8d843c90e9 i18n: prirodzenejšie úvodné novinky 1.2.6.1 2026-09-12 10:18:25 +02:00
VAIO73 9c3f5feda1 i18n: prirodzenejšie texty auditu 2026-09-12 10:18:25 +02:00
VAIO73 e4f78a1227 i18n: preložené úvodné novinky 1.2.6.1 2026-09-12 10:18:25 +02:00
VAIO73 983bf5c610 i18n: preložená záložka Audit a správy 2026-09-12 10:18:25 +02:00
MacRimi 78d2d84f20 Read the login-page version from APP_VERSION 2026-09-12 09:53:33 +02:00
github-actions[bot] 0504fcd41d Update AppImage beta build (2026-09-11 23:02:43) 2026-09-11 23:02:43 +00:00
MacRimi 0b64549230 Focus the Audit & Report tab, scope API tokens 2026-09-12 00:22:14 +02:00
github-actions[bot] f12ca3ed27 Update AppImage beta build (2026-09-11 19:12:00) 2026-09-11 19:12:00 +00:00
MacRimi 2a672889bf Record GPU/TPU host changes in the change journal
Seven gpu_tpu scripts now write through the journal for host changes only — driver and package installs, vfio binding, modprobe.d, /etc/modules, GRUB and udev — while VM and LXC configuration stays out; add_gpu_vm and install_coral_lxc journal only their host writes. amd_gpu_tools records its install, and pmx_journal.sh gains pmx_record_uninstall while changes_journal.py retires an installed package when ProxMenux removes it. What each function writes is byte-identical to before, verified by tests/journal/verify_journal_migration.py.
2026-09-11 21:08:35 +02:00
github-actions[bot] 9c930de186 Update AppImage beta build (2026-09-11 17:59:18) 2026-09-11 17:59:18 +00:00
MacRimi bee242afa9 record Monitor-side installs and dedupe config by file 2026-09-11 19:54:32 +02:00
github-actions[bot] e2dadebda1 Update AppImage beta build (2026-09-11 17:35:28) 2026-09-11 17:35:28 +00:00
MacRimi 1830852901 attribute post-install helpers to their feature and dedupe files 2026-09-11 19:33:23 +02:00
MacRimi 5d73cd4aec Update utils.sh 2026-09-11 19:12:56 +02:00
github-actions[bot] b5b128264d Update AppImage beta build (2026-09-11 17:04:05) 2026-09-11 17:04:06 +00:00
MacRimi 08810d4b16 Update changes_journal.py 2026-09-11 18:45:46 +02:00
MacRimi 6b8bb97922 Update utils.sh 2026-09-11 09:17:36 +02:00
github-actions[bot] a61ad04ebe Update AppImage beta build (2026-09-11 06:52:29) 2026-09-11 06:52:29 +00:00
MacRimi 91c453d33c Update audit-changes.tsx 2026-09-11 08:50:03 +02:00
github-actions[bot] 22defbfc7d Update AppImage beta build (2026-09-11 06:40:30) 2026-09-11 06:40:30 +00:00
ProxMenuxBot 0284a7e7da chore(lang): auto-rebuild translation cache
Source: 8e0d4ff
Triggered by: push
2026-09-11 06:38:18 +00:00
MacRimi 8e0d4ff337 eep only package-changing executions and clean up entry presentation 2026-09-11 08:37:23 +02:00
github-actions[bot] ca7c9ec58e Update AppImage beta build (2026-09-11 05:52:37) 2026-09-11 05:52:37 +00:00
MacRimi b68105e9f0 Update audit-changes.tsx 2026-09-10 15:31:53 +02:00
github-actions[bot] 7a45ac2de3 Update AppImage beta build (2026-09-10 13:24:45) 2026-09-10 13:24:45 +00:00
MacRimi 085cbf7e68 scope the change journal to host changes, in three sections
The change journal exists so a sysadmin sees what ProxMenux changed on the host — its own configuration, packages and services — not how it uses the host or configures a guest. Several scripts recorded operations that are neither: disk passthrough to a VM, container conversions, VM import/export, mounting a share into an LXC. Those are restored to their original, uninstrumented form. Scripts that operate on the host while also installing a package now record only the package: format-disk keeps its exFAT-tools install, the UUP ISO builder its build dependencies, and the share/host scripts their packages, services and /etc/fstab writes, while the mount and unmount operations they used to log are dropped.

The page now reads in three sections: what ProxMenux optimized after install (each function under its menu name), what its other host scripts changed (by script), and what it installed (packages and utilities, each referencing the script that installed it). A file reads as created or modified with its diff, a service shows its state transition, and the undo line appears only when a revert is possible.
2026-09-10 15:20:39 +02:00
github-actions[bot] d99006bb3c Update AppImage beta build (2026-09-09 17:37:09) 2026-09-09 17:37:09 +00:00
MacRimi 4915d4044c tolerate flaky third-party apt repo in AppImage build workflows 2026-09-09 19:34:40 +02:00
MacRimi 90c4a720e3 group host changes by function with a truthful before/after 2026-09-09 19:26:22 +02:00
github-actions[bot] 36071e6bd3 Update AppImage beta build (2026-09-09 16:10:34) 2026-09-09 16:10:34 +00:00
MacRimi 715f4195b2 require full_admin scope for terminal tickets and auth disable 2026-09-09 17:19:48 +02:00
MacRimi 023350d7e1 apply sentence spacing to every translation provider 2026-09-09 09:03:09 +02:00
ProxMenuxBot c6fde98a80 chore(lang): auto-rebuild translation cache
Source: 53c27f8
Triggered by: push
2026-09-09 06:56:26 +00:00
MacRimiandGitHub 53c27f89dc Merge pull request #340 from f3rs3n/fix/protect-stacked-system-disks
fix(storage): strengthen system-disk protection before formatting
2026-09-09 08:54:02 +02:00
github-actions[bot] 5ec5e11dae Update AppImage beta build (2026-09-09 06:01:22) 2026-09-09 06:01:22 +00:00
MacRimi fb6c9bf22e capitalize audit socket message in Spanish 2026-09-08 21:12:55 +02:00
ProxMenuxBot 68348e0558 chore(lang): auto-rebuild translation cache
Source: da8a480
Triggered by: push
2026-09-08 19:07:06 +00:00
MacRimiandClaude Opus 5 da8a480eff Add audit and reports page, and a change journal
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer.

The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded.

The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:06:04 +02:00
ProxMenuxBot ae75508eff chore(lang): auto-rebuild translation cache
Source: 1c44842
Triggered by: push
2026-09-08 18:41:22 +00:00
ProxMenuxBot fe9afd90c4 chore(lang): auto-rebuild translation cache
Source: 34041b8
Triggered by: push
2026-09-08 18:39:33 +00:00
MacRimiandGitHub 1c4484266d Merge pull request #341 from f3rs3n/fix/validate-pending-restore
fix(backup): reject incomplete pending restore staging
2026-09-08 20:38:54 +02:00
MacRimiandGitHub 34041b87c6 Merge pull request #339 from f3rs3n/fix/preserve-partition-tables
fix(storage): preserve partition tables when removing signatures
2026-09-08 20:38:26 +02:00
martino cc2983a398 fix(backup): reject incomplete pending restore staging 2026-09-07 12:59:57 +02:00
martino fa4e8fd087 fix(storage): strengthen system-disk protection before formatting 2026-09-07 11:54:28 +02:00
martino 1b592317a1 fix(storage): preserve partition tables when clearing signatures 2026-09-06 23:24:18 +02:00
MacRimiandGitHub b4e34d0902 Merge pull request #338 from MacRimi/fix/pr337-docker-update-hardening
Fix Docker app version tracking, cache consistency and delegated updates
2026-09-05 17:02:08 +02:00
MacRimi 337cb188c3 Fix Docker app version tracking, cache consistency and delegated updates 2026-09-05 17:01:02 +02:00
ProxMenuxBot f1d8b299db chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 3a49648
Triggered by: push
2026-09-05 14:56:42 +00:00
MacRimiandGitHub 3a49648f0d Merge pull request #337 from byGarcia/feature/docker-remote-image-version
Report the version a Docker image would install, and let the apps inside it be registered
2026-09-05 16:55:07 +02:00
MacRimi 19c46a86d7 Merge develop into PR #337 and resolve shared app cache conflict 2026-09-05 16:51:41 +02:00
MacRimi 6644b62f63 Improve LXC updater selection, error handling and dashboard consistency 2026-09-05 16:10:31 +02:00
byGarcia de72e18a77 feat(docker): name containerised applications and the versions they run
An LXC running its workload in Docker could not answer two questions it
already had the data for: which application is in there, and whether a
newer version exists.

**Which version is available.** The Updates tab resolved that number only
for docker.io, and only when a version tag happened to share the digest of
the tag in use. On ghcr.io, lscr.io or quay.io the row said "New image
available" with no number at all. The image a pull would install carries
its own version label, so it is now read from the registry by digest —
over the same protocol and Bearer challenge the digest comparison already
uses, and through the same label lookup the installed version uses, now
shared as _docker_version_from_labels.

The question this answers is the one the tab asks: what do I get if I
re-pull this tag. Not "what is the newest upstream release", which is a
different number whenever a tag is pinned or the publisher tags releases
differently from images.

Docker Hub keeps priority on docker.io: its tag API is not a pull and does
not spend the anonymous pull-rate budget, and official images carry no
labels for the registry path to read. The digest still decides whether an
update exists; this only names it, and declines to name it when the answer
would be a guess — no build for this platform, no labels, an unreadable
manifest, a moving tag, a rebuild of the same version, or two sides whose
versions came from different label keys. Each refusal is recorded in
available_version_source.

Attestation manifests are skipped explicitly: they advertise
unknown/unknown and their config blob is a provenance document, not an
image. Every document is fetched by digest and verified against it, the
config read is bounded, and it is cached per digest, which never changes
content. The CDN redirect is followed by hand, dropping Authorization:
urllib re-sends it to the redirect target and signed-URL storage rejects a
second auth mechanism.

**Which application it is.** The probe already read "1.37.2" out of a
Vaultwarden container and get_suggestions discarded it, so the panel
answered "No new applications were detected" about an application whose
version it had just measured. Containerised applications are now offered
for registration like any other, with their name, logo, published ports
and installed version.

What they do not get is an update path of their own, because they do not
have one: updating Vaultwarden means pulling and recreating its image. A
new update_via=docker marker records that delegation, so one release stays
one badge, one notification and one button. The marker is validated rather
than inferred from installed_via, since docker_exec with an upstream is a
legitimate registration someone may already rely on; combining it with an
upstream is rejected instead of silently stripped, because registering an
app that checks GitHub behind a delegation promising it will not is worse
than an error message.

Three failure modes the delegation had to be defended against: detector
auto-healing would have migrated the app onto a leftover /root/.<app>
marker and quietly un-delegated it; saving replaces the whole record, so
the editor carries the marker explicitly rather than dropping it on the
first port edit; and the release-age hold gates on a publish date a
delegated app never has, which deferred the whole schedule forever.

Their version is resolved server-side through the container the detector
declares — not through the app's name or image, since Immich's compose
service and image are both immich-server while the application is immich.
The annotation happens on the way out of both endpoints rather than into
their caches: the App tab's cache is invalidated by events, not by time,
and the Docker inventory it reads is built asynchronously, so annotating
before storing froze a response taken before the first scan.

The rows carry that name too. display_name was already computed and
already used by the bulk-update section; the image row, the update
notification and the CT badge now use it as well. A delegated app's
pending update counts in the badge only while its image is not already
being counted, so registering just the application does not leave the
container looking up to date, and registering both does not count twice.

Catalog: four detectors verified on real containers, following the rules
in the file. vaultwarden and immich gain docker fallbacks for installs
where the native marker does not exist. netalertx is new — note its
repository is netalertx/NetAlertX; the Docker Hub namespace 404s.
technitiumdns is new and uses Technitium's own update endpoint rather than
GitHub releases: its marker reads 15.4 while the release tag is v15.4.0,
and _version_tuple compares (15,4,0) > (15,4) as an update that would
never clear.

Verified live against ghcr.io (Immich 3.1.0), docker.io (Vaultwarden
1.37.2) and lscr.io (Radarr 6.3.0.10514-ls314), plus postgres:16, which
correctly reports no version because official images carry no labels.
Exercised end to end on Proxmox VE 9.2.4 with NetAlertX reporting
26.6.3 -> 26.9.0. 31 new unit tests cover the resolution rules, every
refusal, the delegation contract and the container-to-image pairing.
2026-09-04 13:16:28 +02:00
MacRimi 4f38d0e2e7 notification delivery gaps and locale corrections 2026-09-04 11:38:44 +02:00
ProxMenuxBot ab5767fe94 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ce6af76
Triggered by: push
2026-09-04 09:28:30 +00:00
MacRimiandGitHub ce6af76cce Merge pull request #336 from Vaso73/fix/lxc-mount-target-normalization
fix: recognize LXC mount targets with trailing slashes
2026-09-04 11:27:35 +02:00
VAIO73 7ae43310f4 fix: recognize LXC mount targets with trailing slashes 2026-09-04 10:36:51 +02:00
ProxMenuxBot 5e60feab84 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 9934bd2
Triggered by: push
2026-09-04 06:09:58 +00:00
MacRimiandGitHub 9934bd2628 Merge pull request #334 from Vaso73/feat/vm-lxc-search-update-filter
feat: add VM and LXC list search and update filter
2026-09-04 08:07:14 +02:00
VAIO73 9cc49da019 feat: add VM and LXC list search and update filter 2026-09-04 06:27:58 +02:00
MacRimi 25b5eaddc5 Fix restoration of custom Proxmox web certificates
Validate and restore pveproxy SSL certificate pairs during post-boot recovery, with fingerprint verification and automatic rollback on failure.
2026-09-03 23:46:03 +02:00
MacRimiandGitHub 6c44beb46d Merge pull request #332 from Vaso73/fix/runtime-verified-app-detectors
fix: improve application version detection
2026-09-03 22:25:41 +02:00
MacRimiandGitHub a3d26a2278 Merge pull request #329 from Vaso73/i18n/sk-apps-overview-terminal
i18n: polish Slovak copy for Apps, overview, and terminal
2026-09-03 22:25:08 +02:00
ProxMenuxBot bfbeb97203 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: a2b0341
Triggered by: push
2026-09-03 20:24:41 +00:00
MacRimiandGitHub a2b03419d6 Merge pull request #330 from Vaso73/i18n/sk-vm-lxc-complete
i18n: translate Slovak VM and LXC status and update scheduling
2026-09-03 22:20:27 +02:00
VAIO73 cecffbc89f fix: prefer runtime-verified app detectors 2026-09-03 13:10:43 +02:00
github-actions[bot] f0f34c3416 Update AppImage beta build (2026-09-03 10:00:28) 2026-09-03 10:00:28 +00:00
ProxMenuxBot 64d1a86fe5 chore(lang): auto-rebuild translation cache
Source: 86305cf
Triggered by: push
2026-09-03 09:56:47 +00:00
MacRimi 86305cf261 fix: harden post-install migrations and bundle multi-ABI Python runtimes
- safely migrate historical Bashrc and Log2RAM installations
- align post-install function versions across both flows
- bundle gevent runtimes for CPython 3.11 and 3.13
- select the correct Python ABI at runtime
- validate both supported Proxmox VE Python versions in AppImage workflows
- fix #327, #328 and #331
2026-09-03 11:55:39 +02:00
VAIO73 d17c31398c i18n: translate VM and LXC status and schedule states 2026-09-03 10:21:53 +02:00
VAIO73 cbb61d65e4 i18n: translate VM and LXC app link fields 2026-09-03 10:21:53 +02:00
VAIO73 6dc123615b i18n: translate terminal command descriptions 2026-09-03 10:21:27 +02:00
VAIO73 56d841aa64 i18n: translate 1.2.6 overview release notes 2026-09-03 10:21:27 +02:00
VAIO73 8899d8bfe1 i18n: translate Apps dashboard and custom link dialog 2026-09-03 10:21:27 +02:00
github-actions[bot] ac09cc24a8 Update AppImage beta build (2026-09-02 20:31:05) 2026-09-02 20:31:05 +00:00
MacRimi cd40abc359 New version 1.2.6
Restores AI Assistant support for OpenAI-compatible endpoints on private IPs, loopback
and Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies)
and surfaces the server's error under the Load button (#325). Aligns the Secure Gateway
wizard's Alpine template selection and pct create with the host's real architecture on
x86_64 and arm64 (#324). Consolidates changes landing on develop: atomic notification
delivery, custom SSH port for Borg remote targets (#236), optional GitHub API token for
app version tracking (#306), and richer replication failure notifications.
2026-09-02 22:19:37 +02:00
MacRimi ea8c29ecbd New version 1.2.6
Restores AI Assistant support for OpenAI-compatible endpoints on private IPs, loopback
and Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies)
and surfaces the server's error under the Load button (#325). Aligns the Secure Gateway
wizard's Alpine template selection and pct create with the host's real architecture on
x86_64 and arm64 (#324). Consolidates changes landing on develop: atomic notification
delivery, custom SSH port for Borg remote targets (#236), optional GitHub API token for
app version tracking (#306), and richer replication failure notifications.
2026-09-02 22:18:52 +02:00
MacRimi eecd93bf1a prevent duplicate concurrent deliveries 2026-09-02 20:09:39 +02:00
github-actions[bot] d1fed2fb45 Update AppImage beta build (2026-09-02 17:07:47) 2026-09-02 17:07:47 +00:00
MacRimi e7b69dae91 replication failure notifications 2026-09-02 16:28:03 +02:00
ProxMenuxBot d10acbc895 chore(lang): auto-rebuild translation cache
Source: 91b4200
Triggered by: push
2026-09-02 12:44:36 +00:00
MacRimi 91b4200179 custom SSH port for Borg remote targets
feat(host-backup): custom SSH port for Borg remote targets

Add Borg destinations no longer assume port 22. The Monitor form and
the shell TUI both take an optional port (default 22, range 1-65535)
and embed it in the persisted ssh://user@host:port/path URL. BORG_RSH,
the sshpass key installer and the capacity probe all honour it.

Reported by @songochain in #236 — NAS-style Borg hosts on non-standard
SSH ports are now first-class targets.

Existing borg-targets.txt entries without a port keep working; the
port is only serialised into the URL when it differs from 22.
2026-09-02 14:43:28 +02:00
github-actions[bot] 45cc2ba36e Update AppImage beta build (2026-09-02 12:13:31) 2026-09-02 12:13:31 +00:00
MacRimi 588d8f629d drop editorial preview/exp/experimental filter in Gemini provider 2026-09-02 14:11:24 +02:00
github-actions[bot] 686c9456f4 Update AppImage beta build (2026-09-02 11:50:48) 2026-09-02 11:50:48 +00:00
MacRimi 14823809d3 chore(ai-models): refresh catalog with 2026-09-02 verifier report 2026-09-02 13:42:08 +02:00
MacRimi 1bef36fb20 drop live AI catalog refresh + GH Action 2026-09-02 12:05:53 +02:00
proxmenux-bot c820685241 chore(ai-models): daily catalog refresh 2026-09-02 09:46:17 +00:00
MacRimi 20cf0a4a7f narrow _exclude to technical incapacity only 2026-09-02 11:32:17 +02:00
proxmenux-bot 287ad44ffd chore(ai-models): daily catalog refresh 2026-09-02 09:16:54 +00:00
MacRimi 580fab6c78 Merge branch 'main' into develop — keep AI catalog fix from develop
Resolves the conflict on AppImage/config/verified_ai_models.json in favour
of develop's curated version with _exclude — main had the bot's first
naive refresh which surfaced allam-2-7b as the Groq recommended model.
develop also absorbs the bot-maintained files main had accumulated since
1.2.5 (helpers_cache, app_tracking_hints, project-growth, FUNDING).
2026-09-02 11:00:36 +02:00
github-actions[bot] 84a3bbe84f chore: update repository growth [skip ci] 2026-09-02 08:48:45 +00:00
MacRimi 1309654b4a AI models catalog — _exclude list + preserve recommended 2026-09-02 10:45:02 +02:00
proxmenux-bot 2f7541040f chore(ai-models): daily catalog refresh 2026-09-02 08:30:04 +00:00
MacRimiandGitHub 3cc020a31b Delete AppImage/ProxMenux-1.2.4.AppImage 2026-09-02 10:14:49 +02:00
MacRimiandGitHub 4baf7ccf7e Merge pull request #320 from MacRimi/develop
New version 1.2.5
2026-09-02 10:11:27 +02:00
MacRimi 674e9dba98 New version 1.2.5 2026-09-02 10:09:16 +02:00
github-actions[bot] b7c5960d9a Update AppImage beta build (2026-09-02 08:04:00) 2026-09-02 08:04:00 +00:00
MacRimi 220a670b46 AI models — live catalog with one-click refresh 2026-09-02 10:00:27 +02:00
ProxMenuxBot 3c39f84290 Update helpers_cache.json 2026-09-02 02:13:31 +00:00
ProxMenuxBot bdab88c2e8 Update app tracking hints 2026-09-02 01:34:51 +00:00
ProxMenuxBot 7cdf562efb Update helpers_cache.json 2026-09-01 20:21:04 +00:00
github-actions[bot] ebfb842dc3 Update AppImage beta build (2026-09-01 17:49:06) 2026-09-01 17:49:06 +00:00
MacRimiandGitHub 2c8fce3df6 Merge pull request #319 from MacRimi/develop
New version 1.2.5
2026-09-01 19:43:57 +02:00
MacRimi b88fd353a2 New version 1.2.5
Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5.

Highlights:

- Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal.
- LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle.
- Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods.
- Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73).
- NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC.
- Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install.

Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
2026-09-01 19:43:10 +02:00
MacRimi 55603990b9 New version 1.2.5
Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5.

Highlights:

- Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal.
- LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle.
- Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods.
- Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73).
- NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC.
- Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install.

Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
2026-09-01 19:42:09 +02:00
github-actions[bot] 976c6b46b0 Update AppImage beta build (2026-09-01 17:34:42) 2026-09-01 17:34:42 +00:00
github-actions[bot] 8a94fdc66d Update AppImage release build (2026-09-01 17:32:19) 2026-09-01 17:32:19 +00:00
ProxMenuxBot 4763897272 Update app tracking hints 2026-09-01 17:26:55 +00:00
MacRimiandGitHub 9946dcfd08 Merge pull request #318 from MacRimi/develop
New version 1.2.5
2026-09-01 19:26:35 +02:00
MacRimi 1262dafba8 New version 1.2.5 2026-09-01 19:21:29 +02:00
MacRimi f439757fe5 New version 1.2.5
Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5.

Highlights:

- Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal.
- LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle.
- Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods.
- Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73).
- NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC.
- Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install.

Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
2026-09-01 19:19:21 +02:00
MacRimi 315259f5ec New version 1.2.5
Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5.

Highlights:

- Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal.
- LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle.
- Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods.
- Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73).
- NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC.
- Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install.

Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
2026-09-01 19:15:42 +02:00
ProxMenuxBot bc8a95263a Update helpers_cache.json 2026-09-01 15:50:15 +00:00
github-actions[bot] a5a2b6f8e1 chore: update repository growth [skip ci] 2026-09-01 09:26:29 +00:00
ProxMenuxBot 7380956975 Update helpers_cache.json 2026-08-31 12:18:12 +00:00
github-actions[bot] ee382f2e48 chore: update repository growth [skip ci] 2026-08-31 10:54:59 +00:00
ProxMenuxBot 59080873cc Update helpers_cache.json 2026-08-30 20:20:23 +00:00
github-actions[bot] c9d9638d67 chore: update repository growth [skip ci] 2026-08-30 09:57:59 +00:00
MacRimi f8e65cc4c4 preserve Helper-Scripts updater detection after updates 2026-08-29 17:00:35 +02:00
github-actions[bot] 4cf5fad46c chore: update repository growth [skip ci] 2026-08-29 11:09:13 +00:00
ProxMenuxBot a3c07c1d23 Update helpers_cache.json 2026-08-28 21:36:54 +00:00
github-actions[bot] 182c1e019f chore: update repository growth [skip ci] 2026-08-28 16:41:39 +00:00
ProxMenuxBot 2fbbca0944 Update helpers_cache.json 2026-08-27 21:30:56 +00:00
MacRimi abf0f7fbf8 identify storage temperature sensors by their actual block device (#315) 2026-08-27 23:18:50 +02:00
ProxMenuxBot d13f5ed900 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 44a446b
Triggered by: push
2026-08-27 15:48:36 +00:00
MacRimiandGitHub 3fbfb5233e Merge pull request #313 from Vaso73/i18n/sk-dashboard-copy
i18n: polish Slovak dashboard copy
2026-08-27 17:48:03 +02:00
MacRimiandGitHub 44a446b309 Merge pull request #314 from Vaso73/i18n/sk-nfs-startup-health
i18n: translate Slovak storage startup health messages
2026-08-27 17:45:43 +02:00
github-actions[bot] cecf550a43 chore: update repository growth [skip ci] 2026-08-27 15:18:02 +00:00
VAIO73 039988b506 i18n: translate NFS startup health messages 2026-08-27 15:37:06 +02:00
VAIO73 0646f6fd96 i18n: refine Slovak backup scheduled title 2026-08-27 15:37:05 +02:00
VAIO73 cce714f873 i18n: translate Overview release notes and chart stats 2026-08-27 15:37:05 +02:00
github-actions[bot] eed9c3e597 Update AppImage beta build (2026-08-27 10:05:22) 2026-08-27 10:05:22 +00:00
MacRimi 71058685ef New 1.2.4.2-beta 2026-08-27 12:03:09 +02:00
MacRimi f80c3cb3f1 install: paint spinner and msg_info purple to match the runtime palette 2026-08-27 11:45:14 +02:00
MacRimi 49b2d6d2c8 install: paint spinner and msg_info purple to match the runtime palette 2026-08-27 11:40:48 +02:00
github-actions[bot] dcb94acee4 Update AppImage beta build (2026-08-27 06:56:07) 2026-08-27 06:56:07 +00:00
MacRimi 4b14dfa47e centralize the Monitor version and prepare the 1.2.4.2-beta
- centralize the app version in a new `AppImage/lib/version.ts` — the dashboard footer, SMART report footer and release-notes modal all read from a single export, so a future bump only has to touch version.ts, package.json and beta_version.txt
- roll `package.json` and `package-lock.json` to `1.2.4.2-beta` (the lockfile was still stuck on the stale `1.2.2.2-beta` marker)
- move the previous `1.2.4.1-beta` feature list into the modal's historical CHANGELOG under its August 17, 2026 date so navigating "Anterior/Próximo" still walks through the complete history
- write the four new What's New entries for the ongoing 1.2.4.2 cycle: smarter LXC app detection (Docker promoted as parent workload during cold start, unregistered suggestions cached at startup, on-demand catalog scan), end-to-end Docker updates (Engine + per-image tracking on the 24h cycle with a "Check now" action), the 380+ workload detection catalog built from the live community-scripts source, and Pushover joining Telegram/Gotify/Discord/Email/Apprise as a native notification channel
- add the four new i18n keys (appDetection, dockerUpdates, appCatalog, pushover) to all eight bundled locales — en and es curated, the six remaining kept in English until the docs-i18n workflow retranslates them
2026-08-27 08:53:04 +02:00
ProxMenuxBot f08ec94521 Update helpers_cache.json 2026-08-27 06:25:33 +00:00
MacRimi c2a6db38a8 improve update notifications and add apt-listchanges channel
- add an apt-listchanges notification event that surfaces package-maintainer NEWS forwarded through the PVE mail queue, with its own template, emoji and per-locale label
- emit a single post_install_update event when a tool exposes a newer version, and persist per-tool notified versions on disk so a Monitor restart no longer re-fires the same update announcement
- force AI enrichment to formatting-only on availability notices (update_available / pve_update / proxmenux_update / post_install_update / apt_listchanges / lxc_updates_available / secure_gateway_update_available / nvidia_driver_update_available / coral_driver_update_available / app_update_available / docker_stack_update_available / update_summary) so translations never turn factual inventories into advice
- add the apt_listchanges channel label to Monitor UI strings across the eight locales
2026-08-26 21:00:00 +02:00
ProxMenuxBot 0fe32d2842 chore(lang): auto-rebuild translation cache
Source: fcfe8da
Triggered by: workflow_dispatch
2026-08-26 18:53:07 +00:00
MacRimi fcfe8da765 refine post-install and hardware GPU docs, Monitor UX and CLI styling
- rewrite the 15 post-install pages and the 3 hardware GPU pages so they reflect the current scripts (reversibility, tracked-tool counts, kernel parameters, per-tool commands, Alpine LXC propagation flow)
- migrate the legacy step-badge helper on post-install/optional and create-vm/synology to the canonical pill component, with the stepLabel key added in each locale
- fix rich-text i18n calls missing helpers across network, automated, optional, security, customization and the post-install landing pages, and escape the `<iface>` placeholder in automated so intl no longer parses it as a tag
- remove the mouse-follow blue overlay from the docs landing layout
- reposition the App-tab Edit button and stack the Search and Register controls vertically on mobile
- move the Bulk update Configure/Edit control into the section header so it behaves the same on desktop and mobile
- show a spinner during the final autoremove/autoclean pass of update-pve-safe so the cleanup step reads as active instead of silent
- restyle the shell spinner and msg_info in a distinctive purple and drop the unused msg_lang duplicate
- add a web-docs i18n build script and its CI workflow, plus tests for the pushover notification channel
2026-08-26 17:23:09 +02:00
ProxMenuxBot 81b163ae4d Update helpers_cache.json 2026-08-26 12:34:58 +00:00
github-actions[bot] fc261b35f5 chore: update repository growth [skip ci] 2026-08-26 04:57:55 +00:00
ProxMenuxBot 82e8cd220c Update helpers_cache.json 2026-08-25 18:27:41 +00:00
ProxMenuxBot b71dd65898 chore(lang): auto-rebuild translation cache
Source: 4615820
Triggered by: push
2026-08-25 17:02:44 +00:00
MacRimi 46158209f1 fix: improve host diagnostics, storage handling, and maintenance workflows
- add zero-downtime Proxmox TLS certificate refresh from the Security panel (#307)
- classify storage availability independently from missing capacity information (#309)
- update ZFS ARC sizing and safely reconcile conflicting module configurations
- preserve and restore migrated ZFS settings without overwriting later administrator changes
- stop memory optimization from forcing the kernel overcommit policy
- correlate multi-line OOM events and identify the affected LXC, cgroup limits, swap and killed process
- add selectable Bash prompt path styles and clearer shell activation guidance
- protect technical names during automatic translation and correct localized terminology
- update the Coral and VM/LXC Apps and Updates documentation, translations and screenshots
2026-08-25 18:48:39 +02:00
github-actions[bot] a1f466e561 chore: update repository growth [skip ci] 2026-08-25 04:56:14 +00:00
ProxMenuxBot 942384f0dd Update helpers_cache.json 2026-08-25 00:48:37 +00:00
ProxMenuxBot 0207cdf4af Update helpers_cache.json 2026-08-24 18:27:55 +00:00
ProxMenuxBot 0473430f7a Update helpers_cache.json 2026-08-24 12:31:42 +00:00
github-actions[bot] 3c08f8b943 chore: update repository growth [skip ci] 2026-08-24 05:04:18 +00:00
MacRimi 2302b0967b add SMART passthrough for USB-SATA bridges
- Add a shared smartctl transport resolver with sat, sat,12 and sat,16 fallbacks.
- Prevent USB bridges from reporting a superficial PASSED result without real disk telemetry.
- Reuse the resolved transport across storage details, health monitoring, temperature history, and manual/scheduled SMART tests.
- Bundle the new resolver in the Monitor AppImage.
- Update the Coral TPU and LXC Apps/Updates documentation in the supported documentation locales.
2026-08-23 23:18:15 +02:00
ProxMenuxBot d62f8c6bfd chore(lang): auto-rebuild translation cache
Source: 0251f77
Triggered by: push
2026-08-23 10:48:14 +00:00
MacRimi 0251f77331 overhaul app tracking and update orchestration
- Generate and ship a verified 389-app tracking catalog with 23 runtime overrides, fallback detectors, ports, logos, and Docker Hub tag previews.
- Support modern Proxmox VE Helper-Scripts markers, historical installations, and official or manual app deployments.
- Rework the LXC App and Updates tabs with cached suggestions, explicit discovery, version tracking, web links, custom updaters, and complete i18n.
- Add independent OS, app, Docker Engine, Docker image, bulk, and scheduled update targets.
- Add digest-based Docker inventory, Compose dependency grouping, safe standalone-container recreation with rollback, and package-scoped Docker Engine updates.
- Refresh per-LXC caches after lifecycle and update tasks, then emit idempotent notifications based on the verified final state.
- Harden Coral USB recovery by removing orphaned gasket DKMS registrations and validating that dpkg is healthy before reporting success.
2026-08-23 12:43:03 +02:00
github-actions[bot] 5971626bc9 chore: update repository growth [skip ci] 2026-08-23 04:54:15 +00:00
ProxMenuxBot f90f72dcac Update helpers_cache.json 2026-08-22 18:17:19 +00:00
ProxMenuxBot e22699602c Update helpers_cache.json 2026-08-22 06:25:15 +00:00
github-actions[bot] 0b932df450 chore: update repository growth [skip ci] 2026-08-22 04:52:28 +00:00
ProxMenuxBot 15db6c282b Update helpers_cache.json 2026-08-21 18:24:46 +00:00
github-actions[bot] d98b39540f chore: update repository growth [skip ci] 2026-08-21 04:56:29 +00:00
ProxMenuxBot 69a7b07a8d Update helpers_cache.json 2026-08-21 00:51:22 +00:00
ProxMenuxBot f7b29a9bbf Update helpers_cache.json 2026-08-20 12:31:01 +00:00
github-actions[bot] 1353a1cd8d chore: update repository growth [skip ci] 2026-08-20 04:56:00 +00:00
ProxMenuxBot 8604e66d60 Update helpers_cache.json 2026-08-20 00:46:14 +00:00
ProxMenuxBot 78c0508b5a Update helpers_cache.json 2026-08-19 18:21:35 +00:00
MacRimiandGitHub 7244201810 Merge pull request #304 from Vaso73/fix/sk-2fa-auto-submit
fix(login): auto-submit completed 2FA code
2026-08-19 11:46:23 +02:00
ProxMenuxBot 812ca1ba7d chore(lang): auto-rebuild translation cache
Source: 9bf0ff9
Triggered by: push
2026-08-19 09:41:32 +00:00
MacRimiandGitHub 9bf0ff9848 Merge pull request #302 from Vaso73/fix/pve-update-failure-status
fix: report Proxmox update failures correctly
2026-08-19 11:40:32 +02:00
VAIO73 f8605b7583 fix(login): auto-submit completed 2FA code 2026-08-19 10:13:24 +02:00
Vaso73 e7bb511821 fix: report Proxmox update failures correctly 2026-08-19 07:13:58 +02:00
github-actions[bot] 9c33adc77c chore: update repository growth [skip ci] 2026-08-19 04:54:32 +00:00
MacRimi bce139f9d1 Update apply_updates.sh 2026-08-18 23:47:03 +02:00
ProxMenuxBot e6de8b6df9 Update helpers_cache.json 2026-08-18 18:25:15 +00:00
MacRimiandGitHub c5050ae022 Merge pull request #300 from Vaso73/codex/sk-ui-copy-polish-optimizations
i18n: polish Slovak monitor copy
2026-08-18 20:08:20 +02:00
VAIO73 44d2dc7b43 i18n: polish Slovak monitor copy 2026-08-18 19:47:14 +02:00
MacRimi 7d81724e14 Update flask_server.py 2026-08-18 17:16:27 +02:00
ProxMenuxBot c3d2b0f181 Update helpers_cache.json 2026-08-18 12:28:53 +00:00
github-actions[bot] dd4584c774 chore: update repository growth [skip ci] 2026-08-18 04:54:31 +00:00
MacRimi 29cd269e08 revert: restore canonical project growth image path 2026-08-17 19:42:10 +02:00
MacRimi 668035895b revert: restore canonical project growth image path 2026-08-17 19:42:09 +02:00
MacRimi b2b1b5e3a1 fix: sync project growth README with main 2026-08-17 19:40:39 +02:00
MacRimi 56de7322f7 fix: refresh project growth image cache 2026-08-17 19:37:51 +02:00
github-actions[bot] 14793f8787 chore: update repository growth [skip ci] 2026-08-17 17:33:34 +00:00
MacRimi d8ba3b06ae feat: track Git clones in project growth 2026-08-17 19:18:13 +02:00
github-actions[bot] 9d8a9fb378 Update AppImage beta build (2026-08-17 15:02:35) 2026-08-17 15:02:35 +00:00
MacRimi d88c36f6ca Update verified_ai_models.json 2026-08-17 16:59:44 +02:00
ProxMenuxBot 6166486ede Update helpers_cache.json 2026-08-17 12:26:59 +00:00
github-actions[bot] 1dac6166ab Update AppImage beta build (2026-08-17 10:19:32) 2026-08-17 10:19:32 +00:00
MacRimi 513a9359fd New 1.2.4.1-beta 2026-08-17 12:16:41 +02:00
ProxMenuxBot 9f66c004a2 chore(lang): auto-rebuild translation cache
Source: b5476c7
Triggered by: push
2026-08-17 10:08:00 +00:00
MacRimi b5476c75fa gpu: nvidia install drops VFIO autoload; GPU flows self-repair stale vfio.conf
The nvidia driver installer now writes only `nvidia` and `nvidia_uvm` into `/etc/modules-load.d/nvidia-vfio.conf`, leaving the vfio-pci modules for `switch_gpu_mode.sh` to add and remove when the user explicitly transitions between LXC and VM mode. A host with residual `/etc/modprobe.d/vfio.conf` entries from an earlier VM passthrough setup no longer has vfio-pci racing nvidia at boot and capturing the GPU by ID.

All three GPU-facing entry points also detect a stale `vfio.conf` entry whose vid:did belongs to a GPU currently bound to a non-vfio driver, and offer to clean it in place. `nvidia_installer.sh` runs the check as a pre-flight before install. `switch_gpu_mode.sh` presents an interactive dialog and rebuilds initramfs. `switch_gpu_mode_direct.sh` performs the same cleanup transparently for the Monitor web variant. Once cleaned, "Add GPU to LXC", the driver install and Switch Mode all proceed without any manual intervention.

The two supporting helpers `_pci_driver_of` and `_clean_vfio_conf_ids` previously local to `add_gpu_lxc.sh` move to `scripts/global/pci_passthrough_helpers.sh`, so every GPU-related script resolves them from a single source without duplication.
2026-08-17 12:05:40 +02:00
ProxMenuxBot b047c0b876 Update helpers_cache.json 2026-08-17 06:36:47 +00:00
github-actions[bot] 8158082fa5 chore: update repository growth [skip ci] 2026-08-17 05:00:37 +00:00
ProxMenuxBot ec01d4abb6 Update helpers_cache.json 2026-08-17 00:47:46 +00:00
ProxMenuxBot 11e030b9de chore(lang): auto-rebuild translation cache
Source: c5d717c
Triggered by: push
2026-08-16 15:54:04 +00:00
MacRimi c5d717c85a Update add_gpu_lxc.sh 2026-08-16 17:51:43 +02:00
github-actions[bot] e73ec8e24c Update AppImage beta build (2026-08-16 09:11:57) 2026-08-16 09:11:57 +00:00
MacRimi b577c154ee Update health-status-modal.tsx 2026-08-16 11:09:50 +02:00
ProxMenuxBot ec129b8262 chore(lang): auto-rebuild translation cache
Source: 698c89f
Triggered by: push
2026-08-16 08:34:42 +00:00
MacRimi 698c89fd3b NVIDIA installer: dynamic version picker + Pattern A UX 2026-08-16 10:33:34 +02:00
github-actions[bot] 8915f8692a chore: update repository growth [skip ci] 2026-08-16 04:51:51 +00:00
ProxMenuxBot 14da6d9e23 Update helpers_cache.json 2026-08-16 00:50:32 +00:00
github-actions[bot] 7bbccef81e Update AppImage beta build (2026-08-15 20:14:32) 2026-08-15 20:14:32 +00:00
MacRimi b45799d319 GPU realtime: cache endpoint TTL 4s + trim intel_gpu_top warmup 2026-08-15 22:11:41 +02:00
ProxMenuxBot af40ee5447 Update helpers_cache.json 2026-08-15 18:16:49 +00:00
ProxMenuxBot 17d00f2db7 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 58a28dd
Triggered by: push
2026-08-15 15:48:51 +00:00
MacRimi 58a28dd81e retry i18n auto-commit on push race 2026-08-15 17:46:45 +02:00
ProxMenuxBot 42b4aa2489 chore(lang): auto-rebuild translation cache
Source: 0beeb7a
Triggered by: push
2026-08-15 15:34:12 +00:00
MacRimi 0beeb7a68b Beta cycle bundle over 1.2.4.1
- **VM/LXC modal** — PVE tags (dots on list cards, editable pills in modal with click-to-edit) using NVIDIA-style hash colour and SAPC contrast; Status tab redesign (single card, always-visible subsections, Edit button, autostart toggle, blue subsection icons); Backups and Firewall tabs now fill the full modal height with sticky headers/notes; stopped VMs no longer shift the metrics grid; mount-point card brightness unified across breakpoints.
- **Disks modal** — Overview / SMART / History / Schedule tabs adopt the VM/LXC modal size and the mobile icon-only tab pattern; SMART attributes table drops the 15-row cap and gains a sticky "View full SMART report" footer; Print/Save-as-PDF collapses to two icons in the report; loose i18n and layout follow-ups.
- **NVIDIA driver installer (#298)** — version picker cross-checks kernel + NVIDIA's Production/New Feature/Legacy branch classification (scraped from `nvidia.com/en-us/drivers/unix/`) + the PCI Device IDs of every host GPU, with a release-count heuristic to keep superseded production branches selectable while dropping Vulkan-beta ones; Recommended follows same-branch head when a driver is installed, Production Branch head on a fresh install; Hardware card now shows installed alongside available driver version.
- **Custom notifications (#297)** — `event_type: "custom"` accepts `title`/`message` at the root or nested under `data`; defensive strip of stray `[TITLE]`/`[BODY]` markers echoed by the AI enhancer.
- **App tab** — new "Exclude from the LXC updates counter" toggle; the CT's aggregate updates badge now sums OS packages plus registered apps (respecting the flag); Docs page updated; App suggestion no longer treats bare OS helper slugs (alpine/ubuntu/debian…) as installable apps.
- **i18n and copy** — Monitor UI available in EN / ES / DE / FR / IT / PT / SV / SK (thanks @vaso73) surfaced as the first entry in the What's New modal with a link to the contributor's profile; ES cleanup pass (`Historial`, `Velocidad de rotación`, `Consumo actual`, `Ejecutar`, `Eliminar`, `Activar`, `Ver contenido`, `Repuesto disp.`, `Registrar`, `Ocultar`, `Descartar`); redundant "Tip: search any Linux/Proxmox command" line removed from the terminal command search across all locales.
2026-08-15 17:33:05 +02:00
github-actions[bot] 6b5d45fd6b chore: update repository growth [skip ci] 2026-08-15 04:49:48 +00:00
ProxMenuxBot ff575776f4 Update helpers_cache.json 2026-08-15 00:46:41 +00:00
github-actions[bot] 34b8c47415 Update AppImage beta build (2026-08-14 18:47:35) 2026-08-14 18:47:35 +00:00
ProxMenuxBot c25f72febe chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: f0c8e73
Triggered by: push
2026-08-14 18:45:01 +00:00
MacRimi f0c8e73446 Persist partial i18n progress on transient translator failures 2026-08-14 20:43:44 +02:00
ProxMenuxBot c83382806d Update helpers_cache.json 2026-08-14 18:42:02 +00:00
MacRimi 157ab50f9b i18n workflow — expand trigger + fix sk/sv Translate pivots 2026-08-14 20:37:20 +02:00
MacRimi 9eb576f8f3 Fix i18n cleaner regex — add sk/sv Translate pivots 2026-08-14 20:30:21 +02:00
MacRimi f6da553aeb Fix i18n cleaner regex — add sk/sv Translate pivots 2026-08-14 20:27:06 +02:00
ProxMenuxBot cf0c21eff3 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 99ad69e
Triggered by: push
2026-08-14 18:20:47 +00:00
MacRimi 99ad69e8cf Event-only modal cache + Settings edit gating + ES i18n polish
Modal caches now refresh on events only — the periodic prewarmer runs one-shot at startup, mount points split into static/runtime endpoints, and backups get a client 6-hour gate. Updates tab shows post-apply feedback and the script terminal no longer closes the parent modal. Settings adds edit gating on 3 cards with the 3-level contrast rule applied consistently. ES translation batch (~25 fixes) and What's New for 1.2.4.1-beta refreshed.
2026-08-14 20:17:45 +02:00
github-actions[bot] d1adba65a0 chore: update repository growth [skip ci] 2026-08-14 05:48:04 +00:00
ProxMenuxBot 452902bd9a chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: e1fb203
Triggered by: push
2026-08-13 21:54:33 +00:00
MacRimi e1fb203e46 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-08-13 23:53:03 +02:00
MacRimi ac2c28ad2c Update build-i18n-messages.yml 2026-08-13 23:52:53 +02:00
ProxMenuxBot 8481e588b2 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: d9944ed
Triggered by: push
2026-08-13 21:50:50 +00:00
MacRimi d9944edf5e Update build_i18n_messages.py 2026-08-13 23:49:17 +02:00
MacRimi 037421d762 Instant VM/LXC modals via bulk hydration + server prewarmer
Backend keeps every guest's modal payload (details, backups, apps, schedule, mount points) warm in-memory and exposes them through a single `/api/vms/modal-cache-all` endpoint. The dashboard hydrates its entire modal cache from that one request on page load, so opening any guest — first click or after coming back later — renders instantly. Replaces ~84 per-guest fetches with 1.
2026-08-13 22:35:13 +02:00
ProxMenuxBot 92df8b59a9 Update helpers_cache.json 2026-08-13 18:51:53 +00:00
ProxMenuxBot 5a2ed51af3 Update helpers_cache.json 2026-08-13 07:14:53 +00:00
github-actions[bot] 0ea8797a81 chore: update repository growth [skip ci] 2026-08-13 05:50:27 +00:00
MacRimi 3fe9a49ebc Update network-flow.tsx 2026-08-12 17:28:12 +02:00
ProxMenuxBot dbc60585f9 Update helpers_cache.json 2026-08-12 12:50:40 +00:00
MacRimiandGitHub 805003d811 Merge pull request #292 from Vaso73/codex/i18n-vms-lxcs-docs-sk
docs(i18n): translate Slovak VM/LXC app docs
2026-08-12 09:34:46 +02:00
VAIO73 bc88a34390 docs(i18n): translate Slovak VM LXC app docs 2026-08-12 09:17:54 +02:00
ProxMenuxBot b6e18a5a37 Update helpers_cache.json 2026-08-12 07:11:57 +00:00
github-actions[bot] 153839285b chore: update repository growth [skip ci] 2026-08-12 05:48:22 +00:00
MacRimiandGitHub ef859d5ec3 Merge pull request #291 from Vaso73/codex/iframe-frame-ancestors-227
fix(security): allow trusted frame ancestors
2026-08-11 20:14:39 +02:00
MacRimi 55a95a2346 docs(monitor): new App + Updates sub-pages for VMs & LXCs with i18n 2026-08-11 20:14:17 +02:00
ProxMenuxBot 36e27d6154 Update helpers_cache.json 2026-08-11 12:44:36 +00:00
VAIO73 c81f552ed0 fix(security): allow trusted frame ancestors 2026-08-11 10:28:48 +02:00
github-actions[bot] 25986f062c chore: update repository growth [skip ci] 2026-08-11 05:30:22 +00:00
MacRimi d2c7be9a32 Update README.md 2026-08-10 19:22:47 +02:00
MacRimi bf6c580877 docs: refresh CONTRIBUTING + README for coordination flow + Monitor section 2026-08-10 19:15:01 +02:00
MacRimiandGitHub 3dc263f9fb Merge pull request #289 from Vaso73/codex/i18n-slovak-polish-277
i18n: polish Slovak labels and locale parity
2026-08-10 17:19:53 +02:00
MacRimiandGitHub 05e5277ae5 Merge pull request #288 from Vaso73/fix/fwbr-udev-rule-order
fix(post-install): run fwbr udev rule after systemd
2026-08-10 17:16:43 +02:00
VAIO73 c2ba648ebe i18n: polish Slovak labels and locale parity 2026-08-10 16:59:33 +02:00
VAIO73 2bfa84d5e0 fix(post-install): run fwbr udev rule after systemd 2026-08-10 16:29:54 +02:00
MacRimi d6162af020 i18n: ES catalog polish + AppImage 1.2.4.1-beta rebuild 2026-08-10 15:09:14 +02:00
MacRimiandGitHub c4b8ad65da Merge pull request #287 from yeager/agent/swedish-translation-review
Review Swedish CLI and Monitor translations
2026-08-10 14:53:32 +02:00
MacRimi a056d7e0dc Update common.json 2026-08-10 14:39:52 +02:00
Daniel Nylander 87bda3bbb6 Review Swedish CLI and Monitor translations 2026-08-10 14:19:51 +02:00
MacRimi c1bd4b3403 Update common.json 2026-08-10 14:18:22 +02:00
MacRimi f2420d9978 i18n polish + Swedish wired + roadmap slim + AppImage 1.2.4.1-beta
## i18n

- Swedish (sv) wired end-to-end: `MESSAGE_CATALOG` in `provider.tsx` now imports `sv/common.json`; `SUPPORTED_LANGUAGES` reordered as English + alphabetical native and all eight locales marked complete.
- Settings → Interface language: the per-locale status cards grid is retired now that all eight locales are populated end-to-end; the dropdown selector is the only control.
- CLI change-language dialog (`scripts/menus/config_menu.sh`) picks up Swedish and matches the Monitor ordering.
- Spanish catalog: label polish across the dashboard — *VM y LXC activos* / *11 VMs* / *11 detenidos*, LXC modal tabs shortened to *App / Actualiz. / Montajes / Copias*, *Limpiar / Cerrar* replacing the wrong *Claro / Cerca* in terminal controls, *En ejecución / Ejecutar* replacing the literal *Correr*.
- Cross-locale normalisation across ES / DE / FR / IT / PT / SK / SV: *OK* / *Proxmox System Dashboard* / *Warn* labels stay consistent, *AVG / MAX / MIN* kept verbatim as universal abbreviations so the stats row no longer overflows.
- Tailscale brand preserved verbatim across ES / FR / IT / PT — previous machine-translated variants like *"escala trasera"* / *"à grande échelle"* restored.

## Docs

- `ROADMAP.md` rewritten as a short invitation to the [ProxMenux Roadmap project board](https://github.com/users/MacRimi/projects/1). `ROADMAP.es.md` removed — it mirrored the same outdated version-milestone plan and the docs site keeps the Spanish surface for users who need it.
- `release.md` annotated with nine pending sections for the v1.2.4.1 beta cycle.

## AppImage

- `ProxMenux-1.2.4.1-beta.AppImage` replaces the previous `ProxMenux-1.2.4.AppImage`. Sidecar `ProxMenux-Monitor.AppImage.sha256` regenerated.
2026-08-10 13:23:26 +02:00
MacRimi 2901b22be6 update ROADMAP.md
The document now points to the ProxMenux Roadmap project on GitHub, where per-item status, ownership and descriptions live. The previous version-milestone plan (v1.2.2-beta / v1.2.3-beta / v1.3.0) is retired in favour of pillar-based tracking on the board.

ROADMAP.es.md removed — it mirrored the same outdated content and the public docs site keeps the Spanish surface for users who need it.
2026-08-10 11:59:47 +02:00
MacRimiandGitHub 7b2d9a3268 Merge pull request #284 from Vaso73/fix/openai-compatible-ai-models
fix(notifications): respect OpenAI-compatible model aliases
2026-08-10 11:33:32 +02:00
MacRimi 9dae948a7c i18n: fill 7 locales, preserve brand names, manual quality review
Rebuild of the 7 supported translation catalogs (ES/DE/FR/IT/PT/SK/SV) covering Monitor (`AppImage/messages/<lang>/common.json`, 3903 keys each) and CLI (`lang/<lang>.json`, 5272 keys each). Swedish (sv) is added as a new locale end-to-end.
2026-08-10 09:44:39 +02:00
ProxMenuxBot 1ed65d502b Update helpers_cache.json 2026-08-10 07:15:11 +00:00
github-actions[bot] 1ddb460b46 chore: update repository growth [skip ci] 2026-08-10 05:47:15 +00:00
ProxMenuxBot 8e8883cf21 Update helpers_cache.json 2026-08-10 01:07:27 +00:00
ProxMenuxBot f4fac11cd3 Update helpers_cache.json 2026-08-09 18:27:49 +00:00
VAIO73 66a1781298 fix(notifications): respect OpenAI-compatible model aliases 2026-08-09 13:56:40 +02:00
MacRimiandGitHub 504d34a3ee Merge pull request #283 from Vaso73/fix/vzdump-webhook-truncation
fix(notifications): preserve full vzdump webhook messages
2026-08-09 11:00:46 +02:00
Codex 3d83d9d561 fix(notifications): preserve full vzdump webhook messages 2026-08-09 10:52:12 +02:00
MacRimi 5c33852a65 feat(i18n): add Swedish support + include Slovak in auto-fill defaults
Two related changes to the translation infrastructure:

1. Add Swedish (sv) as a supported locale in both the CLI and Monitor
   translation systems. Inspired by PR #121, which arrived before the
   current auto-generated cache layout existed.

2. Include Slovak (sk) in the default auto-fill target set. Guardrail #1
   in build_i18n_messages.py never overwrites a key whose target value
   differs from EN, so Vaso73's curated Slovak strings stay intact —
   auto-translation only fills keys that are still on the English
   fallback. Trade-off accepted: sk users see decent machine translation
   for new keys immediately instead of raw English while human curation
   catches up, and Vaso73 keeps full ownership of the wording via
   follow-up PRs.

Changes:
- AppImage/lib/i18n/languages.ts: add "sv" to LanguageCode + register
  in SUPPORTED_LANGUAGES.
- AppImage/messages/sv/common.json: stub — will be filled by the i18n
  workflow on the local bootstrap.
- .github/scripts/build_translation_cache.py: add "sv" to
  DEFAULT_LANGUAGES.
- .github/scripts/build_i18n_messages.py: add "sk" and "sv" to
  DEFAULT_LANGUAGES; update comments to explain the sk decision.
- .github/workflows/build-i18n-messages.yml: default input includes
  sk + sv; comments updated to match.
2026-08-09 10:35:03 +02:00
MacRimiandGitHub a2740b6873 Merge pull request #282 from Vaso73/i18n/slovak-lxc-updates-app-editor
i18n(lxc): localize updates and fix terminal completion
2026-08-09 10:19:08 +02:00
Codex 547942cac8 fix(terminal): handle completed scripts without websocket error 2026-08-09 09:50:18 +02:00
Codex 8a5f7a4cb8 i18n(lxc): localize update and application management flows 2026-08-09 09:08:52 +02:00
github-actions[bot] 78caf6598e chore: update repository growth [skip ci] 2026-08-09 05:25:29 +00:00
ProxMenuxBot 03e034b326 Update helpers_cache.json 2026-08-09 01:05:59 +00:00
MacRimi d2a3720ec4 ci(i18n): add auto-translation workflow for Monitor messages catalog
Adds .github/scripts/build_i18n_messages.py and its companion workflow
build-i18n-messages.yml, so keys added to AppImage/messages/en/common.json
propagate automatically to es/de/fr/it/pt on push.

Guardrails:
- Never overwrites a key whose target value differs from EN, protecting
  human-curated locales (Vaso73's sk is excluded from the default set
  entirely).
- {placeholder} tokens are extracted to opaque tokens before the
  translator sees the text and restored afterwards, so interpolation
  keys stay intact regardless of provider behavior.

Mirrors the pattern of build-translation-cache.yml (CLI translations)
and reuses its providers (googletrans / google-web / appimage), so the
CI environment stays uniform between the two workflows.

First run will bootstrap ~3800 keys × 5 locales; subsequent runs only
process new keys added to en/common.json.
2026-08-09 01:31:30 +02:00
MacRimiandGitHub 732f1e8ef7 Merge pull request #281 from MacRimi/feature/lxc-updates-refresh
Feature/lxc updates refresh
2026-08-09 01:02:22 +02:00
MacRimi e05b4e94dc chore(post_install): bump setup_persistent_network FUNC_VERSION to 1.2
Bump FUNC_VERSION from 1.1 to 1.2 in the setup_persistent_network wrapper
for both customizable and auto post_install variants.

Rationale:
The generator pmx_setup_persistent_network in utils-install-functions.sh
now emits `.link` files with Kind=!bridge (PR #280), so existing installs
need their `.link` files regenerated to pick up the fix. Bumping the
wrapper's FUNC_VERSION triggers the Monitor + CLI post-install version
tracker to surface persistent_network as a pending update, prompting
users to re-run the optimization — which regenerates the files with the
fix applied.

Not touched:
- network_menu.sh copy of setup_persistent_network: uses a simpler
  register_tool() that doesn't track versions (menu-driven invocation
  is on-demand, not tracked as auto-update-eligible by design).
2026-08-09 00:54:22 +02:00
MacRimi 05476b99c6 i18n(vmLxc): extract new LXC Updates + Options + App editor strings (EN only)
Extracts 212 hardcoded English strings from virtual-machines.tsx and
lxc-app-panel.tsx into AppImage/messages/en/common.json, matching the
i18n pattern established by PR #277.

Added namespaces under vmLxc:
- options (14 keys): Snapshot / Restart / Backup storage / Edit / Cancel / Save
- scheduled (22 keys): Scheduled updates, Frequency, Cron, target, Delete
- cronChip (6 keys): variants + scope for external cron detection
- cronPresets (6 keys): Hourly / Daily / Weekly / Monthly picker labels
- updates (58 keys): OS packages, Helper info, Apply buttons, custom
  command editor, footer combined buttons
- tabs (6 keys): tab labels
- appEditor (105 keys): app registration form — installed_via methods,
  upstream source selectors, ports, form field labels + placeholders

Other locales (sk/de/fr/it/pt/es) fall back to English via the provider's
built-in fallback until translations are contributed.

Follow-up: i18n coverage issue for translation work in the other locales.
2026-08-09 00:49:44 +02:00
MacRimi 06f41f5792 feat(lxc-updates): revamp Updates + Options card + curated hints
Updates tab
- Unified OS + Application update card with per-section Apply buttons
  and a combined "Apply OS + <app>" footer button
- Helper-scripts install detection: uses helper_slug from managed_installs
  cache (hostname fuzzy-match against helpers_cache) so the button
  surfaces even when /usr/bin/update was removed
- Runs the community-scripts helper INSIDE the CT via pct exec so
  build.func picks the silent update path (PHS_SILENT=1) instead of the
  install menu — works with and without /usr/bin/update
- HELPER_SLUG env passthrough from backend to apply_updates.sh: falls
  back to constructing the ct/<slug>.sh URL when the CT no longer
  carries the marker file
- Post-apply state refresh via managed_installs.check_for_updates(force)
  in the /applied hook so the badge updates without a manual reload

Options card
- Rewrote as view / edit mode split with a single Edit button
- Unified apply defaults (snapshot + storage + restart) shared by
  manual and scheduled runs
- Scheduled updates (M5): cron picker + preset dropdown + What-to-update
  target + Delete schedule button, wired to a background scheduler thread
  that fires apply_updates.sh headless with the schedule's env vars
- External host cron detection with variant + scope reporting
  (tteck-legacy / community-scripts / custom, OS-only), shown as an
  informational chip only in edit mode

App tab editor
- Multi-app registration with per-app upstream tracking method
  (github / http_json / docker_hub)
- Card-contrast pattern in edit mode (bg-card + bg-background inputs)
- Auto-heal for missing installed_version via alt_detectors +
  file_fallbacks

Curated tracking hints (M6)
- Add http_json upstream for Plex (plex.tv API)
- Add binary+github hints for Emby (MediaBrowser/Emby.Releases) and
  PhotoPrism (photoprism/photoprism)
- Extend CI merge whitelist with upstream_type / upstream_url /
  upstream_json_path / docker_image

Tab reorder
- LXC modal tabs: Status | App | Updates | Mounts | Backups | Firewall

apply_updates.sh
- New helper execution path: parse ct/<slug>.sh URL, run inside CT
  with PHS_SILENT=1, respecting HELPER_SLUG fallback when
  /usr/bin/update is missing
2026-08-09 00:49:44 +02:00
MacRimiandGitHub fb8b41b445 Merge pull request #279 from Vaso73/feature/fail2ban-trusted-networks
Add trusted IP and network management to Fail2Ban
2026-08-09 00:48:16 +02:00
MacRimiandGitHub eeb39e6e0f Merge pull request #280 from f3rs3n/fix/persistent-nic-bridge-match
Fix persistent NIC rules matching Linux bridges
2026-08-09 00:42:51 +02:00
MacRimiandGitHub 508a4eb22c Update GitHub Sponsors username in FUNDING.yml 2026-08-08 19:53:55 +02:00
ProxMenuxBot ba5ed26339 Update helpers_cache.json 2026-08-08 06:37:17 +00:00
github-actions[bot] 2d49b6b4da chore: update repository growth [skip ci] 2026-08-08 05:11:04 +00:00
martino fda41ae8d5 fix: exclude bridges from persistent NIC rules 2026-08-08 03:12:35 +02:00
ProxMenuxBot 5d5aa97814 Update helpers_cache.json 2026-08-07 18:42:24 +00:00
MacRimiandGitHub 976c61805a Change Ko-fi username to 'macrimi'
Updated Ko-fi username in FUNDING.yml
2026-08-07 11:23:55 +02:00
MacRimiandGitHub 450940c77f Change Ko-fi username to full URL
Updated Ko-fi link to a full URL format.
2026-08-07 11:21:26 +02:00
MacRimiandGitHub 4e2deaeba2 Update Ko-fi username to lowercase 2026-08-07 11:17:00 +02:00
MacRimiandGitHub ab64fb962d Add Ko-fi username for funding 2026-08-07 11:13:32 +02:00
github-actions[bot] 9effec62cb chore: update repository growth [skip ci] 2026-08-07 05:52:31 +00:00
ProxMenuxBot c127be6868 Update helpers_cache.json 2026-08-06 23:51:28 +00:00
Codex 3a7e450b55 feat(fail2ban): manage trusted IP addresses and networks 2026-08-06 16:57:38 +02:00
MacRimiandGitHub 7d781d570c Merge pull request #278 from Vaso73/fix/auth-setup-blocked-after-decline
Fix auth setup after authentication is declined
2026-08-06 16:19:02 +02:00
MacRimiandGitHub e581aa0121 Merge pull request #277 from Vaso73/feature/monitor-dashboard-i18n-v2
Complete Slovak i18n for the Monitor dashboard
2026-08-06 16:18:21 +02:00
ProxMenuxBot f168440892 Update helpers_cache.json 2026-08-06 13:45:10 +00:00
github-actions[bot] 94aa3709f3 chore: update repository growth [skip ci] 2026-08-06 06:48:40 +00:00
ProxMenuxBot 8dfb747c42 Update helpers_cache.json 2026-08-06 01:50:52 +00:00
Codex ef53fa0827 fix(i18n): localize login error messages 2026-08-05 09:04:18 +02:00
github-actions[bot] cbb84e0284 chore: update repository growth [skip ci] 2026-08-05 06:44:22 +00:00
ProxMenuxBot d2ff21b12d Update helpers_cache.json 2026-08-05 01:50:00 +00:00
ProxMenuxBot 76d7ceb647 Update helpers_cache.json 2026-08-04 19:20:07 +00:00
Codex 86517860ff fix(auth): allow setup after authentication is declined 2026-08-04 18:14:45 +02:00
Codex d62fc29581 fix(monitor): localize dynamic labels and generated status text
Localize runtime labels for SMART/NVMe details, backup target badges, firewall interface types, settings units, and generated health status copy while keeping the English and Slovak catalogs in key parity.
2026-08-04 17:02:03 +02:00
Codex 39e7a7d2db i18n(monitor): add complete Slovak dashboard translation
Complete the Slovak Monitor dashboard catalog, mark Slovak as complete, and update the translation notes to reflect the finished AppImage UI coverage.
2026-08-04 17:01:37 +02:00
Codex 88f1d09cb8 i18n(monitor): extract English dashboard messages
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
2026-08-04 17:01:19 +02:00
Codex 2109cf2508 feat(monitor): add dashboard i18n infrastructure
Add the Monitor dashboard i18n provider, supported language metadata, message catalogs, fallback behavior, and the initial language selector/wiring for the AppImage UI.
2026-08-04 17:01:02 +02:00
ProxMenuxBot bf7e049e44 Update helpers_cache.json 2026-08-04 13:51:35 +00:00
ProxMenuxBot dc14bc55ff Update helpers_cache.json 2026-08-04 08:24:05 +00:00
github-actions[bot] 0cdb089890 chore: update repository growth [skip ci] 2026-08-04 06:43:19 +00:00
ProxMenuxBot 7128fa8a96 Update helpers_cache.json 2026-08-03 19:21:15 +00:00
MacRimi 04a028245b update 1.2.4.1 2026-08-03 17:00:18 +02:00
ProxMenuxBot 526ad3f363 Update helpers_cache.json 2026-08-03 14:20:28 +00:00
ProxMenuxBot 03045c9d48 Update helpers_cache.json 2026-08-03 09:27:52 +00:00
github-actions[bot] 4255531db5 chore: update repository growth [skip ci] 2026-08-03 07:50:22 +00:00
ProxMenuxBot f10ff18abc chore(lang): auto-rebuild translation cache
Source: 359a5c2
Triggered by: push
2026-08-02 18:50:23 +00:00
MacRimiandGitHub 359a5c269c Merge pull request #273 from Vaso73/feature/add-slovak-translation
feat(i18n): add Slovak translation
2026-08-02 20:49:31 +02:00
Vaso73 2a23cc9079 feat(i18n): add Slovak translation 2026-08-02 15:58:29 +02:00
github-actions[bot] 99c977445b chore: update repository growth [skip ci] 2026-08-02 06:47:37 +00:00
MacRimi 7fcbc467df update 1.2.4.1 beta - apis 2026-08-01 11:29:32 +02:00
github-actions[bot] b89259840b chore: update repository growth [skip ci] 2026-08-01 06:42:31 +00:00
ProxMenuxBot de8738ef32 Update helpers_cache.json 2026-08-01 02:04:35 +00:00
ProxMenuxBot f2d9c9fb8e chore(lang): auto-rebuild translation cache
Source: 2c05c80
Triggered by: push
2026-07-31 22:57:26 +00:00
MacRimi 2c05c800d9 Update update-pve-safe.sh 2026-08-01 00:56:18 +02:00
ProxMenuxBot c6a90361d0 Update helpers_cache.json 2026-07-31 19:11:25 +00:00
MacRimiandGitHub 89fa4e73a5 Merge pull request #272 from f3rs3n/fix/uninstall-boolean-registry
Fix uninstall menu for boolean registry entries
2026-07-31 19:00:45 +02:00
MacRimi c4a574422b Update flask_server.py 2026-07-31 18:56:10 +02:00
ProxMenuxBot 9c6940200e Update helpers_cache.json 2026-07-31 13:45:24 +00:00
martino e7a9e3a5b6 Fix uninstall menu for boolean registry entries 2026-07-31 13:15:02 +02:00
ProxMenuxBot 7eeb9fefb1 Update helpers_cache.json 2026-07-31 08:37:16 +00:00
github-actions[bot] 42b07b5ba9 chore: update repository growth [skip ci] 2026-07-31 07:17:07 +00:00
ProxMenuxBot b9b6939eff Update helpers_cache.json 2026-07-31 02:03:03 +00:00
ProxMenuxBot aba17c82e7 Update helpers_cache.json 2026-07-30 19:12:38 +00:00
ProxMenuxBot 9a952bdb38 Update helpers_cache.json 2026-07-30 13:40:49 +00:00
ProxMenuxBot 185d94183a Update helpers_cache.json 2026-07-30 08:09:59 +00:00
github-actions[bot] e880872612 chore: update repository growth [skip ci] 2026-07-30 06:44:14 +00:00
ProxMenuxBot 8d9a913b95 Update helpers_cache.json 2026-07-29 13:50:36 +00:00
ProxMenuxBot 8976e517d3 Update helpers_cache.json 2026-07-29 08:25:14 +00:00
github-actions[bot] b9e655c8cf chore: update repository growth [skip ci] 2026-07-29 06:47:20 +00:00
MacRimi cbb46cb70a Update beta_version.txt 2026-07-29 00:16:32 +02:00
MacRimi 37189ed67e update 1.2.4.1 2026-07-29 00:15:39 +02:00
MacRimi ebd41b8a46 Update security_manager.py 2026-07-28 22:40:06 +02:00
MacRimi 42ca256bac update 1.2.4.1 2026-07-28 22:03:01 +02:00
ProxMenuxBot c67c24c210 Update helpers_cache.json 2026-07-28 19:11:21 +00:00
ProxMenuxBot f1dbf0ed42 Update helpers_cache.json 2026-07-28 13:45:18 +00:00
ProxMenuxBot f876b7cd20 Update helpers_cache.json 2026-07-28 08:19:22 +00:00
github-actions[bot] 5929e69793 chore: update repository growth [skip ci] 2026-07-28 06:43:13 +00:00
ProxMenuxBot 10c7b335fb Update helpers_cache.json 2026-07-27 14:14:00 +00:00
ProxMenuxBot 3d0ded5435 Update helpers_cache.json 2026-07-27 09:35:04 +00:00
github-actions[bot] 4498bd8981 chore: update repository growth [skip ci] 2026-07-27 07:55:25 +00:00
ProxMenuxBot e438e283f7 Update helpers_cache.json 2026-07-26 18:59:31 +00:00
ProxMenuxBot 3c7f4bffd3 Update helpers_cache.json 2026-07-26 13:02:46 +00:00
github-actions[bot] 04f1bbe761 chore: update repository growth [skip ci] 2026-07-26 07:06:09 +00:00
github-actions[bot] 6935932dd0 chore: update repository growth [skip ci] 2026-07-25 06:31:25 +00:00
ProxMenuxBot 082f6ec0e8 Update helpers_cache.json 2026-07-24 13:21:10 +00:00
github-actions[bot] 594e266d3f chore: update repository growth [skip ci] 2026-07-24 06:41:44 +00:00
github-actions[bot] 3d3b1fc902 Update AppImage beta build (2026-07-23 22:12:37) 2026-07-23 22:12:37 +00:00
github-actions[bot] 1f10d8908b Update AppImage release build (2026-07-23 22:09:46) 2026-07-23 22:09:46 +00:00
MacRimiandGitHub e168107a78 Merge pull request #265 from MacRimi/develop
update documentation
2026-07-24 00:04:33 +02:00
MacRimi f5aa0db9ef Merge branch 'main' into develop — resolve README modify/delete (keep develop) 2026-07-24 00:02:15 +02:00
MacRimi 03a7f58dfe Update README.md 2026-07-23 23:59:07 +02:00
MacRimiandGitHub 61c778cc80 Delete AppImage/ProxMenux-1.2.4.AppImage 2026-07-23 23:53:34 +02:00
MacRimiandGitHub fb06ed4f8a Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-07-23 23:53:22 +02:00
MacRimiandGitHub b4a4bb3ddd Delete README.md 2026-07-23 23:53:05 +02:00
MacRimi 497934f854 update 1.2.4.1 beta 2026-07-23 23:49:31 +02:00
MacRimi 20f1372816 Update README.md 2026-07-23 23:25:21 +02:00
github-actions[bot] 34ec56324f Update AppImage beta build (2026-07-23 21:06:56) 2026-07-23 21:06:56 +00:00
ProxMenuxBot 031084decd chore(lang): auto-rebuild translation cache
Source: 91d503e
Triggered by: push
2026-07-23 20:56:37 +00:00
MacRimi 91d503ea67 new beta 1.2.4.1 2026-07-23 22:55:28 +02:00
github-actions[bot] 057136b1e0 chore: update repository growth [skip ci] 2026-07-23 14:53:22 +00:00
github-actions[bot] 1eeb44bae6 chore: update repository growth [skip ci] 2026-07-23 06:41:51 +00:00
github-actions[bot] 763ed5dcc6 chore: update repository growth [skip ci] 2026-07-22 18:20:18 +00:00
MacRimi 7bf709a679 chore: backfill repository growth history [skip ci] 2026-07-22 20:10:42 +02:00
github-actions[bot] 1e6cb58a7c chore: update repository growth [skip ci] 2026-07-22 18:05:38 +00:00
github-actions[bot] 61b45a5331 chore: update repository growth [skip ci] 2026-07-22 18:01:04 +00:00
MacRimiandGitHub b9c9a8bb13 Merge pull request #261 from MacRimi/feat/repo-growth
Replace the broken Star History embed with a repository-owned Repo Growth workflow and SVG dashboard.
2026-07-22 19:55:13 +02:00
MacRimi 06b546427b feat: replace Star History with Repo Growth 2026-07-22 19:54:05 +02:00
MacRimi 37f06b9e88 update changelog 2026-07-22 18:16:23 +02:00
MacRimiandGitHub 807edc5700 Actualiza changelog para ProxMenux v1.2.4
Añade detalles sobre mejoras y nuevas funciones en la versión 1.2.4 de ProxMenux, incluyendo el botón de actualización y optimizaciones en el flujo de restauración.
2026-07-22 18:14:10 +02:00
MacRimiandGitHub 1c0910563b Update changelog date and version details
Actualiza la fecha de la versión y detalla mejoras en el dashboard, restauración de Backups, y optimización de ZFS ARC.
2026-07-22 18:09:47 +02:00
MacRimi abf5ec7405 update changelog 2026-07-22 18:08:06 +02:00
MacRimiandGitHub d9aea2ed1d Update CHANGELOG for ProxMenux v1.2.4 release
Updated release date and added details for ProxMenux v1.2.4.
2026-07-22 18:06:37 +02:00
MacRimi 047217dc93 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-07-22 17:46:36 +02:00
MacRimi 46cc91e52b Update install_proxmenux.sh 2026-07-22 17:46:22 +02:00
github-actions[bot] d94554302f Update AppImage beta build (2026-07-22 15:44:29) 2026-07-22 15:44:29 +00:00
github-actions[bot] af5cf30e34 Update AppImage release build (2026-07-22 15:41:39) 2026-07-22 15:41:40 +00:00
MacRimiandGitHub fd3986ccfd New version 1.2.4
New version 1.2.4
2026-07-22 17:34:56 +02:00
MacRimiandGitHub 91355372b6 Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-07-22 17:34:32 +02:00
MacRimiandGitHub 70ae8326f2 Delete AppImage/ProxMenux-1.2.4.AppImage 2026-07-22 17:34:20 +02:00
MacRimi d24d4372a7 Update es.json 2026-07-22 17:31:43 +02:00
MacRimi 2b7c498570 New version 1.2.4 2026-07-22 17:14:04 +02:00
github-actions[bot] ad90f6c99a Update AppImage beta build (2026-07-22 15:12:46) 2026-07-22 15:12:46 +00:00
github-actions[bot] 98d8badb52 Update AppImage release build (2026-07-22 15:09:50) 2026-07-22 15:09:50 +00:00
MacRimiandGitHub dda0492152 v1.2.4
This release adds two in-dashboard improvements — a one-click Proxmox update trigger from the Health Monitor and a mobile PWA install prompt — extends the Backups restore flow with atomic pmxcfs (`config.db`) snapshots and automatic ZFS data-pool import, sharpens Log2RAM behaviour on hosts running Proxmox Backup Server as a service, hardens firewall bridge sysctl tuning across VM lifecycle events, narrows the ZFS ARC optimization to its own scope, makes persistent NIC naming idempotent across reruns, rebuilds DKMS drivers automatically when a new kernel is staged, keeps the Monitor terminal session intact when a ProxMenux update is available, and reinforces five notification templates plus three Health panel checks.
2026-07-22 17:04:55 +02:00
MacRimiandGitHub 005f668d7e Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-07-22 17:04:00 +02:00
MacRimiandGitHub 76ddc8b408 Delete AppImage/ProxMenux-1.2.3.AppImage 2026-07-22 17:03:49 +02:00
MacRimi 8645ee0744 Update 1.2.4 2026-07-22 17:02:36 +02:00
MacRimi 78c5765330 Update es.md 2026-07-22 16:46:04 +02:00
MacRimi 4f5ccc4933 update 1.2.4 2026-07-22 16:39:34 +02:00
github-actions[bot] acebb1755d Update AppImage beta build (2026-07-22 13:33:42) 2026-07-22 13:33:42 +00:00
MacRimi b461b85dec update 1.2.4 2026-07-22 15:31:33 +02:00
ProxMenuxBot 05dc2cbd6a Update helpers_cache.json 2026-07-22 13:24:54 +00:00
github-actions[bot] c8613fc864 Update AppImage beta build (2026-07-21 17:27:19) 2026-07-21 17:27:19 +00:00
MacRimi 827ccbab57 update 1.2.4 2026-07-21 19:25:08 +02:00
MacRimi c53289753d update 1.2.4 2026-07-21 19:09:59 +02:00
github-actions[bot] f2516010c7 Update AppImage beta build (2026-07-21 16:53:02) 2026-07-21 16:53:02 +00:00
ProxMenuxBot 96f88d3eb5 chore(lang): auto-rebuild translation cache
Source: 2330179
Triggered by: push
2026-07-21 16:48:24 +00:00
MacRimi 2330179036 update 1.2.4 2026-07-21 18:47:19 +02:00
github-actions[bot] fd951f134c Update AppImage beta build (2026-07-21 16:34:38) 2026-07-21 16:34:38 +00:00
ProxMenuxBot ff0f8f1133 chore(lang): auto-rebuild translation cache
Source: b2c6a6c
Triggered by: push
2026-07-21 16:30:53 +00:00
MacRimi b2c6a6c536 Update 1.2.4 2026-07-21 18:26:11 +02:00
ProxMenuxBot c81230b0f3 chore(lang): auto-rebuild translation cache
Source: cf58719
Triggered by: push
2026-07-20 17:13:04 +00:00
MacRimi cf5871981d update 1.2.4 2026-07-20 19:11:29 +02:00
ProxMenuxBot 2e0746e850 Update helpers_cache.json 2026-07-20 08:43:55 +00:00
ProxMenuxBot 1fb57c77c6 Update helpers_cache.json 2026-07-19 12:59:03 +00:00
ProxMenuxBot dcf682af76 Update helpers_cache.json 2026-07-19 08:02:36 +00:00
github-actions[bot] 9f014a9784 Update AppImage beta build (2026-07-18 19:28:41) 2026-07-18 19:28:41 +00:00
MacRimi c2442565bd Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-07-18 21:22:18 +02:00
MacRimi 6b02c13e23 update v1.2.4 2026-07-18 21:22:02 +02:00
github-actions[bot] 016681caba Update AppImage beta build (2026-07-18 19:13:04) 2026-07-18 19:13:04 +00:00
ProxMenuxBot 4630be1921 chore(lang): auto-rebuild translation cache
Source: 451f541
Triggered by: push
2026-07-18 19:10:33 +00:00
MacRimi 451f541342 new version 1.2.4 2026-07-18 21:09:40 +02:00
ProxMenuxBot ab4120a81e Update helpers_cache.json 2026-07-18 12:56:28 +00:00
ProxMenuxBot c21441a56e Update helpers_cache.json 2026-07-18 07:36:51 +00:00
ProxMenuxBot 8108c42eae Update helpers_cache.json 2026-07-17 18:56:10 +00:00
ProxMenuxBot 3c5cad6ab5 Update helpers_cache.json 2026-07-17 13:10:19 +00:00
MacRimi 52d7e20979 Update AppImage 1.2.3 2026-07-17 00:05:06 +02:00
MacRimi db79470d32 Update AppImage 1.2.3 2026-07-16 23:43:58 +02:00
MacRimi 9dc077feec update AppImage 1.2.3 2026-07-16 23:32:54 +02:00
MacRimi 1f3702b700 update AppImage 1.2.3 2026-07-16 23:19:12 +02:00
MacRimi 21c8b7a62e update AppImage 1.2.3 2026-07-16 23:12:28 +02:00
MacRimi 2770c8172b Update notification_events.py 2026-07-16 23:07:25 +02:00
github-actions[bot] f4cd661480 Update AppImage beta build (2026-07-16 21:01:14) 2026-07-16 21:01:14 +00:00
MacRimi 3f61bca07c Update AppImage 1.2.3 2026-07-16 22:58:50 +02:00
github-actions[bot] af7bb9aa3f Update AppImage beta build (2026-07-16 20:56:36) 2026-07-16 20:56:36 +00:00
MacRimi bcb8ec0f81 update roxmenux-monitor-v2 2026-07-16 22:53:57 +02:00
ProxMenuxBot e0fd85faac Update helpers_cache.json 2026-07-16 18:58:50 +00:00
ProxMenuxBot 0b01bad764 Update helpers_cache.json 2026-07-16 13:25:15 +00:00
MacRimiandGitHub 032ec197d6 Merge pull request #254 from MacRimi/hotfix/scheduler-pbs-attached-encryption
hotfix: prompt PBS encryption in attached-mode scheduled jobs + reorder backend menu
2026-07-15 23:45:32 +02:00
MacRimi 2fdf94f322 hotfix: prompt PBS encryption in attached-mode scheduled jobs + reorder backend menu
Two changes, both scoped to scripts/backup_restore/backup_scheduler.sh, worth
shipping to main ahead of the full v1.2.3 release PR:

1. Attached-mode PBS jobs never asked about encryption. `_create_job_attached`
   ran `hb_select_pbs_repository` and jumped straight to writing the .env with
   PBS_REPOSITORY / PBS_PASSWORD / PBS_BACKUP_ID — no `hb_ask_pbs_encryption`
   call, no PBS_KEYFILE / PBS_ENCRYPTION_PASSWORD emitted. The runner then
   invoked `proxmox-backup-client backup` without `--keyfile`, so every
   attached-mode backup landed on PBS unencrypted regardless of what the
   operator would have picked. Confirmed via `git show` on eight historical
   commits back to 61ff665c (beta 1.2.2.2) — the encryption call has NEVER
   been in the attached branch; the standalone `_create_job_new` branch had
   it since day one, they just diverged silently.

   Fix mirrors `_create_job_new` exactly (lines 413-443):
     hb_ask_pbs_encryption || return 1               # abort on cancel
     local pbs_kf_val=""
     [[ -n "${HB_PBS_KEYFILE_OPT:-}" ]] && pbs_kf_val="$HB_STATE_DIR/pbs-key.conf"
     lines+=(... "PBS_KEYFILE=${pbs_kf_val}" "PBS_ENCRYPTION_PASSWORD=${HB_PBS_ENC_PASS:-}")

   The Monitor Web path (flask_server.py::api_host_backups_job_create)
   already accepted pbs_encrypt_mode for both modes and passed it through
   correctly, so the fix is confined to the CLI wizard.

2. Backend selection menu reordered from `local | borg | pbs` to
   `pbs (recommended) | borg | local` so the recommended default sits at the
   top of the list. PBS gets the "(recommended)" suffix in its label.

Deployed and verified on the four test hosts (.50, .55, .89, .1.10) —
attached-mode wizard now shows the encryption dialog immediately after the
PBS job picker, and the .env carries PBS_KEYFILE + PBS_ENCRYPTION_PASSWORD
when the operator opts in.
2026-07-15 23:44:16 +02:00
MacRimi ed9b027c19 Update backup_scheduler.sh 2026-07-15 23:39:54 +02:00
github-actions[bot] dee9d4ae40 Update AppImage release build (2026-07-15 15:41:08) 2026-07-15 15:41:08 +00:00
MacRimiandGitHub 4ac112ff39 Merge pull request #252 from MacRimi/develop
Release 1.2.3
2026-07-15 17:36:17 +02:00
MacRimi 3915b219cc Merge branch 'main' into develop — resolve PBS page.tsx conflict (keep em helper fix) 2026-07-15 17:31:20 +02:00
MacRimi ddd9e35c83 change-language.json 2026-07-15 17:28:10 +02:00
MacRimiandGitHub 695dd1da7d Merge pull request #253 from MacRimi/docs-sync-20260715
docs: promote /web from develop — v1.2.3 changelog + Backups video + install page refresh
2026-07-15 17:20:49 +02:00
MacRimi 2a17561477 docs: promote /web from develop — v1.2.3 changelog + Backups video + install page refresh + PBS keyfile docs polish
Consolidates the /web changes that were made on develop for the v1.2.3 stable
release, so the release PR (develop → main) can focus on version.txt +
AppImage + shell scripts without web conflicts.

Contents:

- CHANGELOG.md + web/data/changelog/es.md — v1.2.3 stable entry prepended
  (bullets + tables, not paragraphs) with the release header image.
- images/ProxMenux_backup.png — new v1.2.3 release header image.
- web/app/[locale]/docs/backup-restore/page.tsx +
  web/messages/{en,es}/docs/backup-restore/index.json — new Video walkthrough
  block for the YouTube demo of the Backups feature (videoId K7A1KtOe4IQ),
  embedded high on the overview page via the shared YouTubeEmbed component.
- web/app/[locale]/docs/installation/page.tsx +
  web/messages/{en,es}/docs/installation.json +
  web/public/install/install.png — stale Translation-flavour and Python-from-
  PyPI mentions removed from the install page; screenshot refreshed and moved
  to local /install/install.png (was pointing at the legacy GitHub Pages URL).
- web/messages/es/docs/backup-restore/destinations/pbs.json — minor polish
  aligned with PR #247 already on main.
- web/public/images/docs/backup-restore/pbs-paired-backup-groups.png — updated
  screenshot to match the reworked PBS-paired-group naming shipped in the beta
  cycle.
- images/logos_txt/jc_channel.txt — new asset for the contributors page.
2026-07-15 17:19:23 +02:00
MacRimi 892a90fa3c new version 1.2.3 2026-07-15 17:12:29 +02:00
MacRimi bbebef6929 update web 2026-07-15 17:09:02 +02:00
MacRimi 1b992988eb Update changelog 2026-07-15 16:22:58 +02:00
ProxMenuxBot fe6e70fa61 Update helpers_cache.json 2026-07-15 13:18:42 +00:00
MacRimi 4fe335224d Delete ProxMenux-1.2.2.3-beta.AppImage 2026-07-14 19:51:13 +02:00
github-actions[bot] b4dddebd6c Update AppImage beta build (2026-07-14 17:47:14) 2026-07-14 17:47:14 +00:00
MacRimi 8060f59d69 Update version 1.2.3 2026-07-14 19:22:33 +02:00
ProxMenuxBot ac531ff42f Update helpers_cache.json 2026-07-14 13:14:32 +00:00
MacRimi 1872a309ec update version 1.2.3 2026-07-13 23:19:24 +02:00
MacRimi 1b623cd275 updage installer 2026-07-13 22:03:25 +02:00
MacRimi 362409e654 update menus 2026-07-13 21:43:48 +02:00
MacRimi e771a11441 Update es.json 2026-07-13 21:20:18 +02:00
MacRimi c43bf19bae Update es.json 2026-07-13 21:16:59 +02:00
ProxMenuxBot aaab644e47 Update helpers_cache.json 2026-07-13 19:14:45 +00:00
MacRimi a1167dad4b Update hw_grafics_menu.sh 2026-07-13 21:10:17 +02:00
MacRimi 63a6378f2d Update menus 2026-07-13 20:47:14 +02:00
MacRimi fffeb2c06c Update es.json 2026-07-13 20:32:40 +02:00
ProxMenuxBot 4d4eabf922 Update helpers_cache.json 2026-07-13 14:12:24 +00:00
ProxMenuxBot 627cd80b5e Update helpers_cache.json 2026-07-13 02:01:10 +00:00
ProxMenuxBot ebc668077f chore(lang): auto-rebuild translation cache
Source: f5c72f1
Triggered by: push
2026-07-12 10:53:23 +00:00
MacRimi f5c72f19b8 update 1.2.2.3 beta 2026-07-12 12:52:16 +02:00
ProxMenuxBot 2f85ca47c3 Update helpers_cache.json 2026-07-12 01:59:09 +00:00
ProxMenuxBot cf59b3c621 Update helpers_cache.json 2026-07-11 18:49:10 +00:00
ProxMenuxBot 6ad92a908c Update helpers_cache.json 2026-07-10 19:17:45 +00:00
ProxMenuxBot 07da709ffa Update helpers_cache.json 2026-07-10 09:20:54 +00:00
ProxMenuxBot b3cfdd28be Update helpers_cache.json 2026-07-10 02:10:09 +00:00
ProxMenuxBot 1242cb8ae8 Update helpers_cache.json 2026-07-09 19:25:21 +00:00
ProxMenuxBot 89d658ab2e Update helpers_cache.json 2026-07-08 19:11:15 +00:00
MacRimi aa3714c2ed Update pbs-paired-backup-groups.png 2026-07-08 19:59:13 +02:00
MacRimi 837d5cd95a Update pbs.json 2026-07-08 19:58:07 +02:00
MacRimiandGitHub 5783b6e0bc Merge pull request #247 from MacRimi/docs/pbs-encryption-keyfile
docs: promote /web from develop — PBS keyfile UX (explicit escrow, wrong-key panel, Monitor management)
2026-07-08 19:43:06 +02:00
MacRimi 17246ebdde update documentation 2026-07-08 19:42:48 +02:00
MacRimi 2d3b1fd823 docs(glossary): drop stale /root/pbs-key.recovery-*.enc mention
The recovery envelope no longer lands in /root/ — code was cleaned up
earlier this cycle to keep the keyfile and its envelope exclusively at
/usr/local/share/proxmenux/. The glossary entries for "Recovery envelope"
(EN) / "Sobre de recuperación" (ES) still described the /root/ mirror
as an "offsite backup" / "respaldo local", which is now inaccurate.

Removes the trailing sentence from both entries.
2026-07-08 19:40:54 +02:00
MacRimi 3b96837fba docs(pbs): promote /web from develop — keyfile UX refresh
- Explicit yes/no escrow choice reinforced ("nothing gets uploaded to PBS
  until the operator answers Yes") in intro + recoveryTitle + recoveryBody
- Wrong-key detection: recoverBody extended to describe the structured
  amber panel that Monitor shows on View contents / Download / Restore
  when the installed keyfile doesn't match the backup's manifest, with
  the required fingerprint rendered prominently
- New encryption.monitorManagement section: describes the Download /
  Upload / Delete inline actions in each PBS destination row, plus the
  Yes/No + passphrase + contextual Apply escrow toggle
- ES: "operador" → "usuario" throughout (glossary rule)
- page.tsx: renders the new subsection and passes {code, em, strong} to
  the extended recoveryBody
2026-07-08 19:38:26 +02:00
MacRimi 87454b5b2d Update 1.2.2.3 beta 2026-07-08 19:17:13 +02:00
MacRimi 20f85001d5 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-07-08 18:02:36 +02:00
MacRimi 71780fccf4 Update backup_host.sh 2026-07-08 18:02:26 +02:00
ProxMenuxBot c0e7a96406 chore(lang): auto-rebuild translation cache
Source: 1f5fc9a
Triggered by: push
2026-07-08 15:51:17 +00:00
MacRimi 1f5fc9ad88 Update backup_host.sh 2026-07-08 17:50:14 +02:00
ProxMenuxBot 65a5755120 chore(lang): auto-rebuild translation cache
Source: 5e368cd
Triggered by: push
2026-07-08 13:47:29 +00:00
MacRimi 5e368cd8b3 Update vzdump-hook.sh 2026-07-08 15:46:12 +02:00
MacRimi 77ae299512 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-07-08 15:37:08 +02:00
MacRimi 1b6776a53f Update lib_host_backup_common.sh 2026-07-08 15:36:55 +02:00
ProxMenuxBot d57924e25a chore(lang): auto-rebuild translation cache
Source: cc71d14
Triggered by: push
2026-07-08 13:32:19 +00:00
MacRimi cc71d14dfa Update lib_host_backup_common.sh 2026-07-08 15:30:53 +02:00
ProxMenuxBot 197998d231 chore(lang): auto-rebuild translation cache
Source: 8b6fdcf
Triggered by: push
2026-07-08 13:25:07 +00:00
MacRimi 8b6fdcf9e1 update 1.2.2.3 beta 2026-07-08 15:20:38 +02:00
MacRimi 04a1cffe7f Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-07-08 12:46:46 +02:00
MacRimi a2a0fb4705 update docs 2026-07-08 12:46:36 +02:00
ProxMenuxBot 13435a8b41 chore(lang): auto-rebuild translation cache
Source: 341681a
Triggered by: push
2026-07-08 10:22:21 +00:00
MacRimi 341681a3dc Update 1.2.2.3 beta 2026-07-08 12:18:52 +02:00
ProxMenuxBot 0722469fb5 Update helpers_cache.json 2026-07-08 01:57:32 +00:00
ProxMenuxBot f4406c8770 Update helpers_cache.json 2026-07-06 19:44:31 +00:00
MacRimiandGitHub 2586819a47 Merge pull request #246 from MacRimi/docs-sync-20260706
docs: promote /web from develop — backup-restore glossary and PBS encryption clarifications
2026-07-06 18:56:43 +02:00
MacRimi f64c7269c7 docs: promote /web from develop — backup-restore glossary and PBS encryption clarifications
- Add Backup & Restore glossary (ES + EN) linked from the PBS encryption section
- Rewrite PBS client-side encryption block in plainer language and reinforce keyrecovery security callout (AES-256 + PBKDF2 wrapping happens on the host before upload)
- Add "Recommended frequency" callout to the scheduled-jobs page
- Gloss technical terms (rootfs, layout, fingerprint) inline; drop "instalación fresca" wording in favour of "equipo recién instalado"
- Restoring page screenshot updates from prior work
2026-07-06 18:56:04 +02:00
MacRimi 4789371f4d update documentation 2026-07-06 18:48:25 +02:00
MacRimi 6f0fc68c3d Update 1.2.2.3 beta 2026-07-06 17:15:22 +02:00
ProxMenuxBot 201849e691 Update helpers_cache.json 2026-07-06 15:15:21 +00:00
MacRimi 63f82d971d update 1.2.2.3 beta 2026-07-06 12:00:43 +02:00
MacRimiandGitHub a037cd15af Update community scripts to use sourced utils 2026-07-06 08:41:20 +02:00
ProxMenuxBot 6ae7494a45 Update helpers_cache.json 2026-07-06 02:28:22 +00:00
ProxMenuxBot 64c8f4fbb3 chore(lang): auto-rebuild translation cache
Source: 9c90722
Triggered by: push
2026-07-05 23:01:22 +00:00
MacRimi 9c90722ef9 update 1.2.2.3 beta 2026-07-06 00:59:50 +02:00
ProxMenuxBot 9f94d72b69 chore(lang): auto-rebuild translation cache
Source: 7156af1
Triggered by: push
2026-07-05 22:40:47 +00:00
MacRimi 7156af1965 update 1.2.2.3 beta 2026-07-06 00:39:43 +02:00
ProxMenuxBot 20d10fc268 chore(lang): auto-rebuild translation cache
Source: 8df8a77
Triggered by: push
2026-07-05 22:34:54 +00:00
MacRimi 8df8a77bf1 update 1.2.2.3 beta 2026-07-06 00:30:47 +02:00
ProxMenuxBot e07c83f4a9 chore(lang): auto-rebuild translation cache
Source: 487ab04
Triggered by: push
2026-07-05 22:00:21 +00:00
MacRimi 487ab04a14 update 1.2.2.3 beta 2026-07-05 23:58:58 +02:00
MacRimi fd1aeb1ead Update run_scheduled_backup.sh 2026-07-05 23:38:12 +02:00
MacRimi e35ef38fea update 1.2.2.3 beta 2026-07-05 23:22:39 +02:00
MacRimiandClaude Opus 4.7 26b47e63d9 host-backup(pbs): fix scheduled-job encryption + surface real key import error + pass --keyfile on downloads
Three bugs against the PBS encryption flow:

1. Create-scheduled-job with encryption failed with "Recovery setup
   failed: no PBS keyfile present" whenever the operator picked
   "Generate a new keyfile" but had no keyfile installed yet. The
   frontend called /pbs-recovery/setup before creating the job, but
   the keyfile was only materialised later during job creation. The
   endpoint now generates the keyfile atomically if missing before
   building the escrow blob — same prompt-first order the CLI wizard
   applies. Existing keyfiles are still trusted and never rotated.

2. Importing a valid PBS keyfile via the Web dialog returned a
   generic "did not recognise this file as a valid PBS keyfile" that
   hid the real reason (kdf mismatch, missing passphrase, corrupt
   JSON, ...). The endpoint now attaches the stderr of
   `proxmox-backup-client key info` as `tool_output` and the frontend
   renders it verbatim inside the red banner. Also strips a leading
   UTF-8 BOM before validating so an editor-inserted BOM stops being
   silently classified as "invalid keyfile".

3. Downloading an encrypted PBS snapshot failed with "missing key —
   manifest was created with key XX:XX:..." even when the correct
   keyfile was installed at /usr/local/share/proxmenux/pbs-key.conf,
   because the restore worker invoked `proxmox-backup-client restore`
   without `--keyfile`. The flag is now passed whenever a local
   keyfile exists (PBS ignores it for unencrypted archives). On a
   fingerprint mismatch the error now appends the installed key's
   fingerprint so it can be compared side-by-side with the manifest's
   expected value — same fingerprint also exposed via
   /pbs-recovery/status for the UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-05 23:14:52 +02:00
ProxMenuxBot a4f74929fa Update helpers_cache.json 2026-07-05 19:04:12 +00:00
MacRimiandClaude Opus 4.7 4b022d05f2 docs(restoring): swap live-progress figures and correct their captions
The two Details modal screenshots were displayed in the wrong order and
both captioned as post-completion snapshots. The one named `-details.png`
was actually captured mid-run (Restore in progress badge, ~2m left) and
the one named `-card.png` after completion (Restore complete badge,
0m53s duration). Reorders the figures to running-first then completed,
and rewrites the four alt/caption entries in EN and ES to match what
each image actually shows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-05 18:05:25 +02:00
MacRimiandGitHub 118d271ea4 Update beta version from 1.2.2.2 to 1.2.2.3 2026-07-05 17:54:05 +02:00
MacRimiandGitHub def007d743 Merge pull request #245 from MacRimi/docs-cleanup-20260705
docs: remove accidental duplicate monitor-restore-progress-card 2.png
2026-07-05 17:50:21 +02:00
MacRimiandClaude Opus 4.7 7acf18508d docs: remove accidental duplicate of monitor-restore-progress-card.png
The file "monitor-restore-progress-card 2.png" was created by an
autonaming drag-and-drop upload during PR #244 review. Identical blob
to the intended monitor-restore-progress-card.png (same SHA), not
referenced anywhere in the docs. Removing it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-05 17:49:23 +02:00
MacRimiandGitHub e7b11c6498 Merge pull request #244 from MacRimi/docs-sync-20260705
docs: promote /web from develop — live restore progress + PBS encryption UX refresh
2026-07-05 17:40:24 +02:00
MacRimi b833ca23ba Create monitor-restore-progress-card 2.png 2026-07-05 17:37:48 +02:00
MacRimiandClaude Opus 4.7 380a25a546 docs: promote /web from develop (live restore progress + PBS encryption UX refresh)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-05 17:37:33 +02:00
MacRimiandClaude Opus 4.7 f92af374c7 docs(restoring): add second Details modal screenshot for live progress section
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-05 17:34:48 +02:00
MacRimi 5b22039600 Update 1.2.2.3 beta 2026-07-05 17:23:00 +02:00
ProxMenuxBot 6560d0ced7 chore(lang): auto-rebuild translation cache
Source: 8bcfcd6
Triggered by: push
2026-07-05 14:59:37 +00:00
MacRimi 8bcfcd6059 update 1.2.2.3 beta 2026-07-05 16:58:27 +02:00
ProxMenuxBot 6c1c317921 chore(lang): auto-rebuild translation cache
Source: 7fc7125
Triggered by: push
2026-07-05 14:53:32 +00:00
MacRimi 7fc7125c71 create 1.2.2.3 beta 2026-07-05 16:50:06 +02:00
ProxMenuxBot 9f03164258 chore(lang): auto-rebuild translation cache
Source: 29bca61
Triggered by: push
2026-07-05 07:32:39 +00:00
MacRimi 29bca610a0 update 1.2.2.2 beta 2026-07-05 09:30:13 +02:00
MacRimiandGitHub 790c8d2fd4 Update beta_version.txt 2026-07-04 23:38:21 +02:00
MacRimi 877f7a3d81 update 1.2.2.2 beta 2026-07-04 22:58:07 +02:00
MacRimi d59b1af8a9 update 1.2.2.2 beta 2026-07-04 22:30:10 +02:00
MacRimiandGitHub 85184e174d Merge pull request #243 from MacRimi/docs-sync-20260704
docs: promote /web from develop (backup-restore, Log2RAM, Network Flow)
2026-07-04 22:12:09 +02:00
MacRimi 720f3fdbd2 docs: promote /web from develop for backup-restore, Log2RAM and Network Flow guides
Brings 69 files from develop under /web/:
- Full Backup & Restore section (11 pages EN + ES: overview, how-it-works, destinations,
  creating backups, scheduled jobs, restoring, cross-kernel hydration)
- Log2RAM dedicated block in post-install/optional with commands + upstream link
- Network Flow diagram documented on monitor/dashboard/network
- Rewritten category descriptions in post-install/customizable
- Fixed automated.json thresholds + link to Log2RAM section
- Updated screenshots (network-flow-overview, storage-top-row, vms modals)

No code, config or AppImage binaries touched — /web/ scope only. Merging deploys
the documentation site to the current beta release notes.
2026-07-04 22:10:49 +02:00
MacRimi 0d6c7290e5 Update 1.2.2.2 beta 2026-07-04 22:03:45 +02:00
MacRimi f768d9eff8 Update 1.2.2.2 beta 2026-07-04 21:50:29 +02:00
MacRimi 66dd3ec014 update 1.2.2.2 beta 2026-07-04 21:37:39 +02:00
ProxMenuxBot 29ebfdc324 chore(lang): auto-rebuild translation cache
Source: 17de0a5
Triggered by: push
2026-07-03 19:28:26 +00:00
MacRimi 17de0a5a77 Update 1.2.2.2 beta 2026-07-03 21:27:33 +02:00
ProxMenuxBot efc07056aa chore(lang): auto-rebuild translation cache
Source: 9a81c63
Triggered by: push
2026-07-03 17:29:14 +00:00
MacRimi 9a81c631fa Update backup_host.sh 2026-07-03 19:28:28 +02:00
ProxMenuxBot 2f01950d45 chore(lang): auto-rebuild translation cache
Source: c25441c
Triggered by: push
2026-07-03 17:19:03 +00:00
MacRimi c25441cca2 Update backup_host.sh 2026-07-03 19:17:03 +02:00
MacRimi 35cb10ff44 update 1.2.2.2 beta 2026-07-03 18:59:45 +02:00
ProxMenuxBot eb67fb9bd9 chore(lang): auto-rebuild translation cache
Source: c455d66
Triggered by: push
2026-07-02 21:54:25 +00:00
MacRimi c455d66b91 update 1.2.2.2 beta 2026-07-02 23:52:04 +02:00
MacRimi 153aae659c Update lib_host_backup_common.sh 2026-07-02 22:23:21 +02:00
MacRimi a00a6e5f2b Update backup_host.sh 2026-07-02 21:50:48 +02:00
ProxMenuxBot 2eef425f41 chore(lang): auto-rebuild translation cache
Source: ce3ad61
Triggered by: push
2026-07-02 19:15:15 +00:00
MacRimi ce3ad61f8f Update lib_host_backup_common.sh 2026-07-02 21:14:17 +02:00
MacRimi 0ad84572a7 Update lib_host_backup_common.sh 2026-07-02 21:11:54 +02:00
MacRimi c6fb7182e9 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-07-02 21:04:10 +02:00
MacRimi 003ae3d31a Update lib_host_backup_common.sh 2026-07-02 21:03:54 +02:00
ProxMenuxBot f17a83a1b3 chore(lang): auto-rebuild translation cache
Source: 827c88d
Triggered by: push
2026-07-02 18:58:09 +00:00
MacRimi 827c88d24f update 1.2.2.2 beta 2026-07-02 20:57:11 +02:00
github-actions[bot] 872f79a9ea Update AppImage beta build (2026-07-02 18:18:20) 2026-07-02 18:18:20 +00:00
ProxMenuxBot 0b2879ead0 chore(lang): auto-rebuild translation cache
Source: 357b2e8
Triggered by: push
2026-07-02 18:12:19 +00:00
MacRimi 357b2e8ac0 update 1.2.2.2 beta 2026-07-02 20:07:05 +02:00
github-actions[bot] efa84b0fa1 Update AppImage beta build (2026-07-02 16:24:06) 2026-07-02 16:24:06 +00:00
ProxMenuxBot 8705f638d5 chore(lang): auto-rebuild translation cache
Source: bc3c771
Triggered by: push
2026-07-02 16:15:13 +00:00
MacRimi bc3c771137 update 1.2.2.2 beta 2026-07-02 18:12:13 +02:00
ProxMenuxBot a92420d654 Update helpers_cache.json 2026-07-02 13:42:02 +00:00
ProxMenuxBot c0b950e637 Update helpers_cache.json 2026-07-02 02:34:29 +00:00
github-actions[bot] f0e79e93b6 Update AppImage beta build (2026-07-01 18:57:10) 2026-07-01 18:57:10 +00:00
MacRimi f2b0b1b039 Update notification_templates.py 2026-07-01 20:54:52 +02:00
github-actions[bot] 8235549e4f Update AppImage beta build (2026-07-01 18:19:00) 2026-07-01 18:19:00 +00:00
MacRimi 6b173c42b6 Update 1.2.2.2 beta 2026-07-01 20:13:59 +02:00
ProxMenuxBot f5d25f735c Update helpers_cache.json 2026-07-01 14:13:45 +00:00
ProxMenuxBot ac4370f6f3 Update helpers_cache.json 2026-07-01 09:31:53 +00:00
github-actions[bot] d522b9a337 Update AppImage beta build (2026-06-30 16:05:14) 2026-06-30 16:05:14 +00:00
MacRimi 33a8f4baa5 update 1.2.2.2 beta 2026-06-30 17:58:32 +02:00
ProxMenuxBot cbb58d0363 Update helpers_cache.json 2026-06-30 14:01:28 +00:00
ProxMenuxBot 35ebc90fa2 Update helpers_cache.json 2026-06-30 08:58:26 +00:00
ProxMenuxBot 2501b08c02 Update helpers_cache.json 2026-06-30 02:38:47 +00:00
ProxMenuxBot 24fd2d6e38 Update helpers_cache.json 2026-06-29 19:45:50 +00:00
ProxMenuxBot e79b1065d9 Update helpers_cache.json 2026-06-29 15:33:24 +00:00
ProxMenuxBot 2494f9a773 Update helpers_cache.json 2026-06-29 10:26:48 +00:00
ProxMenuxBot 9d3c2dd0d3 Update helpers_cache.json 2026-06-28 13:20:44 +00:00
MacRimi b2753be204 update 1.2.2.2 beta 2026-06-28 15:17:30 +02:00
ProxMenuxBot a3d282f33e chore(lang): auto-rebuild translation cache
Source: 51b9285
Triggered by: push
2026-06-28 11:08:04 +00:00
MacRimi 51b9285980 update 1.2.2.2 beta 2026-06-28 13:06:40 +02:00
ProxMenuxBot 0167e46eb7 Update helpers_cache.json 2026-06-26 14:07:30 +00:00
MacRimi ecfdcf1bac update 1.2.2.2 beta 2026-06-26 11:11:39 +02:00
MacRimi 63b9d69f3f update 1.2.2.2 beta 2026-06-26 10:25:06 +02:00
MacRimi a56afecccf update 1.2.2.2 beta 2026-06-25 17:29:55 +02:00
MacRimi 484f0ce897 update 1.2.2.2 beta 2026-06-25 17:07:58 +02:00
ProxMenuxBot 4efd5ae2d3 Update helpers_cache.json 2026-06-25 14:08:01 +00:00
ProxMenuxBot 2052308be0 Update helpers_cache.json 2026-06-25 02:35:55 +00:00
MacRimi cdb4522e5f update 1.2.2.2 beta 2026-06-25 00:26:03 +02:00
MacRimi 4f2494e135 Update 1.2.2.2 beta 2026-06-25 00:00:12 +02:00
MacRimi 202068124b update 1.2.2.2 beta 2026-06-24 23:40:22 +02:00
MacRimi 87a29f324b Update 1.2.2.2 beta 2026-06-24 21:58:21 +02:00
MacRimi 61b9fd12bb update 1.2.2.2 beta 2026-06-24 18:23:16 +02:00
MacRimi b7380fd582 Update lib_host_backup_common.sh 2026-06-24 16:14:33 +02:00
MacRimi cd2a075fab update 1.2.2.2 beta 2026-06-24 16:02:35 +02:00
ProxMenuxBot 9f23c6e852 Update helpers_cache.json 2026-06-23 14:31:55 +00:00
MacRimi 93553574b3 Update 1.2.2.2 beta 2026-06-23 11:19:04 +02:00
ProxMenuxBot 5807159e5f Update helpers_cache.json 2026-06-22 20:35:24 +00:00
MacRimi 6ab9d4ca27 Update 1.2.2.2 beta 2026-06-22 18:52:26 +02:00
ProxMenuxBot b5882cee04 Update helpers_cache.json 2026-06-22 16:40:38 +00:00
MacRimi cfdd78244d Update 1.2.2.2 beta 2026-06-22 17:52:20 +02:00
MacRimi 194523c13a Update backup_host.sh 2026-06-22 17:41:34 +02:00
MacRimi 84b53fe64c Update es.json 2026-06-22 17:29:08 +02:00
MacRimi 99e0227fec Update beta_version.txt 2026-06-22 17:26:02 +02:00
ProxMenuxBot 00ecf0497c chore(lang): auto-rebuild translation cache
Source: beb3e7e
Triggered by: push
2026-06-22 15:09:41 +00:00
MacRimi beb3e7e0c4 Update 1.2.2.2 beta 2026-06-22 17:08:31 +02:00
ProxMenuxBot 14a865dff4 Update helpers_cache.json 2026-06-22 11:38:10 +00:00
MacRimi 3b365f8ad5 Update proxmenux_debug.sh 2026-06-22 12:55:42 +02:00
MacRimi b8038f4d41 Update proxmenux_debug.sh 2026-06-22 12:25:49 +02:00
MacRimi 68c8c03642 update 1.2.2.2 pre-beta 2026-06-22 11:38:06 +02:00
ProxMenuxBot be4c31be92 Update helpers_cache.json 2026-06-22 02:58:38 +00:00
MacRimi c4cab77319 update 1.2.2.2 beta 2026-06-22 01:16:49 +02:00
MacRimi 9d099ba358 Update 1.2.2.2 beta 2026-06-22 00:55:34 +02:00
ProxMenuxBot 62fd69b147 chore(lang): auto-rebuild translation cache
Source: f50265a
Triggered by: push
2026-06-21 22:45:01 +00:00
MacRimi f50265a212 Update backup_host.sh 2026-06-22 00:44:02 +02:00
MacRimi e5669dd982 update 1.2.2.2 beta 2026-06-22 00:26:31 +02:00
MacRimi 7e6022da59 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-06-22 00:14:57 +02:00
MacRimi d807c9f085 Update flask_server.py 2026-06-22 00:14:46 +02:00
ProxMenuxBot d9a56ae0f1 chore(lang): auto-rebuild translation cache
Source: a9661a7
Triggered by: push
2026-06-21 21:52:11 +00:00
MacRimi a9661a71ff update 1.2.2.2 beta 2026-06-21 23:49:41 +02:00
MacRimi 7080570b43 update 1.2.2.2 beta 2026-06-21 23:20:00 +02:00
MacRimi a47198b37f Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-06-21 23:08:30 +02:00
MacRimi cab9c81c63 Update nvidia_installer.sh 2026-06-21 23:08:17 +02:00
ProxMenuxBot 25612b2252 chore(lang): auto-rebuild translation cache
Source: 09a67bf
Triggered by: push
2026-06-21 20:44:40 +00:00
MacRimi 09a67bf4e6 update 1.2.2.2 beta 2026-06-21 22:41:54 +02:00
ProxMenuxBot 3bfb7fa796 Update helpers_cache.json 2026-06-21 19:18:47 +00:00
ProxMenuxBot de118ac259 Update helpers_cache.json 2026-06-21 13:51:33 +00:00
ProxMenuxBot 6c7520b75e Update helpers_cache.json 2026-06-21 09:38:51 +00:00
ProxMenuxBot 5d36b41c1e Update helpers_cache.json 2026-06-21 02:55:43 +00:00
MacRimi 8e92df5bd7 update 1.2.2.2 beta 2026-06-21 00:35:22 +02:00
MacRimi 57e936785d update 1.2.2.2 beta 2026-06-20 21:59:19 +02:00
ProxMenuxBot d7b67ba4a8 Update helpers_cache.json 2026-06-20 19:12:51 +00:00
ProxMenuxBot 7192b7b2eb Update helpers_cache.json 2026-06-20 13:44:07 +00:00
MacRimi 3fd7f4b2a4 Update 1.2.2.2 beta 2026-06-19 23:38:57 +02:00
ProxMenuxBot 320dbe1948 Update helpers_cache.json 2026-06-19 19:28:31 +00:00
ProxMenuxBot 73d4d141bc Update helpers_cache.json 2026-06-19 14:46:29 +00:00
ProxMenuxBot 273966e77f Update helpers_cache.json 2026-06-18 14:50:46 +00:00
ProxMenuxBot 1c0c4df3e3 Update helpers_cache.json 2026-06-17 20:02:43 +00:00
ProxMenuxBot fe79d35cc0 Update helpers_cache.json 2026-06-16 16:27:39 +00:00
ProxMenuxBot 132a52c7b2 Update helpers_cache.json 2026-06-15 11:49:19 +00:00
ProxMenuxBot bf1f68d5e6 Update helpers_cache.json 2026-06-13 19:12:04 +00:00
MacRimi 22a8cbc402 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-06-13 19:05:11 +02:00
MacRimi c4447eae5e update 1.2.2.2 beta 2026-06-13 19:05:00 +02:00
ProxMenuxBot d88d0f765e chore(lang): auto-rebuild translation cache
Source: 7ea9f10
Triggered by: push
2026-06-13 16:54:49 +00:00
MacRimi 7ea9f10d6f Update 1.2.2.2 beta 2026-06-13 18:52:28 +02:00
MacRimi 024ca83afd Update es.json 2026-06-13 18:29:03 +02:00
ProxMenuxBot 2f01c815e8 chore(lang): auto-rebuild translation cache
Source: 4c65d5a
Triggered by: push
2026-06-13 15:38:26 +00:00
MacRimi 4c65d5a07a update 1.2.2.2 beta 2026-06-13 17:34:11 +02:00
MacRimi 905fa4afce Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-06-13 17:11:49 +02:00
MacRimi 217d9735a6 Update es.json 2026-06-13 17:09:09 +02:00
ProxMenuxBot e3e9d740ff chore(lang): auto-rebuild translation cache
Source: 0920659
Triggered by: push
2026-06-13 14:44:52 +00:00
MacRimi 0920659693 Update commands_share.sh 2026-06-13 16:44:10 +02:00
MacRimi ae68d59a20 Update es.json 2026-06-13 16:39:39 +02:00
MacRimi 8b5d7c65d9 Update es.json 2026-06-13 16:30:05 +02:00
MacRimi b45f032f89 Update es.json 2026-06-13 16:19:59 +02:00
MacRimi ee58c21fef Update select_windows_iso.sh 2026-06-13 11:35:48 +02:00
MacRimi 2a198db593 Update select_linux_iso.sh 2026-06-13 11:34:12 +02:00
MacRimi f8be7b06d7 Update iso_storage_helpers.sh 2026-06-13 11:31:54 +02:00
MacRimi 70ed3c6f1f Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-06-13 11:20:45 +02:00
MacRimi 64356965fb Update 1.2.2.2 beta 2026-06-13 11:20:33 +02:00
ProxMenuxBot 75dcb8935d chore(lang): auto-rebuild translation cache
Source: d66dc07
Triggered by: push
2026-06-13 09:14:41 +00:00
MacRimi d66dc07ae1 Update 1.2.2.2 bate 2026-06-13 11:03:48 +02:00
ProxMenuxBot f066eb0a60 Update helpers_cache.json 2026-06-13 02:40:36 +00:00
MacRimi d9fee64c35 Update backup_host.sh 2026-06-12 23:58:21 +02:00
ProxMenuxBot 9039064652 chore(lang): auto-rebuild translation cache
Source: e3ce042
Triggered by: push
2026-06-12 21:54:17 +00:00
MacRimi e3ce042be4 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-06-12 23:53:13 +02:00
MacRimi c37d3fa34e update 1.2.2.2 beta 2026-06-12 23:53:08 +02:00
ProxMenuxBot a592eb896f chore(lang): auto-rebuild translation cache
Source: 2d3c8f5
Triggered by: push
2026-06-12 21:18:46 +00:00
MacRimi 2d3c8f5713 Update 1.2.2.2 beta 2026-06-12 23:17:11 +02:00
MacRimi c6d93278cd update 1.2.2.2 beta 2026-06-12 22:06:26 +02:00
MacRimi 03b6f25e14 Update nvidia_installer.sh 2026-06-12 21:03:01 +02:00
MacRimi 6ccb54e64a Update pci_passthrough_helpers.sh 2026-06-12 20:57:45 +02:00
MacRimi 6e1e47d9fd update 1.2.2.2 beta 2026-06-12 20:05:17 +02:00
MacRimi 761357b737 Update 1.2.2.2 beta 2026-06-12 19:57:54 +02:00
MacRimi 57f1ebc358 Update lib_host_backup_common.sh 2026-06-12 18:28:06 +02:00
MacRimi 345d66e0fd Update nvidia_installer.sh 2026-06-12 17:19:20 +02:00
MacRimi b5cdf1d2a6 Update apply_cluster_postboot.sh 2026-06-12 17:04:56 +02:00
ProxMenuxBot fe81ae8f7d Update helpers_cache.json 2026-06-12 09:58:41 +00:00
MacRimi 382493ca84 update 1.2.2.2 beta 2026-06-12 00:05:58 +02:00
ProxMenuxBot 450fceec62 chore(lang): auto-rebuild translation cache
Source: 41de70a
Triggered by: push
2026-06-11 21:49:48 +00:00
MacRimi 41de70a1b9 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-06-11 23:49:16 +02:00
MacRimi 9bedb4f311 update 1.2.2.2 beta 2026-06-11 23:49:13 +02:00
ProxMenuxBot 7166a10ac2 chore(lang): auto-rebuild translation cache
Source: ed924b6
Triggered by: push
2026-06-11 21:10:52 +00:00
MacRimi ed924b67fe update 1.2.2.2 beta 2026-06-11 23:08:56 +02:00
ProxMenuxBot 0b8c0f57f8 Update helpers_cache.json 2026-06-11 20:11:23 +00:00
ProxMenuxBot a4d1e9fbb9 chore(lang): auto-rebuild translation cache
Source: 9afbf0e
Triggered by: push
2026-06-11 17:16:13 +00:00
MacRimi 9afbf0ea5e Update lib_host_backup_common.sh 2026-06-11 19:11:23 +02:00
ProxMenuxBot 8e9c853a81 Update helpers_cache.json 2026-06-11 15:39:44 +00:00
ProxMenuxBot 3d9ade0f37 chore(lang): auto-rebuild translation cache
Source: 6094ab8
Triggered by: push
2026-06-11 15:26:13 +00:00
MacRimi 6094ab8e1c update beta 1.2.2.2 2026-06-11 17:24:20 +02:00
ProxMenuxBot 1f13a35c51 Update helpers_cache.json 2026-06-11 10:10:01 +00:00
ProxMenuxBot 38845197d8 Update helpers_cache.json 2026-06-10 20:16:47 +00:00
MacRimi f9cf931828 Update run_scheduled_backup.sh 2026-06-10 20:10:26 +02:00
MacRimi 7a88971114 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-06-10 20:00:08 +02:00
MacRimi 827aa7154f update 1.2.2.2 beta 2026-06-10 19:59:56 +02:00
ProxMenuxBot 1ebf4d0a28 chore(lang): auto-rebuild translation cache
Source: df95b50
Triggered by: push
2026-06-10 17:55:16 +00:00
MacRimi df95b50f8c Update beta 1.2.2.2 2026-06-10 19:53:40 +02:00
MacRimi 7ad5508623 lang: seed translation cache (es, fr, de, it, pt) 2026-06-10 19:12:45 +02:00
MacRimi 4dc8be7387 Add beta 1.2.2.2 2026-06-10 19:05:13 +02:00
ProxMenuxBot d9d63b8f1c Update helpers_cache.json 2026-06-09 19:58:09 +00:00
MacRimi 165e8c9636 Update backup_host.sh 2026-06-09 19:44:14 +02:00
MacRimi cff2ca3c95 Update lib_host_backup_common.sh 2026-06-09 19:17:20 +02:00
MacRimi d41871cc53 update 1.2.2.1 beta 2026-06-09 19:14:27 +02:00
MacRimi 6b3c42e0ed Delete test_backup_restore.sh 2026-06-09 17:58:52 +02:00
MacRimi d41eaef8a2 delete files backups scripts 2026-06-09 17:56:17 +02:00
MacRimi f54118843e Create jc_channel.txt 2026-06-09 17:48:23 +02:00
MacRimi f0b8474350 Update 1.2.2.1 beta 2026-06-09 17:42:51 +02:00
MacRimi 61ff665cec update beta 1.2.2.2 2026-06-09 00:13:24 +02:00
ProxMenuxBot dac5ff72a3 Update helpers_cache.json 2026-06-07 12:30:17 +00:00
MacRimi 6844406cf7 Update 1.2.2.1 2026-06-07 11:31:50 +02:00
ProxMenuxBot 083f8e5fd7 Update helpers_cache.json 2026-06-07 00:36:27 +00:00
ProxMenuxBot 218ab2aa89 Update helpers_cache.json 2026-06-06 18:25:14 +00:00
MacRimi 61ff98e830 Update beta 1.2.2.1 2026-06-06 18:30:11 +02:00
ProxMenuxBot 9a938d129b Update helpers_cache.json 2026-06-06 12:24:58 +00:00
MacRimi 66419777d8 Update beta 1.2.2.1 2026-06-06 11:37:54 +02:00
ProxMenuxBot 09abef2d15 Update helpers_cache.json 2026-06-06 00:34:59 +00:00
ProxMenuxBot 0a09fa4987 Update helpers_cache.json 2026-06-05 18:37:30 +00:00
MacRimi d401e5f7de Add new beta 1.2.2.1 2026-06-05 19:45:46 +02:00
MacRimi 3191f5250d Update 1.2.2.1 beta 2026-06-05 19:22:07 +02:00
MacRimi 9656b04a3e Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2026-06-05 17:12:27 +02:00
MacRimi 3629fe8848 Add beta 1.2.2.1 2026-06-05 17:12:23 +02:00
ProxMenuxBot e6fe598e7a Update helpers_cache.json 2026-06-05 12:57:15 +00:00
MacRimi e855fca0b3 new beta 1.2.2.1 2026-06-03 18:04:58 +02:00
MacRimiandGitHub 9b0e498c6d Merge pull request #229 from MacRimi/feature/installer-clear-beta-marker
install_proxmenux: clear stale beta_version.txt on every stable install
2026-06-03 16:35:37 +02:00
MacRimiandClaude Opus 4.7 371f61fa08 install_proxmenux: clear stale beta_version.txt on every stable install
A user who rode the beta channel and later switched back to stable
keeps a leftover beta_version.txt under /usr/local/share/proxmenux/.
The `menu` launcher's beta-mode update check (`check_updates_beta`)
short-circuits when that file isn't present, but it stays put across
stable installs and updates today, so the user keeps seeing the
"Beta update available" prompt on top of the legitimate stable one
even though they're no longer on the beta channel.

Drop the marker on every stable install/update, in both the update
path (around the `cp ./version.txt` near the scripts-tree wipe) and
the fresh-install path (the symmetric block lower in the file).
The comment about which files survive a scripts-tree wipe is
updated to no longer mention beta_version.txt, since that's exactly
what we're removing.

If the user re-opts into the beta program, install_proxmenux_beta
re-creates the file — this only clears stale state that the user no
longer has any way to update from anyway.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 16:34:20 +02:00
ProxMenuxBot fa6ff43c6f Update helpers_cache.json 2026-06-03 13:32:28 +00:00
MacRimiandGitHub bde36dd241 Merge pull request #225 from MacRimi/revert/v1.2.2.1-bump
Revert v1.2.2.1 patch release — keep code fix, drop unnecessary bump
2026-06-02 21:08:15 +02:00
MacRimiandClaude Opus 4.7 ae91fc4cdd Revert v1.2.2.1 patch release — keep code fix, drop unnecessary bump
PR #223 shipped the install_proxmenux.sh unit-rewrite fix together
with a version bump to 1.2.2.1 and a matching CHANGELOG entry.
With both the fix (#223) and the menu self-heal (#224) already in
main the bump turns out to be unnecessary for recovery:

* Users on v1.2.1 stable updating now pull the corrected installer
  from main and arrive at v1.2.2 working.
* Users stuck on a broken v1.2.2 get repaired by
  `auto_repair_monitor_unit` on every menu launch.
* Users on a healthy v1.2.2 had nothing to fix.

Leaving 1.2.2.1 published would force a no-op update prompt across
every healthy v1.2.2 install. Revert version.txt to 1.2.2 and drop
the v1.2.2.1 CHANGELOG section (EN+ES) so the public release notes
stay clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:06:39 +02:00
MacRimiandGitHub 6c7a8cae92 Merge pull request #224 from MacRimi/hotfix/v1.2.2.1-menu-self-heal
v1.2.2.1 part 2: self-heal monitor unit on menu launch (#222)
2026-06-02 20:48:00 +02:00
MacRimiandClaude Opus 4.7 7cea5563a7 menu: self-heal broken monitor unit on launch (belt-and-suspenders for #222)
The installer fix in this PR rewrites the systemd unit on every
v1.2.2.x update, which catches every user once they accept the
update prompt. But the prompt in `menu` uses `--defaultno` so a
user who presses Enter by reflex stays on the broken state and
opens a fresh issue, which is the scenario unfolding in #222.

Add a tiny `auto_repair_monitor_unit` function that runs before
`check_updates` on every menu launch. It only touches anything when
the bug's exact fingerprint is present:

  1. /etc/systemd/system/proxmenux-monitor.service exists
  2. Its ExecStart points at /usr/local/share/proxmenux/ProxMenux-Monitor.AppImage
  3. The extracted AppRun is already on disk at /usr/local/share/proxmenux/monitor-app/AppRun

When all three are true the function rewrites the unit, reloads
systemd, restarts the service, and logs a single msg_ok line. For
healthy installs and for hosts that never had the Monitor at all,
it returns immediately without touching anything — safe to ship
unconditionally.

Verified on .55 by simulating the broken unit (ExecStart on the
bare AppImage → 203/EXEC + activating loop) and running the new
menu script: unit rewritten to AppRun, service active, single
"ProxMenux Monitor unit repaired and restarted" line printed.

CHANGELOG entries (EN+ES) updated to mention the auto-repair so
users on the broken state know the simpler recovery is now "just
run menu".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 20:44:12 +02:00
MacRimiandGitHub 755c289894 Merge pull request #223 from MacRimi/hotfix/v1.2.2.1-monitor-unit-rewrite
v1.2.2.1: rewrite monitor unit on update to point at AppRun (fixes #222)
2026-06-02 20:34:22 +02:00
MacRimiandClaude Opus 4.7 17c5b89cc8 v1.2.2.1: rewrite monitor unit on every update to point at AppRun (#222)
The v1.2.2 install layout extracts the AppImage into
/usr/local/share/proxmenux/monitor-app/ and runs AppRun out of that
directory — but install_proxmenux_monitor's update branch only
called create_monitor_service on fresh installs, leaving the inherited
unit's `ExecStart=/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage`
in place. That path used to be the FUSE-mounted AppImage entry point,
which v1.2.2 deliberately replaced to clear a Wazuh rule-521 false
positive on /tmp/.mount_*. On PVE 9.x / Debian 13 the bare AppImage
fails to exec straight away (status=203/EXEC) so the service entered
the activating loop reported in #222 and never came back up.

Always rewrite the unit before the post-update `systemctl start` —
idempotent for installs whose unit is already correct, recovering
for those whose isn't. The new helper
`_proxmenux_rewrite_monitor_unit_for_apprun` mirrors the unit body
the fresh-install path emits in `create_monitor_service`, with the
same template-from-repo / inline-fallback fork, so both paths
converge on the same content.

Reproduced and validated on PVE 9.x lab:

  before:
    Process: ExecStart=/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage
             (code=exited, status=203/EXEC)
    Active: activating (auto-restart)

  after:
    ExecStart=/usr/local/share/proxmenux/monitor-app/AppRun
    Active: active (running)

Bumps version.txt to 1.2.2.1 so the existing menu update path picks
this up automatically. For users already stuck on a broken v1.2.2,
re-running the installer manually applies the same fix:
  bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 20:31:33 +02:00
MacRimiandGitHub 32a3e20c76 Merge pull request #221 from MacRimi/hotfix/doc-nav-storage-share-anchors
Hotfix: doc-navigation skips sidebar anchor-only section headers
2026-06-02 19:50:41 +02:00
MacRimiandClaude Opus 4.7 5e795a654d doc-navigation: skip sidebar anchor-only entries from Prev/Next walk
`#host` and `#lxc-net` are visual sidebar section headers for the
Storage Share Manager page — they group their submenu items in the
sidebar tree but point back at the parent Overview with an anchor,
so they aren't standalone docs the reader advances to. Including
them in the flat Previous/Next sequence produced two regressions:

* On `/docs/storage-share/#host` the Next button targeted `#host`
  again, so clicking it didn't move. The earlier hash-tracking fix
  intended to catch this, but a `useEffect` with an empty dep array
  only runs on mount — and Next.js Link navigations don't fire
  `hashchange` when the path changes too, so a cross-page navigation
  that lands on `#host` (sidebar click) rendered with hash="" and
  re-collapsed to the section header.
* On `/docs/storage-share/lxc-mount-points/` the Next button pointed
  at `#lxc-net` instead of advancing to `lxc-nfs-client`, since the
  section header sat between the two real pages in the flat list.

Filter out any sidebar entry whose href contains `#` at walk time so
the flat list only carries real pages. With them gone, an anchored
URL collapses to its parent Overview and Next walks straight into
the first subpage. The hash effect + state are no longer needed so
the component drops them, keeping only the pathname-based match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 19:49:36 +02:00
github-actions[bot] 3103b87249 Update AppImage release build (2026-06-02 16:52:21) 2026-06-02 16:52:21 +00:00
MacRimiandGitHub 98e9fb3a14 Merge pull request #218 from MacRimi/develop
Release 1.2.2 — version.txt + CHANGELOG (EN/ES) + contributor link
2026-06-02 18:47:03 +02:00
MacRimi 43e9d5db67 Update share_menu.sh 2026-06-02 18:43:03 +02:00
MacRimiandClaude Opus 4.7 bcf13d71b2 share_menu: drop redundant Host-only sub-header
The HOST block already groups everything that runs on the host
side, so the secondary "Host-only resources" divider between item 4
and item 5 was visual noise — the user reading the menu sees two
HOST sub-titles back to back and has to wonder how items 1-4 differ
from item 5 in scope. They don't; items 1-4 register an external or
local resource as a Proxmox storage, item 5 creates a local shared
directory. Both are host-side actions.

While here, retitle item 5 from "Add Shared Directory on Host" to
"Create Shared Directory on Host" — items 1-4 add an existing
external/local resource, item 5 creates a new one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:40:25 +02:00
MacRimiandClaude Opus 4.7 0ac84dc3e4 lxc-mount-manager: start stopped CT on request and verify writes
Two related improvements to the post-add verification step that the
user hit while testing the stopped-CT case.

A stopped container couldn't be probed at all — the previous patch
just told the user "mount will activate on next start" and left
them to discover any issues later (the typical issue being
permission denied on the host directory, since the dialog confirms
the bind-mount was added but never proves it works). Offer to start
the container right now so the user gets feedback in the same
session; if they decline, fall back to the informational line.

The post-restart / post-start probe used `test -d $ct_mount_point`
which only checks that the directory is visible inside the
container. That always succeeds whenever the bind-mount took
effect, even if the host directory permissions don't let the
unprivileged-LXC mapped uid write — exactly the case the user just
ran into with /mnt/disk-sda (700 → others gets r-x). Replace with a
touch+rm probe in a new `_lmm_verify_writable` helper used by both
branches so the user is told straight away when writes will fail
and, when they will, is given the exact `chmod o+rwx` / `setfacl`
command and a pointer to the host-perms prompt.

Verified on .55 / LXC 112 (unprivileged) against /mnt/disk-sda:
container stopped → start prompt → start → directory visible →
touch probe → success.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:36:17 +02:00
MacRimiandClaude Opus 4.7 a5fdf43b70 lxc-mount-manager: drop dangling spinner on stopped-CT message
The earlier fix to handle stopped containers in the add flow used
`msg_info` for the "Container is stopped — mount will activate
automatically on next start" line. `msg_info` is the spinner-start
half of the msg_info/msg_ok pair — it never gets closed here
because there's nothing to wait on, so the spinner glyph stays
visible (⠋) right before the "Press Enter to continue" prompt.

Switch to `msg_ok` so the line renders as a clean static success
mark, matching the visual style of the other terminal messages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:33:46 +02:00
MacRimi bbfe3adc54 Update disk_host.sh 2026-06-02 18:29:29 +02:00
MacRimiandClaude Opus 4.7 a921aac51e disk_host: open host mount path for unprivileged LXC bind-mounts
The fstab-only mount method explicitly says "for LXC bind-mounts" in
its dialog wording, but the mount point left behind by mkfs +
mkdir is owned root:root with mode 0755. An unprivileged LXC sees
the directory through its uid offset (root inside → host uid 100000)
which lands under the directory's "others" bits — so the container
can read but never write, and the user has to track down the
chmod / setfacl step manually.

lxc-mount-manager_minimal.sh already offers exactly this fix as
`lmm_offer_host_permissions` when the user adds the bind-mount
through that script, but the disk-side script never closed its half
of the loop. Add a small `_apply_lxc_bind_mount_perms` helper that
runs `chmod o+rwx` plus `setfacl o::rwx + default ACL` whenever
MODE_FSTAB=1, and call it from both `mount_disk_permanently`
(format path) and `mount_existing_disk` (use_existing path). Pure
pvesm-only mounts keep the original behaviour — chmod o+rwx on a
VM/backup storage isn't desirable.

Verified on .55 against the existing /mnt/disk-sda + LXC 112
(unprivileged): unprivileged container root could not write before
(Permission denied), writes succeed after the perms are applied and
land on the host as uid 100000 as expected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:21:27 +02:00
MacRimiandClaude Opus 4.7 15ed0f84e7 disk_host: surface fstab-only mounts in view, filter out network shares
Two related gaps in the disk-host script that surfaced while testing
the new dual-flow (pvesm / fstab-only / both) added earlier today.

view_disk_storages used to read only from `pvesm status`, so a disk
added via the new fstab-only path — exactly the case where the user
wants a local disk available for LXC bind-mounts without registering
it as a Proxmox storage — never showed up. Replicate the same fstab
scan remove_disk_storage already performs and list those mounts in a
second section underneath the pvesm ones. Empty state and wording
updated so the panel no longer claims "No local storage configured
in Proxmox" when fstab-only mounts are present.

Both view_disk_storages (new code) and remove_disk_storage (existing
fstab branch) were happily picking up `/mnt/Archivos` and any other
CIFS/NFS share mounted under /mnt. samba_host.sh and nfs_host.sh own
those — surfacing them in the local-disk menus would let a user
remove a network share from the wrong screen. Filter by fstype
(skip cifs/smbfs/nfs/nfs4/nfsv4/sshfs/fuse) and additionally require
that the resolved source be a real block device, which drops bind
mounts and anything else whose backing source isn't a disk.

Verified on .55 with the test disk-sda fstab-only mount alongside
the existing //192.168.0.15/Archivos CIFS mount: only disk-sda is
listed by the local-disk view/remove flows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:15:37 +02:00
MacRimiandClaude Opus 4.7 2a376d2c9b scripts/share: don't reboot a stopped CT, add fstab-only mode to disk_host
Two release-day fixes in the host-side share tooling, both reported
during testing of the v1.2.2 candidate.

lxc-mount-manager_minimal.sh
  After adding a mount point on a stopped LXC the script offered to
  `pct reboot $ct` unconditionally — which fails on a stopped CT
  because `pct reboot` only accepts running ones, so the user saw a
  bogus "Failed to restart" right after a successful mount. Gate the
  prompt on `pct status` and, when the CT is stopped, tell the user
  the mount will activate on next start instead of trying to reboot
  it. The matching restart prompt in the remove flow (around line
  540) was already doing the check correctly; this just brings the
  add flow in line.

disk_host.sh
  The script always registered the disk as a Proxmox storage via
  `pvesm add dir|zfspool`. nfs_host.sh and samba_host.sh already
  offered a dual-flow chooser ("Proxmox storage" / "host fstab only"
  / both) so a user could mount the share on the host for LXC
  bind-mounts without surfacing it as a Proxmox storage. Replicate
  that chooser for local disks:

  * new `select_mount_method` checklist with `pvesm` and `fstab`,
    inserted after filesystem selection. ZFS is forced into the
    pvesm path because a ZFS pool can't be expressed as an fstab
    mount.
  * `configure_disk_storage` skips the Content Types prompt when
    only fstab is selected and renames "Storage ID" → "Mount Name"
    in the same case so the wording matches what the user will
    actually see (or not see) in Proxmox.
  * `format_and_mount_disk` title and summary lines adapt to the
    chosen mode.
  * the trailing `add_proxmox_dir_storage` call in `add_local_disk_storage`
    runs only when `MODE_PVESM=1`; in fstab-only mode the final
    message points users at the LXC Mount Manager for bind-mounts.

  Verified end-to-end on a 32 GB USB disk against LXC 112
  (unprivileged) on .55: fstab-only path → bind-mount → root inside
  CT writes mapped to host uid 100000, regular user writes mapped to
  host uid 101000, both reads/writes successful from inside the
  container.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:03:00 +02:00
MacRimi f27b3d480a Update lxc-mount-manager_minimal.sh 2026-06-02 17:49:37 +02:00
MacRimiandClaude Opus 4.7 5a116e77b9 Discord channel: split oversized digests across embeds (#220)
A mass-backup webhook that exceeded ~2 KB used to be silently
truncated by `desc = message[:MAX_EMBED_DESC]` with MAX_EMBED_DESC
set to 2048 — half of Discord's real description limit and far
below what a multi-VM backup digest produces. The trailing jobs
just vanished from the channel.

Bring the channel up to Discord's actual webhook contract:

* description limit raised to the real 4096-char cap
* if the body still doesn't fit, split it on line boundaries into
  one embed per chunk so every backup entry is preserved
* keep title + fields on the first embed only; attach the footer
  and timestamp to the last embed so the rendered card has the
  normal head/tail framing even when split across many embeds
* enforce Discord's 6000-char-per-embed cap (title + description +
  every field name+value) — only kicks in when many large fields
  combine with a chunk already near the description ceiling
* batch up to 10 embeds per webhook POST (Discord's per-message
  limit) and POST additional messages sequentially with a 0.4 s
  gap so a >10-embed digest doesn't trip the 5/2 s webhook rate
  limit

Verified with synthetic mass-backup payloads:
* 14 KB / 200 jobs → 4 embeds, 1 POST
* 60 KB / 60 lines → 15 embeds, 2 POSTs (10 + 5)

New AppImage SHA-256:
  16ad59ea63a64e5be460cd73f87315e8b39b756bf1c61f3cb2019e9fa3e76361

Closes #220.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:30:59 +02:00
ProxMenuxBot 1d66011b08 Update helpers_cache.json 2026-06-02 13:11:28 +00:00
MacRimiandGitHub 72b613a00e Update contributors image link in README
Updated contributors image link to include version parameter.
2026-06-02 11:32:10 +02:00
MacRimiandClaude Opus 4.7 17cae5d3a4 Refresh AppImage binary + sha256 after NVMe-obs-count fix
New build picks up the get_disks_observation_counts NVMe-rename fix.

SHA-256:
  3b44eb1172b4b1b7e6a36d1c9f1cd5a237ec04d52543bb791358525b0653a402

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:52:39 +02:00
MacRimiandClaude Opus 4.7 642bd8ecae health_persistence: stop leaking obs counts across NVMe device renames
`get_disks_observation_counts` maps each serial's count to that
serial's "most recent" device_name (so renames like ata8 -> sdh keep
the badge attached). When several physical disks have passed through
the same kernel name across reboots — common with NVMe, the kernel
probes in a different order depending on which slots are populated —
disk_registry keeps a row per (device_name, serial) seen and the
"most recent" device_name for a serial can now be in use by an
entirely different disk.

Concrete case from the wild: serial 211716800490 was nvme0n1 during
the previous boot and earned a real I/O observation. After removing
four of five NVMes, the surviving disk (serial 243332800236) booted
into nvme0n1. The badge layer mirrored 211716800490's count onto
nvme0n1 — which is now a different physical disk — and showed
"1 obs." on the wrong drive, while the modal (which scopes by the
current (device_name, serial) registry row) found nothing and
rendered an empty history.

Only mirror a serial's count onto its device_name when that
device_name is currently owned by the same serial, determined from
the freshest disk_registry row. The serial-keyed entry stays
unconditional so observations remain reachable when the disk is
re-plugged under another device name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:52:11 +02:00
MacRimiandClaude Opus 4.7 92385f44b0 Drop stale ProxMenux-1.2.0.AppImage binary
The v1.2.0 binary lingered in the repo after later releases. Remove
it so AppImage/ holds only the current shipping artefact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:22:13 +02:00
MacRimiandClaude Opus 4.7 4cd1cb4e39 Refresh AppImage binary + sha256 for v1.2.2
The tracked binary still pointed at the build made before the
last two fixes landed (resolution_reason persistence in
health_persistence and disk-temp breakdown alignment in
storage-overview). Re-build the AppImage so the GitHub-published
binary matches what is actually running on the deploy targets.

New SHA-256:
  d043e2f27f21315931ab53d87f02390b1a66b0c1730e8b7699aafb565809efbb

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:21:24 +02:00
MacRimiandClaude Opus 4.7 7a1fe0b0fc storage-overview: drive disk-temp breakdown from configurable thresholds
`getDiskHealthBreakdown` carried its own hardcoded ladder (HDD ≤45
normal, ≤55 warning) that was much stricter than the configurable
defaults consumed by `getTempColor` via `useDiskTempThresholds`
(HDD warn 60, hot 65). HDDs at 48 °C therefore rendered a green
"Healthy 48°C" badge on the card but were tallied as "warning" in
the top-of-page "X normal, Y warning, Z critical" summary, leaving
the user with the misleading "6 normal, 5 warning" line.

Use the same threshold map as the per-disk badge so the colour and
the count are always consistent, and so Settings → Health Monitor
Thresholds → Disk temperature actually applies to the breakdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:13:55 +02:00
MacRimiandClaude Opus 4.7 3c5beb0286 Persist resolution_reason on resolve_error so the audit log is useful
The UPDATE in `_resolve_error_impl` only touched `resolved_at` — the
`reason` argument every caller passes was silently dropped, and the
`resolution_reason` / `resolution_type` columns stayed NULL for every
auto-resolved error. The columns were added back in a previous sprint
for exactly this audit-log purpose, but the writer was never updated
to populate them.

Fix the SQL to write `resolution_reason = ?` and tag
`resolution_type = COALESCE(existing, 'auto')` so admin-cleared
errors (whose type is set elsewhere) keep their value while the
default auto path correctly labels itself.

Verified end-to-end on the lab host: re-injected the `disk_nvme2n1`
warning, waited one scan cycle, the row now reads
`resolution_type='auto'` and
`resolution_reason='Transient I/O cleared, SMART now reports healthy'`
— previously these columns stayed NULL even though the resolve_error
call passed a descriptive reason.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:02:52 +02:00
MacRimiandClaude Opus 4.7 9677c5cb19 Health Monitor: reconcile stale disk warnings across reboots
When a host gets transient I/O events on a disk while smartctl is
momentarily unavailable (the canonical case: late in a noisy
shutdown), the disk-scan code records a `disk_<name>` WARNING tagged
"SMART: unavailable" exactly once and trusts the next scan to clear
it. That trust is misplaced: the clear path only fires when the
device shows up in the current dmesg window with zero events. After
a reboot, dmesg is empty for that device — so the device never gets
iterated, resolve_error is never called, and the dashboard stays
orange for a disk whose SMART now reports PASSED.

Caught on a lab host where `disk_nvme2n1` had been stuck as WARNING
for hours after a reboot. SMART was 100% healthy at the moment of
inspection (Critical Warning 0x00, 0 media errors, 100% spare). The
error's first_seen and last_seen were identical and pre-dated the
current boot, confirming a one-shot record that nothing had cleared.

Fix: add a `_reconcile_stale_disk_warnings()` pass at the top of
`_check_disks_optimized()`. For every active `disk_*` error
(skipping `disk_fs_*`, which is already reconciled separately):

  - device gone from /dev/   → resolve "Device no longer present"
  - device present + SMART PASSED → resolve "Transient I/O cleared,
    SMART now reports healthy"
  - device present + SMART UNKNOWN/FAILED → leave active so the
    main loop can re-classify on the next dmesg window

Acknowledged errors are left alone so the user's explicit dismiss
intent isn't overridden.

Verified end-to-end: re-injected the original `disk_nvme2n1`
warning into the persistence DB on the lab host, waited one scan
cycle, error was resolved automatically with `resolved_at` set and
`resolution_reason = 'Transient I/O cleared, SMART now reports
healthy'`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 22:54:14 +02:00
MacRimiandClaude Opus 4.7 d25faedc2b Rebuild AppImage with actual Next.js 15.1.9 + always reconcile node_modules
The previous bump commit (2f24de25) shipped a binary that still carried
Next.js 15.1.6 in the bundled chunks even though AppImage/package.json
was at 15.1.9. Root cause: build_appimage.sh only ran `npm install`
when `node_modules` did not exist; on the .50 build host node_modules
had been cached since the 1.2.1 build cycle, so the bump was silently
ignored and the build re-used the stale tree.

Fix the script: always run `npm install --legacy-peer-deps` on every
build. npm reconciles against the lockfile in under a second when
everything is already in sync, so the change is free on a warm tree
and correct on a stale one.

Rebuild from a clean node_modules on .50, redeploy to all four hosts
(SHA 4602b8d4aa130c6f...), runtime grep confirms the bundle now
contains 15.1.9 with no traces of 15.1.6 left. Same architecture and
threat model as before — Flask serves the static export on :8008,
no Next.js runtime — but the version banner now matches the lockfile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 22:37:44 +02:00
MacRimiandClaude Opus 4.7 2f24de2592 Bump Next.js to 15.1.9 + doc nav handles in-page anchors + help_info_menu
Three changes that fold into the v1.2.2 release PR:

1. AppImage: bump Next.js 15.1.6 -> 15.1.9 (CVE-2025-55182)
   GHSA-9qr9-h5gf-34mp / React2Shell is a pre-auth RCE in React Server
   Components when Server Functions deserialize attacker payloads. The
   ProxMenux Monitor ships Next.js in `output: "export"` mode behind
   Flask on :8008, so there is no runtime Next.js server and no
   "use server" directive in the source tree — the exploitable path is
   not reachable. Bumping to 15.1.9 anyway because OpenVAS and similar
   scanners flag the version string from the JS bundle regardless of
   architecture; raising the floor removes false-positive noise across
   every install. Reported by @rost43 in #219.

2. web/components/ui/doc-navigation.tsx: handle sidebar entries that
   point to in-page anchors. The Storage Share Manager sidebar has
   entries for `/docs/storage-share#host` and
   `/docs/storage-share#lxc-net` as section headers, but
   usePathname() does not include the hash so every visit collapsed
   to the parent page. As a result Next/Previous on /docs/storage-share
   stayed stuck at #host, and Next from .../lxc-mount-points/ pointed
   back at #host instead of #lxc-net. Read window.location.hash on
   mount (and on hashchange) and try the pathname+hash match before
   falling back to the pathname-only lookup. SSR hydrates with an
   empty hash and refreshes once mounted — brief render before
   hydration is the same as the previous behaviour, so no regression.

3. scripts/help_info_menu.sh: user-side improvement (mirrored from
   develop).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 22:31:12 +02:00
ProxMenuxBot 751a0ef6f4 Update helpers_cache.json 2026-06-01 13:57:32 +00:00
ProxMenuxBot d47f66ebba Update helpers_cache.json 2026-06-01 07:41:55 +00:00
MacRimiandClaude Opus 4.7 3b2665c4ac Release date: shift CHANGELOG v1.2.2 from 2026-05-31 to 2026-06-02
The drafting date (today) was used as a placeholder. The actual
release date is Tuesday 2026-06-02, when PR #218 merges and the
update notifier picks up 1.2.2. Aligns the changelog header in both
EN and ES with the publication date users will see.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:15:36 +02:00
MacRimiandClaude Opus 4.7 e83950c0c2 Release 1.2.2 — version.txt bump + CHANGELOG (EN+ES) + contributors
Final ingredient of the v1.2.2 stable release: flip version.txt from
1.2.1 to 1.2.2 so the stable channel's update notifier picks it up
on every running install, ship the consolidated v1.2.2 entry on both
CHANGELOG.md (English) and lang/es/CHANGELOG.md (Spanish), and add
the GitHub link to Jonatan Castro on the contributors page.

CHANGELOG.md entry (and its ES mirror) consolidates the four v1.2.1.x
betas into a single stable note grouped by theme — Health Monitor
configurability, Apprise full feature parity, LXC update detection,
Coral TPU latest upstream drivers, performance optimizations (smartctl
scheduler, fail2ban cache, lxc-info /proc), HTTPS terminal handshake,
PVE 9.x kernel update detection, NVIDIA installer improvements, i18n
documentation site — plus an Acknowledgments section crediting
@jcastro (5 direct commits), @pespinel (1 commit) and @ghosthvj
(field reports that shaped the GPU + Coral work).

contributors/page.tsx: Contributor interface now carries an optional
`githubUrl`; when set, the displayed name is wrapped in an
ExternalLink to that URL (target=_blank). Jonatan Castro's entry gets
`githubUrl: https://github.com/jcastro` so users can reach his repos
from the testers grid.

After this PR merges:
- Users running `menu` will be offered the 1.2.2 upgrade
- proxmenux.com/en/changelog and /es/changelog ship the new entry
  (deploy.yml triggers because CHANGELOG.md, lang/** and web/** are
  all touched)
- Jonatan Castro's name on the contributors page becomes clickable

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:14:34 +02:00
MacRimiandGitHub 857f1cecb7 Merge pull request #217 from MacRimi/develop
Ship lang/es/CHANGELOG.md so /es/changelog/ renders in Spanish
2026-05-31 18:41:34 +02:00
MacRimiandClaude Opus 4.7 d022c4fe81 Fix typo: contributor is JF_Car (one r), not JF_Carr
Last commit renamed images/avatars/JF_Car.png to JF_Carr.png on the
assumption the file name was the typo. It wasn't — the user's name
is actually JF_Car. Rename the file back and update the display
name + avatar URL in the contributors page accordingly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:40:36 +02:00
MacRimiandClaude Opus 4.7 66262fddc8 Contributors: switch new 4 testers to custom avatars
Replace the temporary github.com/<handle>.png placeholders with the
project's standard raw.githubusercontent.com/.../images/avatars/<name>.png
pattern used by every other contributor on the page.

Added avatar files:
- images/avatars/heriberto.png
- images/avatars/JF_Carr.png   (renamed from JF_Car.png on disk so the
                                filename matches the display name)
- images/avatars/rafapuerta.png
- images/avatars/JcMinarro.png

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:39:18 +02:00
MacRimiandClaude Opus 4.7 fcc2c3d542 Add 4 testers to contributors page + refine deploy.yml triggers
Contributors page (app/[locale]/docs/about/contributors/page.tsx):
add heriberto, JF_Carr, rafapuerta and JcMinarro to the testers
grid. All with the "testing" role and GitHub's default avatar URL
(https://github.com/<handle>.png) so the entries work immediately
without requiring custom avatar files. Swap to a per-contributor
/images/avatars/<name>.png later if/when custom artwork is ready.

deploy.yml triggers: drop `guides/**` (the legacy /guides/[slug]/
page that read those markdown files was removed in PR #211 — the
folder no longer affects what the web renders) and `scripts/**`
(the bash scripts get rsynced into public/scripts/ during prebuild
but they are downloaded by users via install_proxmenux.sh, not
browsed via the doc site, so a stale copy is harmless). Add
`lang/**` so future translated CHANGELOG / docs (e.g. the Spanish
CHANGELOG just shipped in PR #217) auto-deploy.

Local build verified: 232 pages, 14450 indexed words, no errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:37:48 +02:00
MacRimiandClaude Opus 4.7 e07bba7dbd lang/es/CHANGELOG.md: ship the Spanish-translated changelog
The Next.js changelog page (app/[locale]/changelog/page.tsx) already
has the per-locale lookup logic — it reads lang/<locale>/CHANGELOG.md
first and falls back to the canonical English CHANGELOG.md at the
repo root if no localized copy exists. The Spanish file has lived in
develop for weeks (65 KB, 1065 lines, full translation including the
v1.2.1 SR-IOV / GPU passthrough hardening notes) but was never
committed to the repo, so the live site at proxmenux.com/es/changelog
fell back to English on every visit.

Add the file to the tree so the lookup finds it. No code change in
the page — the resolution logic in `resolveChangelogPath()` already
handles both branches and is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:16:11 +02:00
MacRimiandGitHub 7f55bd6038 Merge pull request #216 from MacRimi/develop
Hotfix: doc nav + sidebar active-page broken after trailingSlash
2026-05-31 14:49:35 +02:00
MacRimiandClaude Opus 4.7 e9e10e4ffa Fix doc nav + sidebar active-page detection after trailingSlash:true
PR #212 added `trailingSlash: true` to next.config.mjs so GitHub Pages
would serve the locale roots correctly. That changed what usePathname()
returns at runtime — `/docs/.../page/` with a trailing slash — but
the sidebar config (sidebarItems in DocSidebar.tsx) still declares
hrefs without the trailing slash. Every equality check
`pathname === item.href` therefore returned false on every page, and
two things broke:

1. components/ui/doc-navigation.tsx — the Previous/Next bar at the
   bottom of every doc page. With `findIndex` returning -1,
   `prevPage` was null and `nextPage = allPages[0]` (Introduction).
   So every doc page showed "Next: Introduction" regardless of
   where the user was.

2. components/DocSidebar.tsx — four comparisons that drove (a) the
   highlighted active item in the sidebar, (b) the active-section
   auto-open when navigating directly to a nested page, (c) the
   leaf-item highlight when the item has no submenu. All silently
   broken on every page.

Fix: a `stripTrailingSlash` helper plus a derived `currentPath` that
is compared instead of the raw `pathname`. `collectHrefs(...)` results
are also normalized at the point of comparison so the
`.includes(currentPath)` checks behave correctly.

Verified locally with `npm run build` — 232 pages indexed, no errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 14:44:52 +02:00
MacRimiandGitHub 260aed3838 Merge pull request #215 from MacRimi/develop
Update install_proxmenux.sh
2026-05-31 14:32:24 +02:00
MacRimi 11884799b7 Update install_proxmenux.sh 2026-05-31 14:31:42 +02:00
MacRimiandGitHub a0058857b9 Merge pull request #214 from MacRimi/develop
Update install ProxMenux
2026-05-31 14:28:51 +02:00
MacRimi b24daf41e2 Update install_proxmenux.sh 2026-05-31 14:27:33 +02:00
ProxMenuxBot 91eea93a58 Update helpers_cache.json 2026-05-31 12:26:18 +00:00
MacRimiandGitHub 9b6be615df Enhance license badge with caching
Updated license badge to include cache for improved loading.
2026-05-31 14:16:18 +02:00
MacRimiandGitHub 2567f802df Update README.md 2026-05-31 14:15:31 +02:00
MacRimiandGitHub eef6eebffa Update README.md 2026-05-31 14:15:06 +02:00
MacRimi 2d971d229c Update README.md 2026-05-31 14:11:14 +02:00
MacRimiandGitHub d82be76b05 Merge pull request #213 from MacRimi/develop
LICENSE: strip project header so GitHub detects GPL-3.0
2026-05-31 14:03:38 +02:00
MacRimi 01579e99fb Update ProxMenux 1.2.2 2026-05-31 14:02:44 +02:00
MacRimiandClaude Opus 4.7 f4f9b37510 LICENSE: strip project header so GitHub's licensee detects GPL-3.0
The previous commit added a 5-line project header above the canonical
GPL-3.0 text:

  ProxMenux — An Interactive Menu and Web Dashboard for Proxmox VE
  Copyright (c) 2025 MacRimi

  This program is licensed under the GNU General Public License v3.0.
  The full text of the license follows.

That header (~270 bytes in a 35 KB file) pushed the file below the
Sørensen-Dice similarity threshold GitHub's licensee gem uses to
fingerprint licenses, so the repo kept showing "License not
identifiable by GitHub" (API: spdx_id = "NOASSERTION", key = "other")
even though the canonical text was right below.

Strip the header. LICENSE is now byte-exact to the FSF reference:
https://www.gnu.org/licenses/gpl-3.0.txt
(SHA256: 3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986)

The project copyright still lives where GPL-3.0 expects it — in each
source file's header (as enforced by CONTRIBUTING.md's "Script Header
Template" section). LICENSE is the legal reference document; the
per-file copyright notice is the project-specific declaration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 14:00:29 +02:00
MacRimiandGitHub 21ad85c870 Merge pull request #212 from MacRimi/develop
Hotfix: trailingSlash:true so GitHub Pages serves /en/ and /es/
2026-05-31 13:49:06 +02:00
MacRimiandClaude Opus 4.7 65a1220758 Hotfix: trailingSlash:true so GitHub Pages serves the locale roots
The previous deploy went live but every visitor hit a 404. Root cause:

GitHub Pages serves the URL `/foo/` by looking for `out/foo/index.html`.
Next.js's static export with the default `trailingSlash: false` instead
emits `out/foo.html`, which Pages only serves for `/foo` (no trailing
slash).

The i18n root redirect in app/page.tsx points users at
`/<defaultLocale>/` (with slash) because that is what next-intl's
`<Link>` components generate. So every visitor landed on
`https://proxmenux.com/en/` → Pages looked for `out/en/index.html`,
did not find it (the export had emitted `out/en.html`), and fell back
to `out/404.html`. Result: the site looked deployed but every page
was a Next.js 404 template.

Setting `trailingSlash: true` makes the export emit
`out/<route>/index.html` for every page — locale roots
(`out/en/index.html`, `out/es/index.html`), nested doc pages
(`out/en/docs/monitor/dashboard/settings/index.html`, etc.), changelog,
guides — so Pages serves them directly.

Local verification: 232 pages built, root redirect intact at
out/index.html, and every locale + doc + guide URL now resolves to
its own index.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:48:21 +02:00
MacRimiandGitHub 4798133374 Delete AppImage/ProxMenux-1.2.0.AppImage 2026-05-31 13:41:35 +02:00
MacRimiandGitHub ab2db176de Merge pull request #211 from MacRimi/develop
Hotfix: pagefind missing in web/package.json + AppImage build cache path
2026-05-31 13:36:53 +02:00
MacRimiandClaude Opus 4.7 4f3750a8ab Fix CI: add pagefind to web devDeps + portable AppImage cache path
Two regressions surfaced after the 1.2.2 release merge to main, both
in workflows that auto-trigger on push to main:

* Deploy to GitHub Pages — build failed with `pagefind: not found`
  (exit 127) after Next.js prerendered all 241 routes. pagefind was
  not declared in web/package.json; the local build only worked
  because the project root had its own package.json with pagefind
  as a devDep (the one we just gitignored in the previous commit).
  Add `pagefind: ^1.5.2` to web/package.json devDependencies and
  regenerate web/package-lock.json so `npm ci` in CI puts the
  binary at web/node_modules/.bin/pagefind.

* Build ProxMenux Monitor AppImage — failed at the first step with
  `mkdir: cannot create directory '/var/cache/proxmenux-build':
  Permission denied`. The cache path was hardcoded to /var/cache/,
  which is writable when the script runs as root (the .50 host
  manual build) but not as the unprivileged GitHub Actions runner.
  Switch to `${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/` —
  works identically in both environments.

Verified locally: `cd web && npm ci && npm run build` produces 2804
files in out/, 231 pages indexed by pagefind, root redirect intact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:34:30 +02:00
MacRimiandGitHub 1a39e59a6e Merge pull request #210 from MacRimi/develop
Release 1.2.2 stable — consolidated v1.2.1.x cycle + web i18n migration
2026-05-31 13:27:30 +02:00
MacRimi 964f2083b6 Merge main into develop to resolve conflicts before 1.2.2 release PR
# Conflicts:
#	AppImage/ProxMenux-Monitor.AppImage.sha256
#	LICENSE
#	install_proxmenux_beta.sh
2026-05-31 13:24:58 +02:00
MacRimi 9e8434a16b Release 1.2.2 stable — consolidated v1.2.1.x cycle
Promote the v1.2.1.x beta cycle to stable: version markers bumped
from 1.2.1.4-beta to 1.2.2 across version.txt, AppImage/package.json,
flask_server.py (3 places) and the four UI labels in login,
proxmox-dashboard, storage-overview and release-notes-modal.

Replace AppImage/ProxMenux-1.2.1.4-beta.AppImage with
ProxMenux-1.2.2.AppImage and regenerate the .sha256 sidecar
(097e2344675d4b21f1dd18c531c956c299a6507fbc3d0c9695418063581ba2b0).
The new binary is verified on all 4 lab hosts (.50 / .55 / .89 /
1.10) — same sha, all services active, runtime version markers
report 1.2.2.

CHANGELOG["1.2.2"] in release-notes-modal.tsx consolidates every beta
in the 1.2.1.x line (12 added / 13 changed / 18 fixed), and
CURRENT_VERSION_FEATURES is rewritten with the four stable highlights:
Health Monitor Thresholds, granular dismiss control (per-event
duration + Active Suppressions panel), Apprise notification channel
parity, and LXC update detection.
2026-05-31 13:15:39 +02:00
MacRimi 3eebbfb9d7 Update LICENSE 2026-05-31 12:54:33 +02:00
MacRimi 5ca3463bf6 complete i18n migration to /[locale]/ with EN+ES content
Full rewrite of the docs site under app/[locale]/ with next-intl
in localePrefix:"always" mode. Every page now exists at both
/en/<path> and /es/<path>; the root / shows a meta-refresh + JS
redirect to /<defaultLocale>/ so GitHub Pages serves something
on the apex URL.

Highlights:
- 107 doc pages migrated to file-per-page JSON namespaces under
  messages/en/ and messages/es/. Spanish content is fully
  translated (no copy-of-English placeholders).
- New documentation for the Active Suppressions section in the
  Settings tab and the per-event Dismiss dropdown in the Health
  Monitor modal.
- New screenshots: dismiss-duration-dropdown.png and an updated
  health-suppression-settings.png.
- Pagefind integrated for client-side search; index is built on
  every CI deploy (not committed).
- RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml
  for backward compat.
- Removed the dead app/[locale]/guides/[slug]/ route — every
  guide now has its own static page and no markdown source
  remains.
- Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in
  docs/hardware/nvidia-host.
- Removed obsolete components (footer2, calendar, drawer).

Verified locally with `npm ci && npm run build`: 2804 files in
out/, 231 pages indexed by pagefind, root redirect intact, both
locale roots and the new Active Suppressions docs render OK.
2026-05-31 12:41:10 +02:00
MacRimiandClaude Opus 4.7 875910b4d7 Refresh README, add CONTRIBUTING guide, harden CI
- README: modernize visual layout (status badges row, tagline,
  Ko-fi shields badge, expanded Contributing section). Update
  web URLs to proxmenux.com/en for the new locale-prefixed site.
- CONTRIBUTING.md: add as the canonical contributor guide. Fix
  the workflow section to branch from develop (not main), add
  a dedicated "dialog vs whiptail" section, reorder so Script
  Header comes first.
- deploy.yml: switch npm install -> npm ci so the build uses
  the committed lockfile; fix cache-dependency-path to track
  web/package-lock.json (was package.json); add scripts/** to
  the path triggers so script edits redeploy the doc site.
- .gitignore: ignore the accidental root-level package.json /
  package-lock.json (pagefind is declared in web/package.json)
  and the regenerated build artifacts web/public/pagefind/ and
  web/public/scripts/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 12:38:48 +02:00
MacRimi 853bcbde35 Fix AppRise 2026-05-31 11:03:04 +02:00
MacRimi 2442ca63be Update AppImage 1.2.1.4 2026-05-31 10:36:16 +02:00
ProxMenuxBot f2f48376d7 Update helpers_cache.json 2026-05-31 00:35:02 +00:00
MacRimi 91ded0125e Update AppImage 1.2.1.4 2026-05-30 22:14:51 +02:00
MacRimi 4bf49675d2 Update ProxMenux 1.2.1.4-beta 2026-05-30 21:54:32 +02:00
ProxMenuxBot 66058cea97 Update helpers_cache.json 2026-05-29 13:01:14 +00:00
MacRimi d2ef8f0899 update add_gpu_vm.sh 2026-05-28 20:49:59 +02:00
MacRimi 17248eeedf Update cleanup_gpu_hookscripts.sh 2026-05-28 17:13:22 +02:00
MacRimi 9bce6b0a6b Cleanup NEED_HOOK_SYNC 2026-05-28 17:07:40 +02:00
MacRimiandGitHub 53ba7b3b2f Revise and clarify ROADMAP.es.md content
Updated the Spanish roadmap document for ProxMenux, improving clarity and correcting phrasing throughout. Adjusted sections on version planning and contributions.
2026-05-28 14:11:05 +02:00
ProxMenuxBot 48fd8579c5 Update helpers_cache.json 2026-05-28 07:16:42 +00:00
ProxMenuxBot 62bf372e1d Update helpers_cache.json 2026-05-27 18:44:04 +00:00
MacRimi fe1297936f Update AppImage 1.2.1.3 2026-05-27 17:55:41 +02:00
MacRimi e22ff85dc8 Update install_coral_lxc.sh 2026-05-27 17:36:11 +02:00
ProxMenuxBot 3a4654141f Update helpers_cache.json 2026-05-27 13:04:44 +00:00
ProxMenuxBot 967dcfcb37 Update helpers_cache.json 2026-05-27 07:23:05 +00:00
ProxMenuxBot d7faf186f8 Update helpers_cache.json 2026-05-26 18:46:39 +00:00
MacRimi 3143fedb7a Updates scripts share 2026-05-26 17:21:24 +02:00
ProxMenuxBot f819aa68d5 Update helpers_cache.json 2026-05-26 12:55:36 +00:00
MacRimi 2dc3a2b93c Update scripts share 2026-05-26 12:41:50 +02:00
MacRimi a3aa5d9c1a Update flask_server.py 2026-05-25 18:01:24 +02:00
ProxMenuxBot 975ad20d5a Update helpers_cache.json 2026-05-25 00:33:36 +00:00
ProxMenuxBot a94106ae61 Update helpers_cache.json 2026-05-24 18:19:55 +00:00
MacRimi b299227da2 Update AppImage 1.2.1.3 2026-05-24 17:52:04 +02:00
MacRimi 3286fc315c Update AppImage 1.2.1.3 2026-05-24 16:42:44 +02:00
MacRimi 105576cf17 Update AppImage 2026-05-24 11:37:20 +02:00
ProxMenuxBot 777e830aec Update helpers_cache.json 2026-05-24 00:32:25 +00:00
MacRimi 4b934db7db Update AppImage 1.2.1.3 2026-05-23 21:27:18 +02:00
ProxMenuxBot bbf2dc903a Update helpers_cache.json 2026-05-23 00:32:41 +00:00
MacRimi 9d2685d4a8 Update beta_version.txt 2026-05-22 18:48:30 +02:00
MacRimi 4507eacf1a Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-05-22 18:47:46 +02:00
MacRimi f2a40b993a Update AppImage 1.2.1.3 2026-05-22 18:47:30 +02:00
MacRimiandGitHub 69956a46d0 Update beta version from 1.2.1.3 to 1.2.1.2 2026-05-22 18:39:53 +02:00
MacRimi 840385272c Add ProxMenux beta 1.2.1.3 2026-05-22 18:24:03 +02:00
ProxMenuxBot 8fb05858c8 Update helpers_cache.json 2026-05-22 12:45:25 +00:00
MacRimi 95d0667077 Update AppImage 1.2.1.2 2026-05-21 22:25:29 +02:00
MacRimi 56fac4c34b Update AppImage 1.2.1.2 2026-05-21 22:00:35 +02:00
MacRimi 2d523b030f Update AppImage 1.2.1.2 2026-05-21 21:41:27 +02:00
MacRimi f5b7a0a74b Update AppImage 1.2.1.2 2026-05-21 21:17:59 +02:00
ProxMenuxBot d0d0f30d64 Update helpers_cache.json 2026-05-21 18:35:52 +00:00
MacRimi 3e9dd599a6 Update AppImage 1..2.1.2 2026-05-21 19:31:47 +02:00
MacRimi 0651f57e86 Update customizable_post_install.sh 2026-05-21 18:43:27 +02:00
MacRimi 7eccc3119b Update customizable_post_install.sh 2026-05-21 18:21:25 +02:00
MacRimi 9545587b67 Update AppImage 1.2.1.2 2026-05-21 17:24:09 +02:00
MacRimi ef22c88861 Update AppImage 1.2.1.2 2026-05-21 17:18:23 +02:00
ProxMenuxBot 04185f9a31 Update helpers_cache.json 2026-05-21 13:02:35 +00:00
MacRimi 3723888b0c Update Install 2026-05-20 20:46:58 +02:00
MacRimi bb982629b5 Update roadmap 2026-05-20 20:19:39 +02:00
MacRimi 48300d7f01 Update roadmap 2026-05-20 20:12:18 +02:00
MacRimi 2ae838b4a4 Add Roadmap 2026-05-20 20:05:55 +02:00
MacRimi ceb563cd60 Add ProxMenux Phases 2026-05-20 19:50:17 +02:00
MacRimi 298cd2c6d4 Update Beta 1.2.1.2 2026-05-20 19:47:42 +02:00
MacRimi 4112323961 Update AppImage 2026-05-20 18:14:32 +02:00
MacRimi 1087a87ea2 Update samba_lxc_server.sh 2026-05-20 16:32:03 +02:00
MacRimiandClaude Opus 4.7 73389d842a Reset auth_fail cooldowns on NotificationManager.start()
Pedro Rico, 19/05: after reinstalling the Monitor from GitHub a real
SSH/web login failure went unnotified. Root cause was the auth_fail
cooldown surviving across the service restart — install_proxmenux_beta
extracts the new AppImage but leaves the notification_last_sent SQLite
table intact (desirable: we don't want to lose legitimate cooldowns
on every update). On startup `_load_cooldowns_from_db()` then loaded
the stale auth_fail row from the previous run into the in-memory
cache, and `_passes_cooldown` blocked the new event.

This extends the existing reset-on-start mechanism (already in place
for update_summary, proxmenux_update, post_install_update, …) to also
clear auth_fail rows. A security-relevant event shouldn't be silenced
because the same source IP happened to fail to log in yesterday.

- Rename `_UPDATE_EVENT_TYPES_RESET_ON_START` → `_EVENT_TYPES_RESET_ON_START`
  (the list no longer covers only update-status reports).
- Rename `_reset_update_cooldowns_on_start()` → `_reset_cooldowns_on_start()`
  for the same reason.
- Add `'auth_fail'` to the curated list.

High-frequency sources (log_critical_*, disk SMART errors, …) are
deliberately NOT on this list — they keep their 24h cooldown across
restarts to prevent inbox floods if the user toggles the service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:18:49 +02:00
MacRimiandClaude Opus 4.7 4e26c5942f Reset update-type cooldowns on NotificationManager.start()
When the user reinstalls or restarts the Monitor (deploy of a new
beta AppImage), they expect to see a fresh "what's available now"
summary in Telegram/Gotify/etc. instead of silence — even if the
24h anti-spam cooldown for `update_summary` etc. hasn't expired yet.

Without this, the operator had to wait up to 24h after every
deploy before the next `update_summary`, `proxmenux_update`,
`post_install_update`, `pve_update`, `update_available`,
`nvidia_driver_update_available` or `secure_gateway_update_available`
notification fired. The 24h cooldown is the right default for steady
state (don't pester the user every poll cycle with the same "177
packages pending" reminder), but a service restart is an explicit
signal that the user wants a fresh status report.

- New _UPDATE_EVENT_TYPES_RESET_ON_START tuple lists the event types
  to clear (everything in the "*_update*" + "update_*" family).
- New _reset_update_cooldowns_on_start() runs at start() right after
  the running flag flips, before watchers/dispatcher come up.
- Patterns match both fingerprint shapes:
    "<host>:<entity>:<event_type>:"               trailing-colon form
    "<host>:<entity>:<event_type>"                no-suffix form (managed installs)
- In-memory `_cooldowns` cache is also pruned so the live dispatcher
  picks up the reset immediately, without waiting for the next
  `_load_cooldowns_from_db()` cycle.

Non-update cooldowns (auth_fail, log_critical_*, disk errors, …) are
preserved so a restart doesn't unleash a backlog of stale alerts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:03:44 +02:00
MacRimiandClaude Opus 4.7 06e6ae417e Fix notification dispatch: NameError in _dispatch_to_channels (Quiet Hours)
`_dispatch_to_channels` does NOT receive the NotificationEvent object —
only the rendered primitives (title, body, severity, event_type, …).
The Quiet Hours + Daily Digest merge introduced two references to
`event.severity` / `event` inside this function, which raised
`NameError: name 'event' is not defined` for every event passing
through dispatch.

The dispatch loop swallows the exception with a broad `except`, so the
visible symptom was "the Test button works but no real event ever
arrives" — both for community beta users (multiple reports on
Telegram, 9-18 May) and verified live on a test host (id 905 in
notification_history confirms the pipeline post-fix).

- _dispatch_to_channels: read `severity` / `event_type` directly
  instead of `event.severity` / `event.event_type`.
- _should_buffer_for_digest: take (ch_name, severity, event_type)
  primitives instead of a NotificationEvent.
- _buffer_digest_event: same — take (ch_name, event_type,
  event_group, severity, title, body).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:23:29 +02:00
MacRimiandClaude Opus 4.7 6eb1312c61 1.2.1.1-beta: notification + LXC + post-install fixes
- flask_notification_routes: PVE webhook X-Webhook-Secret written in
  standard base64 so PVE can decode it (GH #198)
- notification_channels: Gmail SMTP App Password handling — normalize
  tls_mode (None/empty → starttls), reject creds without host (false-
  positive sendmail delivery), surface "AUTH not advertised" hint
- notification_events: is_vzdump_active_on_host() reads /var/log/pve/
  tasks/active directly so backup_start fallback and vm_shutdown
  suppression survive a Monitor restart mid-backup
- notification_templates: extract --storage flag from vzdump log →
  "PBS-Cloud: vm/104/…" instead of generic "PBS:" prefix when multiple
  PBS endpoints exist
- health_monitor: pve_storage_capacity + zfs_pool_capacity respect
  per-item dismiss (don't keep category WARNING/CRITICAL after user
  dismisses); updates_check cache invalidated when /var/log/apt/
  history.log mtime advances
- lxc_mount_points: PVE volume size from subvol quota (df via
  /proc/<host_pid>/root/<target> + lxc.conf size=NNNG fallback);
  host_source_state detects "host detached" zombie binds; per-mount
  subprocess work parallelised via ThreadPoolExecutor so a CT with
  many bind mounts doesn't trip the Caddy 3s reverse-proxy timeout
- virtual-machines: "host detached" badge on bind mounts whose host
  source path disappeared
- auto/customizable_post_install: log2ram FUNC_VERSION 1.1 → 1.2; new
  log2ram-check.sh vacuums journal + truncates non-rotating logs
  (pveproxy/access.log, pveam.log) instead of only calling
  `log2ram write` (which leaves the tmpfs full); auto flow gains the
  missing SystemMaxUse in /etc/systemd/journald.conf

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:06:49 +02:00
MacRimi 81844fa456 Update AppImage 2026-05-18 18:09:15 +02:00
MacRimi 4bedeb9fcd Update appimage 2026-05-18 17:47:25 +02:00
MacRimiandGitHub c13601cd2d Merge pull request #203 from jcastro/feature/fix-open-bug-issues
Fix webhook loopback detection and update handoff

Thanks for the thorough work here, jcastro — really appreciate the bug triage on top of the fix itself, it makes reviewing much easier.
The changes look good to me:
	•	_is_loopback_addr() is a clean solution. Using the stdlib ipaddress module and explicitly handling the IPv4-mapped IPv6 case (::ffff:127.0.0.1) is exactly what was needed after the dual-stack binding change. The docstring explaining why is a nice touch.
	•	The exec bash swap in menu is the right fix for #180. Replacing the shell before the installer overwrites /usr/local/bin/menu avoids the half-old/half-new parsing that was causing the syntax error on line 105. Good catch removing the now-unreachable return 0 too.
Validation looks solid (8/8 loopback cases, syntax checks on all scripts).
Merging into develop. Thanks again! 🙌
2026-05-14 17:25:59 +02:00
MacRimiandGitHub c0dd7eacb6 Merge pull request #204 from jcastro/feature/select-iso-from-all-storages
Select VM ISOs from all ISO storages
2026-05-14 17:05:17 +02:00
MacRimiandGitHub f2d5eac330 Merge pull request #202 from jcastro/feature/settings-release-channel
Add release channel switcher to Settings
2026-05-14 15:58:50 +02:00
jcastro 092b548d20 Select VM ISOs from all ISO storages 2026-05-14 14:45:45 +02:00
jcastro 70ab072c79 Fix webhook loopback detection and update handoff 2026-05-14 14:33:27 +02:00
jcastro f8a8c43d0d Add release channel switcher to settings 2026-05-14 14:23:43 +02:00
MacRimiandGitHub fcd431b421 Merge pull request #200 from jcastro/feature/enable-zfs-autotrim-auto-post-install
Enable ZFS autotrim in auto post-install
2026-05-14 07:28:27 +02:00
MacRimiandGitHub 2a9ba5b526 Merge pull request #201 from jcastro/feature/update-figurine-2.0.0
Update Figurine to 2.0.0
2026-05-14 07:26:12 +02:00
jcastro aba9402830 Update Figurine to 2.0.0 2026-05-14 06:52:08 +02:00
jcastro 8877f9871f Enable ZFS autotrim in auto post-install 2026-05-14 06:46:08 +02:00
MacRimiandGitHub f569826b78 Merge pull request #197 from pespinel/codex/fix-beta-monitor-service
fix(monitor): update beta service runtime path
2026-05-10 23:06:29 +02:00
pespinel 0daab74a58 fix(monitor): update beta service runtime path 2026-05-10 22:24:09 +02:00
MacRimiandGitHub 16c97e94cc Comment out extraction info message
Commented out the message logging for extracting AppImage runtime.
2026-05-10 08:48:12 +02:00
MacRimiandGitHub bd9af49412 Add ProxMenux-Monitor AppImage SHA256 file 2026-05-10 06:10:09 +02:00
MacRimi ab5c7093eb Update AppImage 2026-05-10 05:19:36 +02:00
MacRimi b4e8c5101a Update AppImage 2026-05-10 05:11:51 +02:00
MacRimi 911886b90c Update AppImage 2026-05-10 05:00:00 +02:00
MacRimi c14b72456f Update AppImage 2026-05-10 04:46:33 +02:00
MacRimi 6d7e06a0d2 Update AppImage 2026-05-09 23:37:46 +02:00
MacRimi 0288c14a29 Update AppImage 2026-05-09 23:22:45 +02:00
MacRimi 748334eed6 Update beta_version.txt 2026-05-09 19:10:56 +02:00
MacRimi 07301ea599 Update beta_version.txt 2026-05-09 19:10:05 +02:00
MacRimi 2f919de9e3 update beta ProxMenux 1.2.1.1-beta 2026-05-09 18:59:59 +02:00
MacRimi 5ed1fc44fd Update 1.2.1.1-beta 2026-05-09 18:31:47 +02:00
MacRimiandGitHub 32bbf5bb27 Update attribution clause in LICENSE file 2026-05-07 17:16:24 +02:00
github-actions[bot] b8b49da99e Update AppImage beta build (2026-04-21 19:30:40) 2026-04-21 19:30:40 +00:00
2143 changed files with 618876 additions and 31663 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: MacRimi # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: macrimi # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+105
View File
@@ -0,0 +1,105 @@
name: OCI validation report
description: Report a real test of an application from the OCI manager catalog.
title: "OCI validation: "
labels: ["oci-validation"]
body:
- type: markdown
attributes:
value: |
One report describes one application and the scenario you tested. Once it is reviewed, the application is recorded as verified: it shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md](https://github.com/MacRimi/ProxMenux/blob/develop/oci/VALIDATION.md) with your GitHub user, the date and a link to this report.
Reports that an application does not work are just as useful: they tell others what to expect and what needs fixing.
- type: input
id: application
attributes:
label: Application
description: Name or ID as it appears in the OCI catalog, for example "Glances" or "glances".
validations:
required: true
- type: input
id: version
attributes:
label: Image version
description: Version or tag shown by the installer.
validations:
required: true
- type: input
id: digest
attributes:
label: Image digest
description: Shown in the App tab of the container in ProxMenux Monitor, after the build date — for example "2026-09-06 · b1f339cf". The full sha256 digest is also accepted.
placeholder: b1f339cf
validations:
required: true
- type: input
id: proxmox
attributes:
label: Proxmox VE version
placeholder: "9.1"
validations:
required: true
- type: dropdown
id: install
attributes:
label: Install mode
options:
- Default configuration
- Advanced configuration
validations:
required: true
- type: dropdown
id: storage
attributes:
label: Data storage
options:
- Volume on Proxmox storage
- Host directory
- Both
- No persistent data
validations:
required: true
- type: dropdown
id: network
attributes:
label: Network
options:
- DHCP
- Static address
validations:
required: true
- type: dropdown
id: gpu
attributes:
label: GPU
options:
- No GPU
- Intel
- AMD
- NVIDIA
validations:
required: true
- type: checkboxes
id: checks
attributes:
label: What was checked
options:
- label: Installs cleanly
- label: The application responds (web interface or service)
- label: Keeps working after a container restart
- label: Survives a Proxmox backup and restore
- label: An image update keeps the data
- type: dropdown
id: result
attributes:
label: Result
options:
- Works
- Works with problems
- Does not work
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
description: Anything worth knowing. If something failed, what you saw and how to reproduce it.
+67
View File
@@ -0,0 +1,67 @@
{
"name": "proxmenux-offline-node-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxmenux-offline-node-tests",
"version": "1.0.0",
"dependencies": {
"lucide-react": "0.454.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"typescript": "5.9.3"
}
},
"node_modules/lucide-react": {
"version": "0.454.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
"integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.6"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "proxmenux-offline-node-tests",
"private": true,
"version": "1.0.0",
"description": "Only the dependencies of the qualified tests/*.cjs CI lane",
"dependencies": {
"typescript": "5.9.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"lucide-react": "0.454.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
# GitHub users who can validate an OCI validation report by commenting
# /validated on it. A reviewer does not validate their own report.
MacRimi
Vaso73
f3rs3n
File diff suppressed because it is too large Load Diff
+464
View File
@@ -0,0 +1,464 @@
#!/usr/bin/env python3
"""
Auto-translate missing keys in AppImage/messages/<locale>/common.json
against the English source (AppImage/messages/en/common.json).
Guardrails:
- Non-empty string translations are PRESERVED, including values equal
to English. --refresh explicitly opts into replacing valid strings.
- Arrays, objects and non-string leaves retain their JSON types. Array
entries are translated individually, never as serialized Python text.
- Preflight all locales; skip locales with existing type mismatches without
provider calls or writes, including with --refresh. Valid locales proceed,
but any skipped locale makes the run exit 1 (partial failure).
The diagnostic identifies the locale
and JSON Pointer requiring manual reconciliation. No automatic migration
of serialized arrays is attempted: parseability or equal length cannot
prove correspondence to current English indices. Recover human text only
after checking its original source/order; do not delete it to force refill.
Null string leaves remain eligible for filling, as in earlier versions.
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
are extracted before translation and restored afterwards, so the
interpolation contract stays intact regardless of what the
translation provider does with the surrounding text.
- `sk` IS translated by default too. Guardrail #1 protects every key
Vaso73 has curated by hand; auto-translation only fills the keys
that are still on the English fallback in sk.
Workflow limitation: build-i18n-messages.yml runs Commit + push only after
successful generation. Exit 1 for skipped locales prevents that step, even
when valid locales made local progress. This does not restore automatic
publication for the default locale set until mismatches are reconciled or
workflow policy is separately changed. Invalid JSON syntax still aborts
preflight globally; per-locale skipping covers parsed catalog type mismatches.
Reuses the same translation providers as build_translation_cache.py so
the CI environment (googletrans pinning, AppImage provider) stays
identical.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from pathlib import Path
# Reuse providers + cleaner from the CLI translation script.
sys.path.insert(0, str(Path(__file__).parent))
from build_translation_cache import ( # noqa: E402
clean_translation,
protect_technical_terms,
restore_technical_terms,
translate_appimage,
translate_google_web,
translate_googletrans,
)
# sk IS included in the default. Guardrail #1 (never overwrite a key
# whose target value differs from EN) protects every string Vaso73 has
# already curated by hand — auto-translation only ever touches keys
# that are still on the English fallback in sk. Trade-off accepted:
# users on sk see a decent auto-translation for new keys instead of raw
# English while the human maintainer catches up, and Vaso73 keeps full
# ownership of the wording via follow-up PRs.
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard. Translate:"
# next-intl / ICU-style placeholders: {name}, {vmid}, {count}, {app_name}.
# We deliberately do NOT match `{{ escaped }}` or nested braces — the
# codebase uses only the simple form.
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
CatalogPath = tuple[str | int, ...]
def flatten(node: object, prefix: CatalogPath = ()) -> dict[CatalogPath, object]:
"""Keep typed path segments and empty container markers, without coercion."""
if isinstance(node, dict):
out = {prefix: {}}
children = node.items()
elif isinstance(node, list):
out = {prefix: []}
children = enumerate(node)
else:
return {prefix: node}
for key, value in children:
out.update(flatten(value, prefix + (key,)))
return out
def unflatten(flat: dict[CatalogPath, object]) -> object:
"""Rebuild a tree from typed paths; parents precede children by depth."""
nodes = {}
for path in sorted(flat, key=len):
value = flat[path]
nodes[path] = {} if isinstance(value, dict) else [] if isinstance(value, list) else value
if path:
parent = nodes[path[:-1]]
key = path[-1]
if isinstance(parent, list):
while len(parent) <= key:
parent.append(None)
parent[key] = nodes[path]
return nodes[()]
def protect_placeholders(text: str) -> tuple[str, list[str]]:
"""Swap each ``{xxx}`` for an opaque token that machine translators
tend to leave alone. Order is preserved so restore_placeholders can
walk it linearly."""
placeholders: list[str] = []
def _swap(match: re.Match) -> str:
placeholders.append(match.group(0))
# No underscores: a local model splits on them and returns
# "PMX PH 0", so the placeholder never comes back.
return f"PMXPH{len(placeholders) - 1:03d}"
return PLACEHOLDER_RE.sub(_swap, text), placeholders
def restore_placeholders(text: str, placeholders: list[str]) -> str:
"""Reverse of ``protect_placeholders``. If the provider mangled a
token beyond recognition we leave the mangled form in place — the
fallback assignment (target = existing or EN) upstream catches
the worst case."""
for i, original in enumerate(placeholders):
text = text.replace(f"PMXPH{i:03d}", original)
return text
def translate_argos(text: str, lang: str) -> str:
"""LibreTranslate's engine, running locally.
The remote providers answer a few thousand strings and then start
refusing, and a refusal here hands back the English and stores it as
a translation. Local models have no quota and no silent failure mode,
which is what makes them the right choice for filling a catalogue by
hand before it is committed.
"""
try:
import argostranslate.translate as argos # type: ignore
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"argostranslate is not installed. Install argostranslate and the "
"en->target packages, or run with another provider."
) from exc
return argos.translate(text, "en", lang)
def translate_one(
text: str,
lang: str,
provider: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
"""Dispatch to the correct provider. Reuses the same
implementations as build_translation_cache.py so there is exactly
one place to fix if a provider changes upstream."""
if provider == "googletrans":
raw = translate_googletrans(text, lang, context)
elif provider == "google-web":
raw = translate_google_web(text, lang, context, timeout)
elif provider == "appimage":
raw = translate_appimage(text, lang, context, timeout, appimage_path)
elif provider == "argos":
raw = translate_argos(text, lang)
else:
raise ValueError(f"Unknown provider: {provider}")
return clean_translation(raw) or text
def read_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid JSON at {path}: {exc}") from exc
def write_json(path: Path, data: dict) -> None:
"""Write with indent=2, no sort_keys — we want to keep the same
top-level ordering the maintainer uses in en/common.json so diffs
stay readable side-by-side."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
encoding="utf-8",
)
tmp.replace(path)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--source",
type=Path,
default=Path("AppImage/messages/en/common.json"),
help="Path to the English source catalog.",
)
parser.add_argument(
"--messages-dir",
type=Path,
default=Path("AppImage/messages"),
help="Directory that contains per-locale subdirectories.",
)
parser.add_argument(
"--languages",
default=",".join(DEFAULT_LANGUAGES),
help=(
"Comma-separated target locales. Includes sk by default; "
"guardrail #1 never overwrites keys whose sk value differs "
"from EN, so Vaso73's curated translations are safe."
),
)
parser.add_argument(
"--provider",
choices=("argos", "appimage", "googletrans", "google-web"),
default="googletrans",
help=(
"Translation provider. The default matches build_translation_cache "
"and is what CI uses; `argos` runs locally with no quota and is the "
"one to reach for when filling a catalogue before committing it."
),
)
parser.add_argument(
"--appimage-path",
type=Path,
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument("--sleep", type=float, default=0.15)
parser.add_argument(
"--refresh",
action="store_true",
help=(
"Retranslate the keys named by --refresh-keys. On its own it has "
"no effect: an existing translation is never replaced."
),
)
parser.add_argument(
"--refresh-keys",
type=Path,
help=(
"File with one dotted message key per line. Only these may be "
"retranslated over an existing value, and only with --refresh."
),
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Only translate the first N missing keys per locale (test runs).",
)
parser.add_argument(
"--save-every",
type=int,
default=50,
help="Write the locale JSON every N translated keys so a crash mid-run leaves partial progress on disk.",
)
return parser
def main() -> int:
args = build_arg_parser().parse_args()
source = args.source.resolve()
messages_dir = args.messages_dir.resolve()
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
if not source.is_file():
print(f"Source not found: {source}", file=sys.stderr)
return 1
if not languages:
print("No destination languages selected.", file=sys.stderr)
return 1
en_nested = read_json(source)
en_flat = flatten(en_nested)
print(f"Source: {source}", flush=True)
print(f"EN keys: {len(en_flat)}", flush=True)
print(f"Target locales: {', '.join(languages)}", flush=True)
print(f"Provider: {args.provider}", flush=True)
print(f"Sleep between calls: {args.sleep}s", flush=True)
refresh_keys: set[str] = set()
if args.refresh_keys is not None:
if not args.refresh:
print("--refresh-keys needs --refresh.", file=sys.stderr)
return 1
refresh_keys = {
line.strip()
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
if line.strip()
}
print(f"Refreshing {len(refresh_keys)} named keys.", flush=True)
elif args.refresh:
print("--refresh without --refresh-keys: existing translations are kept.",
flush=True)
total_failures: list[tuple[str, CatalogPath, str]] = []
# Preflight every locale before any provider call or write. Serialized
# arrays have no trustworthy index correspondence, even if JSON parses.
# Never guess, evaluate Python repr, or discard human text on --refresh.
targets = {}
skipped = set()
for lang in languages:
locale_path = messages_dir / lang / "common.json"
target_flat = flatten(read_json(locale_path))
targets[lang] = target_flat
for key, en_value in en_flat.items():
if key not in target_flat:
continue
existing = target_flat[key]
if isinstance(en_value, str) and existing is None:
continue # Historical null string leaves are treated as empty.
if type(existing) is not type(en_value):
pointer = "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in key)
location = f"JSON Pointer {json.dumps(pointer)}" + (" (root)" if not key else "")
print(
f"{locale_path}: {location}: expected {type(en_value).__name__}, "
f"found {type(existing).__name__}; manual reconciliation required. "
"Locale skipped; its catalog will not be written.", file=sys.stderr,
)
skipped.add(lang)
for lang in languages:
if lang in skipped:
continue
locale_path = messages_dir / lang / "common.json"
target_flat = targets[lang]
# Decide what needs translating. Same rule as
# build_translation_cache.py: only touch keys whose target
# value is empty. Never overwrite an existing value — that
# covers three legitimate cases in one line:
# 1. Human-curated translations (protected trivially).
# 2. Universal tokens the maintainer left equal to EN on
# purpose (Hardware, Terminal, Normal, SMART, OK, CPU %,
# {count}h, product names, etc.). Around 120 keys in
# es/common.json — the old `existing == en_value` rule
# kept resending these to Google every run, and the
# provider sometimes mangled them (`Terminal` →
# `terminal`, `CPU %` → `% de CPU`, ...).
# 3. Auto-fills from prior runs whose output happened to
# match EN — leaving them alone is the intended
# steady-state, not a bug.
# An existing translation is never replaced. --refresh only reaches
# the keys named in --refresh-keys, so an overwrite is always one
# somebody asked for by name rather than a side effect of a run.
# Seed structural nodes and non-text leaves before any checkpoint.
# Only strings are sent to a translation provider.
original_flat = target_flat.copy()
for key, en_value in en_flat.items():
if not isinstance(en_value, str) or not en_value:
target_flat.setdefault(key, en_value)
elif key and isinstance(key[-1], int):
# Null keeps an untranslated array position addressable while
# allowing the runtime English fallback and a later retry.
target_flat.setdefault(key, None)
missing: list[CatalogPath] = []
for key, en_value in en_flat.items():
if not isinstance(en_value, str) or not en_value:
continue
existing = target_flat.get(key, "")
if not existing or ".".join(str(part) for part in key) in refresh_keys:
missing.append(key)
if args.limit > 0:
missing = missing[: args.limit]
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
if not missing:
if target_flat != original_flat:
write_json(locale_path, unflatten(target_flat))
print(f" {lang}: nothing to translate", flush=True)
continue
failures_for_lang: list[tuple[str, CatalogPath, str]] = []
for index, key in enumerate(missing, start=1):
en_value = en_flat[key]
protected, placeholders = protect_placeholders(en_value)
protected, technical_terms = protect_technical_terms(protected)
try:
translated = translate_one(
protected,
lang,
args.provider,
args.context,
args.timeout,
args.appimage_path,
)
translated = restore_technical_terms(translated, technical_terms)
target_flat[key] = restore_placeholders(translated, placeholders)
print(
f" [{lang} {index}/{len(missing)}] {key}: "
f"{en_value[:60]!r} → {target_flat[key][:60]!r}",
flush=True,
)
except Exception as exc: # noqa: BLE001
# Fall back to whatever we already had (or EN) so the
# runtime fallback still kicks in for this key.
target_flat[key] = target_flat.get(key) or en_value
failures_for_lang.append((lang, key, str(exc)))
print(f" [{lang}] {key}: FAILED — {exc}", file=sys.stderr, flush=True)
if args.save_every > 0 and index % args.save_every == 0:
# Preserve keys already in target_flat + write partial progress.
write_json(locale_path, unflatten(target_flat))
time.sleep(args.sleep)
write_json(locale_path, unflatten(target_flat))
print(f" wrote {locale_path}", flush=True)
total_failures.extend(failures_for_lang)
if total_failures:
print(
f"\nCompleted with {len(total_failures)} translation failures "
f"(partial progress persisted, next run will retry).",
file=sys.stderr,
flush=True,
)
for lang, key, error in total_failures[:20]:
print(f" - {lang}: {key} → {error}", file=sys.stderr, flush=True)
if len(total_failures) > 20:
print(f" ... and {len(total_failures) - 20} more.", file=sys.stderr, flush=True)
# Exit 0 on partial failure so the workflow's Commit + push
# step still runs and the keys that DID translate reach
# develop. The un-translated keys stay empty and the next
# workflow tick (or a manual dispatch) retries them.
# Previously we returned 2, which failed the whole run and
# discarded 36 out of 38 successful translations because
# 2 googletrans timeouts hit sv at the start of the burst.
if not skipped:
return 0
if skipped:
names = ', '.join(dict.fromkeys(lang for lang in languages if lang in skipped))
print(
f"\nPartial failure: skipped {len(skipped)} locale(s): {names}. "
"Valid locales processed; skipped catalogs unchanged. "
"Manual reconciliation required; exiting 1.",
file=sys.stderr, flush=True,
)
return 1
print("\ni18n messages generated successfully.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+895
View File
@@ -0,0 +1,895 @@
#!/usr/bin/env python3
"""
Build the ProxMenux translation cache from translate calls in scripts/.
The generated JSON keeps the same shape used by scripts/utils.sh:
{
"Original English text": {
"es": "Translated text",
"fr": "Translated text"
}
}
"""
from __future__ import annotations
import argparse
import ast
import asyncio
import inspect
import json
import os
import subprocess
import re
import sys
import time
from pathlib import Path
from typing import Iterable
from urllib.parse import quote
from urllib.request import Request, urlopen
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
# googletrans and the public Google endpoint used by this workflow do not
# support Cloud Translation glossaries. Protect product names, package names
# and command identifiers with opaque tokens before sending text to any
# provider, then restore the exact source spelling afterwards. Keep longer
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
PROTECTED_TECHNICAL_TERMS = (
"google/gasket-driver",
"feranick/gasket-driver",
"libedgetpu1-std",
"Proxmox VE Helper-Scripts",
"Docker Compose",
"gasket-driver",
"gasket-dkms",
"libedgetpu1",
"libedgetpu",
"Google Coral",
"Edge TPU",
"ProxMenux",
"Proxmox",
"AppImage",
"smartctl",
"systemctl",
"pveproxy",
"apt-get",
"Frigate",
# Kernel driver and subsystem names. Left to a translator, `nouveau`
# becomes the adjective it is borrowed from — "el nuevo controlador"
# in Spanish, "Jugendstil" in German — and the reader is told about a
# driver that does not exist instead of the one being blacklisted.
"nvidia-container-toolkit",
"nvidia-smi",
"initramfs",
"nouveau",
"vfio-pci",
"modprobe",
"Docker",
"Coral",
"gasket",
"apex",
"lspci",
"dpkg",
"DKMS",
"QEMU",
"LXC",
"ZFS",
"SSH",
"fork",
# Vendor, API and acceleration names. A label like "NVIDIA (NVDEC/CUDA)"
# is a product name end to end: every provider hands it back as it came,
# and without these entries that correct answer is read as a failure and
# the string is dropped from the catalogue.
"VA-API",
"NVIDIA",
"NVDEC",
"NVENC",
"WebUI",
"Intel",
"CUDA",
"KFD",
"GPU",
"CPU",
"AMD",
# Proxmox's own words for a container and a virtual machine. Read as
# initials they get reordered — "CT" comes back as "TC" in Spanish —
# and the reader is told about something Proxmox does not call that.
"CT",
"VM",
# Open Container Initiative. Read as a word it becomes "BEC" in French,
# which named nothing, across thirty-one strings of the OCI section.
"OCI manager Apps",
"OCI",
"beta",
# Debian release names. "Bookworm" is a bird and a reader of books, and
# came back as "el libro de Debian"; the others are equally ordinary
# words in the languages that borrowed them.
"Bookworm",
"Trixie",
"Bullseye",
"Buster",
"Noble",
"Jammy",
)
TECHNICAL_TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in sorted(PROTECTED_TECHNICAL_TERMS, key=len, reverse=True)
),
re.IGNORECASE,
)
TRANSLATE_CALL_RE = re.compile(
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
re.VERBOSE | re.DOTALL,
)
# Same shape the Monitor's generator uses, so a message reads identically
# whichever of the two catalogues it comes from.
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
# Literals a reader copies and runs. Translated as words they stop working:
# /dev/apex_0 came back as "/dev/apex 0", --auto-uninstall as "desinstalación
# automática", and "pct config 110" as "configuration PCT 110". A glossary can
# only hold what someone listed; these are recognised by shape.
STRUCTURAL_LITERAL_RE = re.compile(
r"""
/(?:dev|etc|usr|var|opt|srv|proc|sys|run|boot|tmp)/[\w./+-]*[\w/+-]
| (?<![\w-])--[A-Za-z][\w-]*
# Angle-bracket placeholders a reader substitutes: <CTID> came back as
# "■CTID confiar", with the closing bracket translated as a verb.
| <[A-Za-z][\w-]*>
# Shell operators. "&&" became the HTML entity "&apos;" in Spanish, and
# the command it joined stopped being one command; a lone pipe came back
# as the word "Silencio".
| &&|\|\||(?<!\|)\|(?!\|)
# Environment variable names. THELOUNGE_HOME lost its underscore and
# became two words, so the command no longer set anything.
| \b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b
| \b[\w.-]+\.(?:sh|py|json|yml|yaml|conf|service|timer|func|cfg|list|log)\b
# Reverse-DNS identifiers: org.opencontainers.image.version came back as
# "org.open containers.image.version" — a label name with a space in it.
| \b[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,}\b
# A command plus a real subcommand. An allowlist, not "any word": with
# a wildcard, "pvesm command not found" and "apt-get exited" were read as
# invocations, and the strings a contributor had already translated by
# hand were discarded as damaged.
| \b(?:pct|qm)\s+(?:config|start|stop|shutdown|reboot|destroy|create|clone
|enter|exec|push|pull|set|list|status|snapshot|rollback|resize|migrate
|template|unlock|mount|unmount|df|fsck|restore|suspend|resume|monitor)\b
| \bpvesm\s+(?:path|status|list|alloc|free|add|remove|set|scan|export|import)\b
| \b(?:pveam|pveum|pvecm)\s+(?:available|download|list|update|remove|add|create
|delete|status|nodes|keygen|expected|modify|set|useradd|userdel|passwd)\b
| \bapt-get\s+(?:install|update|upgrade|remove|purge|autoremove|dist-upgrade|clean)\b
| \bdpkg\s+(?:-l|-i|-r|-P|--configure|--get-selections)\b
| \b(?:systemctl|journalctl)\s+(?:start|stop|restart|reload|enable|disable|status
|daemon-reload|mask|unmask|is-active|is-enabled|list-units|-u|-f|-b)\b
| \bzpool\s+(?:status|list|import|export|scrub|trim|get|set|history)\b
| \bsmartctl\s+(?:-a|-A|-H|-i|-t|-l|-x|--all|--health|--info)\b
| \b(?:modprobe|blkid|lsblk|mkfs)\s+(?:-a|-r|-n|-o|-f|-t|\.[a-z0-9]+)\b
""",
re.VERBOSE,
)
# Providers that answer the same thing every time for the same input, so a
# second attempt cannot produce a different result. `appimage` shells out to a
# binary that may reach a network service, so it is not on the list.
DETERMINISTIC_PROVIDERS = frozenset({"argos"})
def protect_catalog_titles(directories) -> None:
"""Add every application name in the catalog to the protected glossary.
They are product names, and a translator treats them as words: "HAOS One"
comes back as "HAOS Man". Protecting them costs nothing and the failure it
prevents reaches the reader as an application that does not exist.
"""
global TECHNICAL_TERM_RE
titles: set[str] = set()
for directory in directories:
for path in sorted(Path(directory).rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
title = ((data.get("catalog_ui") or {}).get("title") or {}).get("en_US")
if isinstance(title, str) and title.strip():
titles.add(title.strip())
if not titles:
return
terms = tuple(sorted(set(PROTECTED_TECHNICAL_TERMS) | titles, key=len, reverse=True))
# Same word boundaries as the module-level pattern. Without them a short
# term matches inside longer words, and every application title in the
# catalogue joins the glossary here.
TECHNICAL_TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in terms
),
re.IGNORECASE,
)
print(f"Protected application names: {len(titles)}", flush=True)
def protect_placeholders(text: str) -> tuple[str, list[str]]:
"""Replace each ``{name}`` with a token before translation.
A provider reads the name inside the braces as a word and translates
it: ``{count}`` comes back as ``{conta}`` in Portuguese and ``{počet}``
in Slovak. The consumer then finds no placeholder to substitute and
falls back to English, so the translation was paid for and never used.
The token carries no underscores because argos splits on them.
"""
found: list[str] = []
def _swap(match: re.Match[str]) -> str:
found.append(match.group(0))
return f"PMXPH{len(found) - 1:03d}"
return PLACEHOLDER_RE.sub(_swap, text), found
def restore_placeholders(text: str, found: list[str]) -> str:
"""Put the original ``{name}`` back where each token landed."""
for index, original in enumerate(found):
text = text.replace(f"PMXPH{index:03d}", original)
return text
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
"""Replace glossary terms with stable tokens before translation.
Structural literals go first. A glossary can only list what someone
thought of, and what reaches the reader as a broken command is never on
that list: an absolute path, a long option, a filename. They are
recognised by shape instead, which also covers the next one added.
"""
protected: list[str] = []
def _swap(match: re.Match[str]) -> str:
protected.append(match.group(0))
return f"PMXTERM{len(protected) - 1:03d}"
text = STRUCTURAL_LITERAL_RE.sub(_swap, text)
return TECHNICAL_TERM_RE.sub(_swap, text), protected
def restore_technical_terms(text: str, protected: list[str]) -> str:
"""Restore glossary terms exactly as they appeared in the source."""
for index, original in enumerate(protected):
text = text.replace(f"PMXTERM{index:03d}", original)
return text
# A provider can alter a token instead of carrying it through: argos returned
# MPXTERM000 for PMXTERM000, the replace found nothing, and the token shipped
# to the reader — "MPXTERM000 configuré" is in the French catalogue now.
SENTINEL_RESIDUE_RE = re.compile(r"[MP][MPX]X?\s?(?:TERM|PH)\s?\d{2,4}", re.IGNORECASE)
def assert_no_sentinel_residue(text: str) -> None:
"""Fail the translation when a protection token did not survive intact.
Leaving the key out is recoverable: the next run tries again. Storing a
string with a token in it is not, because nothing looks at a value that
already exists.
"""
found = SENTINEL_RESIDUE_RE.search(text)
if found:
raise RuntimeError(f"the provider altered a protection token: {found.group(0)!r}")
def iter_script_files(
scripts_dir: Path, extra_files: Iterable[Path] = ()
) -> Iterable[Path]:
# Walk the main scripts tree.
for path in sorted(scripts_dir.rglob("*")):
if not path.is_file():
continue
if path.name == "utils.sh":
continue
if path.suffix not in {".sh", ".func"}:
continue
yield path
# Yield additional files passed explicitly (e.g. the root-level `menu`
# entry point or install_proxmenux*.sh). These live outside scripts/
# but still contain translate "..." calls we want in the cache.
# No extension filter and no utils.sh skip — the caller decided
# they belong, we just check the file actually exists.
for extra in extra_files:
if extra.is_file():
yield extra
def decode_shell_string(raw: str, quote_char: str) -> str:
if quote_char == "'":
return raw
try:
return ast.literal_eval(f'"{raw}"')
except Exception:
return raw.replace(r"\"", '"').replace(r"\\", "\\")
def extract_translate_texts(
scripts_dir: Path, extra_files: Iterable[Path] = ()
) -> list[str]:
found: dict[str, None] = {}
for path in iter_script_files(scripts_dir, extra_files):
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
for match in TRANSLATE_CALL_RE.finditer(content):
text = decode_shell_string(match.group("text"), match.group("quote"))
text = text.strip()
if text and "$" not in text and "`" not in text:
found.setdefault(text, None)
return sorted(found)
PYTHON_TRANSLATE_CALLS = {"translate", "N_"}
CATALOG_TEXT_KEYS = {"prompt", "enable_prompt", "path_prompt", "size_prompt", "label", "warning"}
CATALOG_TEXT_LISTS = {"stack_completion_notes", "completion_notes"}
def extract_python_texts(directories: Iterable[Path]) -> list[str]:
"""translate("...") and N_("...") calls with a literal argument. The parser
joins implicitly concatenated literals, so wrapped strings are found whole."""
found: dict[str, None] = {}
for directory in directories:
for path in sorted(directory.rglob("*.py")):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and getattr(node.func, "id", None) in PYTHON_TRANSLATE_CALLS
and node.args and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)):
text = node.args[0].value.strip()
if text:
found.setdefault(text, None)
return sorted(found)
def extract_catalog_texts(directories: Iterable[Path]) -> list[str]:
"""User-visible text stored in the OCI catalog JSON: prompts, labels,
warnings, completion notes, category labels and descriptive usernames."""
found: dict[str, None] = {}
def add(value: object) -> None:
if isinstance(value, str) and value.strip():
found.setdefault(value.strip(), None)
def walk(value: object, key: str = "") -> None:
if isinstance(value, dict):
for child_key, child in value.items():
if child_key in CATALOG_TEXT_KEYS:
add(child)
elif child_key in CATALOG_TEXT_LISTS and isinstance(child, list):
for item in child:
add(item)
elif child_key == "username" and isinstance(child, str) and " " in child:
add(child)
elif child_key == "catalog_ui" and isinstance(child, dict):
# What the application detail screen shows: the tagline,
# and the description only where there is no tagline. The
# catalog stores the source English; the translation lives
# in the language cache with every other string.
tagline = (child.get("tagline") or {}).get("en_US")
add(tagline or (child.get("description") or {}).get("en_US"))
elif child_key == "labels" and key == "" and isinstance(child, dict):
for item in child.values():
add(item)
walk(child, child_key)
elif isinstance(value, list):
for item in value:
walk(item, key)
for directory in directories:
for path in sorted(directory.rglob("*.json")):
try:
walk(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
return sorted(found)
def translate_argos(text: str, dest_lang: str) -> str:
"""LibreTranslate's engine, running locally.
A public endpoint answers a few thousand strings and then starts
refusing — and the library wrapper around it returns the English
unchanged rather than raising, which writes the source text into the
catalogue as if it were a translation. Local models have no quota and
no silent failure mode.
"""
try:
import argostranslate.translate as argos # type: ignore
except Exception as exc:
raise RuntimeError(
"argostranslate is not installed. Install argostranslate and the "
"en->target packages, or run with another provider."
) from exc
return argos.translate(text, "en", dest_lang)
def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
try:
from googletrans import Translator # type: ignore
except Exception as exc:
raise RuntimeError(
"googletrans is not installed. Install googletrans==4.0.0-rc1 "
"or run with --provider google-web."
) from exc
translator = Translator()
full_text = f"{context} {text}".strip()
result = translator.translate(full_text, dest=dest_lang)
if inspect.isawaitable(result):
result = asyncio.run(result)
return result.text
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
# The public Google endpoint is not prompt-aware: if we prepend context,
# it often translates and returns that context as part of the result.
full_text = text
url = (
"https://translate.googleapis.com/translate_a/single"
f"?client=gtx&sl=en&tl={quote(dest_lang)}&dt=t&q={quote(full_text)}"
)
req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"})
with urlopen(req, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
return "".join(part[0] for part in payload[0] if part and part[0])
def translate_appimage(
text: str,
dest_lang: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
if not appimage_path.exists():
prev_path = appimage_path.with_name(appimage_path.name + ".prev")
if prev_path.exists():
appimage_path = prev_path
else:
raise FileNotFoundError(f"AppImage not found: {appimage_path}")
req = {
"text": text,
"dest_lang": dest_lang,
"context": context,
"cache_file": "",
}
env = os.environ.copy()
env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1")
completed = subprocess.run(
[str(appimage_path), "--translate"],
input=json.dumps(req, ensure_ascii=False),
text=True,
capture_output=True,
timeout=timeout,
check=False,
env=env,
)
if completed.returncode != 0:
raise RuntimeError((completed.stderr or completed.stdout).strip())
# AppRun may print a startup line before translate_cli.py emits JSON.
for line in reversed(completed.stdout.splitlines()):
line = line.strip()
if not line.startswith("{"):
continue
payload = json.loads(line)
if payload.get("success"):
return str(payload.get("text", text))
raise RuntimeError(str(payload.get("error", "unknown AppImage translation error")))
raise RuntimeError(f"AppImage did not return JSON: {completed.stdout.strip()}")
def clean_translation(value: str) -> str:
separator = r"[\s\u00a0]*[::]"
# `Translate` pivot in every locale currently supported. Without
# the target-language variant here, the cleaner can't locate the
# boundary between the context prompt and the real translation,
# and the whole payload leaks through as the translated context.
# Caught on the 2026-08-14 workflow run — every sk / sv key ended
# up as "Technický text používateľského rozhrania…" or
# "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Övers"
# because Preložiť / Översätt / Översätta were missing.
translate_labels = (
"Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
"|Preložiť|Prelož|Preloz" # sk
"|Översätta|Översätt" # sv
)
context_labels = (
"Context|Contexto|Contexte|Kontext|Contesto"
"|Sammanhang" # sv alternate
)
value = re.sub(
rf"^.*?({translate_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
value = re.sub(
rf"^.*?({context_labels}){separator}.*?({translate_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
value = re.sub(
rf"^.*?({context_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
return value.strip()
# Lowercase-or-digit, then the mark, then an uppercase letter: that is a
# sentence boundary and not `8.0`, `storage.cfg` or `proxmox.com/docs`.
_SENTENCE_RUN = re.compile(
"(?<=[a-z0-9\u00e0-\u00ff\u0107\u010d\u011b\u013e\u0148\u0159\u0161\u0165\u016f\u017a\u017e])"
"([.?!])"
"(?=[A-Z\u00c0-\u00de\u0106\u010c\u011a\u013d\u0147\u0158\u0160\u0164\u016e\u0179\u017d])"
)
def restore_sentence_spacing(text: str) -> str:
"""Every provider drops the blank between sentences it translated apart."""
return _SENTENCE_RUN.sub(r"\1 ", text)
def translate_text(
text: str,
dest_lang: str,
provider: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
protected_text, placeholders = protect_placeholders(text)
protected_text, protected_terms = protect_technical_terms(protected_text)
if provider == "argos":
translated = translate_argos(protected_text, dest_lang)
elif provider == "googletrans":
translated = translate_googletrans(protected_text, dest_lang, context)
elif provider == "google-web":
translated = translate_google_web(protected_text, dest_lang, context, timeout)
elif provider == "appimage":
translated = translate_appimage(
protected_text, dest_lang, context, timeout, appimage_path
)
else:
raise ValueError(f"Unknown provider: {provider}")
translated = restore_technical_terms(clean_translation(translated), protected_terms)
translated = restore_placeholders(translated, placeholders)
assert_no_sentinel_residue(translated)
return restore_sentence_spacing(translated) if translated else text
def load_language_cache(path: Path) -> dict[str, str]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
return {str(text): str(value) for text, value in data.items()}
def is_fully_protected(source: str) -> bool:
"""Whether the string is protected material and punctuation, nothing else.
"Docker Volume Backup" and "NVIDIA (NVDEC/CUDA)" are product and API
names from end to end, and "apt-get exited" is a command with a word
the command itself supplies. Coming back unchanged is the right answer
for all of them, so the guard below must not read it as a silent
failure and throw the result away. Both kinds of protection count:
the glossary catches the names, the structural pattern the rest.
"""
remainder = STRUCTURAL_LITERAL_RE.sub(" ", source)
return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", remainder))
def looks_untranslated(source: str, result: str) -> bool:
"""Whether a provider handed back the text it was given.
A single technical word legitimately survives translation — Docker, GPU,
LXC — but a sentence coming back byte-identical means the provider failed
without saying so. Accepting it writes English into the catalogue, where
it counts as translated and is never looked at again.
"""
if source.strip() != result.strip():
return False
if is_fully_protected(source):
return False
return len([word for word in re.findall(r"[A-Za-z]{2,}", source)]) >= 3
def write_language_cache(path: Path, cache: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
tmp_path.write_text(
json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
tmp_path.replace(path)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Extract translate calls from scripts/ and build json/cache.json."
)
parser.add_argument("--scripts-dir", default="scripts", type=Path)
parser.add_argument(
"--extra-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Extra directory scanned for translate calls in .sh files. Repeatable.",
)
parser.add_argument(
"--python-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Directory scanned for translate()/N_() calls in .py files. Repeatable.",
)
parser.add_argument(
"--catalog-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Directory of OCI catalog JSON files with user-visible text. Repeatable.",
)
parser.add_argument(
"--extra-file",
action="append",
default=[],
type=Path,
metavar="PATH",
help=(
"Extra individual files to scan for translate calls in addition "
"to --scripts-dir. Useful for the root-level `menu` entry point "
"and install_proxmenux*.sh, which sit outside scripts/. "
"Pass multiple times to add more than one file."
),
)
parser.add_argument(
"--output-dir",
default=Path("lang"),
type=Path,
help="Directory where per-language JSON files are written. Default: lang",
)
parser.add_argument(
"--output",
default=None,
type=Path,
help="Deprecated combined cache path. If used, per-language files are written next to it under its parent directory.",
)
parser.add_argument(
"--languages",
default=",".join(DEFAULT_LANGUAGES),
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
)
parser.add_argument(
"--provider",
choices=("argos", "appimage", "googletrans", "google-web"),
default="appimage",
help="Translation provider to use. Default: appimage",
)
parser.add_argument(
"--appimage-path",
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
type=Path,
help="Path to the ProxMenux AppImage when using --provider appimage.",
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", default=30, type=int)
parser.add_argument("--sleep", default=0.15, type=float)
parser.add_argument("--retries", default=4, type=int,
help="Attempts per string before giving up on it.")
parser.add_argument("--retry-wait", default=15, type=float,
help="Seconds before the first retry; it doubles each time.")
parser.add_argument(
"--refresh",
action="store_true",
help="Retranslate the strings named by --refresh-keys. On its own it has "
"no effect: an existing translation is never replaced.",
)
parser.add_argument(
"--refresh-keys",
type=Path,
help="File with one source string per line. Only these may be "
"retranslated over an existing value, and only with --refresh.",
)
parser.add_argument(
"--extract-only",
action="store_true",
help="Only update the cache keys; missing translations are left empty.",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Only process the first N extracted strings. Useful for test runs.",
)
parser.add_argument(
"--save-every",
type=int,
default=1,
help="Write the output JSON every N translated items. Default: 1",
)
return parser
def main() -> int:
args = build_arg_parser().parse_args()
scripts_dir = args.scripts_dir.resolve()
if args.output is not None:
output_dir = args.output.resolve().parent / "lang"
else:
output_dir = args.output_dir.resolve()
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
if not scripts_dir.is_dir():
print(f"Scripts directory not found: {scripts_dir}", file=sys.stderr)
return 1
if not languages:
print("No destination languages selected.", file=sys.stderr)
return 1
texts = extract_translate_texts(scripts_dir, args.extra_file)
for directory in args.extra_dir:
if directory.is_dir():
texts += extract_translate_texts(directory.resolve())
texts += extract_python_texts(d.resolve() for d in args.python_dir if d.is_dir())
catalog_dirs = [d.resolve() for d in args.catalog_dir if d.is_dir()]
protect_catalog_titles(catalog_dirs)
texts += extract_catalog_texts(catalog_dirs)
texts = sorted(dict.fromkeys(text for text in texts if "$" not in text and "`" not in text))
if args.limit > 0:
texts = texts[: args.limit]
existing_by_lang = {
lang: load_language_cache(output_dir / f"{lang}.json")
for lang in languages
}
# Seeded with what is already translated so a periodic save — or an
# interrupted run — writes a superset of the file it replaces, never a
# truncated one.
next_by_lang: dict[str, dict[str, str]] = {
lang: dict(existing_by_lang.get(lang, {})) for lang in languages
}
print(f"Found {len(texts)} unique translate strings.", flush=True)
print(f"Output directory: {output_dir}", flush=True)
print(f"Languages: {', '.join(languages)}", flush=True)
refresh_keys: set[str] = set()
if args.refresh_keys is not None:
if not args.refresh:
print("--refresh-keys needs --refresh.", file=sys.stderr)
return 1
refresh_keys = {
line.rstrip("\n")
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
if line.strip()
}
print(f"Refreshing {len(refresh_keys)} named strings.", flush=True)
elif args.refresh:
print("--refresh without --refresh-keys: existing translations are kept.",
flush=True)
failures: list[tuple[str, str, str]] = []
total = len(texts) * len(languages)
done = 0
for lang in languages:
existing = existing_by_lang.get(lang, {})
print(f"Starting language: {lang}", flush=True)
for index, text in enumerate(texts, start=1):
done += 1
# A translation that is already there is never replaced. Most of
# this catalogue is hand-written by the people who speak the
# language, and a machine pass over it trades their wording for
# something worse without anyone seeing the swap. Only a string
# named in --refresh-keys may be overwritten, so every overwrite
# is one somebody asked for by name.
if existing.get(text) and text not in refresh_keys:
next_by_lang[lang][text] = existing[text]
continue
if args.extract_only:
next_by_lang[lang][text] = existing.get(text, "")
continue
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
# A rate limit is a "come back later", not an answer. Retrying with
# a growing wait recovers it; giving up on the first one is what
# left thousands of strings untranslated.
value, last_error = None, None
for attempt in range(1, args.retries + 1):
unchanged = False
try:
value = translate_text(
text,
lang,
args.provider,
args.context,
args.timeout,
args.appimage_path,
)
if looks_untranslated(text, value):
value = None
unchanged = True
raise RuntimeError("the provider returned the source text unchanged")
break
except Exception as exc:
last_error = exc
# Unchanged text from a remote provider is how a rate limit
# shows up, so it is worth waiting for. A local engine is
# deterministic: asking again returns the same string, and
# the backoff only buys minutes of sleeping per phrase.
if unchanged and args.provider in DETERMINISTIC_PROVIDERS:
break
if attempt < args.retries:
wait = args.retry_wait * (2 ** (attempt - 1))
print(f" retry {attempt}/{args.retries - 1} in {wait}s: {exc}",
file=sys.stderr, flush=True)
time.sleep(wait)
if value is not None:
next_by_lang[lang][text] = value
print(f" => {value[:100]}", flush=True)
else:
# The key is left out on purpose. Writing the English here
# would count as a translation on the next run and the string
# would never be translated again.
previous = existing.get(text)
if previous:
next_by_lang[lang][text] = previous
failures.append((text, lang, str(last_error)))
print(f" failed: {last_error}", file=sys.stderr, flush=True)
if args.save_every > 0 and index % args.save_every == 0:
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
time.sleep(args.sleep)
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
print(f"Completed language: {lang}", flush=True)
for lang, cache in next_by_lang.items():
write_language_cache(output_dir / f"{lang}.json", cache)
if failures:
print(
f"Completed with {len(failures)} translation failures "
f"(partial progress persisted, next run will retry).",
file=sys.stderr, flush=True,
)
for text, lang, error in failures[:20]:
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
if len(failures) > 20:
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
# Exit 0 on partial failure so the workflow's Commit + push
# step still runs. Otherwise a couple of transient googletrans
# timeouts would fail the whole workflow and discard every
# successful translation from the same batch.
return 0
print("Translation cache generated successfully.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+899
View File
@@ -0,0 +1,899 @@
#!/usr/bin/env python3
"""Build missing translations for the ProxMenux documentation catalog."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
sys.path.insert(0, str(Path(__file__).parent))
from build_translation_cache import ( # noqa: E402
clean_translation,
translate_appimage,
translate_argos,
translate_google_web,
translate_googletrans,
)
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = (
"Context: ProxMenux technical documentation for Proxmox VE users. "
"Preserve product names, commands, paths, variables and placeholders. Translate:"
)
TECHNICAL_TERMS = (
"Proxmox VE Helper-Scripts",
"Proxmox Backup Server",
"Proxmox Mail Gateway",
"Proxmox VE",
"ProxMenux Monitor",
"ProxMenux Scripts",
"Docker Compose",
"Docker Engine",
"Google Coral",
"Edge TPU",
"Let's Encrypt",
"Cloudflare",
"Pushover",
"Telegram",
"Discord",
"Microsoft Teams",
"GitHub",
"Gotify",
"Apprise",
"Frigate",
"Vaultwarden",
"Portainer",
"ProxMenux",
"AppImage",
"systemctl",
"journalctl",
"smartctl",
"pveproxy",
"apt-get",
"gasket-dkms",
"libedgetpu",
"QEMU",
"LXC",
"ZFS",
"Ceph",
"Docker",
"OpenAI",
"WebSocket",
"OAuth",
"DKMS",
"SSH",
"API",
)
PROTECTED_PATTERNS = (
# Keep rich-text tags visible to Google Translate. It preserves their
# structure while translating the enclosed prose, whereas replacing
# opening/closing tags with adjacent sentinels can make the provider drop
# one side of the pair. The contract is validated after translation.
re.compile(r"`[^`]+`"),
re.compile(r"https?://[^\s<>]+"),
re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}"),
re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"),
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
)
LITERAL_TAG_RE = re.compile(
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
re.IGNORECASE | re.DOTALL,
)
TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in sorted(TECHNICAL_TERMS, key=len, reverse=True)
),
re.IGNORECASE,
)
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
# Every tag as written, attributes and self-closing slash included.
ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
# Set from --provider: a local model rewrites bracketed sentinels, a remote
# one drops bare ones beside inline markup. See protect_text.
BARE_SENTINELS = False
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
NON_TRANSLATABLE_KEYS = {
"command",
"code",
"href",
"icon",
"id",
"path",
"route",
"slug",
"src",
"url",
}
SOURCE_STATE_VERSION = 1
@dataclass(frozen=True)
class Leaf:
path: tuple[str | int, ...]
source: str
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def iter_leaves(node: Any, path: tuple[str | int, ...] = ()) -> list[Leaf]:
leaves: list[Leaf] = []
if isinstance(node, dict):
for key, value in node.items():
leaves.extend(iter_leaves(value, path + (key,)))
elif isinstance(node, list):
for index, value in enumerate(node):
leaves.extend(iter_leaves(value, path + (index,)))
elif isinstance(node, str):
leaves.append(Leaf(path, node))
return leaves
def get_at_path(node: Any, path: tuple[str | int, ...]) -> Any:
current = node
try:
for part in path:
if isinstance(part, int):
if not isinstance(current, list):
return None
current = current[part]
else:
if not isinstance(current, dict):
return None
current = current[part]
except (IndexError, KeyError, TypeError):
return None
return current
def path_key(path: tuple[str | int, ...]) -> str:
return ".".join(str(part) for part in path)
def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
if not source.strip() or not re.search(r"[A-Za-z]", source):
return True
last = str(path[-1]).lower() if path else ""
if last in NON_TRANSLATABLE_KEYS or any(
last.endswith(suffix)
for suffix in ("url", "href", "path", "command", "code", "icon")
):
return True
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
return True
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
return True
return False
def leaf_token(path: tuple[str | int, ...]) -> str:
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
def source_fingerprints(source: Any) -> dict[str, str]:
return {
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
for leaf in iter_leaves(source)
}
def needs_translation(
source: str,
target: Any,
path: tuple[str | int, ...],
forced_tokens: set[str] | None = None,
) -> bool:
if should_copy(source, path):
return False
# An existing translation is never replaced. Only a leaf whose token was
# named on the command line may be retranslated over a real value, so an
# overwrite is always one somebody asked for rather than a side effect.
if forced_tokens and leaf_token(path) in forced_tokens:
return True
return not isinstance(target, str) or not target.strip() or target == source
def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
"""Give translatable rich-text tags opaque names during translation.
Google can remove semantic tags such as ``strong`` or ``em`` after
translating their contents. Unknown tag names are retained, so rename
non-literal tags temporarily and restore them before contract validation.
Literal ``code``, ``kbd`` and ``pre`` elements stay untouched because the
provider preserves both their markup and their contents.
"""
names: dict[str, str] = {}
reverse: dict[str, str] = {}
def replace(match: re.Match[str]) -> str:
original = match.group(1)
if original.lower() in {"code", "kbd", "pre"}:
return match.group(0)
key = original.lower()
internal = names.get(key)
if internal is None:
internal = f"pmxrich{len(names):04d}"
names[key] = internal
reverse[internal] = original
slash = "/" if match.group(0).startswith("</") else ""
return f"<{slash}{internal}>"
return TAG_RE.sub(replace, text), reverse
def restore_rich_tags(text: str, mapping: dict[str, str]) -> str:
for internal, original in mapping.items():
text = re.sub(
rf"<(/?){re.escape(internal)}>",
lambda match: f"<{match.group(1)}{original}>",
text,
flags=re.IGNORECASE,
)
return text
def protect_text(text: str) -> tuple[str, dict[str, str]]:
# Google already keeps both the markup and the contents of literal rich-
# text blocks. Replacing a filename or path inside one of these blocks can
# leave the sentinel as its only child, which the provider may discard.
literal_ranges = [(match.start(), match.end()) for match in LITERAL_TAG_RE.finditer(text)]
def overlaps_literal(start: int, end: int) -> bool:
return any(start < literal_end and end > literal_start for literal_start, literal_end in literal_ranges)
candidates: list[tuple[int, int]] = []
for pattern in PROTECTED_PATTERNS:
candidates.extend(
(match.start(), match.end())
for match in pattern.finditer(text)
if not overlaps_literal(match.start(), match.end())
)
candidates.extend(
(match.start(), match.end())
for match in TERM_RE.finditer(text)
if not overlaps_literal(match.start(), match.end())
)
if BARE_SENTINELS:
# A remote provider carries inline tags through untouched, so they are
# only validated afterwards. A local model rewrites them — dropping a
# </code>, merging two <em> — and the whole file is then discarded for
# a broken contract. Protecting them costs the translator the tag as
# context and saves the paragraph.
candidates.extend((match.start(), match.end()) for match in ANY_TAG_RE.finditer(text))
# A local model also rewrites what a literal block holds: it drops the
# underscores of an identifier, translates a subcommand and turns a
# pipe into punctuation. The whole block is one opaque token instead.
candidates.extend(literal_ranges)
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
selected: list[tuple[int, int]] = []
cursor = -1
for start, end in candidates:
if start >= cursor:
selected.append((start, end))
cursor = end
mapping: dict[str, str] = {}
chunks: list[str] = []
cursor = 0
for index, (start, end) in enumerate(selected):
# Google Translate can drop underscore-delimited sentinels when they
# sit directly beside inline markup (for example an opening <em>
# token followed by translated prose). Triple brackets remain opaque
# in that position and preserve the complete rich-text contract.
#
# A local model treats the brackets as punctuation instead, and
# rewrites them: argos returns [PMXDOC0000] in German, [[PMXDOC0000]]
# in Slovak and [[[[PMXDOC0000]]]]] in Italian. The bare identifier
# survives every provider tried, so it is what a local run uses.
token = f"PMXDOC{index:04d}" if BARE_SENTINELS else f"[[[PMXDOC{index:04d}]]]"
chunks.append(text[cursor:start])
chunks.append(token)
mapping[token] = text[start:end]
cursor = end
chunks.append(text[cursor:])
return "".join(chunks), mapping
def restore_text(text: str, mapping: dict[str, str]) -> str:
missing = [token for token in mapping if token not in text]
if missing:
raise ValueError(f"translation provider changed protected token {missing[0]}")
for token, original in mapping.items():
text = text.replace(token, original)
return text
def validate_contract(source: str, target: str) -> None:
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
raise ValueError("rich-text tag contract changed")
if sorted(ANY_TAG_RE.findall(source)) != sorted(ANY_TAG_RE.findall(target)):
raise ValueError("markup changed")
literal = lambda text: sorted(match.group(0) for match in LITERAL_TAG_RE.finditer(text))
if literal(source) != literal(target):
raise ValueError("literal code changed")
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
raise ValueError("placeholder contract changed")
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
def translate(text: str, language: str) -> str:
if args.provider == "argos":
raw = translate_argos(text, language)
elif args.provider == "googletrans":
raw = translate_googletrans(text, language, args.context)
elif args.provider == "google-web":
raw = translate_google_web(text, language, args.context, args.timeout)
else:
raw = translate_appimage(
text,
language,
args.context,
args.timeout,
args.appimage_path,
)
return clean_translation(raw).strip()
return translate
def translate_with_retry(
source: str,
language: str,
translate: Callable[[str, str], str],
retries: int,
delay: float,
) -> str:
rich_text, rich_mapping = protect_rich_tags(source)
protected, mapping = protect_text(rich_text)
last_error: Exception | None = None
for attempt in range(retries + 1):
try:
translated = translate(protected, language)
if not translated:
raise ValueError("translation provider returned an empty value")
translated = restore_text(translated, mapping)
translated = restore_rich_tags(translated, rich_mapping)
validate_contract(source, translated)
return translated
except Exception as exc: # network/provider errors are retried together
last_error = exc
if attempt < retries:
time.sleep(delay * (attempt + 1))
raise RuntimeError(str(last_error)) from last_error
def merge_tree(
source: Any,
target: Any,
translations: dict[tuple[str | int, ...], str],
path: tuple[str | int, ...] = (),
) -> Any:
if isinstance(source, dict):
target_dict = target if isinstance(target, dict) else {}
return {
key: merge_tree(value, target_dict.get(key), translations, path + (key,))
for key, value in source.items()
}
if isinstance(source, list):
target_list = target if isinstance(target, list) else []
return [
merge_tree(
value,
target_list[index] if index < len(target_list) else None,
translations,
path + (index,),
)
for index, value in enumerate(source)
]
if isinstance(source, str):
if path in translations:
return translations[path]
if should_copy(source, path):
return source
if isinstance(target, str) and target.strip():
return target
return source
return source
def source_files(source_root: Path, section: str) -> list[Path]:
scope = (source_root / section).resolve()
root = source_root.resolve()
if scope != root and root not in scope.parents:
raise ValueError("section must stay inside the English messages directory")
if scope.is_file():
if scope.suffix != ".json":
raise ValueError("section file must be JSON")
return [scope]
if not scope.is_dir():
raise ValueError(f"section does not exist: {scope}")
return sorted(
path
for path in scope.rglob("*.json")
if not any(part.startswith(".") for part in path.relative_to(root).parts)
)
def collect_memory(source_root: Path, messages_root: Path, language: str) -> dict[str, str]:
memory: dict[str, str] = {}
conflicts: set[str] = set()
for source_path in sorted(source_root.rglob("*.json")):
if any(part.startswith(".") for part in source_path.relative_to(source_root).parts):
continue
target_path = messages_root / language / source_path.relative_to(source_root)
source = read_json(source_path)
target = read_json(target_path)
if target is None:
continue
for leaf in iter_leaves(source):
translated = get_at_path(target, leaf.path)
if not isinstance(translated, str) or not translated.strip() or translated == leaf.source:
continue
previous = memory.get(leaf.source)
if previous is not None and previous != translated:
conflicts.add(leaf.source)
else:
memory[leaf.source] = translated
for source in conflicts:
memory.pop(source, None)
return memory
def pending_leaves(
source: Any,
target: Any,
forced_tokens: set[str] | None = None,
) -> list[Leaf]:
return [
leaf
for leaf in iter_leaves(source)
if needs_translation(
leaf.source,
get_at_path(target, leaf.path),
leaf.path,
forced_tokens,
)
]
def schema_matches(source: Any, target: Any) -> bool:
if type(source) is not type(target):
return False
if isinstance(source, dict):
return list(source) == list(target) and all(
schema_matches(source[key], target[key]) for key in source
)
if isinstance(source, list):
return len(source) == len(target) and all(
schema_matches(left, right) for left, right in zip(source, target)
)
if isinstance(source, str):
# Localized strings intentionally differ from the English source.
return True
# Booleans, numbers and null values are structural data and must stay in
# sync with the English catalog instead of retaining an obsolete value.
return source == target
def empty_source_state() -> dict[str, Any]:
return {
"version": SOURCE_STATE_VERSION,
"initialized": False,
"source": {},
"pending": {},
}
def load_source_state(path: Path) -> dict[str, Any]:
if not path.exists():
return empty_source_state()
try:
value = read_json(path)
except ValueError:
return empty_source_state()
if not isinstance(value, dict) or value.get("version") != SOURCE_STATE_VERSION:
return empty_source_state()
if not isinstance(value.get("source"), dict) or not isinstance(value.get("pending"), dict):
return empty_source_state()
value.setdefault("initialized", True)
return value
def state_pending_tokens(state: dict[str, Any], language: str, relative: str) -> set[str]:
language_state = state.setdefault("pending", {}).setdefault(language, {})
values = language_state.get(relative, [])
if not isinstance(values, list):
return set()
return {str(value) for value in values}
def set_state_pending_tokens(
state: dict[str, Any], language: str, relative: str, tokens: set[str]
) -> None:
language_state = state.setdefault("pending", {}).setdefault(language, {})
if tokens:
language_state[relative] = sorted(tokens)
else:
language_state.pop(relative, None)
if not language_state:
state["pending"].pop(language, None)
def translate_file(
source: Any,
target: Any,
leaves: list[Leaf],
language: str,
memory: dict[str, str],
translate: Callable[[str, str], str],
args: argparse.Namespace,
) -> tuple[Any | None, list[str], int, set[str]]:
resolved: dict[tuple[str | int, ...], str] = {}
failures: list[str] = []
failed_tokens: set[str] = set()
jobs: dict[str, list[tuple[str | int, ...]]] = {}
for leaf in leaves:
if leaf.source in memory:
resolved[leaf.path] = memory[leaf.source]
continue
jobs.setdefault(leaf.source, []).append(leaf.path)
if jobs:
with ThreadPoolExecutor(max_workers=args.workers) as executor:
futures = {
executor.submit(
translate_with_retry,
text,
language,
translate,
args.retries,
args.retry_delay,
): text
for text in jobs
}
for future in as_completed(futures):
text = futures[future]
try:
translated = future.result()
memory[text] = translated
for path in jobs[text]:
resolved[path] = translated
except Exception as exc:
failures.append(f"{text[:90]}: {exc}")
failed_tokens.update(leaf_token(path) for path in jobs[text])
if args.sleep:
time.sleep(args.sleep)
# What did translate is kept. merge_tree leaves a string it has no
# translation for as whatever the locale already held, or as the English
# source, so a file is written with the strings that worked rather than
# discarded whole: a dense paragraph that a provider cannot carry its
# protected tokens through used to take the other hundred and seventy
# strings of its file with it.
return merge_tree(source, target, resolved), failures, len(jobs), failed_tokens
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-dir", type=Path, default=Path("web/messages/en"))
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
parser.add_argument(
"--section",
default=".",
help="Relative file or directory below the English messages directory.",
)
parser.add_argument(
"--provider",
choices=("argos", "google-web", "googletrans", "appimage"),
default="googletrans",
help=(
"Translation provider. The default is what CI uses; `argos` runs "
"locally with no quota, which is what a corpus this size needs."
),
)
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
parser.add_argument(
"--source-state",
type=Path,
default=None,
help=(
"Source fingerprint state used to detect changed English strings. "
"Defaults to <messages-dir>/.docs-i18n-source-state.json."
),
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--retry-delay", type=float, default=2.0)
parser.add_argument("--sleep", type=float, default=0.0)
parser.add_argument(
"--max-files",
type=int,
default=0,
help="Maximum pending files per locale; zero processes all files.",
)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--check", action="store_true")
parser.add_argument(
"--refresh",
action="store_true",
help="Overwrite existing translations in the selected scope.",
)
parser.add_argument("--strict", action="store_true")
return parser
def main() -> int:
global BARE_SENTINELS
args = build_parser().parse_args()
BARE_SENTINELS = args.provider == "argos"
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
return 2
source_root = args.source_dir.resolve()
messages_root = args.messages_dir.resolve()
source_state_path = (
args.source_state.resolve()
if args.source_state is not None
else messages_root / ".docs-i18n-source-state.json"
)
languages = [item.strip() for item in args.languages.split(",") if item.strip()]
try:
files = source_files(source_root, args.section)
except ValueError as exc:
print(exc, file=sys.stderr)
return 2
if not languages:
print("No target languages selected.", file=sys.stderr)
return 2
if args.refresh:
print("--refresh no longer overwrites existing translations; "
"name the leaves with --tokens instead.")
translate = provider_function(args)
state = load_source_state(source_state_path)
state_was_initialized = bool(state.get("initialized"))
previous_source_state = state.setdefault("source", {})
file_info: dict[
str, tuple[Path, Any, dict[str, str], set[str], set[str]]
] = {}
selected_relatives: set[str] = set()
for source_path in files:
relative = source_path.relative_to(source_root).as_posix()
selected_relatives.add(relative)
source = read_json(source_path)
current_fingerprints = source_fingerprints(source)
translatable_tokens = {
leaf_token(leaf.path)
for leaf in iter_leaves(source)
if not should_copy(leaf.source, leaf.path)
}
previous_fingerprints = previous_source_state.get(relative, {})
if not isinstance(previous_fingerprints, dict):
previous_fingerprints = {}
if state_was_initialized:
changed_tokens = {
token
for token, fingerprint in current_fingerprints.items()
if token in translatable_tokens
# Existing English leaves whose text changed must be sent to
# the provider even when the target still contains the old,
# non-empty translation. New leaves are handled by the usual
# missing-target detection, preserving a translation supplied
# manually in the same commit.
and token in previous_fingerprints
and previous_fingerprints[token] != fingerprint
}
else:
# The first run establishes the baseline without replacing
# existing human/Codex translations. Missing target values are
# still discovered separately for every locale below.
changed_tokens = set()
file_info[relative] = (
source_path,
source,
current_fingerprints,
changed_tokens,
translatable_tokens,
)
# Materialize every locale's pending queue before advancing the shared
# English baseline. If the runner stops halfway through, unprocessed
# locales retain the exact changed leaf tokens for the next run.
for language in languages:
for relative, (
_,
source,
_,
changed_tokens,
translatable_tokens,
) in file_info.items():
target_path = messages_root / language / relative
target = read_json(target_path)
pending_tokens = (
state_pending_tokens(state, language, relative)
& translatable_tokens
)
pending_tokens.update(changed_tokens)
pending_tokens.update(
leaf_token(leaf.path)
for leaf in pending_leaves(source, target)
)
set_state_pending_tokens(state, language, relative, pending_tokens)
for relative, (_, _, current_fingerprints, _, _) in file_info.items():
previous_source_state[relative] = current_fingerprints
state["initialized"] = True
# A complete run also mirrors deletion of an English catalog. Partial
# --section runs deliberately leave unrelated paths untouched.
if args.section in (".", ""):
removed_files = set(previous_source_state) - selected_relatives
for relative in sorted(removed_files):
for language in languages:
target_path = messages_root / language / relative
if target_path.exists() and not (args.check or args.dry_run):
target_path.unlink()
print(f"[{language}] removed obsolete catalog {target_path}")
set_state_pending_tokens(state, language, relative, set())
previous_source_state.pop(relative, None)
if not (args.check or args.dry_run):
write_json(source_state_path, state)
total_failures = 0
total_written = 0
# The provider decides the sentinel format, so a run that cannot be
# attributed to one cannot be diagnosed afterwards: a night of failures
# reading "translation provider changed protected token" left no way to
# tell whether the provider was the one the tokens were shaped for.
print(f"Provider: {args.provider} | bare sentinels: {BARE_SENTINELS}")
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
for language in languages:
memory = collect_memory(source_root, messages_root, language)
# A target value paired with a newly changed English source is the old
# translation, not valid translation memory for the new sentence.
# Remove every queued source text before provider reuse; successful
# translations repopulate memory normally for later files.
queued_source_texts: set[str] = set()
for relative_key, (_, source, _, _, _) in file_info.items():
queued_tokens = state_pending_tokens(state, language, relative_key)
queued_source_texts.update(
leaf.source
for leaf in iter_leaves(source)
if leaf_token(leaf.path) in queued_tokens
)
for source_text in queued_source_texts:
memory.pop(source_text, None)
pending: list[
tuple[Path, Path, Any, Any, list[Leaf], set[str], bool]
] = []
total_strings = 0
missing_strings = 0
for source_path in files:
relative = source_path.relative_to(source_root)
relative_key = relative.as_posix()
target_path = messages_root / language / relative
source = file_info[relative_key][1]
target = read_json(target_path)
queued_tokens = state_pending_tokens(state, language, relative_key)
leaves = pending_leaves(source, target, queued_tokens)
total_strings += len(iter_leaves(source))
missing_strings += len(leaves)
schema_changed = not schema_matches(source, target)
if leaves or schema_changed:
pending.append(
(
source_path,
target_path,
source,
target,
leaves,
queued_tokens,
schema_changed,
)
)
print(
f"[{language}] {missing_strings}/{total_strings} strings pending "
f"across {len(pending)} files; reusable translations: {len(memory)}"
)
if args.check or args.dry_run:
continue
if args.max_files:
pending = pending[: args.max_files]
for index, (
source_path,
target_path,
source,
target,
leaves,
queued_tokens,
schema_changed,
) in enumerate(pending, 1):
relative = source_path.relative_to(source_root)
relative_key = relative.as_posix()
suffix = " + schema sync" if schema_changed else ""
print(
f"[{language} {index}/{len(pending)}] {relative} "
f"({len(leaves)} strings{suffix})",
flush=True,
)
built, failures, calls, failed_tokens = translate_file(
source,
target,
leaves,
language,
memory,
translate,
args,
)
if failures:
total_failures += len(failures)
print(
f" {len(failures)} strings left untranslated ({calls} calls)",
file=sys.stderr,
)
for failure in failures[:5]:
print(f" - {failure}", file=sys.stderr)
write_json(target_path, built)
# The strings that failed stay pending, so the next run comes back
# for them and leaves the ones that translated alone.
set_state_pending_tokens(state, language, relative_key, failed_tokens)
if failures:
write_json(source_state_path, state)
write_json(source_state_path, state)
total_written += 1
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
if args.check or args.dry_run:
return 0
print(f"Completed: {total_written} files written; {total_failures} failed strings.")
return 1 if args.strict and total_failures else 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -59,7 +59,7 @@ def fetch_directory_json(api_url: str) -> list[dict]:
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
raise RuntimeError("GitHub API no devolvió una lista.")
raise RuntimeError("The GitHub API did not return a list.")
return data
@@ -67,7 +67,7 @@ def main() -> int:
try:
directory = fetch_directory_json(API_URL)
except Exception as e:
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
print(f"ERROR: Could not read the JSON index: {e}", file=sys.stderr)
return 1
cache: list[dict] = []
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""OCI validation record: renders oci/VALIDATION.md from verification.json,
checks the record, checks who a pull request credits, and records a reviewed
validation report."""
import argparse
import datetime
import json
import os
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[2]
VERIFICATION = ROOT / "oci/catalog/verification.json"
INDEX = ROOT / "oci/catalog/index.json"
OUTPUT = ROOT / "oci/VALIDATION.md"
REVIEWERS = ROOT / ".github/oci-validation-reviewers"
REPO = "https://github.com/MacRimi/ProxMenux"
BOARD = "https://github.com/users/MacRimi/projects/1"
FORM = f"{REPO}/issues/new?template=oci-validation.yml"
DISCUSSION = f"{REPO}/discussions/360"
MAINTAINERS = {"macrimi"}
LAB = "laboratory-validated"
USER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")
REPORT_RE = re.compile(r"^https://github\.com/MacRimi/ProxMenux/"
r"(issues/[0-9]+|discussions/[0-9]+(#discussioncomment-[0-9]+)?)$")
ENTRY_KEYS = {"status", "community_tested"}
COMMUNITY_KEYS = {"by", "date", "report", "digest", "scenario"}
DIGEST_RE = re.compile(r"^[a-f0-9]{8,64}$")
# Form fields that describe the tested scenario, in the order they are read.
SCENARIO_FIELDS = ("Install mode", "Data storage", "Network", "GPU")
NO_RESPONSE = "_No response_"
def load(path):
return json.loads(Path(path).read_text(encoding="utf-8"))
def dump(data):
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def applications(index):
return {item["id"]: item for item in index.get("applications", [])}
def lab_validated(entry, item):
# Same rule as the catalog: an application that cannot be installed is not verified.
return entry.get("status") == LAB and bool(item.get("automatic_install_candidate"))
def community(entry):
tested = entry.get("community_tested")
return tested if isinstance(tested, dict) and tested.get("by") else None
def cell(text):
return str(text).replace("|", "\\|").strip()
def render(verification, index):
apps = applications(index)
entries = {app: entry for app, entry in verification.get("applications", {}).items()
if app in apps and isinstance(entry, dict)}
lab = sorted((app for app, entry in entries.items() if lab_validated(entry, apps[app])),
key=lambda app: str(apps[app].get("title") or app).casefold())
tested = sorted((app for app, entry in entries.items() if community(entry)),
key=lambda app: str(apps[app].get("title") or app).casefold())
visible = sum(1 for item in apps.values() if not item.get("hidden"))
verified = len(set(lab) | set(tested))
lines = [
"# OCI application validation",
"",
"<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->",
"",
f"{verified} of {visible} applications in the OCI catalog have been tested for real: "
f"{len(lab)} in the ProxMenux lab and {len(tested)} by the community. "
"The OCI installer shows them as verified, with a ✓ in the lists.",
"",
"Each test describes the scenario that was run and names the image it ran on. It does not cover every possible "
"configuration of the application, and a newer image has not been tested until someone reports it.",
"",
"## Taking part",
"",
"Any application that is not listed here is open for testing.",
"",
f"1. Check the [ProxMenux Roadmap]({BOARD}) to see which applications someone is already testing. "
"With access to the board, create a card for the application and move it to In progress while you test it.",
f"2. Test the application with the OCI manager and fill in the [OCI validation report]({FORM}). "
"The report records the image and the exact scenario: install mode, storage, network and GPU.",
"3. A reviewer reads the report and comments `/validated` on it. The application is then added to this list "
"with your GitHub user and shown as verified in the OCI installer, and the report is closed.",
"",
"Reviewers are listed in [.github/oci-validation-reviewers](../.github/oci-validation-reviewers), and a "
"reviewer does not validate their own report. A validation can also be added with a pull request that adds "
"the entry to `oci/catalog/verification.json` and regenerates this file with "
"`python3 .github/scripts/oci_validation.py render`; CI checks that the entry credits the author of the pull request.",
"",
"An application that cannot be validated for a reason outside the tester's hands — hardware nobody has, something "
"only Docker provides, a fix still pending in ProxMenux — moves to Blocked on the board, with a line saying why "
"and what would make it worth trying again.",
"",
f"Questions and ideas about OCI testing go in [discussion #360]({DISCUSSION}).",
"",
"## Tested by the community",
"",
]
if tested:
lines += ["| Application | Tested by | Date | Image | Scenario | Report |", "|---|---|---|---|---|---|"]
for app in tested:
entry = community(entries[app])
report = f"[Report]({entry['report']})" if entry.get("report") else "—"
image = f"`{entry['digest'][:12]}`" if entry.get("digest") else "—"
lines.append(f"| {cell(apps[app].get('title') or app)} | [@{entry['by']}](https://github.com/{entry['by']}) "
f"| {entry.get('date') or '—'} | {image} | {cell(entry.get('scenario') or '—')} | {report} |")
else:
lines.append("No application has been tested by the community yet.")
lines += ["", "## Tested in the ProxMenux lab", ""]
if lab:
lines += ["| Application | Category |", "|---|---|"]
for app in lab:
lines.append(f"| {cell(apps[app].get('title') or app)} | {cell(apps[app].get('category_label') or '—')} |")
else:
lines.append("No application has been tested in the ProxMenux lab yet.")
return "\n".join(lines) + "\n"
def problems(verification, index, rendered=None, today=None):
today = today or datetime.date.today()
apps = applications(index)
found = []
entries = verification.get("applications") if isinstance(verification, dict) else None
if not isinstance(entries, dict):
return ["verification.json must hold an \"applications\" object"]
for app, entry in entries.items():
where = f"verification.json: {app}"
if app not in apps:
found.append(f"{where}: not an application of the OCI catalog")
if not isinstance(entry, dict) or not entry:
found.append(f"{where}: the entry must be a non-empty object")
continue
if set(entry) - ENTRY_KEYS:
found.append(f"{where}: unknown keys {sorted(set(entry) - ENTRY_KEYS)}")
if "status" in entry and entry["status"] != LAB:
found.append(f"{where}: status can only be \"{LAB}\"")
if "community_tested" not in entry:
continue
tested = entry["community_tested"]
if not isinstance(tested, dict):
found.append(f"{where}: community_tested must be an object")
continue
if set(tested) - COMMUNITY_KEYS:
found.append(f"{where}: unknown community_tested keys {sorted(set(tested) - COMMUNITY_KEYS)}")
if not USER_RE.fullmatch(str(tested.get("by", ""))):
found.append(f"{where}: \"by\" must be a GitHub user name")
try:
date = datetime.date.fromisoformat(str(tested.get("date", "")))
if date > today + datetime.timedelta(days=1):
found.append(f"{where}: the date is in the future")
except ValueError:
found.append(f"{where}: \"date\" must be YYYY-MM-DD")
if "report" in tested and not REPORT_RE.fullmatch(str(tested["report"])):
found.append(f"{where}: \"report\" must link to an issue or discussion of MacRimi/ProxMenux")
if "digest" in tested and not DIGEST_RE.fullmatch(str(tested["digest"])):
found.append(f"{where}: \"digest\" must be 8 to 64 lowercase hex characters of the image digest")
if "scenario" in tested and (not isinstance(tested["scenario"], str) or len(tested["scenario"]) > 300):
found.append(f"{where}: \"scenario\" must be text of at most 300 characters")
if rendered is not None and rendered != render(verification, index):
found.append("oci/VALIDATION.md is out of date: run python3 .github/scripts/oci_validation.py render")
return found
def author_problems(base, head, author):
"""A pull request only adds or changes community tests credited to its author."""
if author.casefold() in MAINTAINERS:
return []
found = []
before = base.get("applications", {}) if isinstance(base, dict) else {}
after = head.get("applications", {})
for app in sorted(set(before) | set(after)):
old = before.get(app) if isinstance(before.get(app), dict) else {}
new = after.get(app) if isinstance(after.get(app), dict) else {}
if old.get("status") != new.get("status"):
found.append(f"{app}: the ProxMenux lab status is changed by the maintainer only")
if old.get("community_tested") == new.get("community_tested"):
continue
credited = community(new)
if not credited:
found.append(f"{app}: a community test is removed by the maintainer only")
elif str(credited["by"]).casefold() != author.casefold():
found.append(f"{app}: credits @{credited['by']}, but the pull request is from @{author}")
return found
def parse_form(body):
"""Issue form answers, keyed by the field label."""
fields, label, lines = {}, None, []
for line in (body or "").replace("\r\n", "\n").split("\n"):
if line.startswith("### "):
if label is not None:
fields[label] = "\n".join(lines).strip()
label, lines = line[4:].strip(), []
elif label is not None:
lines.append(line)
if label is not None:
fields[label] = "\n".join(lines).strip()
return {key: ("" if value == NO_RESPONSE else value) for key, value in fields.items()}
def resolve(name, index):
wanted = " ".join(name.split()).casefold()
matches = [item["id"] for item in index.get("applications", [])
if wanted in (str(item["id"]).casefold(), " ".join(str(item.get("title") or "").split()).casefold())]
return matches[0] if len(matches) == 1 else None
def image_digest(text):
"""The digest as the Monitor shows it (b1f339cf) or in full (sha256:b1f3...)."""
value = str(text or "").strip().lower()
value = value.split("·")[-1].strip()
value = value[7:] if value.startswith("sha256:") else value
return value if DIGEST_RE.fullmatch(value) else None
def scenario(fields):
parts = [fields.get(name, "") for name in SCENARIO_FIELDS]
if fields.get("Proxmox VE version"):
parts.append(f"Proxmox VE {fields['Proxmox VE version']}")
if fields.get("Image version"):
parts.append(f"image {fields['Image version']}")
text = " · ".join(part.strip() for part in parts if part and part.strip())
if fields.get("Result") == "Works with problems":
text = f"Works with problems: {text}"
return text[:300]
def reviewers(text):
"""User names of the reviewer list, one per line; # starts a comment."""
names = {line.split("#", 1)[0].strip() for line in text.splitlines()}
return {name.casefold() for name in names if USER_RE.fullmatch(name)}
def record(event, verification, index, today, allowed=frozenset()):
"""Returns (verification, app id, message); raises ValueError with the reason to refuse.
A label can only be added by someone with write access to the repository.
A /validated comment is accepted from the reviewer list, never from the
report's own author unless it is the maintainer."""
issue = event.get("issue") or {}
labels = {label.get("name") for label in issue.get("labels", [])}
if "oci-validation" not in labels:
raise ValueError("This issue is not an OCI validation report.")
author = str((issue.get("user") or {}).get("login", ""))
report = str(issue.get("html_url", ""))
if not USER_RE.fullmatch(author) or not REPORT_RE.fullmatch(report):
raise ValueError("The report author or link could not be read.")
reviewer = str(((event.get("comment") or {}).get("user") or {}).get("login", ""))
if event.get("comment") is not None:
if reviewer.casefold() not in allowed:
raise ValueError(f"@{reviewer} is not in the list of OCI validation reviewers "
"(.github/oci-validation-reviewers), so the report is not recorded.")
if reviewer.casefold() == author.casefold() and reviewer.casefold() not in MAINTAINERS:
raise ValueError("A report is validated by a reviewer other than its author.")
fields = parse_form(issue.get("body"))
name = fields.get("Application", "")
app = resolve(name, index)
if app is None:
raise ValueError(f"\"{name}\" does not match one application of the OCI catalog. "
"Edit the Application field with its name or ID as shown in the catalog, then add the label again.")
if fields.get("Result") == "Does not work":
raise ValueError("The report says the application does not work, so it is not recorded as verified.")
digest = image_digest(fields.get("Image digest"))
if digest is None:
raise ValueError("The Image digest field does not hold an image digest. Edit it with the digest shown in the "
"App tab of the Monitor, for example b1f339cf, then add the label again.")
data = json.loads(json.dumps(verification))
entries = data.setdefault("applications", {})
entry = entries.setdefault(app, {})
previous = community(entry)
entry["community_tested"] = {"by": author, "date": today.isoformat(), "report": report,
"digest": digest, "scenario": scenario(fields)}
data["applications"] = dict(sorted(entries.items()))
title = applications(index)[app].get("title") or app
validated = f" Validated by @{reviewer}." if reviewer else ""
message = (f"Thank you, @{author}! {title} is now recorded as verified.{validated} "
f"It shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md]({REPO}/blob/develop/oci/VALIDATION.md).")
if previous and previous.get("by") != author:
message += f" This report replaces the previous one from @{previous['by']}."
return data, app, message
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("render", help="Write oci/VALIDATION.md")
commands.add_parser("check", help="Check verification.json and that oci/VALIDATION.md is current")
authors = commands.add_parser("authors", help="Check who a pull request credits")
authors.add_argument("--base", required=True, help="verification.json of the base branch; may be missing")
authors.add_argument("--author", required=True)
rec = commands.add_parser("record", help="Record a reviewed validation report")
rec.add_argument("--event", required=True)
rec.add_argument("--message", required=True, help="File that receives the reply for the issue")
args = parser.parse_args(argv)
verification, index = load(VERIFICATION), load(INDEX)
if args.command == "render":
OUTPUT.write_text(render(verification, index), encoding="utf-8")
return 0
if args.command == "check":
rendered = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
found = problems(verification, index, rendered)
elif args.command == "authors":
base = load(args.base) if Path(args.base).exists() and Path(args.base).stat().st_size else {}
found = author_problems(base, verification, args.author)
else:
try:
allowed = reviewers(REVIEWERS.read_text(encoding="utf-8")) if REVIEWERS.exists() else frozenset()
data, app, message = record(load(args.event), verification, index, datetime.date.today(), allowed)
except ValueError as error:
Path(args.message).write_text(str(error) + "\n", encoding="utf-8")
return 2
VERIFICATION.write_text(dump(data), encoding="utf-8")
OUTPUT.write_text(render(data, index), encoding="utf-8")
Path(args.message).write_text(message + "\n", encoding="utf-8")
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"app={app}\n")
return 0
for problem in found:
print(f"::error::{problem}")
return 1 if found else 0
if __name__ == "__main__":
sys.exit(main())
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Run only the independently qualified, host-independent tests/ fixtures.
No filesystem discovery widens this manifest. Run from the repository root;
Python and Node have separate CI jobs so either failure blocks the check.
"""
import argparse
import os
from pathlib import Path
import subprocess
import sys
PYTHON_FILES = (
"tests/test_audit_presentation.py",
"tests/test_audit_safety_wording.py",
"tests/test_audit_policy.py",
"tests/test_audit_report.py",
"tests/test_audit_catalog.py",
"tests/storage/test_nvme_status_message.py",
"tests/test_fastfetch_config_generation.py",
"tests/test_notification_active_payloads.py",
)
NODE_FILES = (
"tests/lxc_updates/test_docker_delegated_ui.cjs",
"tests/test_audit_diagnostic_document.cjs",
"tests/test_audit_policy.cjs",
"tests/test_audit_presentation.cjs",
"tests/test_audit_safety_wording.cjs",
"tests/test_audit_summary.cjs",
"tests/test_backup_archives_empty.cjs",
"tests/test_backup_destination_messages.cjs",
"tests/test_borg_ssh_guidance.cjs",
"tests/test_storage_messages.cjs",
"tests/test_health_text.cjs",
)
# unittest's CLI accepts a missing pattern as a successful zero-test run.
# Load the exact file in a fresh interpreter and reject zero tests and skips.
PYTHON_RUN = """import sys, unittest
folder, filename = sys.argv[1:]
suite = unittest.TestLoader().discover(start_dir=folder, pattern=filename)
count = suite.countTestCases()
if count == 0:
raise SystemExit('No tests discovered: ' + folder + '/' + filename)
print('DISCOVERED=' + str(count), flush=True)
result = unittest.TextTestRunner(verbosity=2).run(suite)
sys.exit(0 if result.wasSuccessful() and result.testsRun == count and not result.skipped else 1)
"""
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--lane", required=True, choices=("python", "node"))
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
files = PYTHON_FILES if args.lane == "python" else NODE_FILES
if not files or Path.cwd().resolve() != root:
parser.error("nonempty manifest and repository-root working directory required")
for path in files:
if not (root / path).is_file():
print(f"Missing qualified test: {path}", file=sys.stderr)
return 1
if args.lane == "node" and not (root / "AppImage/node_modules/typescript").is_dir():
print("Install the locked offline-node dependencies first", file=sys.stderr)
return 1
for path in files:
print(f"RUN {path}", flush=True)
if args.lane == "python":
folder, filename = str(Path(path).parent), Path(path).name
command = [sys.executable, "-I", "-B", "-c", PYTHON_RUN, folder, filename]
else:
command = ["node", path]
result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False)
if result.returncode != 0:
print(f"FAIL {path}: exit {result.returncode}", file=sys.stderr)
return 1
print(f"PASS {path}", flush=True)
print(f"PASS {args.lane}: {len(files)} qualified files", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,558 @@
{
"pin": "dede876f0b92f8a571325e96ed7591b30a85368e",
"fallback_indices": [
9,
10,
19,
20,
21,
22,
31,
32,
33,
34,
43,
44,
45,
58,
59
],
"locales": {
"es": [
"Listar todos los nodos Proxmox",
"Listar VM en un nodo",
"Listar contenedores LXC en un nodo",
"Listar almacenamiento en un nodo",
"Listar interfaces de red",
"Listar todas las máquinas virtuales QEMU/KVM",
"Iniciar una máquina virtual",
"Detener una máquina virtual",
"Apagar una máquina virtual de forma segura",
"Mostrar estado de VM",
"Mostrar configuración de VM",
"Crear una instantánea de VM",
"Listar todos los contenedores LXC",
"Iniciar un contenedor LXC",
"Detener un contenedor LXC",
"Abrir una consola de contenedor LXC",
"Mostrar configuración del contenedor",
"Mostrar estado de almacenamiento",
"Enumerar el contenido del almacenamiento",
"Probar el rendimiento del sistema Proxmox",
"Mostrar la versión de Proxmox VE",
"Verificar el estado del clúster",
"Mostrar el estado del clúster",
"Enumerar los nodos del clúster",
"Mostrar el estado del grupo ZFS",
"Enumerar todos los grupos ZFS",
"Enumerar todo Conjuntos de datos ZFS",
"Enumerar todos los archivos con detalles",
"Cambiar directorio",
"Crear un directorio",
"Eliminar un directorio de forma recursiva",
"Copiar archivos o directorios",
"Mover o cambiar el nombre de archivos",
"Mostrar el contenido del archivo",
"Buscar un patrón en un archivo",
"Buscar archivos por nombre",
"Cambiar permisos de archivos",
"Cambiar el propietario del archivo",
"Extraer un archivo tar.gz",
"Crear un tar.gz archivo",
"Mostrar uso del disco",
"Mostrar tamaños de directorio",
"Mostrar uso de memoria",
"Mostrar procesos en ejecución",
"Buscar un proceso en ejecución",
"Forzar la eliminación de un proceso",
"Verificar el estado del servicio",
"Iniciar un servicio",
"Detener un servicio",
"Reiniciar un servicio",
"Actualizar paquetes de Debian/Ubuntu",
"Instalar un paquete de Debian/Ubuntu",
"Eliminar un paquete",
"Listar contenedores en ejecución",
"Listar Docker imágenes",
"Abrir un shell en un contenedor",
"Mostrar direcciones IP",
"Probar conectividad de red",
"Obtener encabezados HTTP",
"Descargar un archivo",
"Conectarse a través de SSH",
"Copiar un archivo a través de SSH",
"Seguir un archivo de registro en tiempo real",
"Mostrar historial de comandos",
"Borrar la pantalla del terminal"
],
"de": [
"Alle Proxmox-Knoten auflisten",
"VMs auf einem Knoten auflisten",
"LXC-Container auf einem Knoten auflisten",
"Speicher auf einem Knoten auflisten",
"Netzwerkschnittstellen auflisten",
"Alle virtuellen QEMU/KVM-Maschinen auflisten",
"Eine virtuelle Maschine starten",
"Eine virtuelle Maschine stoppen",
"Eine virtuelle Maschine sicher herunterfahren",
"VM-Status anzeigen",
"VM-Konfiguration anzeigen",
"Einen VM-Snapshot erstellen",
"Alle LXC-Container auflisten",
"Einen LXC-Container starten",
"Einen LXC-Container stoppen",
"Eine LXC-Containerkonsole öffnen",
"Containerkonfiguration anzeigen",
"Speicherstatus anzeigen",
"Speicherinhalte auflisten",
"Proxmox-Systemleistung testen",
"Proxmox VE-Version anzeigen",
"Clusterstatus prüfen",
"Clusterstatus anzeigen",
"Clusterknoten auflisten",
"ZFS-Poolstatus anzeigen",
"Alle ZFS-Pools auflisten",
"Alle ZFS-Datensätze auflisten",
"Alle Dateien mit Details auflisten",
"Verzeichnis ändern",
"Verzeichnis erstellen",
"Verzeichnis rekursiv entfernen",
"Dateien oder Verzeichnisse kopieren",
"Dateien verschieben oder umbenennen",
"Dateiinhalte anzeigen",
"Nach einem Muster in einer Datei suchen",
"Dateien nach Namen suchen",
"Dateiberechtigungen ändern",
"Dateieigentümer ändern",
"Tar.gz-Archiv extrahieren",
"Tar.gz-Archiv erstellen",
"Festplattennutzung anzeigen",
"Verzeichnisgrößen anzeigen",
"Speichernutzung anzeigen",
"Laufende Prozesse anzeigen",
"Laufenden Prozess suchen",
"Prozess beenden",
"Dienststatus prüfen",
"Dienst starten",
"Dienst stoppen",
"Dienst neu starten",
"Debian/Ubuntu-Pakete aktualisieren",
"Debian/Ubuntu-Paket installieren",
"Paket entfernen",
"Laufende Container auflisten",
"Docker-Images auflisten",
"Eine Shell in einem Container öffnen",
"IP-Adressen anzeigen",
"Netzwerkkonnektivität testen",
"HTTP-Header abrufen",
"Eine Datei herunterladen",
"Über SSH verbinden",
"Eine Datei über SSH kopieren",
"Eine Protokolldatei in Echtzeit verfolgen",
"Befehlsverlauf anzeigen",
"Terminalbildschirm löschen"
],
"fr": [
"Liste de tous les nœuds Proxmox",
"Liste des machines virtuelles sur un nœud",
"Liste des conteneurs LXC sur un nœud",
"Liste du stockage sur un nœud",
"Liste des interfaces réseau",
"Liste de toutes les machines virtuelles QEMU/KVM",
"Démarrer une machine virtuelle",
"Arrêter une machine virtuelle",
"Arrêter une machine virtuelle en toute sécurité",
"Afficher l'état de la VM",
"Afficher la VM configuration",
"Créer un instantané de VM",
"Liste de tous les conteneurs LXC",
"Démarrer un conteneur LXC",
"Arrêter un conteneur LXC",
"Ouvrir une console de conteneur LXC",
"Afficher la configuration du conteneur",
"Afficher l'état du stockage",
"Liste du contenu du stockage",
"Tester les performances du système Proxmox",
"Afficher la version Proxmox VE",
"Vérifier l'état du cluster",
"Afficher l'état du cluster",
"Liste des nœuds du cluster",
"Afficher l'état du pool ZFS",
"Liste de tous les pools ZFS",
"Liste de tous les ensembles de données ZFS",
"Liste de tous les fichiers avec détails",
"Changer de répertoire",
"Créer un répertoire",
"Supprimer un répertoire de manière récursive",
"Copier des fichiers ou des répertoires",
"Déplacer ou renommer des fichiers",
"Afficher le contenu du fichier",
"Rechercher un modèle dans un fichier",
"Rechercher des fichiers par nom",
"Modifier les autorisations du fichier",
"Modifier le propriétaire du fichier",
"Extraire une archive tar.gz",
"Créer une archive tar.gz",
"Afficher l'utilisation du disque",
"Afficher la taille des répertoires",
"Afficher l'utilisation de la mémoire",
"Afficher les processus en cours d'exécution",
"Trouver un processus en cours d'exécution",
"Forcer l'arrêt d'un processus",
"Vérifier le service\". statut",
"Démarrer un service",
"Arrêter un service",
"Redémarrer un service",
"Mettre à jour les packages Debian/Ubuntu",
"Installer un package Debian/Ubuntu",
"Supprimer un package",
"Liste des conteneurs en cours d'exécution",
"Liste des images Docker",
"Ouvrir un shell dans un conteneur",
"Afficher les adresses IP",
"Tester la connectivité réseau",
"Obtenir les en-têtes HTTP",
"Télécharger un fichier",
"Connecter via SSH",
"Copier un fichier via SSH",
"Suivre un fichier journal en temps réel",
"Afficher l'historique des commandes",
"Effacer l'écran du terminal"
],
"it": [
"Elenca tutti i nodi Proxmox",
"Elenca VM su un nodo",
"Elenca container LXC su un nodo",
"Elenca storage su un nodo",
"Elenca interfacce di rete",
"Elenca tutte le VM QEMU/KVM",
"Avvia una VM",
"Arresta una VM",
"Spegni una VM in modo ordinato",
"Mostra stato VM",
"Mostra configurazione VM",
"Crea snapshot VM",
"Elenca tutti i container LXC",
"Avvia un container LXC",
"Arresta un container LXC",
"Apri una shell in un container LXC",
"Mostra configurazione container",
"Mostra stato storage",
"Elenca contenuto storage",
"Verifica prestazioni del sistema Proxmox",
"Mostra versione Proxmox VE",
"Mostra stato servizio pve-cluster",
"Mostra stato cluster",
"Elenca nodi cluster",
"Mostra stato pool ZFS",
"Elenca tutti i pool ZFS",
"Elenca filesystem e volumi ZFS",
"Elenca tutti i file con dettagli",
"Cambia directory",
"Crea una directory",
"Rimuovi una directory ricorsivamente",
"Copia file",
"Sposta o rinomina file",
"Visualizza contenuto file",
"Cerca un pattern in un file",
"Trova file per nome",
"Modifica permessi file",
"Cambia proprietario file",
"Estrai un archivio tar.gz",
"Crea un archivio tar.gz",
"Mostra utilizzo disco",
"Mostra dimensioni directory",
"Mostra utilizzo memoria",
"Mostra processi in esecuzione",
"Trova un processo in esecuzione",
"Termina forzatamente un processo",
"Controlla stato servizio",
"Avvia un servizio",
"Arresta un servizio",
"Riavvia un servizio",
"Aggiorna pacchetti Debian/Ubuntu",
"Installa un pacchetto Debian/Ubuntu",
"Rimuovi un pacchetto",
"Elenca i container in esecuzione",
"Elenca immagini Docker",
"Apri una shell in un container",
"Mostra indirizzi IP",
"Verifica connettività di rete",
"Ottieni intestazioni HTTP",
"Scarica un file",
"Connettiti tramite SSH",
"Copia un file tramite SSH",
"Segui un file di log in tempo reale",
"Mostra cronologia comandi",
"Pulisci lo schermo del terminale"
],
"pt": [
"Listar todos os nós Proxmox",
"Listar VMs em um nó",
"Listar contêineres LXC em um nó",
"Listar armazenamento em um nó",
"Listar interfaces de rede",
"Listar todas as máquinas virtuais QEMU/KVM",
"Iniciar uma máquina virtual",
"Parar uma máquina virtual",
"Desligar uma máquina virtual com segurança",
"Mostrar status da VM",
"Mostrar configuração da VM",
"Criar um instantâneo de VM",
"Listar todos os LXC containers",
"Iniciar um contêiner LXC",
"Parar um contêiner LXC",
"Abrir um console de contêiner LXC",
"Mostrar configuração do contêiner",
"Mostrar status de armazenamento",
"Listar conteúdo de armazenamento",
"Testar desempenho do sistema Proxmox",
"Mostrar a versão Proxmox VE",
"Verificar status do cluster",
"Mostrar status do cluster",
"Listar nós do cluster",
"Mostrar status do pool ZFS",
"Listar todos os pools ZFS",
"Listar todos os conjuntos de dados ZFS",
"Listar todos os arquivos com detalhes",
"Alterar diretório",
"Criar um diretório",
"Remover um diretório recursivamente",
"Copiar arquivos ou diretórios",
"Mover ou renomear arquivos",
"Exibir conteúdo do arquivo",
"Pesquisar um padrão em um arquivo",
"Encontrar arquivos por nome",
"Alterar permissões de arquivo",
"Alterar o proprietário do arquivo",
"Extrair um arquivo tar.gz",
"Criar um arquivo tar.gz",
"Mostrar uso de disco",
"Mostrar tamanhos de diretório",
"Mostrar uso de memória",
"Mostrar processos em execução",
"Encontrar um processo em execução",
"Forçar a eliminação de um processo",
"Verificar status do serviço",
"Iniciar um serviço",
"Parar um serviço",
"Reiniciar um serviço",
"Atualizar pacotes Debian/Ubuntu",
"Instalar um pacote Debian/Ubuntu",
"Remover um pacote",
"Listar contêineres em execução",
"Listar imagens do Docker",
"Abrir um shell em um contêiner",
"Mostrar endereços IP",
"Testar conectividade de rede",
"Obter cabeçalhos HTTP",
"Baixar um arquivo",
"Conectar através de SSH",
"Copiar um arquivo através de SSH",
"Seguir um arquivo de log em tempo real",
"Mostrar histórico de comandos",
"Limpar a tela do terminal"
],
"sv": [
"Lista alla Proxmox-noder",
"Lista virtuella datorer på en nod",
"Lista LXC-behållare på en nod",
"Lista lagringsutrymme på en nod",
"Lista nätverksgränssnitt",
"Lista alla virtuella QEMU/KVM-maskiner",
"Starta en virtuell maskin",
"Stoppa en virtuell maskin",
"Stäng av en virtuell maskin på ett säkert sätt",
"Show VM status",
"Show VM configuration",
"Skapa en VM Snapshot",
"Lista alla LXC-behållare",
"Starta en LXC-behållare",
"Stoppa en LXC-behållare",
"Öppna en LXC-containerkonsol",
"Visa behållarkonfiguration",
"Visa lagringsstatus",
"Lista lagringsinnehåll",
"Test Proxmox system performance",
"Show the Proxmox VE version",
"Check cluster status",
"Show cluster status",
"Lista klusternoder",
"Visa ZFS-poolstatus",
"Lista alla ZFS-pooler",
"Lista alla ZFS-datauppsättningar",
"Lista alla filer med detaljer",
"Ändra katalog",
"Skapa en katalog",
"Ta bort en katalog rekursivt",
"Copy files or directories",
"Move or rename files",
"Display file contents",
"Search for a pattern in a file",
"Hitta filer efter namn",
"Ändra filbehörigheter",
"Ändra filägare",
"Extrahera ett tar.gz-arkiv",
"Skapa ett tar.gz-arkiv",
"Visa diskanvändning",
"Visa katalogstorlekar",
"Visa minnesanvändning",
"Show running processes",
"Find a running process",
"Force-kill a process",
"Kontrollera tjänsten status",
"Starta en tjänst",
"Stoppa en tjänst",
"Starta om en tjänst",
"Uppdatera Debian/Ubuntu-paket",
"Installera ett Debian/Ubuntu-paket",
"Ta bort ett paket",
"Lista behållare som körs",
"Lista Docker-avbildningar",
"Öppna ett skal i en behållare",
"Visa IP-adresser",
"Testa en nätverksanslutning",
"Get HTTP headers",
"Download a file",
"Anslut via SSH",
"Kopiera en fil via SSH",
"Följ en loggfil i realtid",
"Visa kommandohistorik",
"Rensa terminalskärmen"
]
},
"english": [
"List all Proxmox nodes",
"List VMs on a node",
"List LXC containers on a node",
"List storage on a node",
"List network interfaces",
"List all QEMU/KVM virtual machines",
"Start a virtual machine",
"Stop a virtual machine",
"Shut down a virtual machine safely",
"Show VM status",
"Show VM configuration",
"Create a VM Snapshot",
"List all LXC containers",
"Start an LXC container",
"Stop an LXC container",
"Open a shell in an LXC container",
"Show container configuration",
"Show storage status",
"List storage contents",
"Test Proxmox system performance",
"Show the Proxmox VE version",
"Show pve-cluster service status",
"Show cluster status",
"List cluster nodes",
"Show ZFS pool status",
"List all ZFS pools",
"List ZFS filesystems and volumes",
"List all files with details",
"Change directory",
"Create a directory",
"Remove a directory recursively",
"Copy files",
"Move or rename files",
"Display file contents",
"Search for a pattern in a file",
"Find files by name",
"Change file permissions",
"Change the file owner",
"Extract a tar.gz archive",
"Create a tar.gz archive",
"Show disk usage",
"Show directory sizes",
"Show memory usage",
"Show running processes",
"Find a running process",
"Force-kill a process",
"Check service status",
"Start a service",
"Stop a service",
"Restart a service",
"Update Debian/Ubuntu packages",
"Install a Debian/Ubuntu package",
"Remove a package",
"List running containers",
"List Docker images",
"Open a shell in a container",
"Show IP addresses",
"Test network connectivity",
"Get HTTP headers",
"Download a file",
"Connect through SSH",
"Copy a file through SSH",
"Follow a log file in real time",
"Show command history",
"Clear the terminal screen"
],
"commands": [
"pvesh get /nodes",
"pvesh get /nodes/{node}/qemu",
"pvesh get /nodes/{node}/lxc",
"pvesh get /nodes/{node}/storage",
"pvesh get /nodes/{node}/network",
"qm list",
"qm start <vmid>",
"qm stop <vmid>",
"qm shutdown <vmid>",
"qm status <vmid>",
"qm config <vmid>",
"qm snapshot <vmid> <snapname>",
"pct list",
"pct start <vmid>",
"pct stop <vmid>",
"pct enter <vmid>",
"pct config <vmid>",
"pvesm status",
"pvesm list <storage>",
"pveperf",
"pveversion",
"systemctl status pve-cluster",
"pvecm status",
"pvecm nodes",
"zpool status",
"zpool list",
"zfs list",
"ls -la",
"cd /path/to/dir",
"mkdir dirname",
"rm -rf dirname",
"cp source dest",
"mv source dest",
"cat filename",
"grep 'pattern' file",
"find . -name 'file'",
"chmod 755 file",
"chown user:group file",
"tar -xzf file.tar.gz",
"tar -czf archive.tar.gz dir/",
"df -h",
"du -sh *",
"free -h",
"top",
"ps aux | grep process",
"kill -9 PID",
"systemctl status service",
"systemctl start service",
"systemctl stop service",
"systemctl restart service",
"apt update && apt upgrade",
"apt install package",
"apt remove package",
"docker ps",
"docker images",
"docker exec -it container bash",
"ip addr show",
"ping host",
"curl -I url",
"wget url",
"ssh user@host",
"scp file user@host:/path",
"tail -f /var/log/syslog",
"history",
"clear"
]
}
@@ -0,0 +1,76 @@
// Execute the actual Security handlers against inert UI state and transport.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const root = path.resolve(__dirname, '../../../..');
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
const catalogs = Object.fromEntries(['en','de','es','fr','it','pt','sk','sv'].map(lang => [lang, require(path.join(root, `AppImage/messages/${lang}/common.json`)).securityPage]));
const begin = source.indexOf(' const handleUninstallLynis = async () => {');
const end = source.indexOf('\n const loadFail2banDetails =', begin);
assert(begin !== -1 && end > begin);
const eraseTypes = text => text.replace(/fetchApi<\{[^}]+\}>/g, 'fetchApi')
.replace(/ as \{ partial\?: boolean \} \| undefined/g, '');
const uninstall = eraseTypes(source.slice(begin, end));
const anchor = source.indexOf('if (confirm(st("confirm.deleteAuditReport")))');
assert(anchor !== -1);
const clickBegin = source.lastIndexOf('onClick={(e) => {', anchor);
const clickEnd = source.indexOf('\n className=', anchor);
assert(clickBegin !== -1 && clickEnd > anchor);
const click = eraseTypes(source.slice(clickBegin + 'onClick={'.length, clickEnd).trim().replace(/}\s*$/, ''));
async function exercise(lang, action, reply, priorSuccess = '') {
const state = {report: {id: 'inert'}, expanded: true, success: priorSuccess, error: '', loads: 0, calls: []};
const st = (key) => key.split('.').reduce((v, k) => v?.[k], catalogs[lang]);
const ctx = {
st, Error, confirm: () => true,
setLynisReport: v => {state.report = v}, setLynisShowReport: v => {state.expanded = v},
setSuccess: v => {state.success = v}, setError: v => {state.error = v},
setUninstallingLynis: () => {}, setShowLynisUninstallConfirm: () => {},
loadSecurityTools: () => {state.loads++},
fetchApi: (endpoint, options) => {
assert.equal(endpoint, action === 'delete' ? '/api/security/lynis/report' : '/api/security/lynis/uninstall');
assert.equal(options.method, action === 'delete' ? 'DELETE' : 'POST');
state.calls.push(options.method);
return reply instanceof Error ? Promise.reject(reply) : Promise.resolve(reply);
},
};
const fn = vm.runInNewContext(action === 'delete' ? `(${click})` : `${uninstall}; handleUninstallLynis`, ctx);
await fn({stopPropagation() {}});
await new Promise(resolve => setImmediate(resolve));
return state;
}
(async () => {
for (const lang of Object.keys(catalogs)) {
for (const action of ['delete', 'uninstall']) {
const success = await exercise(lang, action, {success:true, message:'inert'});
assert.equal(success.report, null, `${lang} ${action} clean report`);
assert(success.success, `${lang} ${action} clean success`);
assert.equal(success.error, '', `${lang} ${action} clean no error`);
assert.equal(success.loads, 1);
for (const [kind, reply] of [
['no_files', {success:false,outcome:'no_files',message:'No report files found to delete'}],
['malformed_success', {success:'false',message:'inert malformed response'}],
['first_failure', {success:false,partial:false,message:'inert denied'}],
['partial_failure', {success:false,partial:true,message:'inert denied'}],
['http_failure', Object.assign(new Error('inert denied'), {body:{success:false,partial:true,message:'inert denied'}})],
['nonjson_failure', new Error('Invalid JSON response')],
['network_failure', new Error('network failure')],
]) {
const state = await exercise(lang, action, reply);
assert.notEqual(state.report, null, `${lang} ${action} ${kind}: report retained`);
assert.equal(state.success, '', `${lang} ${action} ${kind}: no success`);
assert.equal(state.loads, 0, `${lang} ${action} ${kind}: no success refresh`);
assert(state.error, `${lang} ${action} ${kind}: error visible`);
const expectedKey = kind === 'no_files' ? (action === 'delete' ? 'deleteReportNoFiles' : 'lynisUninstallNoFiles')
: kind === 'partial_failure' || kind === 'http_failure' ? 'lynisRemovalPartial'
: 'lynisRemovalUnconfirmed';
assert.equal(state.error, catalogs[lang].errors[expectedKey], `${lang} ${action} ${kind}: native whole-message feedback`);
if (action === 'delete' && kind === 'no_files') {
const stale = await exercise(lang, action, reply, 'Earlier unrelated success');
assert.equal(stale.success, '', `${lang} delete clears stale success before failure`);
}
}
}
}
console.log('actual Lynis handlers: eight locales, clean/no-files/pre-first/partial/HTTP/non-JSON/network state');
})().catch(err => { console.error(err); process.exitCode = 1 });
@@ -0,0 +1,57 @@
// Execute the actual printable-report generator, without mounting the app.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const root = path.resolve(__dirname, '../../../..');
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
const begin = source.indexOf(' const generatePrintableReport = (report: LynisReport) => {');
const end = source.indexOf('\n const loadSslStatus =', begin);
assert(begin > 0 && end > begin, 'print consumer not found');
const body = source.slice(begin, end)
.replace('(report: LynisReport)', '(report)')
.replace('(raw: unknown): string', '(raw)');
const lang = process.env.LYNIS_FIXTURE_LANG || 'en';
assert(['en','de','es','fr','it','pt','sk','sv'].includes(lang));
const catalog = require(path.join(root, `AppImage/messages/${lang}/common.json`));
const translate = (key, params = {}) => {
const value = key.split('.').reduce((v, k) => v?.[k], catalog.securityPage);
assert.equal(typeof value, 'string', `missing ${lang} key ${key}`);
return value.replace(/\{(\w+)\}/g, (_, k) => String(params[k]));
};
const context = {
st: translate,
window: { location: { origin: 'https://offline.invalid' } },
document: { documentElement: { lang } },
getLynisScoreState: () => ({rawScore: 80, displayScore: 80, reportComplete: true, hasAdjustment: false}),
getActionableCount: (total, expected) => Math.max(0, total - expected),
lynisCountText: (key, count) => translate(`lynis.counts.${key}.${count === 1 ? 'one' : 'many'}`, { count }),
};
const render = vm.runInNewContext(`${body}\ngeneratePrintableReport`, context);
const report = {
hostname: 'inert', os_name: 'Linux', os_version: '', os_fullname: 'Linux',
kernel_version: 'test', lynis_version: 'test', datetime_start: '2026-01-01',
hardening_index: 80, tests_performed: 1,
warnings: [
{test_id: 'EXPECTED-W', description: 'expected warning', solution: '', proxmox_expected: true},
{test_id: 'ACTION-W', description: 'actionable warning', solution: '', proxmox_expected: false},
],
suggestions: [
{test_id: 'EXPECTED-S', description: 'expected suggestion', solution: '', proxmox_expected: true},
{test_id: 'ACTION-S', description: 'actionable suggestion', solution: '', proxmox_expected: false},
],
proxmox_expected_warnings: 1, proxmox_expected_suggestions: 1, sections: [],
};
const html = render(report);
if (process.env.LYNIS_FIXTURE_HTML) fs.writeFileSync(process.env.LYNIS_FIXTURE_HTML, html);
for (const id of ['EXPECTED-W', 'ACTION-W', 'EXPECTED-S', 'ACTION-S']) {
assert(html.includes(id), `missing actual finding ${id}`);
}
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-W[\s\S]*?f-tag-pve/);
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-S[\s\S]*?f-tag-pve/);
for (const key of ['lynis.report.warningsDescription', 'lynis.report.suggestionsDescription']) {
const description = translate(key);
assert(!/hidden|nascosti|ocult|caché|skryt|dold|versteckt/i.test(description));
assert(html.includes(description), `missing rendered description ${key}`);
}
console.log(`print HTML (${lang}): expected/actionable findings shown and badged`);
@@ -0,0 +1,88 @@
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
import datetime
import sqlite3
import tempfile
import threading
import time
import types
import typing
from html.parser import HTMLParser
from pathlib import Path
from notification_fixture import templates, SCRIPTS, EmailChannel, extract
def visible(markup):
class Text(HTMLParser):
def __init__(self): super().__init__(); self.parts = []; self.tags = []
def handle_data(self, data): self.parts.append(data)
def handle_starttag(self, tag, attrs): self.tags.append(tag)
parser = Text(); parser.feed(markup)
return '\n'.join(p.strip() for p in parser.parts if p.strip()), parser.tags
def restore_event(warnings):
events = []
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname':'node-a', 'guests':'3', 'stubs':'1', 'stale_nodes':'0',
'components':'2', 'duration':'2m', 'warnings':warnings}),
'notification_manager':types.SimpleNamespace(emit_event=lambda **kw:events.append(kw)),
'jsonify':lambda value:value}
handler = extract(SCRIPTS/'flask_notification_routes.py', 'internal_restore_event', None, ns)
response, status = handler()
assert status == 200 and len(events) == 1
return events[0]
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 = []
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
def sink(title, body, severity, data):
markup = channel._format_html(title, body, severity, data)
text, tags = visible(markup)
captured.append(dict(title=title, body=body, severity=severity, data=dict(data), html=markup, text=text, tags=tags))
return {'success':True}
manager = types.SimpleNamespace(_config={'email.rich_format':'true'}, _lock=threading.RLock(),
_channels={'email':types.SimpleNamespace(send=sink)},
_group_limiter=types.SimpleNamespace(allow=lambda group:True),
_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},
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'}))
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:
db = Path(scratch)/'pending.sqlite'
rows = []
if quiet:
conn = sqlite3.connect(db)
conn.execute('CREATE TABLE quiet_pending (id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, event_group TEXT, severity TEXT, ts INTEGER, title TEXT, body TEXT)')
conn.commit(); conn.close()
qns = dict(vars(typing), sqlite3=sqlite3, DB_PATH=db, time=time, datetime=datetime.datetime,
_resolve_display_hostname=lambda config:'node-a', runtime_message=templates.runtime_message,
EVENT_EMOJI=templates.EVENT_EMOJI, CATEGORY_EMOJI=templates.CATEGORY_EMOJI)
for name in ('_buffer_quiet_event', '_flush_quiet_for_channel', '_compose_digest_body'):
setattr(manager,name,types.MethodType(extract(SCRIPTS/'notification_manager.py',name,'NotificationManager',qns),manager))
for earlier in quiet_before:
manager._dispatch_event(types.SimpleNamespace(**earlier,source='inert',entity_type='node',entity_id='',event_id='inert',fingerprint='inert'))
if manual:
assert not quiet
result = manager.send_notification(event_type, severity, '', '', dict(data))
assert result['success']
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)
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
manager._flush_quiet_for_channel('email', manager._channels['email'])
conn=sqlite3.connect(db); remaining=conn.execute('SELECT count(*) FROM quiet_pending').fetchone()[0]; conn.close()
assert remaining==0
assert len(captured)==1
if quiet: captured[0]['buffered']=rows
return captured[0]
@@ -0,0 +1,51 @@
"""Assertion-free inert actual consumers; no operational host imports."""
import ast
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import notification_templates as templates
# Display-name resolution is an infrastructure boundary, never load manager.
templates._get_hostname = lambda: 'node-a'
from notification_channels import EmailChannel
LANGUAGES = ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')
def extract(path, name, owner, ns):
tree = ast.parse(path.read_text())
nodes = tree.body if owner is None else next(n.body for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
return ns[name]
def receive(message, severity='info', title='Backup', kind='vzdump'):
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
ns['NotificationEvent'] = Event
methods = {name: extract(SCRIPTS / 'notification_events.py', name, 'ProxmoxHookWatcher', ns)
for name in ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')}
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
class Receiver:
_hostname = 'node-a'
_classify_pve = methods['_classify_pve']
_map_severity = staticmethod(methods['_map_severity'])
_backup_outcome = staticmethod(methods['_backup_outcome'])
process_webhook = methods['process_webhook']
def __init__(self): self._queue = Queue()
target = Receiver()
result = target.process_webhook({'fields': {'type': kind}, 'severity': severity, 'title': title, 'message': message})
assert result['accepted'] and len(target._queue.items) == 1
return target._queue.items[0]
def email(event_type, data, severity='INFO', language='en'):
result = templates.render_template(event_type, data, language)
channel = object.__new__(EmailChannel)
channel.subject_prefix = '[ProxMenux]'
context = {**data, 'severity': severity, '_event_type': event_type, '_group': result['group'], '_notification_language': language}
return result, channel._format_html(result['title'], result['body'], severity, context)
@@ -0,0 +1,58 @@
"""A host backup and a finished restore tell the Monitor on its own port,
over HTTPS once the Monitor has a certificate."""
from pathlib import Path
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LIBRARY = ROOT / 'scripts/backup_restore/lib_host_backup_common.sh'
POSTBOOT = ROOT / 'scripts/backup_restore/apply_cluster_postboot.sh'
SETTING = '/etc/proxmenux/ssl_config.json'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}\n', start) + 3]
class BackupNotifyHttps(TestCase):
def address(self, setting):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'ssl_config.json'
if setting is not None:
path.write_text(setting)
body = function(LIBRARY.read_text(), 'hb_notify_lifecycle').replace(SETTING, str(path))
# The function silences curl, so the stub writes the address it was given.
called = Path(folder) / 'called'
script = (f'curl() {{ printf "%s" "${{@: -1}}" > {called}; }}\n' + body
+ 'HB_NOTIFY_JOB_ID=job1 hb_notify_lifecycle complete\n')
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
return called.read_text()
def test_plain_http_while_the_monitor_has_no_certificate(self):
expected = 'http://127.0.0.1:8008/api/notifications/webhook'
self.assertEqual(self.address(None), expected)
self.assertEqual(self.address('{"enabled": false}'), expected)
def test_https_once_the_monitor_has_a_certificate(self):
self.assertEqual(self.address('{"enabled": true, "source": "proxmox"}'),
'https://127.0.0.1:8008/api/notifications/webhook')
def test_the_finished_restore_follows_the_same_setting(self):
source = POSTBOOT.read_text()
self.assertIn('"${NOTIFY_SCHEME}://127.0.0.1:8008/api/internal/restore-event"', source)
self.assertNotIn('"http://127.0.0.1:8008/api/internal/restore-event"', source)
check = source[source.index('NOTIFY_SCHEME="http"'):source.index('NOTIFY_HTTP=$(curl')]
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'ssl_config.json'
for setting, expected in (('{"enabled": true}', 'https'), ('{"enabled":false}', 'http'), (None, 'http')):
path.unlink(missing_ok=True)
if setting is not None:
path.write_text(setting)
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c',
check.replace(SETTING, str(path)) + '\necho "$NOTIFY_SCHEME"'],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.stdout.strip(), expected, setting)
@@ -0,0 +1,211 @@
"""Offline regression tests for the Monitor catalog generator."""
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
from contextlib import redirect_stdout, redirect_stderr
SCRIPT = Path(__file__).parents[1] / "build_i18n_messages.py"
SPEC = importlib.util.spec_from_file_location("build_i18n_messages", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class I18nMessagesTests(unittest.TestCase):
def run_generator(self, source, target, *options, provider=None):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
en = root / "en" / "common.json"
it = root / "it" / "common.json"
en.parent.mkdir()
it.parent.mkdir()
en.write_text(json.dumps(source), encoding="utf-8")
it.write_text(json.dumps(target), encoding="utf-8")
before = it.read_bytes()
argv = [str(SCRIPT), "--source", str(en), "--messages-dir", str(root),
"--languages", "it", "--sleep", "0", *options]
output = io.StringIO()
with mock.patch.object(sys, "argv", argv), mock.patch.object(
MODULE, "translate_one", side_effect=provider or (lambda text, *args: "IT " + text)
) as translate, redirect_stdout(output), redirect_stderr(output):
result = MODULE.main()
return result, json.loads(it.read_text()), translate.call_args_list, output.getvalue(), before == it.read_bytes()
def test_generator_fills_array_leaves_preserving_existing_values(self):
source = {"items": ["One", {"title": "Two", "children": ["Three"]}],
"empty": [], "object": {}, "enabled": True, "count": 2, "none": None}
target = {"items": ["Uno"], "extra": {"0": "Keep"}}
result, actual, calls, _, _ = self.run_generator(source, target, "--save-every", "1")
self.assertEqual(result, 0)
self.assertEqual(actual, {**source, "items": ["Uno", {"title": "IT Two", "children": ["IT Three"]}],
"extra": {"0": "Keep"}})
self.assertEqual([call.args[0] for call in calls], ["Two", "Three"])
def test_wrong_types_defer_locale_without_calls_or_writes_even_on_refresh(self):
source = {"new": "New", "terminal": {"commandDescriptions": ["One", "Two"]}}
for malformed in ["['Uno', 'Due']", '["Uno", "Due"]', "ambiguous human text", {}, None, 7]:
for options in [(), ("--refresh",)]:
with self.subTest(malformed=malformed, options=options):
target = {"terminal": {"commandDescriptions": malformed}, "human": "Keep"}
result, actual, calls, output, unchanged = self.run_generator(source, target, *options)
self.assertEqual(result, 1)
self.assertEqual(actual, target)
self.assertTrue(unchanged)
self.assertEqual(calls, [])
self.assertIn("/terminal/commandDescriptions", output)
self.assertIn("manual", output)
def test_structure_only_catalog_is_written_without_translation(self):
source = {"items": ["", [], {}, None, False, 0]}
result, actual, calls, _, _ = self.run_generator(source, {})
self.assertEqual(result, 0)
self.assertEqual(actual, source)
self.assertEqual(calls, [])
def test_limit_keeps_array_positions_as_null_fallbacks(self):
result, actual, calls, _, _ = self.run_generator(
{"items": ["One", "", "Three", "Four"]}, {}, "--limit", "1")
self.assertEqual(actual, {"items": ["IT One", "", None, None]})
self.assertEqual(len(calls), 1)
def test_existing_nonempty_including_english_and_stale_text_is_unchanged(self):
source = {"items": ["Changed English", "CPU", "Three"]}
target = {"items": ["Human translation", "CPU", "Tre", "Extra"]}
result, actual, calls, _, unchanged = self.run_generator(source, target)
self.assertEqual(actual, target)
self.assertEqual(calls, [])
self.assertTrue(unchanged)
def test_second_run_retries_null_placeholders_preserving_translations(self):
source = {"items": ["One", "Two", "", "Three", "Four"]}
target = {"items": ["Human"], "extra": "Keep"}
result, first, calls, _, _ = self.run_generator(source, target, "--limit", "1")
self.assertEqual(result, 0)
self.assertEqual(first, {"items": ["Human", "IT Two", "", None, None], "extra": "Keep"})
self.assertEqual([call.args[0] for call in calls], ["Two"])
result, second, calls, _, _ = self.run_generator(source, first, "--save-every", "1")
self.assertEqual(result, 0)
self.assertEqual(second, {"items": ["Human", "IT Two", "", "IT Three", "IT Four"], "extra": "Keep"})
self.assertEqual([call.args[0] for call in calls], ["Three", "Four"])
def test_refresh_and_provider_failure_keep_existing_fallback_semantics(self):
def provider(text, *args):
if text == "Two":
raise RuntimeError("offline fixture failure")
return "IT " + text
# --refresh alone never replaces an existing translation.
result, actual, calls, output, _ = self.run_generator(
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
"--refresh", "--save-every", "1", provider=provider)
self.assertEqual(result, 0)
self.assertEqual(actual, {"items": ["Uno", "Due", "IT Three"]})
self.assertEqual(len(calls), 1)
# Named keys are retranslated; a provider failure keeps the value.
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as keys:
keys.write("items.0\nitems.1\n")
try:
result, actual, calls, output, _ = self.run_generator(
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
"--refresh", "--refresh-keys", keys.name, "--save-every", "1", provider=provider)
finally:
Path(keys.name).unlink()
self.assertEqual(result, 0)
self.assertEqual(actual, {"items": ["IT One", "Due", "IT Three"]})
self.assertEqual(len(calls), 3)
self.assertIn("1 translation failures", output)
def test_null_and_empty_string_are_filled_and_tokens_restored(self):
source = {"items": ["Hello {vmid}", "Use Proxmox"]}
result, actual, calls, _, _ = self.run_generator(source, {"items": [None, ""]})
self.assertEqual(actual, {"items": ["IT Hello {vmid}", "IT Use Proxmox"]})
self.assertNotIn("{vmid}", calls[0].args[0])
self.assertNotIn("Proxmox", calls[1].args[0])
def test_root_pointer_is_empty_not_empty_key_pointer(self):
for source, target, pointer in [({"a": "Text"}, [], 'JSON Pointer "" (root)'),
({"": []}, {"": "Text"}, 'JSON Pointer "/"'),
({"a~/": []}, {"a~/": "Text"}, 'JSON Pointer "/a~0~1"')]:
with self.subTest(pointer=pointer):
result, _, calls, output, unchanged = self.run_generator(source, target)
self.assertEqual(result, 1)
self.assertEqual(calls, [])
self.assertTrue(unchanged)
self.assertIn(pointer + ": expected", output)
def test_wrong_scalar_and_nested_types_are_rejected(self):
for source, target in [({"a": "Text"}, {"a": 42}),
({"a": [{"b": "Text"}]}, {"a": ["human"]}),
({"a": False}, {"a": 0}),
({"a": {}}, {"a": []})]:
with self.subTest(source=source):
result, actual, calls, _, unchanged = self.run_generator(source, target)
self.assertEqual(result, 1)
self.assertTrue(unchanged)
self.assertEqual(calls, [])
def test_real_english_array_round_trip_and_generation(self):
catalog = json.loads((SCRIPT.parents[2] / "AppImage/messages/en/common.json").read_text())
descriptions = catalog["terminal"]["commandDescriptions"]
self.assertIsInstance(descriptions, list)
self.assertTrue(descriptions)
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
result, actual, calls, _, _ = self.run_generator({"terminal": {"commandDescriptions": descriptions}}, {})
self.assertEqual(len(calls), sum(isinstance(text, str) and bool(text) for text in descriptions))
self.assertEqual(actual["terminal"]["commandDescriptions"], ["IT " + text for text in descriptions])
def test_preflight_skips_invalid_locales_but_processes_valid_locales(self):
for options in [(), ("--refresh",)]:
with self.subTest(options=options), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
catalogs = {"en": {"items": ["One", "Two"]},
"sk": {"items": ["Human"]},
"it": {"items": "Uno"}, "de": {"items": {}}}
for lang, data in catalogs.items():
path = root / lang / "common.json"
path.parent.mkdir()
path.write_text(json.dumps(data))
before = {lang: (root / lang / "common.json").read_bytes() for lang in catalogs}
reads = []
real_read = MODULE.read_json
def read(path):
reads.append(path.parent.name)
return real_read(path)
def translate(text, lang, *args):
self.assertEqual(reads, ["en", "sk", "it", "de"])
self.assertEqual(lang, "sk")
return "SK " + text
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
"--messages-dir", str(root), "--languages", "sk,it,de",
"--sleep", "0", "--save-every", "1", *options]
output = io.StringIO()
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(MODULE, "read_json", side_effect=read), \
mock.patch.object(MODULE, "translate_one", side_effect=translate) as provider, \
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as writer, \
redirect_stderr(output), redirect_stdout(output):
self.assertEqual(MODULE.main(), 1)
# --refresh without --refresh-keys keeps the existing value.
self.assertEqual(json.loads((root / "sk/common.json").read_text()),
{"items": ["Human", "SK Two"]})
self.assertEqual(provider.call_count, 1)
self.assertTrue(all(call.args[0].parent.name == "sk" for call in writer.call_args_list))
for lang in ["en", "it", "de"]:
self.assertEqual((root / lang / "common.json").read_bytes(), before[lang])
self.assertIn("Partial failure: skipped 2 locale(s): it, de", output.getvalue())
self.assertNotIn("generated successfully", output.getvalue())
def test_round_trip_preserves_json_types_and_literal_keys(self):
catalog = {"terminal": {"commandDescriptions": ["One", {"nested": ["Two", [], {}]}]},
"0": {"a.b": "literal", "": None}, "flags": [True, False, 2, 1.5]}
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,220 @@
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).parents[1] / "build_web_docs_i18n.py"
SPEC = importlib.util.spec_from_file_location("build_web_docs_i18n", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class WebDocsI18nTests(unittest.TestCase):
def run_generator(self, root, provider):
source_root = root / "messages" / "en"
messages_root = root / "messages"
state_path = messages_root / ".docs-i18n-source-state.json"
argv = [
str(SCRIPT),
"--source-dir",
str(source_root),
"--messages-dir",
str(messages_root),
"--languages",
"de",
"--source-state",
str(state_path),
"--workers",
"1",
]
with mock.patch.object(sys, "argv", argv), mock.patch.object(
MODULE, "provider_function", return_value=provider
):
return MODULE.main()
def test_protected_contract_round_trip(self):
source = (
"Run <code>systemctl restart pveproxy</code> in Proxmox VE, "
"then open <link>Settings</link> at {host}."
)
protected, mapping = MODULE.protect_text(source)
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
self.assertNotIn("Proxmox VE", protected)
self.assertIn("<code>", protected)
self.assertIn("</code>", protected)
self.assertIn("<link>", protected)
self.assertIn("</link>", protected)
self.assertNotIn("<code>", mapping.values())
self.assertNotIn("</code>", mapping.values())
self.assertNotIn("systemctl", mapping.values())
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_human_translation_is_not_pending(self):
source = {"title": "Updates", "url": "https://example.com"}
target = {"title": "Actualizaciones", "url": "https://example.com"}
self.assertEqual(MODULE.pending_leaves(source, target), [])
def test_changed_source_text_is_pending_even_with_existing_translation(self):
source = {"title": "Updated installation guidance"}
target = {"title": "Vorherige Installationsanleitung"}
token = MODULE.leaf_token(("title",))
leaves = MODULE.pending_leaves(
source,
target,
forced_tokens={token},
)
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
def test_source_fingerprints_change_per_leaf(self):
before = MODULE.source_fingerprints({"title": "One", "body": "Same"})
after = MODULE.source_fingerprints({"title": "Two", "body": "Same"})
self.assertNotEqual(
before[MODULE.leaf_token(("title",))],
after[MODULE.leaf_token(("title",))],
)
self.assertEqual(
before[MODULE.leaf_token(("body",))],
after[MODULE.leaf_token(("body",))],
)
def test_schema_mismatch_detects_removed_keys(self):
self.assertFalse(
MODULE.schema_matches(
{"title": "Title"},
{"title": "Título", "removed": "Old"},
)
)
def test_schema_mismatch_detects_changed_structural_values(self):
self.assertFalse(
MODULE.schema_matches(
{"enabled": True, "retries": 2},
{"enabled": False, "retries": 2},
)
)
def test_schema_allows_localized_string_values(self):
self.assertTrue(
MODULE.schema_matches(
{"title": "Updates"},
{"title": "Aktualisierungen"},
)
)
def test_translatable_rich_tags_use_opaque_names(self):
source = (
"Keep <strong>this</strong>, <em>that</em> and "
"<code>systemctl restart pveproxy</code>."
)
protected, mapping = MODULE.protect_rich_tags(source)
self.assertIn("<pmxrich0000>this</pmxrich0000>", protected)
self.assertIn("<pmxrich0001>that</pmxrich0001>", protected)
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
self.assertEqual(MODULE.restore_rich_tags(protected, mapping), source)
def test_source_schema_drives_output(self):
source = {"title": "Title", "items": ["One", "Two"]}
target = {
"title": "Título",
"items": ["Uno", "Dos", "Obsoleto"],
"removed": "Old",
}
translated = {
("items", 0): "Uno",
("items", 1): "Dos",
}
self.assertEqual(
MODULE.merge_tree(source, target, translated),
{"title": "Título", "items": ["Uno", "Dos"]},
)
def test_translation_memory_ignores_conflicts(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_root = root / "messages" / "en"
target_root = root / "messages" / "es"
source_root.mkdir(parents=True)
target_root.mkdir(parents=True)
(source_root / "one.json").write_text(
json.dumps({"label": "Settings"}), encoding="utf-8"
)
(target_root / "one.json").write_text(
json.dumps({"label": "Ajustes"}), encoding="utf-8"
)
(source_root / "two.json").write_text(
json.dumps({"label": "Settings"}), encoding="utf-8"
)
(target_root / "two.json").write_text(
json.dumps({"label": "Configuración"}), encoding="utf-8"
)
memory = MODULE.collect_memory(source_root, root / "messages", "es")
self.assertNotIn("Settings", memory)
def test_source_state_retranslates_an_existing_changed_leaf(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_path = root / "messages" / "en" / "page.json"
target_path = root / "messages" / "de" / "page.json"
source_path.parent.mkdir(parents=True)
target_path.parent.mkdir(parents=True)
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
self.assertEqual(
self.run_generator(root, lambda *_: self.fail("provider called during baseline")),
0,
)
self.assertEqual(read_json_file(target_path), {"title": "Ursprünglich"})
source_path.write_text(json.dumps({"title": "Changed"}), encoding="utf-8")
calls = []
def provider(text, language):
calls.append((text, language))
return "Geändert"
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(calls, [("Changed", "de")])
self.assertEqual(read_json_file(target_path), {"title": "Geändert"})
def test_new_leaf_preserves_translation_supplied_with_source_change(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_path = root / "messages" / "en" / "page.json"
target_path = root / "messages" / "de" / "page.json"
source_path.parent.mkdir(parents=True)
target_path.parent.mkdir(parents=True)
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
self.assertEqual(self.run_generator(root, lambda *_: "unused"), 0)
source_path.write_text(
json.dumps({"title": "Original", "new": "New text"}),
encoding="utf-8",
)
target_path.write_text(
json.dumps({"title": "Ursprünglich", "new": "Neuer Text"}),
encoding="utf-8",
)
self.assertEqual(
self.run_generator(root, lambda *_: self.fail("manual translation overwritten")),
0,
)
self.assertEqual(
read_json_file(target_path),
{"title": "Ursprünglich", "new": "Neuer Text"},
)
def read_json_file(path):
return json.loads(path.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,267 @@
"""Frozen conservative recovery: update the fixture only after explicit review.
Run offline with Python unittest and Node >=22.14 on PATH (or NODE_BINARY).
The consumer test executes only extracted pure lookup functions, not the UI.
"""
import io
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from contextlib import redirect_stdout, redirect_stderr
from unittest import mock
from test_build_i18n_messages import MODULE, SCRIPT
ROOT = SCRIPT.parents[2]
FIXTURE = json.loads((Path(__file__).parent / "fixtures/command_descriptions.json").read_text())
def require_node():
diagnostic = "Node >=22.14 is required; install Node 22 LTS or set NODE_BINARY to its executable"
node = os.environ.get("NODE_BINARY") or shutil.which("node")
if not node:
raise AssertionError(diagnostic)
try:
result = subprocess.run([node, "--version"], capture_output=True, text=True)
except OSError as exc:
raise AssertionError(f"{diagnostic}; cannot execute {node}: {exc}") from exc
version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", result.stdout.strip())
if result.returncode or not version or tuple(map(int, version.groups())) < (22, 14, 0):
raise AssertionError(f"{diagnostic}; found {result.stdout.strip() or result.stderr.strip()!r}")
return node
def catalog(lang):
return json.loads((ROOT / f"AppImage/messages/{lang}/common.json").read_text())
class NodeRuntimeTests(unittest.TestCase):
def test_missing_node_reports_setup_instructions(self):
with mock.patch.dict(os.environ, {"PATH": "", "NODE_BINARY": ""}):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
require_node()
def test_invalid_override_reports_setup_instructions(self):
with mock.patch.dict(os.environ, {"NODE_BINARY": "/nonexistent/f05-node"}):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
require_node()
def test_older_node_reports_detected_version(self):
with mock.patch.dict(os.environ, {"NODE_BINARY": "node"}), \
mock.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess(
["node", "--version"], 0, "v20.19.0\n", "")):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*v20\.19\.0"):
require_node()
class CommandDescriptionsTests(unittest.TestCase):
def test_real_catalogs_exact_indexed_recovery_and_source_order(self):
self.assertEqual(catalog("en")["terminal"]["commandDescriptions"], FIXTURE["english"])
consumer = (ROOT / "AppImage/components/terminal-panel.tsx").read_text()
commands = re.search(r"const proxmoxCommands = (\[.*?\])", consumer, re.S).group(1)
commands = json.loads(re.sub(r",\s*\]$", "]", commands))
self.assertEqual(commands, FIXTURE["commands"])
self.assertIn('desc: t(`terminal.commandDescriptions.${index}`)', consumer)
for lang, expected in FIXTURE["locales"].items():
with self.subTest(locale=lang):
actual = catalog(lang)["terminal"]["commandDescriptions"]
self.assertIsInstance(actual, list)
self.assertEqual(len(actual), len(commands))
self.assertEqual(actual, expected)
self.assertTrue(all(isinstance(x, str) and x for x in actual))
# Explicit English recovery values are frozen locale text, not live fallback.
for index in FIXTURE["fallback_indices"]:
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
FIXTURE["locales"]["sv"][index])
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for lang in ["en", *languages.split(",")]:
path = root / lang / "common.json"
path.parent.mkdir()
shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
# This steady-state fixture assumes generation already finished.
# Seed only intentional message additions; keep the
# recovered command arrays and the repo catalogs exact. Runtime
# missing-key fallback is covered by the JSX seam tests and by
# test_storage_messages.cjs and test_health_text.cjs.
if lang != "en":
temporary = json.loads(path.read_text())
for section, key in (
("archives", "emptyMessage"),
("destinations", "backupsKeptMessage"),
("destinations", "localAttachHelpMessage"),
):
temporary["backup"][section].setdefault(
key, catalog("en")["backup"][section][key])
temporary["storage"].setdefault(
"savedSmartData", catalog("en")["storage"]["savedSmartData"])
report = temporary["storage"]["smartReport"]
source_report = catalog("en")["storage"]["smartReport"]
for key in ("passedAssessment", "noReallocatedSectorsReported",
"passedMeaningTitle", "passedMeaning"):
report.setdefault(key, source_report[key])
for key in ("passedTitle", "passedText"):
report["recommendations"].setdefault(
key, source_report["recommendations"][key])
# New health-popup messages deliberately use runtime English
# fallback until each locale is translated. Model generator
# steady state only in this disposable copy, as for thermal keys.
for key in (
'certificateExpired',
'certificateExpiresDays',
'certificateInconclusive',
'cpuCheckFailed',
'cpuHighSustained',
'cpuSustained',
'fail2banCheckUnavailable',
'fail2banInactive',
'failedLoginsWithinThreshold',
'kernelPackagesMore',
'kernelPveUpdates',
'loginCheckUnavailable',
'memoryCheckFailed',
'memoryPressure',
'noFailedLogins',
'noSwapConfigured',
'noTemperatureSensor',
'nonRunningKernelUpdates',
'pendingPackageUpdates',
'pendingSecurityUpdates',
'pendingSecurityUpdatesDays',
'pendingSecurityUpdatesUnpatched',
'pveVersionAvailable',
'ramHighSustained',
'ramSustained',
'runningKernelUpdate',
'securityCheckUnavailable',
'sensorHighSamplesSpan',
'swapRamPressure',
'systemNotUpdatedMonths',
'systemNotUpdatedYear',
'temperatureElevated',
'unknownDetail',
'updatesAptError',
'updatesAptTimeout',
'updatesCheckUnavailable',
'uptimeKernelAdvice',
'uptimeUnavailable',
):
temporary["healthStatus"]["details"].setdefault(
key, catalog("en")["healthStatus"]["details"][key])
# New thermal messages also await automatic translation.
# Seed only these intentional additions in the temporary copy.
for key in (
"temperatureNoDisks", "temperatureNormal", "temperatureHighCount",
"temperatureCriticalCount", "temperatureUnavailable",
"temperatureIncomplete", "temperatureUnavailableCount",
):
temporary["storage"].setdefault(key, catalog("en")["storage"][key])
# Every shipped locale carries the temperature alert fallback
# keys; the seed only guards a catalog that lacks them. The
# runtime fallback is tested separately.
source_fallback = catalog("en")["runtime"]["notifications"]["fallback"]
fallback = temporary["runtime"]["notifications"]["fallback"]
for key in ("temperatureAlertTitle", "temperatureAlertBody",
"recordedReason", "recordedDetails"):
fallback.setdefault(key, source_fallback[key])
# Slovak remains the exact upstream catalog; model the
# pending outcome-key generator additions in disposable
# copies rather than modifying its curated values.
if lang == 'sk':
local = temporary['runtime']['notifications']
source = catalog('en')['runtime']['notifications']
for key, value in source['backup'].items():
local.setdefault('backup', {}).setdefault(key, value)
local['channels']['email']['status'].setdefault(
'unconfirmed', source['channels']['email']['status']['unconfirmed'])
local['channels']['email']['status'].setdefault(
'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings'])
path.write_text(json.dumps(temporary, ensure_ascii=False))
# Model steady state after the bot fills these intentional new
# messages; keep repository locales and all other leaves intact.
if lang != "en":
messages = json.loads(path.read_text())
english = catalog("en")
for section, key in (("encryption", "pbsHelp"),
("keyfileActions", "pveKeyDescription")):
messages["backup"][section].setdefault(key, english["backup"][section][key])
for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle",
"sshKeySetupHelp", "sshAuthorizedKeyHelp"):
messages["backup"]["destinations"].setdefault(
key, english["backup"]["destinations"][key])
messages["backup"]["actions"].setdefault(
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
path.write_text(json.dumps(messages, ensure_ascii=False))
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
output = io.StringIO()
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
redirect_stdout(output), redirect_stderr(output):
result = MODULE.main()
self.assertEqual(result, 0, output.getvalue())
provider.assert_not_called()
writer.assert_not_called()
self.assertEqual(before, {p: p.read_bytes() for p in before})
for lang in languages.split(","):
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
node = require_node()
source = (ROOT / "AppImage/lib/i18n/provider.tsx").read_text()
functions = source[source.index("function getMessage("):source.index("export function I18nProvider(")]
lookup = re.search(r" const localized =.*?return interpolate\(.*?\)\n", source, re.S).group(0)
catalogs = {lang: catalog(lang) for lang in ["en", *FIXTURE["locales"]]}
script = ('import assert from "node:assert/strict";\n'
'type MessageTree = Record<string, unknown>;\n'
'type TranslationParams = Record<string, string | number>;\n' + functions +
'\nconst MESSAGE_CATALOG = ' + json.dumps(catalogs) + ';\n' +
'function t(language: string, key: string, params?: TranslationParams) {\n' + lookup + '}\n' +
'const fixture = ' + json.dumps(FIXTURE) + ';\n' + '''
fixture.english.forEach((text, i) => {
assert.equal(t("en", `terminal.commandDescriptions.${i}`), text);
});
for (const [lang, texts] of Object.entries(fixture.locales)) {
texts.forEach((text, i) => {
const key = `terminal.commandDescriptions.${i}`;
assert.equal(getMessage(MESSAGE_CATALOG[lang], key), text);
assert.equal(t(lang, key), text);
});
}
for (const i of fixture.fallback_indices) {
const key = `terminal.commandDescriptions.${i}`;
const selected = MESSAGE_CATALOG.sv.terminal.commandDescriptions[i];
MESSAGE_CATALOG.en.terminal.commandDescriptions[i] = "CHANGED EN";
assert.equal(t("sv", key), selected); // Explicit English is a locale value.
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = null;
assert.equal(t("sv", key), "CHANGED EN"); // Null would dynamically fall back.
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = "";
assert.equal(t("sv", key), ""); // Empty strings do NOT fall back.
}
assert.equal(t("sv", "missing.key"), "missing.key");
console.log("390 indexed consumer lookups; 15 explicit-English/null/empty cases passed");
''')
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "consumer.ts"
path.write_text(script)
result = subprocess.run([node, "--experimental-strip-types", str(path)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("390 indexed consumer lookups", result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,172 @@
"""Inert fencing wording contract: execute extracted producers and template renderer."""
import ast
import json
import re
import time
import unittest
from typing import Any
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
EVENTS = ROOT / 'AppImage/scripts/notification_events.py'
TEMPLATES = ROOT / 'AppImage/scripts/notification_templates.py'
MESSAGES = ROOT / 'AppImage/messages'
FIELDS = ('title', 'body', 'label')
def extract_method(path, owner, method, namespace):
tree = ast.parse(path.read_text(encoding='utf-8'))
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == method)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), namespace)
return namespace[method]
def notification_renderer(catalogs):
tree = ast.parse(TEMPLATES.read_text(encoding='utf-8'))
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'render_template')
def lookup(obj, dotted):
for part in dotted.split('.'):
obj = obj.get(part) if isinstance(obj, dict) else None
return obj
namespace = {'TEMPLATES': templates, 'Dict': dict, 'Any': object,
'_get_hostname': lambda: 'node-a',
'_load_runtime_catalog': lambda lang: catalogs[lang]['runtime']['notifications'],
'_catalog_value': lookup, 'runtime_message': lambda key, lang, **kw: key,
'time': time, 're': re}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(TEMPLATES), 'exec'), namespace)
return templates, namespace['render_template']
class FencingWordingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.catalogs = {p.parent.name: json.loads(p.read_text(encoding='utf-8'))
for p in MESSAGES.glob('*/common.json')}
cls.templates, render = notification_renderer(cls.catalogs)
cls.render = staticmethod(render)
def test_webhook_fencing_renders_without_split_brain_or_quorum_diagnosis(self):
namespace: dict[str, Any] = {'re': re}
classify = extract_method(EVENTS, 'ProxmoxHookWatcher', '_classify_pve', namespace)
severity = extract_method(EVENTS, 'ProxmoxHookWatcher', '_map_severity', namespace)
namespace['capture_journal_context'] = lambda **kw: ''
class Event:
def __init__(self, **kw):
self.__dict__.update(kw)
self.event_id = 'inert-id'
class Queue:
def __init__(self): self.items = []
def put(self, item): self.items.append(item)
namespace['NotificationEvent'] = Event
webhook = extract_method(EVENTS, 'ProxmoxHookWatcher', 'process_webhook', namespace)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity)
def __init__(self): self._queue = Queue()
receiver = Receiver()
for fields in ({'type': 'fencing'}, {'type': 'fencing', 'quorum': 'lost'}):
result = webhook(receiver, {'fields': fields, 'severity': 'warning',
'title': 'Fence requested', 'message': 'Check node state'})
self.assertEqual(result['event_type'], 'split_brain')
event = receiver._queue.items.pop()
self.assertEqual(event.severity, 'WARNING')
self.assertNotIn('quorum', event.data)
for lang, catalog in self.catalogs.items():
rendered = self.render(event.event_type, event.data, lang)
expected = catalog['runtime']['notifications']['templates']['split_brain']
self.assertEqual(rendered['title'], expected['title'].format(hostname='node-a'), lang)
# The body names the source event, as received, and adds no diagnosis of its own.
self.assertEqual(rendered['body'], expected['body'].replace('{reason}', event.data['reason']), lang)
self.assertIn('Check node state', rendered['body'], lang)
self.assertNotIn('split-brain', '\n'.join((rendered['title'], rendered['body'])).lower(), lang)
self.assertNotIn('quorum', rendered['body'].lower(), lang)
self.assertEqual(webhook(receiver, {'type': 'fencing', 'severity': 'warning',
'title': 'Unclassified', 'message': 'Other'})['event_type'], 'system_problem')
self.assertEqual(self.templates['split_brain']['group'], 'cluster')
self.assertIs(self.templates['split_brain']['default_enabled'], True)
def test_journal_family_uses_same_truthful_wording(self):
tree = ast.parse(EVENTS.read_text(encoding='utf-8'))
owner = next(n.name for n in tree.body if isinstance(n, ast.ClassDef)
and any(isinstance(m, ast.FunctionDef) and m.name == '_check_cluster_events' for m in n.body))
namespace: dict[str, Any] = {'re': re}
method = extract_method(EVENTS, owner, '_check_cluster_events', namespace)
class Journal:
_hostname = 'node-a'
def __init__(self): self.events = []
def _emit(self, *args, **kwargs): self.events.append((args, kwargs))
for message in ('fencing required for node', 'cluster partition suspected', 'split-brain reported'):
journal = Journal()
method(journal, message, 'syslog')
args, _ = journal.events[0]
self.assertEqual(args[0], 'split_brain')
for lang, catalog in self.catalogs.items():
rendered = self.render(args[0], args[2], lang)
body = catalog['runtime']['notifications']['templates']['split_brain']['body']
self.assertEqual(rendered['body'], body.replace('{reason}', message), lang)
journal = Journal()
method(journal, 'quorum lost', 'syslog')
self.assertEqual(journal.events[0][0][0], 'node_disconnect')
def test_english_family_and_settings_label_align_without_changing_key(self):
en = self.catalogs['en']
self.assertEqual(self.templates['split_brain']['label'], 'Cluster event')
self.assertEqual(en['runtime']['notifications']['templates']['split_brain']['label'], 'Cluster event')
self.assertEqual(en['settings']['notifications']['eventTypes']['split_brain'], 'Cluster events')
settings = (ROOT / 'AppImage/components/notification-settings.tsx').read_text(encoding='utf-8')
self.assertIn('settings.notifications.eventTypes.${evt.type}', settings)
self.assertIn('evt.title : translated', settings)
for field in FIELDS:
self.assertEqual(en['runtime']['notifications']['templates']['split_brain'][field],
self.templates['split_brain'][field])
def test_every_shipped_locale_renders_cluster_event_without_diagnosis(self):
expected = {
'de': ('{hostname}: Clusterereignis gemeldet', 'Ein Clusterereignis wurde gemeldet:\n{reason}', 'Clusterereignis', 'Clusterereignisse'),
'es': ('{hostname}: evento del clúster notificado', 'Se ha notificado un evento del clúster:\n{reason}', 'Evento del clúster', 'Eventos del clúster'),
'fr': ('{hostname}\u00a0: événement du cluster signalé', 'Un événement du cluster a été signalé\u00a0:\n{reason}', 'Événement du cluster', 'Événements du cluster'),
'it': ('{hostname}: evento del cluster segnalato', 'È stato segnalato un evento del cluster:\n{reason}', 'Evento del cluster', 'Eventi del cluster'),
'pt': ('{hostname}: evento do cluster comunicado', 'Foi comunicado um evento do cluster:\n{reason}', 'Evento do cluster', 'Eventos do cluster'),
'sk': ('{hostname}: hlásená udalosť klastra', 'Bola hlásená udalosť klastra:\n{reason}', 'Udalosť klastra', 'Udalosti klastra'),
'sv': ('{hostname}: klusterhändelse rapporterad', 'En klusterhändelse har rapporterats:\n{reason}', 'Klusterhändelse', 'Klusterhändelser'),
}
self.assertEqual(set(self.catalogs), {'en', *expected})
for lang, (title, body, label, settings) in expected.items():
with self.subTest(lang=lang):
leaves = self.catalogs[lang]['runtime']['notifications']['templates']['split_brain']
self.assertEqual((leaves['title'], leaves['body'], leaves['label'],
self.catalogs[lang]['settings']['notifications']['eventTypes']['split_brain']),
(title, body, label, settings))
result = self.render('split_brain', {'reason': 'fencing node node-b'}, lang)
self.assertEqual(result['title'], title.format(hostname='node-a'))
self.assertEqual(result['body'], body.replace('{reason}', 'fencing node node-b'))
self.assertNotIn('quorum', result['body'].lower())
def test_fallback_and_translated_lookup_are_real_for_missing_and_synthetic_values(self):
import copy
catalogs = copy.deepcopy(self.catalogs)
for field in FIELDS:
catalogs['it']['runtime']['notifications']['templates']['split_brain'].pop(field)
_, render = notification_renderer(catalogs)
self.assertEqual(render('split_brain', {'reason': 'fencing node node-b'}, 'it')['body'],
'A cluster event was reported:\nfencing node node-b')
catalogs['it']['runtime']['notifications']['templates']['split_brain']['body'] = 'Evento del cluster segnalato.'
_, render = notification_renderer(catalogs)
self.assertEqual(render('split_brain', {}, 'it')['body'], 'Evento del cluster segnalato.')
for lang, catalog in self.catalogs.items():
if lang == 'en': continue
leaves = catalog['runtime']['notifications']['templates']['split_brain']
self.assertTrue(all(leaves[field] for field in FIELDS), lang)
self.assertTrue(catalog['settings']['notifications']['eventTypes']['split_brain'], lang)
self.assertNotEqual(leaves['body'], self.catalogs['en']['runtime']['notifications']['templates']['split_brain']['body'], lang)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,99 @@
"""The menus that install drivers inside a container leave out the ones
created from an OCI image: those are changed from OCI manager Apps."""
from pathlib import Path
import re
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
MENUS = ('scripts/gpu_tpu/add_gpu_lxc.sh', 'scripts/gpu_tpu/install_coral_lxc.sh')
ORDINARY = "arch: amd64\ncores: 2\nhostname: debian\nostype: debian\nrootfs: local-lvm:vm-111-disk-0,size=8G\n"
OCI = ORDINARY + "entrypoint: /init\nlxc.signal.halt: SIGTERM\n"
def function(path, name):
source = (ROOT / path).read_text()
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
class GpuMenusHideOci(TestCase):
def is_oci(self, config):
with tempfile.TemporaryDirectory() as folder:
(Path(folder) / '111.conf').write_text(config)
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
return subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 111'], capture_output=True).returncode == 0
def test_a_container_from_an_oci_image_is_told_apart_by_its_configuration(self):
self.assertTrue(self.is_oci(OCI))
self.assertTrue(self.is_oci(ORDINARY + "cmd: nginx -g 'daemon off;'\n"))
self.assertFalse(self.is_oci(ORDINARY))
# A setting that only mentions the word is not an entrypoint.
self.assertFalse(self.is_oci(ORDINARY + "description: entrypoint: none\n"))
def test_a_container_without_configuration_is_not_one(self):
with tempfile.TemporaryDirectory() as folder:
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
self.assertNotEqual(subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 999'],
capture_output=True).returncode, 0)
def test_both_menus_skip_them_and_say_so(self):
for menu in MENUS:
body = function(menu, 'select_container')
self.assertLess(body.index('pmx_lxc_is_oci "$ctid"'), body.index('menu_items+=('), menu)
self.assertIn('Containers created from an OCI image are not listed:', body, menu)
self.assertEqual(body.count('${oci_note}'), 2, menu)
SWITCHES = (('scripts/gpu_tpu/switch_gpu_mode.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
('scripts/gpu_tpu/switch_gpu_mode_direct.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
('scripts/gpu_tpu/add_gpu_vm.sh', 'cleanup_lxc_configs', None))
class GpuToVmKeepsOciConfiguration(TestCase):
"""A GPU that moves to a VM stops the containers that use it. One created
from an OCI image keeps its configuration, which OCI manager Apps records."""
def test_an_oci_container_is_never_edited(self):
for script, apply, _ in SWITCHES:
body = function(script, apply)
guard = body.index('pmx_lxc_is_oci "$ctid"')
self.assertLess(guard, body.index('action="keep_gpu_disable_onboot"'), script)
removal = body.index('"$action" == "remove_gpu_keep_onboot"')
self.assertLess(guard, removal, script)
# The only edit of a configuration hangs from the per-container action.
self.assertEqual(len(re.findall(r'_from_lxc_conf "\$', body)), 1, script)
edit = body.index('_from_lxc_conf "$')
condition = body.rindex('if [[', 0, edit)
self.assertIn('"$action" == "remove_gpu_keep_onboot"', body[condition:edit], script)
def test_the_list_names_them_and_nothing_is_asked_when_all_are_oci(self):
for script, _, prompt in SWITCHES:
source = (ROOT / script).read_text() if prompt is None else function(script, prompt)
self.assertIn('", OCI"', source, script)
self.assertIn('OCI containers keep their configuration', source, script)
self.assertIn('"$oci_count" -eq ${#LXC_AFFECTED_CTIDS[@]}', source, script)
class DiskAndSharePickersHideOci(TestCase):
"""Adding a disk or a mount, and the NFS and Samba set-ups, change the
container they are given. A container from an OCI image is not offered."""
def test_every_picker_filters_before_it_lists(self):
pickers = ((function('scripts/global/share-common.func', 'select_lxc_container'), 'options+=('),
(function('scripts/global/share-common.func', 'select_privileged_lxc'), 'ct_list+='),
(function('scripts/share/lxc-mount-manager_minimal.sh', 'select_lxc_container'), 'options+=('))
for body, listing in pickers:
self.assertLess(body.index('pmx_lxc_is_oci "$id"'), body.index(listing))
self.assertIn('Containers created from an OCI image are not listed', body)
disk = (ROOT / 'scripts/storage/disk-passthrough_ct.sh').read_text()
picker = disk[disk.index('CT_LIST=""'):disk.index('if [ -z "$CTID" ]')]
self.assertLess(picker.index('pmx_lxc_is_oci "$ct_id"'), picker.index('CT_LIST+='))
self.assertIn('${OCI_NOTE}" $UI_MENU_H $UI_MENU_W $CT_LIST_H', picker)
def test_a_disk_in_use_is_still_looked_for_in_every_container(self):
for script in ('scripts/storage/disk-passthrough_ct.sh', 'scripts/storage/disk-passthrough.sh'):
source = (ROOT / script).read_text()
check = source[source.index('disk_referenced_in_config "$CT_CONFIG_RAW" "$DISK"'):]
scan = check[:check.index("done < <(pct list | awk 'NR>1 {print $1, $3}')")]
self.assertNotIn('pmx_lxc_is_oci', scan, script)
@@ -0,0 +1,108 @@
"""An addon of the Helper-Scripts runs where its catalog says it belongs: on
the Proxmox host, inside a container, or wherever the user chooses when it
can be both. Until this, every addon ran on the host."""
from pathlib import Path
import subprocess
import textwrap
import unittest
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / "scripts/menus/menu_Helper_Scripts.sh"
PLAIN = ROOT / "scripts/menus/helper_plain_text.py"
# Only the functions under test are loaded, not the menu or its catalog.
PRELUDE = textwrap.dedent(f"""
set -uo pipefail
eval "$(sed -n '/^download_script() {{/,/^RETURN_TO_MAIN=false/p' '{MENU}' | sed '$d')"
translate() {{ printf '%s' "$1"; }}
clear() {{ :; }}
dialog() {{ :; }}
pmx_lxc_is_oci() {{ [[ "$1" == 100 ]]; }}
MENU_HEIGHT=22; MENU_ROWS=14
""")
def bash(script):
return subprocess.run(["bash", "-c", PRELUDE + textwrap.dedent(script)], capture_output=True, text=True)
class TargetChoiceTests(unittest.TestCase):
def choice(self, kind, places):
return bash(f'helper_target_choice {kind} "{places}"').stdout.strip()
def test_an_addon_for_the_host_runs_on_the_host(self):
self.assertEqual(self.choice("addon", "pve"), "host")
def test_an_addon_for_containers_runs_in_one(self):
self.assertEqual(self.choice("addon", "lxc vm"), "lxc")
self.assertEqual(self.choice("addon", "lxc"), "lxc")
def test_an_addon_for_both_asks_where(self):
self.assertEqual(self.choice("addon", "pve vm lxc"), "ask")
self.assertEqual(self.choice("addon", ""), "ask")
def test_what_is_not_an_addon_runs_on_the_host(self):
# A script that creates a container or a VM belongs on the host,
# whatever the catalog lists for the application it installs.
self.assertEqual(self.choice("lxc", "lxc"), "host")
self.assertEqual(self.choice("vm", "vm"), "host")
self.assertEqual(self.choice("pve", "pve"), "host")
class ContainerListTests(unittest.TestCase):
def test_oci_containers_are_left_out_and_a_lock_is_shown(self):
result = bash(r"""
pct() { printf 'VMID Status Lock Name\n100 running chromium\n109 running ordinary\n110 stopped stopped-one\n112 stopped backup locked-one\n'; }
helper_lxc_rows
""")
self.assertEqual(result.stdout,
"109\tordinary · running\n110\tstopped-one · stopped\n112\tlocked-one · stopped · backup\n")
class RunTests(unittest.TestCase):
def test_the_script_runs_on_the_host_or_inside_the_container_chosen(self):
result = bash(r"""
curl() { printf 'printf on-host'; }
pct() { [[ "$1" == exec && "$2" == 109 && "$3" == --keep-env && "$4" == 0 && "$5" == -- ]] && printf 'in-lxc:%s' "${*: -1}"; }
HELPER_LXC_TARGET=''; download_script https://example.invalid/a; echo
HELPER_LXC_TARGET=109; download_script https://example.invalid/a; echo
""")
self.assertEqual(result.stdout, "on-host\nin-lxc:printf on-host\n")
def test_a_failed_or_empty_download_runs_nothing(self):
result = bash(r"""
bash() { echo RAN; }
pct() { echo RAN; }
HELPER_LXC_TARGET=109
curl() { return 22; }; download_script https://example.invalid/a && echo UNEXPECTED
curl() { printf ' \n'; }; download_script https://example.invalid/a && echo UNEXPECTED
echo done
""")
self.assertEqual(result.stdout, "done\n")
def test_cancelling_the_choice_leaves_no_target(self):
result = bash(r"""
dialog() { return 1; }
select_helper_target Test ask && echo UNEXPECTED
printf '[%s]\n' "$HELPER_LXC_TARGET"
""")
self.assertEqual(result.stdout, "[]\n")
def test_the_host_needs_no_question(self):
result = bash(r"""
dialog() { echo ASKED; return 1; }
select_helper_target Test host && printf 'ok[%s]\n' "$HELPER_LXC_TARGET"
""")
self.assertEqual(result.stdout, "ok[]\n")
class PlainTextTests(unittest.TestCase):
def test_html_becomes_text_and_commands_are_kept(self):
result = subprocess.run(["python3", str(PLAIN)], capture_output=True, text=True,
input="<p>Run <code>ls -la &amp;&amp; pwd</code></p><ul><li>one</li><li>two</li></ul>")
self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,69 @@
"""A Proxmox host without a subscription is refused by the enterprise
repositories on every apt refresh. The installers tell that apart from a
refresh that really failed."""
from pathlib import Path
import re
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
INSTALLERS = ('install_proxmenux.sh', 'install_proxmenux_beta.sh')
ENTERPRISE = """Hit:1 http://deb.debian.org/debian trixie InRelease
Err:2 https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease
401 Unauthorized [IP: 51.91.38.34 443]
Err:4 https://enterprise.proxmox.com/debian/pve trixie InRelease
401 Unauthorized [IP: 51.91.38.34 443]
Reading package lists...
E: Failed to fetch https://enterprise.proxmox.com/debian/ceph-squid/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
E: The repository 'https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease' is not signed.
E: Failed to fetch https://enterprise.proxmox.com/debian/pve/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
E: The repository 'https://enterprise.proxmox.com/debian/pve trixie InRelease' is not signed.
"""
NETWORK = """Err:1 http://deb.debian.org/debian trixie InRelease
Temporary failure resolving 'deb.debian.org'
E: Failed to fetch http://deb.debian.org/debian/dists/trixie/InRelease Temporary failure resolving 'deb.debian.org'
"""
LOCKED = "E: Could not get lock /var/lib/apt/lists/lock. It is held by process 4321 (apt-get)\n"
# apt reports a repository it cannot reach as a warning, not as an error.
UNREACHABLE = """Err:6 http://repo.invalid/debian trixie InRelease
Could not resolve 'repo.invalid'
W: Failed to fetch http://repo.invalid/debian/dists/trixie/InRelease Could not resolve 'repo.invalid'
W: Some index files failed to download. They have been ignored, or old ones used instead.
"""
def function(installer, name):
source = (ROOT / installer).read_text()
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
class InstallerAptRefresh(TestCase):
def refused(self, installer, log):
with tempfile.NamedTemporaryFile('w', suffix='.log') as handle:
handle.write(log)
handle.flush()
script = function(installer, 'apt_only_enterprise_refused') + \
f'APT_ERROR_LOG={handle.name if log is not None else ""}\napt_only_enterprise_refused\n'
return subprocess.run(['bash', '-c', script], capture_output=True).returncode == 0
def test_a_refresh_refused_only_by_the_enterprise_repositories_is_good(self):
for installer in INSTALLERS:
self.assertTrue(self.refused(installer, ENTERPRISE), installer)
def test_any_other_failure_is_still_a_failure(self):
for installer in INSTALLERS:
self.assertFalse(self.refused(installer, NETWORK), installer)
self.assertFalse(self.refused(installer, LOCKED), installer)
self.assertFalse(self.refused(installer, ENTERPRISE + NETWORK), installer)
self.assertFalse(self.refused(installer, ENTERPRISE + UNREACHABLE), installer)
self.assertFalse(self.refused(installer, 'Reading package lists...\n'), installer)
def test_the_refresh_step_asks_before_it_warns(self):
for installer in INSTALLERS:
source = (ROOT / installer).read_text()
step = source[source.index('if run_apt update -y; then'):]
step = step[:step.index('\n fi\n')]
self.assertLess(step.index('elif apt_only_enterprise_refused; then'), step.index('apt cache refresh failed'))
self.assertIn('discard_apt_error', step[step.index('elif'):step.index('else\n')])
@@ -0,0 +1,78 @@
"""Inert branch tests for the real Lynis deletion/uninstall producers."""
import ast
import os
import subprocess
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
def extracted(path, name, namespace):
tree = ast.parse((ROOT / path).read_text())
fn = next(x for x in tree.body if isinstance(x, ast.FunctionDef) and x.name == name)
fn.decorator_list = []
exec(compile(ast.fix_missing_locations(ast.Module(body=[fn], type_ignores=[])), str(path), 'exec'), namespace)
return namespace[name]
class LynisFeedbackProducer(unittest.TestCase):
def test_actual_ui_feedback_handlers_all_shipped_locales(self):
result = subprocess.run(['node', str(ROOT / '.github/scripts/tests/fixtures/lynis_feedback_contract.cjs')],
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_uninstall_nothing_to_remove_is_not_reported_as_uninstalled(self):
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: False, join=lambda *p: '/'.join(p)), remove=lambda p: self.fail(p))
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
self.assertFalse(result[0])
self.assertEqual(result[3], 'no_files')
manager = SimpleNamespace(uninstall_lynis=lambda: result)
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
self.assertEqual(route()['outcome'], 'no_files')
def test_report_outcomes(self):
paths = ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
for fail_at in (None, 0, 1, 'missing'):
with self.subTest(fail_at=fail_at):
removed = []
def remove(p):
if fail_at in (0, 1) and p == paths[fail_at]:
raise PermissionError('inert denial')
removed.append(p)
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: fail_at != 'missing'), remove=remove)
ns = {'security_manager': object(), 'jsonify': lambda data: data}
orig_import = __import__
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_os if n == 'os' else orig_import(n, *a, **kw)):
result = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_report_delete', ns)()
body, status = result if isinstance(result, tuple) else (result, 200)
self.assertEqual(removed, paths[:fail_at] if fail_at in (0, 1) else ([] if fail_at == 'missing' else paths))
self.assertEqual(body['success'], fail_at is None)
if fail_at == 'missing':
self.assertEqual(body['outcome'], 'no_files')
elif fail_at in (0, 1):
self.assertEqual(status, 500)
self.assertEqual(body['partial'], fail_at == 1)
self.assertIn('inert denial', body['message'])
def test_uninstall_outcomes_and_route(self):
targets = ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
for fail_at in (None, 0, 2):
with self.subTest(fail_at=fail_at):
removed = []
def remove(path):
if fail_at is not None and path == targets[fail_at]: raise PermissionError('inert denial')
removed.append(path)
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p in targets, join=lambda *parts: '/'.join(parts)), remove=remove)
fake_shutil = SimpleNamespace(rmtree=remove)
orig_import = __import__
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_shutil if n == 'shutil' else orig_import(n, *a, **kw)):
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
self.assertEqual(removed, targets[:fail_at] if fail_at is not None else targets)
self.assertEqual(result[0], fail_at is None)
self.assertEqual(result[2], fail_at == 2)
manager = SimpleNamespace(uninstall_lynis=lambda: result)
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
self.assertEqual(route()['partial'], fail_at == 2)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,148 @@
"""Inert source-contract checks; never import operational modules or touch host paths."""
import ast
import json
import os
import subprocess
import unittest
from collections import defaultdict
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
APP = ROOT / 'AppImage'
def extract_function(path, name, *, owner=None):
tree = ast.parse(path.read_text())
container = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner) if owner else tree
node = next(n for n in container.body if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
namespace = {}
return node, ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[]))
def leaf(pointer):
data = json.loads((APP / 'messages/en/common.json').read_text())
for segment in pointer.split('.'):
data = data[segment]
return data
class LynisThresholdContracts(unittest.TestCase):
def test_delete_confirmation_names_all_three_actual_targets(self):
node, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
removed = []
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=removed.append)
original_import = __import__
def fake_import(name, *args, **kwargs):
if name == 'os':
return fake_os
return original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, str(APP / 'scripts/flask_security_routes.py'), 'exec'), namespace)
result = namespace['lynis_report_delete']()
self.assertEqual(result['success'], True)
self.assertEqual(removed, ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
text = leaf('securityPage.confirm.deleteAuditReport').lower()
self.assertIn('report', text)
self.assertIn('logs', text)
self.assertNotIn('this audit report?', text)
def test_delete_route_partial_failure_is_not_reported_as_full_success(self):
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
removed = []
def remove(path):
if path.endswith('lynis.log'):
raise PermissionError('inert denial')
removed.append(path)
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=remove)
original_import = __import__
def fake_import(name, *args, **kwargs):
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
result, status = namespace['lynis_report_delete']()
self.assertEqual(removed, ['/var/log/lynis-report.dat'])
self.assertEqual(status, 500)
self.assertFalse(result['success'])
self.assertIn('inert denial', result['message'])
def test_delete_route_with_no_files_returns_success_false_not_a_clean_result(self):
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: False), remove=lambda p: self.fail(p))
original_import = __import__
def fake_import(name, *args, **kwargs):
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
result = namespace['lynis_report_delete']()
self.assertFalse(result['success'])
self.assertIn('No report files', result['message'])
def test_uninstall_notice_describes_existing_cleanup_targets(self):
_, module = extract_function(APP / 'scripts/security_manager.py', 'uninstall_lynis')
removed = []
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p != '/usr/local/share/proxmenux/components_status.json', join=lambda *s: '/'.join(s)), remove=removed.append)
class FakeShutil:
@staticmethod
def rmtree(path):
removed.append(path)
original_import = __import__
def fake_import(name, *args, **kwargs):
return FakeShutil if name == 'shutil' else original_import(name, *args, **kwargs)
namespace = {'os': fake_os}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis uninstall>', 'exec'), namespace)
result = namespace['uninstall_lynis']()
self.assertTrue(result[0])
self.assertEqual(removed, ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
for key in ('securityPage.lynis.removeReports', 'securityPage.lynis.uninstallConfirmDescription'):
text = leaf(key).lower()
self.assertIn('report', text)
self.assertIn('logs', text)
self.assertNotIn('monitor-created', text)
def test_swap_hint_qualifies_sampling_and_independent_ram_alarm(self):
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
# The method is bound to an inert class, not the production constructor.
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
MEMORY_WARNING=80, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
memory = SimpleNamespace(percent=50, available=10, total=100)
swap = SimpleNamespace(percent=90, used=90, total=100)
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
'time': SimpleNamespace(time=lambda: 1000)}
exec(compile(module, '<inert memory check>', 'exec'), namespace)
first = namespace['_check_memory_comprehensive'](self_obj)
second = namespace['_check_memory_comprehensive'](self_obj)
self.assertEqual(first['status'], 'OK')
self.assertEqual(second['checks']['swap_usage']['status'], 'CRITICAL')
self_obj.state_history.clear()
memory.percent = 91
swap.percent = 0
for _ in range(8):
last = namespace['_check_memory_comprehensive'](self_obj)
self.assertEqual(last['status'], 'CRITICAL')
self.assertEqual(last['checks']['swap_usage']['status'], 'OK')
hint = leaf('settings.healthThresholds.swapPressureHint').lower()
self.assertIn('swap usage', hint)
self.assertIn('repeated', hint)
self.assertIn('ram', hint)
self.assertNotIn('swap file', hint)
self.assertNotIn('swap file', leaf('settings.healthThresholds.swapHighLabel').lower())
self.assertNotIn('only when both', hint)
def test_print_generator_and_descriptions(self):
node = ROOT / '.github/scripts/tests/fixtures/lynis_print_contract.cjs'
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
result = subprocess.run(['node', str(node)], cwd=ROOT, text=True, capture_output=True,
env={**os.environ, 'LYNIS_FIXTURE_LANG': lang})
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(f'print HTML ({lang}):', result.stdout)
if __name__ == '__main__':
unittest.main()
+182
View File
@@ -0,0 +1,182 @@
"""The search of the main menu finds an option of ProxMenux, an OCI application
or a Helper-Script by its name or by related words, and opens it where it
lives: the scripts it launches are the ones the menus launch."""
import importlib.util
import json
from pathlib import Path
import re
import runpy
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / "scripts"
def load():
spec = importlib.util.spec_from_file_location("search_menu", SCRIPTS / "menus/search_menu.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# The repository in the place of the installed copy.
module.BASE, module.SCRIPTS = ROOT, SCRIPTS
module.OCI_INDEX = ROOT / "oci/catalog/index.json"
module.HELPERS_CACHE = ROOT / "json/helpers_cache.json"
module._translations = {}
return module
class IndexTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.index = json.loads((SCRIPTS / "menus/search_index.json").read_text(encoding="utf-8"))
cls.spanish = json.loads((ROOT / "lang/es.json").read_text(encoding="utf-8"))
def test_every_entry_opens_a_script_that_exists(self):
missing = sorted({item["script"] for item in self.index if not (SCRIPTS / item["script"]).is_file()})
self.assertEqual(missing, [])
def test_every_title_is_a_text_the_menus_already_translate(self):
names = {"ZimaOS", "Synology DSM"}
unknown = [item["title"] for item in self.index
if item["title"] not in self.spanish and item["title"] not in names]
self.assertEqual(unknown, [])
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
def test_every_script_a_menu_launches_can_be_found(self):
indexed = {item["script"] for item in self.index}
launched = set()
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
if menu.name in ("main_menu.sh", "lxc_menu.sh"):
continue
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
launched -= {"menus/main_menu.sh"}
# Opened from their own menu, which is what the index points at.
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
self.assertEqual(sorted(launched - indexed), [])
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
self.assertIn('search "$(translate "Search options and applications")"', menu)
self.assertIn('search_menu.py" --handoff "$handoff"', menu)
self.assertIn('[[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]', menu)
self.assertIn('exec bash "${target[@]}"', menu)
def test_an_option_inside_a_menu_names_one_that_menu_has(self):
for item in self.index:
argument = item.get("argument")
if not argument:
continue
script = (SCRIPTS / item["script"]).read_text()
self.assertIn("PRESELECTED_OPTION", script, item["script"])
if not argument.isdigit():
self.assertIn(argument, script, f"{item['script']}: {argument}")
class CatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.search = load()
cls.items = cls.search.catalog(refresh=False)
def found(self, query):
return self.search.search(self.items, query)
def test_the_three_sources_are_in_the_catalog(self):
self.assertEqual({item["source"] for item in self.items}, set(self.search.SOURCES))
def test_proxmenux_comes_first_then_oci_then_helper_scripts(self):
sources = [item["source"] for item in self.found("samba")]
self.assertEqual(sources, sorted(sources, key=self.search.SOURCES.index))
self.assertEqual(sources[0], "ProxMenux")
def test_a_name_is_found_by_the_start_of_any_of_its_words(self):
self.assertIn("Jellyfin", [item["title"] for item in self.found("jelly")])
self.assertTrue(any(item["source"] == "OCI Manager" for item in self.found("plex")))
def test_accents_and_case_do_not_matter(self):
self.assertEqual([item["title"] for item in self.found("NVIDIA")],
[item["title"] for item in self.found("nvidia")])
def test_related_words_find_an_option(self):
self.assertTrue(any("NFS" in item["title"] for item in self.found("nfs storage")))
self.assertTrue(any(item["action"][-1].endswith("fail2ban_installer.sh") for item in self.found("fail2ban")))
def test_a_match_by_category_comes_after_the_names(self):
matches = self.found("vpn")
self.assertTrue(any(item["title"] == "Tailscale" for item in matches))
by_category = False
for item in matches:
words = re.findall(r"[a-z0-9]+", self.search.fold(item["terms"]))
in_name = any(word.startswith("vpn") for word in words)
self.assertFalse(in_name and by_category)
by_category = by_category or not in_name
def test_an_application_is_not_found_by_what_its_description_mentions(self):
titles = [item["title"] for item in self.found("plex")]
self.assertNotIn("Jellyfin", titles)
self.assertTrue(all("plex" in title.casefold() for title in titles))
def test_nothing_matches_nothing(self):
self.assertEqual(self.found("zzqqxx"), [])
self.assertEqual(self.found(" "), [])
def test_a_sub_menu_is_handed_back_to_the_main_menu(self):
by_script = {item["action"][1]: item for item in self.items if item["source"] == "ProxMenux"}
self.assertTrue(by_script[str(SCRIPTS / "menus/network_menu.sh")]["handoff"])
self.assertFalse(by_script[str(SCRIPTS / "storage/format-disk.sh")]["handoff"])
def test_an_option_inside_a_menu_is_given_to_that_menu(self):
zfs = next(item for item in self.items if item["action"][1:] == [str(SCRIPTS / "help_info_menu.sh"), "7"])
self.assertFalse(zfs["handoff"])
language = next(item for item in self.items if item["action"][-1] == "change_language")
self.assertEqual(language["action"][1], str(SCRIPTS / "menus/config_menu.sh"))
self.assertTrue(language["handoff"])
class OpenedInPlaceTests(unittest.TestCase):
def test_an_option_of_the_post_install_comes_selected(self):
script = (SCRIPTS / "post_install/customizable_post_install.sh").read_text()
self.assertIn('PRESELECTED_OPTION="${1:-}"', script)
self.assertIn('--default-item "${preselected_item:-1}"', script)
def test_a_utility_comes_selected(self):
script = (SCRIPTS / "utilities/system_utils.sh").read_text()
self.assertIn('PRESELECTED_UTILITY="${1:-}"', script)
def test_a_helper_script_is_opened_on_its_own(self):
script = (SCRIPTS / "menus/menu_Helper_Scripts.sh").read_text()
self.assertIn('run_script_by_slug "$1"', script)
def test_an_oci_application_opens_its_page(self):
cli = (ROOT / "oci/src/proxmenux_oci/cli.py").read_text()
self.assertIn('subparsers.add_parser("open"', cli)
class TranslationTests(unittest.TestCase):
"""What the search and the main menu say reaches the translation workflow,
read with the folders the workflow itself passes."""
@classmethod
def setUpClass(cls):
workflow = (ROOT / ".github/workflows/build-translation-cache.yml").read_text()
extractor = runpy.run_path(str(ROOT / ".github/scripts/build_translation_cache.py"))
folders = [ROOT / folder for folder in re.findall(r"--python-dir (\S+)", workflow)]
cls.found = set(extractor["extract_python_texts"](folders))
cls.found |= set(extractor["extract_translate_texts"](SCRIPTS))
cls.triggers = workflow
def test_every_text_of_the_search_is_found(self):
source = (SCRIPTS / "menus/search_menu.py").read_text()
said = set(re.findall(r"translate\('([^']+)'\)", source))
self.assertIn("No results", said)
self.assertEqual(sorted(said - self.found), [])
def test_the_title_of_the_main_menu_is_found(self):
self.assertIn("Main menu", self.found)
def test_a_change_in_the_search_runs_the_workflow(self):
self.assertIn("'scripts/**/*.py'", self.triggers)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,122 @@
"""Backup-only maintainer contract at actual render/dispatch/email seams.
Scope-approved seams: receiver, template lookup, rich enrichment, inert queued
and manual delivery with an email capture sink. No host operations or sends.
"""
import unittest
from notification_fixture import templates, LANGUAGES
from notification_final_fixture import deliver
class BackupSplitTests(unittest.TestCase):
def test_recovery_default_is_upstream_resolved_without_proof(self):
for language in LANGUAGES:
data = {'hostname': 'alias', 'category': 'temperature',
'reason': 'Temperature high (recovered)', 'duration': '2m',
'original_severity': 'WARNING'}
rendered = templates.render_template('error_resolved', data, language)
expected = templates.runtime_message('templates.error_resolved.title', language,
hostname='alias', category='temperature', entity_suffix='')
self.assertEqual(rendered['title'], expected)
rich, _ = templates.enrich_with_emojis('error_resolved', rendered['title'], rendered['body'], data)
self.assertTrue(rich.startswith('✅ '))
result = deliver('error_resolved', data, 'OK', language)
self.assertIn('background:#f0fdf4;', result['html'])
def test_pre_guest_native_subject_keeps_only_cause_once_with_display_alias(self):
from notification_fixture import receive
raw_host = 'pve-production.internal.example'
message = ('Details\n=======\nVMID Name Status Time Size Filename\n'
'\nTotal running time: 0s\nTotal size: 0 B')
for raw in (message, 'ERROR: unable to activate storage PBS\n' + message):
event = receive(raw, 'error', 'vzdump backup status (' + raw_host +
'): backup failed: unable to activate storage PBS')
event.data['hostname'] = 'display-alias {rack.location}'
for language in LANGUAGES:
for manual in (False, True):
result = deliver(event.event_type, event.data, event.severity, language, manual=manual)
self.assertNotIn(raw_host, result['text'])
self.assertNotIn('vzdump backup status', result['text'])
self.assertEqual(result['text'].count('unable to activate storage PBS'), 1)
self.assertIn('display-alias {rack.location}', result['title'])
self.assertEqual(result['data']['pve_title'], event.data['pve_title'])
self.assertEqual(result['data']['pve_message'], raw)
# No global hostname or guest-name substitution: actual diagnostics are
# authoritative, even when they happen to contain the original hostname.
event = receive(message, 'error', 'vzdump backup status (' + raw_host +
'): backup failed: cannot connect to ' + raw_host)
result = deliver(event.event_type, {**event.data, 'hostname': 'alias'}, event.severity)
self.assertEqual(result['body'].count('cannot connect to ' + raw_host), 1)
self.assertNotIn('vzdump backup status', result['body'])
def test_backup_legacy_keys_keep_meaning_and_unknown_uses_new_keys(self):
# Frozen upstream contract; no Git history required in shipped tests.
legacy = {'en': {'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})', 'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}', 'label': 'Backup complete'}, 'de': {'title': '{hostname} → {storage}: Sicherung abgeschlossen – {vmname} ({vmid})', 'body': 'Die Sicherung von {vmname} (ID: {vmid}) wurde am {storage} erfolgreich abgeschlossen.\nGröße: {size}', 'label': 'Sicherung abgeschlossen'}, 'es': {'title': '{hostname} → {storage}: Backup completado — {vmname} ({vmid})', 'body': 'El backup de {vmname} (ID: {vmid}) se ha completado correctamente en {storage}.\nTamaño: {size}', 'label': 'Backup completado'}, 'fr': {'title': '{hostname} → {storage}\xa0: Sauvegarde terminée — {vmname} ({vmid})', 'body': "La sauvegarde de {vmname} (ID\xa0: {vmid}) s'est terminée avec succès le {storage}.\nTaille\xa0: {size}", 'label': 'Sauvegarde terminée'}, 'it': {'title': '{hostname} → {storage}: Backup completato — {vmname} ({vmid})', 'body': 'Backup di {vmname} (ID: {vmid}) completato con successo su {storage}.\nTaglia: {size}', 'label': 'Backup completato'}, 'pt': {'title': '{hostname} → {storage}: Backup concluído — {vmname} ({vmid})', 'body': 'Backup de {vmname} (ID: {vmid}) concluído com sucesso em {storage}.\nTamanho: {size}', 'label': 'Backup concluído'}, 'sk': {'title': '{hostname} → {storage}: Záloha dokončená — {vmname} ({vmid})', 'body': 'Záloha {vmname} (ID: {vmid}) na úložisku {storage} bola úspešne dokončená.\nVeľkosť: {size}', 'label': 'Záloha bola dokončená'}, 'sv': {'title': '{hostname} → {storage}: Säkerhetskopiering klar — {vmname} ({vmid})', 'body': 'Säkerhetskopiering av {vmname} (ID: {vmid}) slutfördes framgångsrikt på {storage}.\nStorlek: {size}', 'label': 'Säkerhetskopieringen är klar'}}
for language, expected in legacy.items():
self.assertEqual(templates._load_runtime_catalog(language)['templates']['backup_complete'], expected)
result = templates.render_template('backup_complete', {'hostname': 'alias {rack.location}'}, language)
self.assertIn('alias {rack.location}', result['title'])
self.assertNotIn('()', result['title'])
new_title = templates.runtime_message('backup.unconfirmedTitle', language, hostname='alias {rack.location}')
self.assertTrue(new_title)
self.assertEqual(result['title'], new_title)
self.assertIn(templates.runtime_message('backup.unconfirmedBody', language), result['body'])
# Absent/blank/non-string translation uses English per-key fallback.
from unittest.mock import patch
english = templates._load_runtime_catalog('en')
for value in (None, '', {}, []):
missing = {'backup': {'unconfirmedTitle': value, 'unconfirmedBody': value}}
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else missing):
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
# Both catalogs missing: new outcome text still has explicit EN defaults.
with patch.object(templates, '_load_runtime_catalog', return_value={}):
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
def test_restore_reports_its_tasks_without_calling_the_node_ready_and_keeps_the_success_icon(self):
import json
from pathlib import Path
from notification_final_fixture import restore_event
messages = Path(__file__).resolve().parents[3] / 'AppImage/messages'
first = {language: json.loads((messages / language / 'common.json').read_text())
['runtime']['notifications']['templates']['system_restore_completed']['body'].split('\n')[0]
for language in LANGUAGES}
for warning in ('', 'missing module zfs'):
event = restore_event(warning)
for language in LANGUAGES:
result = deliver(event['event_type'], event['data'], event['severity'], language)
self.assertTrue(result['title'].startswith('✅ '))
self.assertIn(first[language], result['body'])
self.assertIn(first[language], result['text'])
self.assertNotIn('fully ready', result['body'])
self.assertIn('2m', result['text'])
if warning:
self.assertIn(warning, result['text'])
quiet = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
self.assertIn('✅', quiet['body'])
self.assertIn(first[language], quiet['body'])
self.assertIn('white-space:pre-wrap;', quiet['html'])
def test_spanish_outcome_and_restore_titles_are_capitalized_and_failure_is_exact(self):
expected = {'confirmed': 'Backup completado', 'completed_with_warnings': 'Backup completado con advertencias',
'unconfirmed': 'Resultado del backup sin confirmar', 'failed': 'Backup fallido'}
for outcome, title in expected.items():
result = templates.render_template('backup_complete', {'hostname': 'alias', 'backup_outcome': outcome}, 'es')
self.assertEqual(result['title'], 'alias: ' + title)
failure = templates.render_template('backup_fail', {'hostname': 'alias'}, 'es')
self.assertEqual(failure['title'], 'alias: Backup fallido')
restore = templates.render_template('system_restore_completed', {'hostname': 'alias'}, 'es')
self.assertEqual(restore['title'], 'alias: Restauración del host finalizada')
def test_recovery_only_quiet_release_keeps_upstream_summary_contract(self):
result = deliver('error_resolved', {'hostname': 'alias', 'category': 'temperature',
'reason': 'old (recovered)', 'duration': '2m'}, 'OK', quiet=True)
self.assertIn(templates.runtime_message('digest.lead', 'en', count=1).strip(), result['body'])
self.assertIn(templates.runtime_message('digest.footer', 'en'), result['body'])
self.assertIn('✅', result['body'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,204 @@
"""Whole-PR outcome corrections, inert producer/actual email consumers."""
import html
import unittest
from notification_fixture import templates, receive, email, LANGUAGES
REPORT = """Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
"""
class CorrectionTests(unittest.TestCase):
def test_reversed_finish_is_not_completion_evidence(self):
event = receive('INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Starting Backup of VM 100 (qemu)')
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
def test_interleaved_complete_logs_keep_both_finished_guests(self):
message = ('INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: web\n'
'INFO: Starting Backup of VM 101 (lxc)\nINFO: CT Name: db\n'
'INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Finished Backup of VM 101 (00:01:02)')
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'confirmed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn('✅ VM web (100)', result['body'])
self.assertIn('✅ CT db (101)', result['body'])
self.assertNotIn('❔', result['body'])
self.assertIn('00:01:01', result['body'])
def test_backup_identity_has_event_scoped_mail_compatible_wrapping(self):
result, markup = email('backup_complete', {'hostname': 'n' * 64,
'backup_outcome': 'confirmed', 'pve_message': 'INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: customerproductionpostgresqlreplicaeuropewestdatacenter01\nINFO: Finished Backup of VM 100 (00:01:01)'})
self.assertIn('table-layout:fixed;', markup)
title_tag = markup.split('<h2 style="', 1)[1].split('"', 1)[0]
self.assertIn('overflow-wrap:break-word;', title_tag)
self.assertIn('word-wrap:break-word;', title_tag)
_, unrelated = email('node_reconnect', {'hostname': 'node-a'}, 'OK')
self.assertNotIn('table-layout:fixed;', unrelated)
def test_catalog_parity_accepts_eventual_valid_slovak_keys(self):
from notification_fixture import extract, SCRIPTS
import copy
import string
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
ns = {'string': string}
ns['_placeholders'] = extract(path, '_placeholders', None, ns)
parity = extract(path, 'test_runtime_catalog_keys_and_placeholders_match', 'RuntimeCatalogTests', ns)
probe = unittest.TestCase()
probe.catalogs = {lang: copy.deepcopy(templates._load_runtime_catalog(lang)) for lang in LANGUAGES}
parity(probe) # shipped missing keys remain allowed
probe.catalogs['sk']['backup'] = copy.deepcopy(probe.catalogs['en']['backup'])
probe.catalogs['sk']['channels']['email']['status']['unconfirmed'] = probe.catalogs['en']['channels']['email']['status']['unconfirmed']
parity(probe) # generation of exactly the pending keys is legal
probe.catalogs['sk']['backup']['confirmedTitle'] = 'Missing hostname token'
with self.assertRaises(AssertionError): parity(probe)
def test_actual_neutral_style_is_not_success_green(self):
for event, severity, data in (('backup_complete', 'INFO', {'backup_outcome': 'unconfirmed'}),):
result, markup = email(event, data, severity)
self.assertIn('background:#f9fafb;', markup)
self.assertNotIn('background:#f0fdf4;', markup)
def test_actual_restore_endpoint_warnings_and_counts_reach_email(self):
from notification_fixture import extract, SCRIPTS
from types import SimpleNamespace
for warnings in ('', 'missing module zfs'):
events = []
ns = {'request': SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname': 'node-a', 'guests': '3', 'stubs': '1', 'stale_nodes': '0',
'components': '2', 'duration': '2m', 'warnings': warnings}),
'notification_manager': SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
'jsonify': lambda value: value}
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', None, ns)
response, status = handler()
self.assertEqual(status, 200)
event = events[0]
for language in LANGUAGES:
result, markup = email(event['event_type'], event['data'], event['severity'], language)
self.assertIn('2m', markup)
for line in result['body'].splitlines():
if line.strip(): self.assertIn(line.strip(), html.unescape(markup))
if warnings: self.assertIn(warnings, markup)
def test_raw_display_hostname_is_substituted_exactly_once(self):
for event_type, outcome in (('backup_complete', 'confirmed'), ('backup_complete', 'failed'), ('backup_fail', 'failed')):
for hostname in ('Sala {rack} – Zürich', 'node-{vmid}', 'Sala {rack.location}'):
for language in LANGUAGES:
result, markup = email(event_type, {'hostname': hostname,
'backup_outcome': outcome, 'pve_message': REPORT}, language=language)
self.assertIn(hostname, result['title'])
self.assertIn(hostname, html.unescape(markup))
def test_malformed_numeric_report_is_queued_uncertain_and_renderable(self):
for size in ('1..5 GiB', '..5 GiB'):
message = REPORT.replace('1 GiB ', size.ljust(9)).split('Total size:')[0]
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn(size, result['body'])
def test_unknown_backup_type_keeps_explicit_err_but_cannot_certify_ok(self):
event = receive(REPORT.replace('ok ', 'err '), kind='')
self.assertEqual(event.event_type, 'backup_complete')
self.assertEqual(event.severity, 'INFO')
self.assertEqual(event.data['backup_outcome'], 'failed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn('FAILED', markup)
self.assertEqual(receive(REPORT, kind='').data['backup_outcome'], 'unconfirmed')
def test_confirmed_metadata_only_context_is_retained(self):
data = {'hostname': 'node-a', 'backup_outcome': 'confirmed',
'vmid': '100', 'vmname': 'web {literal}', 'storage': 'PBS', 'size': '1 GiB'}
for language in LANGUAGES:
result, markup = email('backup_complete', data, language=language)
self.assertIn('web {literal} (100)', result['title'])
self.assertIn('1 GiB', result['body'])
self.assertIn('1 GiB', markup)
def test_explicit_guest_failure_overrides_only_the_linked_ok_row(self):
other = '101 db ok 1m 1s 1 GiB ct/101/2026-09-29T17:00:00Z'
message = REPORT.replace('\n\nTotal', '\n' + other + '\n\nTotal')
diagnostic = '100: 2026-09-29 17:00:00 ERROR: Backup of VM 100 failed - archive write failed'
for severity in ('info', 'error'):
event = receive(message + '\n' + diagnostic, severity)
self.assertEqual(event.data.get('vmid'), '100')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn('❌ VM web (100)', result['body'])
self.assertNotIn('✅ VM web (100)', markup)
self.assertIn('✅ CT db (101)', result['body'])
self.assertIn('web (100)', result['title'])
def test_official_week_month_year_durations_stay_confirmed(self):
for duration in ('1w', '1w 1m 1s', '1M', '1y'):
# Fixed-column widths are unchanged for these bounded values.
message = REPORT.replace('1m 1s ', duration.ljust(9))
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'confirmed', duration)
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn(duration, result['body'])
def test_prefixed_error_diagnostics_survive_both_source_severities(self):
diagnostic = '100: 2026-09-29 17:00:00 ERROR: archive write failed: permission denied'
for severity in ('info', 'error'):
event = receive(REPORT + '\n' + diagnostic, severity)
self.assertEqual(event.data['backup_outcome'], 'failed')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(diagnostic, result['body'])
self.assertIn(diagnostic, html.unescape(markup))
# A job-level error must not invent a failed guest.
self.assertIn('✅ VM web (100)', result['body'])
def test_official_completed_report_warning_is_distinct_and_retained(self):
warning = '100: 2026-09-29 17:00:00 WARN: unable to add notes - permission denied'
event = receive(REPORT + '\n' + warning)
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(warning, result['body'])
self.assertIn(warning, html.unescape(markup))
def test_batch_and_abort_failure_titles_have_no_empty_guest_slot(self):
failed = REPORT.replace('ok ', 'err ')
failed = failed.replace('\n\nTotal', '\n101 db err 1m 1s 0 B null\n\nTotal')
for message in (failed, REPORT.replace('ok ', 'todo '),
REPORT.split('100 web')[0] + '\nTotal running time: 0s'):
event = receive(message + '\nINFO: vzdump --storage PBS', 'error')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertNotIn('()', result['title'])
self.assertIn('PBS', result['title'])
self.assertNotIn('web (100)', result['title'])
def test_subject_only_setup_failure_survives_actual_email(self):
reason = 'unable to activate storage PBS'
title = 'vzdump backup status (node-a): backup failed: ' + reason
message = REPORT.split('100 web')[0] + '\nTotal running time: 0s\nTotal size: 0 B'
event = receive(message, 'error', title)
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(reason, result['body'])
self.assertIn(reason, html.unescape(markup))
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,136 @@
"""Final review contracts at actual locale/manual/queued/quiet email seams.
All operational dependencies are inert; no manager or route module import.
"""
import ast
import copy
import html
import unittest
from unittest.mock import patch
from notification_fixture import templates, SCRIPTS, LANGUAGES, receive
from notification_final_fixture import deliver, restore_event
# Literal native send_notification body, produced by pinned PVE Perl helpers.
NATIVE_REPORT = 'Details\n=======\n' + '''VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
Logs
====
vzdump --all 1 --storage PBS --mode snapshot
100: no log available
'''
class FinalCorrectionsTests(unittest.TestCase):
def test_runtime_backup_assertions_accept_generated_and_missing_slovak_title(self):
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
tree = ast.parse(path.read_text())
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'RuntimeCatalogTests')
method = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == 'test_special_formatters_digest_and_test_message_are_slovak')
start = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='backup' for t in n.targets))
stop = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='manager' for t in n.targets))
block = ast.Module(body=method.body[start:stop], type_ignores=[])
english = templates._load_runtime_catalog('en')
for title in (None, '{hostname}: GENERATED_SK potvrdené'):
sk = copy.deepcopy(templates._load_runtime_catalog('sk'))
if title is not None: sk.setdefault('backup', {})['confirmedTitle'] = title
else: sk.get('backup', {}).pop('confirmedTitle', None)
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else sk):
exec(compile(block,str(path),'exec'), {'self':self, 'notification_templates':templates})
def test_native_multiline_failure_diagnostics_survive_receiver_dispatch_once(self):
subject = 'vzdump backup status (node-a): backup failed: multiple problems'
for diagnostic, report in (
('external provider job cleanup failed\njob-abort hook permission denied', NATIVE_REPORT),
('job interrupted\njob-abort hook permission denied', NATIVE_REPORT.replace('ok ', 'todo ')),
('unable to initialize external provider\njob-abort hook permission denied', NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z\n', '')),
):
event = receive(diagnostic + '\n' + report, 'error', subject)
self.assertEqual((event.event_type, event.severity), ('backup_fail', 'CRITICAL'))
for language in LANGUAGES:
result = deliver(event.event_type, event.data, event.severity, language)
for line in diagnostic.splitlines():
self.assertEqual(result['body'].count(line), 1)
self.assertEqual(result['text'].count(line), 1)
if 'ok ' in report:
self.assertIn('✅ VM web (100)', result['body'])
def test_restore_endpoint_quiet_release_keeps_warning_counts_and_truthful_footer(self):
reason = 'missing module zfs <raw> & {literal}'
event = restore_event(reason)
self.assertEqual(event['severity'], 'WARNING')
for language in LANGUAGES:
result = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
self.assertEqual(result['severity'], 'INFO')
buffered_body = result['buffered'][0][3]
self.assertEqual(result['text'].count(reason), 1)
for line in buffered_body.splitlines():
if line.strip(): self.assertIn(line.strip(), result['text'])
self.assertIn('2m', result['text'])
self.assertNotIn(templates.runtime_message('digest.footer', language), result['body'])
self.assertNotIn('script', result['tags'])
def test_raw_restore_cells_use_event_scoped_mail_wrapping(self):
token = 'b' * 64
event = restore_event('Boot check: recorded token ' + token + '; verification pending')
for language in LANGUAGES:
results = [deliver(event['event_type'],event['data'],event['severity'],language,quiet=quiet) for quiet in (False,True)]
for result in results:
self.assertIn('table-layout:fixed;', result['html'])
self.assertIn('word-wrap:break-word;', result['html'])
self.assertIn('overflow-wrap:break-word;', result['html'])
self.assertEqual(result['text'].count(token), 1)
unrelated = deliver('node_reconnect', {'hostname':'node-a'}, 'OK')
self.assertNotIn('table-layout:fixed;', unrelated['html'])
def test_manual_failed_and_unconfirmed_backup_keep_short_actionable_reason(self):
for outcome, reason in (('failed','PBS permission denied for datastore remote'),
('unconfirmed','Task status unavailable: upstream API timed out')):
for language in LANGUAGES:
result = deliver('backup_complete',{'hostname':'node-a','vmid':'100','vmname':'web',
'storage':'PBS','backup_outcome':outcome,'reason':reason},'INFO',language,manual=True)
self.assertEqual(result['text'].count(reason), 1)
self.assertNotIn('>COMPLETED</span>', result['html'])
def test_backup_reason_threshold_and_raw_body_deduplication(self):
for event in ('backup_complete', 'backup_fail'):
for length in (79, 80, 81, 120):
prefix = '<raw> & {rack.location} '
reason = prefix + 'b' * (length - len(prefix))
self.assertEqual(len(reason), length)
for raw in ('', reason):
data = {'hostname':'node-a','backup_outcome':'failed','reason':reason,'pve_message':raw}
for manual in (False, True):
result = deliver(event,data,'INFO','en',manual=manual)
self.assertEqual(result['text'].count(reason), 1, (event,length,raw,manual))
self.assertNotIn('raw', result['tags'])
def test_quiet_restore_after_preview_limit_is_not_omitted_or_called_info(self):
event = restore_event('missing module zfs')
earlier = [dict(event_type='service_fail',severity='WARNING',data={'hostname':'node-a','service_name':f'unit-{i}','reason':'recorded'}) for i in range(8)]
result = deliver(event['event_type'],event['data'],event['severity'],quiet=True,quiet_before=earlier)
self.assertEqual(result['text'].count('missing module zfs'), 1)
self.assertNotIn(templates.runtime_message('digest.lead','en',count=9), result['body'])
self.assertEqual(result['data']['_count'], 9)
def test_native_error_block_does_not_deduplicate_a_substring_of_inventory(self):
event = receive('web\nexternal provider job cleanup failed\n' + NATIVE_REPORT,
'error','vzdump backup status (node-a): backup failed: multiple problems')
result = deliver(event.event_type,event.data,event.severity)
self.assertEqual(result['body'].splitlines().count('web'), 1)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,159 @@
"""Maintainer acceptance at inert actual notification consumers."""
import unittest
from notification_fixture import templates, receive, LANGUAGES, SCRIPTS, extract
from notification_final_fixture import deliver
NATIVE_REPORT = '''Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
Logs
====
vzdump --all 1 --storage PBS --mode snapshot
100: 2026-09-29 17:00:00 INFO: Starting Backup of VM 100 (qemu)
100: 2026-09-29 17:01:01 INFO: Finished Backup of VM 100 (00:01:01)
'''
class MaintainerFollowupTests(unittest.TestCase):
def test_completed_with_warnings_requires_independent_completion(self):
warning = '\n100: 2026-09-29 17:00:01 WARN: file changed during backup'
event = receive(NATIVE_REPORT + warning)
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
self.assertEqual((event.event_type,event.severity), ('backup_complete','INFO'))
for manual in (False,True):
for lang in LANGUAGES:
result = deliver(event.event_type,event.data,event.severity,lang,manual=manual)
label = templates.runtime_message('backup.warningTitle',lang,hostname='node-a')
self.assertIn(label,result['title'])
status = templates.runtime_message('channels.email.status.completed_with_warnings',lang)
self.assertIn(status,result['text'])
self.assertIn('file changed during backup',result['text'])
# Manual sends intentionally skip channel emoji enrichment.
if not manual: self.assertTrue(result['title'].startswith('💾⚠️'))
rich, _ = templates.enrich_with_emojis(event.event_type,result['title'],result['body'],event.data)
self.assertTrue(rich.startswith('💾⚠️'))
self.assertEqual(receive('WARN: file changed during backup').data['backup_outcome'],'unconfirmed')
self.assertEqual(receive(NATIVE_REPORT.split('Total running time:')[0]+warning).data['backup_outcome'],'unconfirmed')
self.assertEqual(receive(NATIVE_REPORT+warning+'\nERROR: cleanup failed').data['backup_outcome'],'failed')
self.assertEqual(receive(NATIVE_REPORT+warning,'warning').data['backup_outcome'],'completed_with_warnings')
self.assertEqual(receive('INFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'+warning).data['backup_outcome'],'completed_with_warnings')
self.assertEqual(receive(NATIVE_REPORT+warning,kind='').data['backup_outcome'],'unconfirmed')
def test_null_filename_failed_guest_uses_own_start_identity(self):
report = NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z',
'100 web err 1m 1s 0 B null')
for kind, prefix in (('qemu','VM'),('lxc','CT')):
own = report.replace('VM 100 (qemu)',f'VM 100 ({kind})')
# Unrelated guest appears first and must never supply the failed type.
message = f'INFO: Starting Backup of VM 999 ({"lxc" if kind == "qemu" else "qemu"})\n' + own
event = receive(message,'error','vzdump backup status (raw-node): backup failed')
parsed = templates._parse_vzdump_message(message)
self.assertEqual(parsed['vms'][0]['type'],kind)
result = deliver(event.event_type,event.data,event.severity)
self.assertIn(f'{prefix} web (100)',result['title'])
self.assertIn(f'❌ {prefix} web (100)',result['body'])
for message in (report.replace('Starting Backup of VM 100','Starting Backup of VM 999'),
report+'\nINFO: Starting Backup of VM 100 (lxc)'):
self.assertEqual(templates._parse_vzdump_message(message)['vms'][0]['type'],'')
def test_native_subject_keeps_cause_without_host_envelope(self):
subject = 'vzdump backup status (raw-host): backup failed: multiple problems'
event = receive('ERROR: archive write failed\n'+NATIVE_REPORT,'error',subject)
event.data['hostname']='configured-alias'
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertNotIn(subject,result['text'])
self.assertEqual(result['text'].count('ERROR: archive write failed'),1)
self.assertIn('configured-alias',result['title'])
setup=receive('Details\n=======\nVMID Name Status Time Size Filename\n\nTotal running time: 0s\nTotal size: 0 B','error',subject.replace('multiple problems','unable to open storage'))
result=deliver(setup.event_type,setup.data,setup.severity)
self.assertEqual(result['text'].count('unable to open storage'),1)
self.assertNotIn(setup.data['pve_title'],result['text'])
unique=receive(NATIVE_REPORT,'error',subject.replace('multiple problems','job-end hook denied'))
result=deliver(unique.event_type,unique.data,unique.severity)
self.assertIn('job-end hook denied',result['body'])
self.assertNotIn('vzdump backup status',result['body'])
def test_backup_diagnostics_are_bounded_with_principal_cause_and_notice(self):
raw = NATIVE_REPORT + '\n' + '\n'.join(f'WARN: repeated warning {i}' for i in range(80)) + '\nERROR: principal archive write failure'
event=receive(raw)
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertIn('ERROR: principal archive write failure',result['body'])
self.assertLessEqual(len('\n'.join(line for line in result['body'].splitlines() if line.startswith(('WARN:','ERROR:')))),1024)
self.assertLessEqual(sum(line.startswith(('WARN:','ERROR:')) for line in result['body'].splitlines()),8)
notice=templates.runtime_message('backup.diagnosticsOmitted',lang,count=73)
self.assertIn(notice,result['body'])
self.assertEqual(event.data['pve_message'],raw)
long=receive('ERROR: '+ 'b'*5000,'error','vzdump backup status (node): backup failed')
result=deliver(long.event_type,long.data,long.severity)
self.assertLess(len(result['body']),1400)
self.assertIn('ERROR: '+ 'b'*100,result['body'])
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted','en',count=1),result['body'])
def test_real_quiet_digest_retains_each_backup_outcome_icon(self):
samples=[(NATIVE_REPORT,'confirmed','💾✅'),(NATIVE_REPORT+'\nWARN: changed file','completed_with_warnings','💾⚠️'),(NATIVE_REPORT+'\nERROR: write failed','failed','💾❌'),('INFO: Starting Backup of VM 100 (qemu)','unconfirmed','💾❔')]
for raw,outcome,icon in samples:
event=receive(raw)
self.assertEqual(event.data['backup_outcome'],outcome)
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang,quiet=True)
self.assertIn(icon,result['body'])
self.assertNotIn('💾❔',result['body']) if outcome!='unconfirmed' else None
import types,datetime
ns={'datetime':datetime.datetime,'runtime_message':templates.runtime_message,'EVENT_EMOJI':templates.EVENT_EMOJI,'CATEGORY_EMOJI':templates.CATEGORY_EMOJI}
compose=extract(SCRIPTS/'notification_manager.py','_compose_digest_body','NotificationManager',ns)
target=types.SimpleNamespace(_notification_language=lambda:'en')
rows=[(i,'backup_complete','backup',1,icon+' node: Backup','') for i,(_,_,icon) in enumerate(samples)]
body=compose(target,rows,use_icons=True)
for _,_,icon in samples:self.assertIn(icon,body)
plain=compose(target,rows,use_icons=False)
for _,_,icon in samples:self.assertNotIn(icon,plain)
should=extract(SCRIPTS/'notification_manager.py','_should_buffer_for_digest','NotificationManager',{})
self.assertFalse(should(types.SimpleNamespace(_DIGEST_EXEMPT_EVENTS={'backup_complete'},_config={'email.digest_enabled':'true'}),'email','INFO','backup_complete'))
def test_quiet_restore_details_are_subordinate_in_text_and_email(self):
from notification_final_fixture import restore_event
event=restore_event('missing module zfs')
for lang in LANGUAGES:
result=deliver(event['event_type'],event['data'],event['severity'],lang,quiet=True)
body_lines=result['buffered'][0][3].splitlines()
for line in body_lines:
if line.strip():
self.assertIn(' '+line.strip(),result['body'])
self.assertIn(' '+__import__('html').escape(line.strip()),result['html'])
self.assertIn('white-space:pre-wrap;',result['html'])
self.assertNotIn(templates.runtime_message('digest.footer',lang),result['body'])
def test_concrete_subject_cause_already_in_error_log_is_not_repeated(self):
event=receive(NATIVE_REPORT+'\n100: ERROR: job-end hook denied','error',
'vzdump backup status (raw-host): backup failed: job-end hook denied')
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertEqual(result['text'].count('job-end hook denied'),1)
self.assertIn('100: ERROR: job-end hook denied',result['body'])
def test_job_level_subject_cause_survives_warning_cap(self):
raw=NATIVE_REPORT+'\n'+'\n'.join('WARN: repeated diagnostic '+str(i) for i in range(80))
event=receive(raw,'error','vzdump backup status (raw-host): backup failed: job-end hook denied')
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertEqual(result['body'].count('job-end hook denied'),1)
self.assertNotIn('vzdump backup status',result['body'])
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted',lang,count=73),result['body'])
def test_backup_quiet_email_uses_event_scoped_wrapping(self):
event=receive(NATIVE_REPORT+'\nWARN: changed file')
result=deliver(event.event_type,event.data,event.severity,'sv',quiet=True)
self.assertTrue(result['data'].get('_backup_summary'))
self.assertIn('table-layout:fixed;',result['html'])
self.assertIn('overflow-wrap:break-word;',result['html'])
unrelated=deliver('node_reconnect',{'hostname':'node-a'},'OK')
self.assertNotIn('table-layout:fixed;',unrelated['html'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,399 @@
"""Inert producer-to-renderer checks for notification outcome claims."""
import ast
import copy
import json
import re
import time
import sys
import types
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import patch
from notification_fixture import templates as actual_templates
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
CATALOG = ROOT / 'AppImage/messages/en/common.json'
EXPECTED = {'system_restore_completed': {'body': 'Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}'}}
def extract(path, name, owner=None, namespace=None):
tree = ast.parse(path.read_text())
nodes = tree.body
if owner:
nodes = next(n.body for n in nodes if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
ns = namespace if namespace is not None else {}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
return ns[name]
def renderer(catalog, translated=None):
path = SCRIPTS / 'notification_templates.py'
tree = ast.parse(path.read_text())
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
def lookup(obj, key):
for part in key.split('.'):
obj = obj.get(part) if isinstance(obj, dict) else None
return obj
def message(key, language='en', **values):
source = translated if language == 'it' and translated is not None else catalog
namespace = source['runtime']['notifications']
value = lookup(namespace, key) or lookup(catalog['runtime']['notifications'], key) or ''
return value.format_map(type('Safe', (dict,), {'__missing__': lambda self, k: ''})(values))
ns = {'TEMPLATES': templates, 'Dict': dict, 'Any': Any, 'time': time, 're': re,
'_get_hostname': lambda: 'node-a',
'_load_runtime_catalog': lambda lang: (translated if lang == 'it' and translated is not None
else catalog)['runtime']['notifications'],
'_catalog_value': lookup, 'runtime_message': message}
from typing import Optional
ns['Optional'] = Optional
extract(path, '_parse_vzdump_table', namespace=ns)
extract(path, '_parse_vzdump_message', namespace=ns)
extract(path, '_format_vzdump_body', namespace=ns)
return templates, extract(path, 'render_template', namespace=ns)
class OutcomeWording(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.catalog = json.loads(CATALOG.read_text())
cls.templates, render = renderer(cls.catalog)
cls.render = staticmethod(render)
def test_spanish_restore_uses_maintainer_guests_terminology(self):
import importlib.util
spec = importlib.util.spec_from_file_location('isolated_spanish_restore', SCRIPTS / 'notification_templates.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = module.render_template('system_restore_completed', {
'hostname':'node-a','guests':3,'stubs':0,'stale_nodes':0,
'components':1,'duration':'2m','warnings_block':''}, 'es')
self.assertIn('Configuración de guests copiada: 3', result['body'])
self.assertNotIn('invitados', result['body'].lower())
def test_settings_labels_stay_at_upstream_values_in_all_locales(self):
# Frozen from develop eb7cc548; CI shallow checkouts have no base history.
labels = {'en': {'backup_complete': 'Backup complete', 'error_resolved': 'Recovery notification'}, 'de': {'backup_complete': 'Sicherung abgeschlossen', 'error_resolved': 'Wiederherstellungsbenachrichtigung'}, 'es': {'backup_complete': 'Backup completado', 'error_resolved': 'Notificación de recuperación'}, 'fr': {'backup_complete': 'Sauvegarde terminée', 'error_resolved': 'Notification de récupération'}, 'it': {'backup_complete': 'Backup completato', 'error_resolved': 'Notifica di recupero'}, 'pt': {'backup_complete': 'Backup concluído', 'error_resolved': 'Notificação de recuperação'}, 'sk': {'backup_complete': 'Záloha bola dokončená', 'error_resolved': 'Problém bol vyriešený'}, 'sv': {'backup_complete': 'Säkerhetskopieringen är klar', 'error_resolved': 'Återställningsmeddelande'}}
for lang in labels:
path = f'AppImage/messages/{lang}/common.json'
current = json.loads((ROOT / path).read_text())
for event in ('backup_complete', 'error_resolved'):
with self.subTest(lang=lang, event=event):
expected = labels[lang][event]
self.assertEqual(current['runtime']['notifications']['templates'][event]['label'], expected)
if lang == 'en': self.assertEqual(self.templates[event]['label'], expected)
def test_exact_four_english_leaves_match_source_and_catalog(self):
for event, fields in EXPECTED.items():
for field, value in fields.items():
with self.subTest(event=event, field=field):
self.assertEqual(self.templates[event][field], value)
self.assertEqual(self.catalog['runtime']['notifications']['templates'][event][field], value)
def test_webhook_backup_outcome_is_evidence_based_without_rerouting(self):
path = SCRIPTS / 'notification_events.py'
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
ns['NotificationEvent'] = Event
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity_map)
_backup_outcome = staticmethod(backup_outcome)
def __init__(self): self._queue = Queue()
header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
row_ok = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','OK','00:01:00','1.5 GiB','archive')
row_warning = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','WARNINGS','00:01:00','1.5 GiB','archive')
row_error = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','ERROR','00:01:00','1.5 GiB','archive')
row_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','err','00:01:00','1.5 GiB','archive')
truncated = 'INFO: Log output was too long to be displayed. Please see task log for details.'
cases = [
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_err+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\n'+header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
('vzdump', 'info', header+'\n'+row_err+'\nTotal running time: 00:01:00\n'+truncated, 'failed'),
('vzdump', 'warning', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'completed_with_warnings'),
('vzdump', 'info', header+'\n'+row_ok, 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_ok, 'unconfirmed'),
('vzdump', 'warning', header+'\n'+row_err+'\nTotal running time: 00:01:00', 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00\n'+truncated, 'unconfirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\nERROR: archive write failed', 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', header+'\n'+row_error, 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'confirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00', 'unconfirmed'),
('vzdump', 'info', header+'\n'+row_ok[:30], 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'confirmed'),
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nWARNING: skipped file', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: TASK OK\n104 alpha WARNINGS: 1', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Finished Backup of VM 105 (00:01:00)', 'confirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed for VM 104', 'failed'),
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'failed'),
('', 'warning', 'Backup scheduled', 'unconfirmed'),
('', 'info', 'Backup complete', 'unconfirmed'),
]
for kind, severity, message, expected in cases:
with self.subTest(message=message, severity=severity):
receiver = Receiver()
receive(receiver, {'fields': {'type': kind}, 'severity': severity,
'title': 'Backup', 'message': message})
event = receiver._queue.items[0]
self.assertEqual(event.event_type, 'backup_complete')
self.assertEqual(event.data['backup_outcome'], expected)
def test_backup_classifier_preserves_confirmed_and_unverified_paths(self):
path = SCRIPTS / 'notification_events.py'
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
ns['NotificationEvent'] = Event
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity_map)
_backup_outcome = staticmethod(backup_outcome)
def __init__(self): self._queue = Queue()
samples = [('vzdump', 'info', 'Backup', 'job finished'),
('vzdump', 'warning', 'Backup', 'job incomplete'),
('', 'warning', 'backup job', 'Backup scheduled')]
for kind, severity, title, message in samples:
with self.subTest(kind=kind, message=message):
event, entity, _ = classify(None, kind, severity, title, message)
self.assertEqual((event, entity), ('backup_complete', 'vm'))
receiver = Receiver()
reply = receive(receiver, {'fields': {'type': kind}, 'severity': severity,
'title': title, 'message': message})
self.assertEqual(reply['event_type'], event)
self.assertEqual(len(receiver._queue.items), 1)
emitted = receiver._queue.items[0]
self.assertEqual(emitted.severity, 'WARNING' if severity == 'warning' else 'INFO')
output = self.render(emitted.event_type, emitted.data, 'en')
self.assertIn('Backup outcome unconfirmed', output['title'])
self.assertEqual(output['body'].splitlines()[-1], message) # raw PVE body retained
self.assertEqual(classify(None, 'vzdump', 'error', 'Backup', 'failed')[0], 'backup_fail')
def test_incomplete_vzdump_log_does_not_certify_a_guest(self):
from typing import Dict, Optional
ns = {'re': re, 'Dict': Dict, 'Optional': Optional, 'Any': Any,
'runtime_message': lambda key, lang, **kw: key}
extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_table', namespace=ns)
parser = extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_message', namespace=ns)
formatter = extract(SCRIPTS / 'notification_templates.py', '_format_vzdump_body', namespace=ns)
incomplete = parser('INFO: Starting Backup of VM 104 (qemu)')
table_header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
table_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','err','00:01:00','1.5 GiB','archive')
failed_table = parser(table_header+'\n'+table_err+'\nTotal running time: 00:01:00')
self.assertEqual(failed_table['vms'][0]['status'].lower(), 'error')
self.assertIn('❌', formatter(failed_table, False, 'en'))
self.assertEqual(incomplete['vms'][0]['status'], 'unknown')
self.assertNotIn('✅', formatter(incomplete, False, 'en'))
mixed = parser('INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Starting Backup of VM 105 (lxc)')
self.assertEqual([vm['status'] for vm in mixed['vms']], ['ok', 'unknown'])
formatted = formatter(mixed, False, 'en')
self.assertEqual(formatted.count('✅'), 1)
self.assertNotIn('❌', formatted)
conflicting = parser('INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed\nINFO: Finished Backup of VM 104 (00:01:00)')
self.assertEqual(conflicting['vms'][0]['status'], 'error')
def test_backup_render_preserves_success_and_marks_unconfirmed_and_failure(self):
for outcome, message, title_part, body_part in (
('confirmed', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'Backup complete', '✅'),
('unconfirmed', 'INFO: Starting Backup of VM 104 (qemu)', 'Backup outcome unconfirmed', '❔'),
('failed', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'Backup error reported', '❌'),
):
with self.subTest(outcome=outcome):
output = self.render('backup_complete', {'hostname': 'node-a', 'pve_type': 'vzdump',
'pve_message': message, 'pve_title': 'Backup complete', 'backup_outcome': outcome}, 'en')
self.assertIn(title_part, output['title'])
self.assertIn(body_part, output['body'])
if outcome != 'confirmed': self.assertNotIn('Backup complete', output['title'])
output = self.render('backup_complete', {'hostname': 'node-a', 'vmname': 'vm', 'vmid': '104'}, 'en')
self.assertIn('Backup outcome unconfirmed', output['title'])
self.assertNotIn('successfully', output['body'])
conflict = self.render('backup_complete', {'hostname': 'node-a','backup_outcome':'failed',
'pve_message':'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nERROR: archive write failed'}, 'en')
self.assertIn('ERROR: archive write failed', conflict['body'])
self.assertNotIn('Backup complete', conflict['title'])
def test_confirmed_title_keeps_single_guest_and_destination_without_misnaming_batches(self):
import importlib.util
spec = importlib.util.spec_from_file_location('isolated_backup_title', SCRIPTS / 'notification_templates.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
actual_render = module.render_template
log = ('INFO: starting new backup job: vzdump 104 --storage PBS-Cloud --mode snapshot\n'
'INFO: Starting Backup of VM 104 (qemu)\nINFO: VM Name: Alpha\n'
'INFO: Finished Backup of VM 104 (00:01:00)')
single = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log}, 'en')
self.assertIn('PBS-Cloud',single['title'])
self.assertIn('VM Alpha (104)',single['title'])
batch = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log+'\nINFO: Starting Backup of VM 105 (lxc)\n'
'INFO: Finished Backup of VM 105 (00:01:00)'}, 'en')
self.assertIn('PBS-Cloud',batch['title'])
self.assertNotIn('Alpha (104)',batch['title'])
no_context = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed'}, 'en')
self.assertEqual(no_context['title'], 'node-a: Backup complete')
named = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log.replace('Alpha','Alpha {literal}')}, 'en')
self.assertIn('Alpha {literal} (104)', named['title'])
def test_html_email_badge_and_backup_status_are_context_specific(self):
import html
path = SCRIPTS / 'notification_channels.py'
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
with self.subTest(lang=lang):
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
english = self.catalog['runtime']['notifications']
def text(key, data=None, **values):
def lookup(source):
value = source['channels']
for part in key.split('.'):
value = value.get(part) if isinstance(value, dict) else None
return value
return (lookup(catalog) or lookup(english) or '').format(**values)
ns = {'Dict': dict, 'Optional': __import__('typing').Optional,
'_runtime_text': text, '_runtime_notification_text': lambda key, data=None: ''}
build = extract(path, '_build_detail_rows', 'EmailChannel', ns)
fmt = extract(path, '_format_html', 'EmailChannel', ns)
class Email:
from notification_channels import EmailChannel
_SEV_STYLE = EmailChannel._SEV_STYLE
_SEV_DEFAULT = EmailChannel._SEV_DEFAULT
subject_prefix = 'ProxMenux'
_build_detail_rows = staticmethod(build)
unrelated = fmt(Email(), 'Reconnected', 'Body', 'OK', {'_event_type': 'node_reconnect',
'_notification_language': lang, '_group': 'cluster'})
self.assertIn('>' + catalog['channels']['email']['severity']['ok'].upper() + '</span>', unrelated)
for outcome, status in [('confirmed', 'completed'), ('unconfirmed','unconfirmed'), ('failed','failed')]:
email = fmt(Email(), 'Backup', 'Details', 'INFO', {'_event_type': 'backup_complete',
'backup_outcome': outcome, '_notification_language': lang, '_group': 'backup'})
label = catalog['channels']['email']['status'].get(status) or english['channels']['email']['status'][status]
self.assertIn(label, html.unescape(email))
badge_label = label.upper()
self.assertIn('>' + badge_label + '</span>', html.unescape(email))
if outcome == 'failed':
self.assertIn('color:#dc2626;font-weight:600;', email)
self.assertIn('background:#fef2f2;', email)
elif outcome == 'unconfirmed':
self.assertIn('background:#f9fafb;', email)
else:
self.assertIn('background:#f0fdf4;', email)
def test_actual_all_locale_rendering_and_fallback_for_backup_outcomes(self):
import importlib.util
module_path = SCRIPTS / 'notification_templates.py'
spec = importlib.util.spec_from_file_location('isolated_notification_templates', module_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # templates and catalogs only; no manager or sends
samples = {
'confirmed': 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)',
'unconfirmed': 'INFO: Starting Backup of VM 104 (qemu)',
'failed': 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed',
}
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
for state, message in samples.items():
with self.subTest(lang=lang, state=state):
data = {'hostname':'node-with-a-long-name','backup_outcome':state,'pve_type':'vzdump',
'pve_message':message, 'pve_title':'Backup complete', '_notification_language':lang}
result = module.render_template('backup_complete', data, lang)
if state != 'unconfirmed':
key = 'confirmedTitle' if state == 'confirmed' else 'errorTitle'
expected_title = (catalog.get('backup', {}).get(key) or
self.catalog['runtime']['notifications']['backup'][key]).format(hostname=data['hostname'])
self.assertTrue(result['title'].startswith(expected_title), result['title'])
else:
self.assertEqual(result['title'], module.runtime_message('backup.unconfirmedTitle', lang, hostname=data['hostname']))
self.assertNotIn('{hostname}', result['title'])
if state == 'unconfirmed':
source = (catalog if catalog.get('backup', {}).get('unconfirmedBody')
else self.catalog['runtime']['notifications'])
self.assertIn(source['backup']['unconfirmedBody'], result['body'])
if state == 'failed':
self.assertIn(catalog.get('backup', {}).get('errorBody') or
self.catalog['runtime']['notifications']['backup']['errorBody'], result['body'])
enriched, _ = module.enrich_with_emojis('backup_complete', result['title'], result['body'], data)
self.assertTrue(enriched.startswith({'confirmed':'💾✅','unconfirmed':'💾❔','failed':'💾❌'}[state]))
restore = module.render_template('system_restore_completed', {'hostname':'node', 'guests':4,
'stubs':1,'stale_nodes':2,'components':1,'duration':'2m','warnings_block':'Missing module'},lang)
self.assertIn('Missing module',restore['body'])
if lang == 'en': self.assertNotIn('fully ready',restore['body'].lower())
def test_warning_and_clean_restore_keep_only_reported_outcome(self):
for warnings in ('', '⚠️ Boot sanity: missing modules\n'):
with self.subTest(warnings=warnings):
events = []
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname': 'node-a', 'guests': '2', 'stubs': '0', 'stale_nodes': '0',
'components': 'none', 'duration': '2m',
'warnings': 'missing modules' if warnings else ''}),
'notification_manager': types.SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
'jsonify': lambda obj: obj}
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', namespace=ns)
response, status = handler()
self.assertEqual((status, response['event_type']), (200, 'system_restore_completed'))
event = events[0]
self.assertEqual(event['severity'], 'WARNING' if warnings else 'INFO')
result = self.render(event['event_type'], event['data'], 'en')
self.assertIn('Post-restore tasks completed', result['body'])
self.assertNotIn('fully ready', result['body'])
if warnings: self.assertIn('missing modules', result['body'])
def test_rich_backup_icon_tracks_outcome_and_digest_default_is_neutral(self):
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
def assign(name):
return ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == name for t in n.targets)))
icons = assign('EVENT_EMOJI')
self.assertNotIn('✅', icons['backup_complete'])
ns = {'TEMPLATES': assign('TEMPLATES'), 'EVENT_EMOJI': icons,
'Dict': dict, 'Any': Any,
'CATEGORY_EMOJI': assign('CATEGORY_EMOJI'), 'SEVERITY_ICONS': assign('SEVERITY_ICONS'),
'FIELD_EMOJI': assign('FIELD_EMOJI'), '_localized_template_labels': lambda *a: {},
'_lxc_update_label_icons': lambda *a: {}}
enrich = extract(SCRIPTS / 'notification_templates.py', 'enrich_with_emojis', namespace=ns)
for state, icon in [('confirmed', '💾✅'), ('unconfirmed', '💾❔'), ('failed', '💾❌')]:
with self.subTest(state=state):
title, body = enrich('backup_complete', 'node-a: Backup', 'Backup report',
{'backup_outcome': state, '_notification_language': 'en'})
self.assertTrue(title.startswith(icon), title)
def test_uncertain_outcomes_have_no_blanket_success_icon(self):
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
icon_map = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'EVENT_EMOJI' for t in n.targets)))
self.assertNotIn('✅', icon_map['backup_complete']) # buffered digest has no outcome metadata
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,50 @@
"""Principal diagnostics must not be deduplicated against inventory substrings."""
import unittest
from notification_fixture import receive, LANGUAGES
from notification_final_fixture import deliver
def noisy_report(name='ordinary-web', storage='PBS', filename='null', cause='denied'):
# Frozen native Perl notifier/log-reader shape; fixed-column table is
# source-modeled from the official plaintext renderer, not a live PVE run.
return ('\nDetails\n=======\nVMID Name Status Time Size Filename\n'
f'100 {name} err 1m 1s 0 B {filename}\n\n'
'Total running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\n'
f'vzdump --all 1 --storage {storage} --mode snapshot\n\n' +
'\n'.join(f'100: 2026-09-29 17:00:00 ERROR: earlier diagnostic {i}' for i in range(12)) +
f'\n100: 2026-09-29 17:00:00 ERROR: {cause}\n\n')
class PrincipalInventoryTests(unittest.TestCase):
def assert_principal(self, raw, cause):
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
for language in LANGUAGES:
for manual in (False, True):
with self.subTest(language=language, manual=manual):
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'},
event.severity, language, manual=manual)
exact = [line for line in result['body'].splitlines()
if line.strip() == cause or line.rstrip().endswith('ERROR: ' + cause)]
self.assertEqual(len(exact), 1)
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
self.assertLessEqual(len(diagnostics), 8)
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
self.assertEqual(result['data']['pve_message'], raw)
self.assertNotIn('raw-host', result['text'])
self.assertIn('alias {rack.location}', result['text'])
self.assertEqual(result['text'].count('ERROR: ' + cause), 1)
def test_late_principal_survives_incidental_guest_storage_and_archive(self):
for name, storage, filename in (('ordinary-web','PBS','null'), ('denied','PBS','null'),
('ordinary-web','denied','null'), ('ordinary-web','PBS','denied.tar')):
with self.subTest(name=name, storage=storage, filename=filename):
self.assert_principal(noisy_report(name,storage,filename), 'denied')
def test_raw_principal_braces_and_markup_survive_once(self):
cause = 'denied {rack.location} <native>'
self.assert_principal(noisy_report('denied', cause=cause), cause)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,192 @@
"""Frozen PVE 9.2 report through inert actual receiver and renderers.
No git history, host-management import, notification send or generated fixture.
"""
import ast
import importlib.util
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
sys.path.insert(0, str(SCRIPTS))
import notification_templates as templates
PVE92 = '''Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
'''
def receiver():
tree = ast.parse((SCRIPTS / 'notification_events.py').read_text())
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'ProxmoxHookWatcher')
names = ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
ns['NotificationEvent'] = Event
for name in names:
node = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), '<inert-webhook>', 'exec'), ns)
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
class Receiver:
_hostname = 'node-a'
_classify_pve = ns['_classify_pve']
_map_severity = staticmethod(ns['_map_severity'])
_backup_outcome = staticmethod(ns['_backup_outcome'])
process_webhook = ns['process_webhook']
def __init__(self): self._queue = Queue()
return Receiver()
def event_for(message, severity='info'):
target = receiver()
target.process_webhook({'fields': {'type': 'vzdump'}, 'severity': severity,
'title': 'Backup', 'message': message})
return target._queue.items[0]
class PVE92Tests(unittest.TestCase):
def test_exact_maintainer_report_is_confirmed(self):
event = event_for(PVE92)
self.assertEqual(event.data['backup_outcome'], 'confirmed')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('Backup complete', result['title'])
self.assertIn('web (100)', result['title'])
self.assertIn('✅', result['body'])
def test_failed_guest_identity_does_not_come_from_timestamp_or_ok_guest(self):
failed = '101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z'
ok = PVE92.splitlines()[3]
for rows in (ok + '\n' + failed, failed + '\n' + ok):
for severity in ('info', 'error'):
with self.subTest(rows=rows, severity=severity):
message = ('INFO: 100 01:01:06 OK\nINFO: Starting Backup of VM 100 (qemu)\n'
+ PVE92.replace(ok, rows))
event = event_for(message, severity)
self.assertEqual(event.data['backup_outcome'], 'failed')
self.assertEqual(event.data.get('vmname'), 'db')
self.assertEqual(event.data.get('vmid'), '101')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('db (101)', result['title'])
self.assertNotIn('web', result['title'])
self.assertNotIn('01:01:06', result['title'])
self.assertIn('❌ CT db (101)', result['body'])
def test_pbs_prefix_is_used_in_parsed_type_and_title(self):
for prefix, kind, label in (('vm', 'qemu', 'VM'), ('ct', 'lxc', 'CT'),
('other', '', 'VM/CT')):
with self.subTest(prefix=prefix):
message = PVE92.replace('vm/100/', prefix + '/100/')
parsed = templates._parse_vzdump_message(message)
self.assertEqual(parsed['vms'][0]['type'], kind)
event = event_for(message)
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn(label + ' web (100)', result['title'])
def test_slovak_uses_normal_locale_resolution_without_source_sentence_overrides(self):
from unittest.mock import patch
data = {'hostname': 'node-a', 'category': 'temperature', 'entity_suffix': '',
'reason': 'old', 'duration': '3d', 'original_severity': 'WARNING',
'vmname': 'web', 'vmid': '100', 'storage': 'PBS', 'size': '1 GiB',
'guests': 3, 'stubs': 0, 'stale_nodes': 0, 'components': 1,
'warnings_block': ''}
slovak = templates._load_runtime_catalog('sk')
english = templates._load_runtime_catalog('en')
for event, field in (('system_restore_completed', 'body'),):
with self.subTest(event=event, field=field):
value = slovak['templates'][event][field]
result = templates.render_template(event, data, 'sk')
self.assertEqual(result[field].strip(), value.format(**data).strip())
self.assertNotIn(value, (SCRIPTS / 'notification_templates.py').read_text())
# Independently updated and absent leaves use the usual provider.
import copy
future = copy.deepcopy(slovak)
future['templates'][event][field] = 'REVIEWED {hostname}'
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
self.assertEqual(templates.render_template(event, data, 'sk')[field], 'REVIEWED node-a')
future['templates'][event].pop(field)
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
self.assertEqual(templates.render_template(event, data, 'sk')[field],
templates.render_template(event, data, 'en')[field])
def test_complete_table_beats_only_truncated_supplemental_log(self):
message = ('INFO: Starting Backup of VM 100 (qemu)\n' + PVE92 +
'\nLogs\n====\nINFO: Log output was too long to be displayed. Please see task log for details.')
self.assertEqual(event_for(message).data['backup_outcome'], 'confirmed')
for message, severity, expected in (
(PVE92.replace('ok ', 'OK '), 'info', 'confirmed'),
(message, 'warning', 'completed_with_warnings'),
(message, 'error', 'failed'),
(message + '\nERROR: archive write failed', 'info', 'failed'),
(message + '\nWARNING: skipped file', 'info', 'completed_with_warnings'),
(PVE92.replace('ok ', 'WARNINGS '), 'info', 'unconfirmed'),
):
with self.subTest(message=message, severity=severity):
self.assertEqual(event_for(message, severity).data['backup_outcome'], expected)
def test_incomplete_and_unrelated_sections_cannot_certify_table(self):
for message in (
PVE92.split('Total running time:')[0],
PVE92.replace('vm/100/2026-09-29T17:00:00Z', ''),
PVE92.replace('\n\nTotal', '\n\nLogs\n======\nTotal'),
PVE92.replace('\n\nTotal', '\n\nUnrelated section\n100 web ok\nTotal'),
PVE92.replace('1m 1s 1 GiB', 'nonsense 1 GiB'),
PVE92.replace('1 GiB vm/', 'garbage vm/'),
PVE92.replace('1m 1s 1 GiB vm/', '1m 1s'),
):
with self.subTest(message=message):
# Complete guest logs cannot rescue a genuinely incomplete table.
message += '\nINFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'
self.assertEqual(event_for(message).data['backup_outcome'], 'unconfirmed')
def test_blank_line_between_rows_does_not_hide_a_failure(self):
message = PVE92.replace('\n\nTotal',
'\n\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
event = event_for(message)
self.assertEqual(event.data['backup_outcome'], 'failed')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('db (101)', result['title'])
self.assertIn('❌ CT db (101)', result['body'])
def test_changed_titles_and_rows_reach_actual_html_email_in_all_locales(self):
import html
from notification_channels import EmailChannel
email = object.__new__(EmailChannel)
email.subject_prefix = '[ProxMenux]'
failed = PVE92.replace('\n\nTotal',
'\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
for message, severity in ((PVE92, 'info'), (failed, 'info'), (failed, 'error')):
with self.subTest(lang=lang, severity=severity, message=message):
event = event_for(message, severity)
result = templates.render_template(event.event_type, event.data, lang)
context = {**event.data, '_event_type': event.event_type,
'_notification_language': lang, '_group': result['group']}
markup = html.unescape(email._format_html(result['title'], result['body'], event.severity, context))
self.assertIn(result['title'], markup)
if event.data['backup_outcome'] == 'failed':
self.assertIn('db (101)', result['title'])
self.assertNotIn('web', result['title'])
self.assertIn('❌ CT db (101)', markup)
status = templates.runtime_message('channels.email.status.failed', lang)
else:
self.assertIn('VM web (100)', result['title'])
self.assertIn('✅ VM web (100)', markup)
status = templates.runtime_message('channels.email.status.completed', lang)
badge = (templates.runtime_message('channels.email.severity.critical', lang)
if event.event_type == 'backup_fail' else status)
self.assertIn('>' + badge.upper() + '</span>', markup)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,24 @@
"""Review regressions at actual consumer seams; no operational imports."""
import unittest
from notification_fixture import receive, LANGUAGES
from notification_final_fixture import deliver
class ReviewCorrectionTests(unittest.TestCase):
def test_subject_equivalent_late_error_is_reserved_before_cap(self):
cause = 'job-end hook denied'
# Frozen native Perl notifier/log-reader output; Rust table source-modeled.
raw = '\nDetails\n=======\nVMID Name Status Time Size Filename \n100 web err 1m 1s 0 B null \n\nTotal running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\nvzdump --all 1 --storage PBS --mode snapshot\n\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 0\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 1\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 2\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 3\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 4\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 5\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 6\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 7\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 8\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 9\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 10\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 11\n100: 2026-09-29 17:00:00 ERROR: job-end hook denied\n\n\n'
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
for language in LANGUAGES:
for manual in (False, True):
with self.subTest(language=language, manual=manual):
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'}, event.severity, language, manual=manual)
self.assertEqual(result['text'].count(cause), 1)
self.assertNotIn('raw-host', result['text'])
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
self.assertLessEqual(len(diagnostics), 8)
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
self.assertEqual(result['data']['pve_message'], raw)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,95 @@
"""Keep the Monitor OCI editor path distinct from coordinated stack recreation."""
import ast
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
CLI = ROOT / 'oci/src/proxmenux_oci/cli.py'
MONITOR = ROOT / 'AppImage/components/virtual-machines.tsx'
WRAPPER = ROOT / 'scripts/oci/manage_instance.sh'
EXTRA_DEVICES = ROOT / 'oci/src/proxmenux_oci/extra_devices.py'
STACK_RECREATION = ROOT / 'oci/src/proxmenux_oci/stack_recreation.py'
def extracted_stack_manager(scope):
source = MENU.read_text(encoding='utf-8').replace(
' from .stack_recreation import modify_stack\n', '')
node = next(item for item in ast.parse(source).body
if isinstance(item, ast.FunctionDef) and item.name == '_manage_stack')
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(MENU), 'exec'), scope)
return scope['_manage_stack']
class MonitorModifyContractTests(TestCase):
def test_monitor_sends_modify_for_every_instance(self):
source = MONITOR.read_text(encoding='utf-8')
self.assertIn('action: "update" | "modify" | "recover"', source)
self.assertIn('action: "modify",', source)
self.assertNotIn('"recreate"', source)
def test_the_editor_has_one_name_for_one_container_and_for_several(self):
menu = MENU.read_text(encoding='utf-8')
self.assertIn("('modify', translate('Modify: edit resources, network, paths and GPU'))", menu)
self.assertIn("('modify', translate('Modify extra paths and devices'))", menu)
self.assertEqual(menu.count("('recreate', translate("), 1)
self.assertIn("('recreate', translate('Recreate every container with its saved configuration'))", menu)
def test_recreate_on_a_single_instance_changes_nothing(self):
node = next(item for item in ast.parse(MENU.read_text(encoding='utf-8')).body
if isinstance(item, ast.FunctionDef) and item.name == 'manage_instance')
row = {'vmid': 101, 'reason': 'matched', 'stack': None, 'pending': False, 'status': 'installed'}
run_lifecycle = Mock()
scope = {'carry_records': lambda *_, **__: {}, 'check_selected': lambda _project, selected: selected,
'translate': lambda text: text, '_run_lifecycle': run_lifecycle, '_manage_stack': Mock()}
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), str(MENU), 'exec'), scope)
ui = Mock()
self.assertFalse(scope['manage_instance'](Path('/inert'), ui, row, action='recreate'))
self.assertEqual(ui.method_calls, [])
run_lifecycle.assert_not_called()
def test_cli_and_monitor_wrapper_accept_the_modify_action(self):
self.assertIn('choices=("update", "modify", "recreate")', CLI.read_text(encoding='utf-8'))
self.assertIn('${ACTION:-} != "modify"', WRAPPER.read_text(encoding='utf-8'))
def test_modify_device_filter_matches_the_device_prompt_contract(self):
caller = STACK_RECREATION.read_text(encoding='utf-8')
device_prompt = EXTRA_DEVICES.read_text(encoding='utf-8')
prompt = next(node for node in ast.parse(device_prompt).body
if isinstance(node, ast.FunctionDef) and node.name == 'ask_extra_devices')
self.assertIn('kinds', [argument.arg for argument in prompt.args.args])
self.assertIn("ask_extra_devices(ui, attached, True, kinds=", caller)
def test_modify_action_opens_the_stack_editor_without_running_lifecycle(self):
primary = {'stack': {'members': [{'vmid': 101}]}}
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda *_: primary
modify_stack = Mock(return_value=True)
run_lifecycle = Mock()
manager = extracted_stack_manager({
'sys': SimpleNamespace(path=[]),
'Path': Path,
'modify_stack': modify_stack,
'_run_lifecycle': run_lifecycle,
})
ui = SimpleNamespace()
with patch.dict(sys.modules, {
'oci_instances': instances,
'oci_stack_replay': ModuleType('oci_stack_replay'),
}):
result = manager(Path('/inert'), ui, {'vmid': 101}, action='modify')
self.assertTrue(result)
modify_stack.assert_called_once_with(Path('/inert'), ui, primary, run_lifecycle)
run_lifecycle.assert_not_called()
if __name__ == '__main__':
import unittest
unittest.main()
@@ -0,0 +1,43 @@
"""Every result the OCI engine reports has a notification the Monitor accepts and words."""
import json
from pathlib import Path
import re
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
EVENTS = [f'oci_{kind}_{result}' for kind in ('update', 'modify', 'recreate') for result in ('completed', 'failed')]
EVENTS += ['oci_watchdog_restarted', 'oci_watchdog_failed']
class OciOperationEvents(TestCase):
def test_the_monitor_accepts_and_words_every_event(self):
route = (ROOT / 'AppImage/scripts/flask_notification_routes.py').read_text()
accepted = route[route.index('_OCI_EVENTS = {'):route.index('@notification_bp.route(\'/api/internal/oci-event\'')]
templates = (ROOT / 'AppImage/scripts/notification_templates.py').read_text()
for event in EVENTS:
self.assertIn(f"'{event}':", accepted, event)
self.assertEqual(len(re.findall(rf"^ '{event}': \{{$", templates, re.MULTILINE)), 1, event)
self.assertEqual(len(re.findall(rf"^ '{event}': +'\\[uU]", templates, re.MULTILINE)), 1, event)
def test_every_language_has_the_label_and_the_message_of_every_event(self):
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
for locale in LOCALES:
catalog = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())
for event in EVENTS:
self.assertTrue(catalog['settings']['notifications']['eventTypes'][event].strip(), f'{locale} {event}')
message = catalog['runtime']['notifications']['templates'][event]
for part in ('title', 'body', 'label'):
self.assertEqual(fields(message[part]), fields(english[event][part]), f'{locale} {event} {part}')
self.assertEqual(message[part].count('\n'), english[event][part].count('\n'), f'{locale} {event} {part}')
def test_changing_an_application_is_reported_as_modified(self):
single = (ROOT / 'oci/remote/oci_update_current.py').read_text()
self.assertIn("kind = 'update' if operation == 'update' else 'modify'", single)
self.assertIn('oci_operation_notice.operation([vmid], kind,', single)
stack = (ROOT / 'oci/remote/oci_stack_modify.py').read_text()
self.assertIn("oci_operation_notice.operation([vmid], 'modify',", stack)
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
self.assertIn('new options', english['oci_modify_completed']['body'])
self.assertNotIn('new options', english['oci_recreate_completed']['body'])
@@ -0,0 +1,235 @@
"""Inert, extracted consumers for Rclone guidance and HedgeDoc metadata."""
import ast
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[3]
INSTALLER = ROOT / 'oci/src/proxmenux_oci/installer.py'
RCLONE = ('Configure the remote in the Rclone Web UI first. Checking its name may start a '
'stopped CT, even in a dry run. Applying the mount restarts the CT and attempts '
'to publish two FUSE views on the host; a dry run does not publish them.')
def extract_function(path, name, **dependencies):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
scope = dict(dependencies)
future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)
exec(compile(ast.fix_missing_locations(ast.Module(body=[future, node], type_ignores=[])),
str(path), 'exec'), scope)
return scope[name]
class RcloneHedgeDocWording(TestCase):
def test_i18n_workflow_runs_for_remote_shell_edits(self):
workflow = (ROOT / '.github/workflows/test-i18n.yml').read_text()
self.assertEqual(workflow.count(" - 'oci/remote/**/*.sh'"), 2)
def test_remote_dry_run_messages_are_extracted_and_translate_with_fallback(self):
import runpy
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
shell = ROOT / 'oci/remote/configure_rclone_mount.sh'
keys = [
'Dry run completed; mount configuration was not applied. The CT was already running.',
'Dry run completed; mount configuration was not applied. The CT was started for the remote check.',
]
extracted = extractor['extract_translate_texts'](ROOT / 'oci/remote')
self.assertTrue(set(keys).issubset(extracted))
ui = (ROOT / 'oci/remote/oci_ui.sh').read_text()
lookup = 'translate() {' + ui.split('translate() {', 1)[1].split('\n}', 1)[0] + '\n}\n'
gate = shell.read_text().split('if [[ $DRY_RUN == 1 ]]; then', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
gate = 'if [[ $DRY_RUN == 1 ]]; then' + gate + '\nfi\n'
with tempfile.TemporaryDirectory() as directory:
cache = Path(directory) / 'locale.json'
for status, expected_key in [('running', keys[0]), ('stopped', keys[1])]:
for translations, expected in [({}, expected_key),
({expected_key: 'MESSAGGIO DI TEST'}, 'MESSAGGIO DI TEST')]:
with self.subTest(status=status, translations=translations):
cache.write_text(json.dumps(translations))
setup = (f'_OCI_LANGUAGE=it\n_OCI_LANG_FILE={cache}\n'
f'STATUS={status}\nDRY_RUN=1\n' + lookup +
'msg_ok() { printf "%s\\n" "$1"; }\n')
result = subprocess.run(['bash', '-c', setup + gate],
text=True, capture_output=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), expected)
def test_remote_dry_run_reports_stopped_ct_start_and_no_mount_publication(self):
script = (ROOT / 'oci/remote/configure_rclone_mount.sh').read_text()
seam = script.split('msg_info "$(translate "Checking the remote...")"', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
# This is the real status/listremotes/dry-run gate, not the root-guarded script.
seam = 'msg_info "$(translate "Checking the remote...")"' + seam + '\nfi\n'
for status, dry_run, starts, post_gate in (
('stopped', '1', 1, False), ('running', '1', 0, False),
('stopped', '0', 1, True), ('running', '0', 0, True)):
with self.subTest(status=status, dry_run=dry_run):
with tempfile.TemporaryDirectory() as directory:
trace = Path(directory) / 'pct.log'
harness = '''
set -Eeuo pipefail
VMID=101 REMOTE_NAME=notes DRY_RUN=%s
TRACE_FILE=%s
pct() {
printf 'pct:%%s\\n' "$*" >>"$TRACE_FILE"
case "$1" in
status) printf 'status: %s\\n' ;;
exec) if [[ "${5:-}" == version ]]; then return 0; else printf 'notes:\\n'; fi ;;
start) return 0 ;;
*) return 97 ;;
esac
}
oci_quiet() { "$@"; }
oci_log() { :; }
sleep() { :; }
translate() { printf '%%s' "$1"; }
msg_info() { printf 'info:%%s\\n' "$1"; }
msg_ok() { printf 'ok:%%s\\n' "$1"; }
die() { printf 'error:%%s\\n' "$1"; exit 1; }
''' % (dry_run, str(trace), status)
result = subprocess.run(['bash', '-c', harness + seam + '\nprintf "POST_DRY_GATE\\n"\n'],
text=True, capture_output=True, timeout=5)
calls = trace.read_text()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(calls.count('pct:start 101'), starts)
self.assertIn('pct:exec 101 -- /usr/local/bin/rclone listremotes', calls)
self.assertEqual('POST_DRY_GATE' in result.stdout, post_gate)
if dry_run == '1':
self.assertIn('mount configuration was not applied', result.stdout)
if starts:
self.assertIn('CT was started for the remote check', result.stdout)
else:
self.assertIn('CT was already running', result.stdout)
self.assertNotIn('pct:stop', calls)
def test_hedgedoc_overlay_survives_index_enrichment(self):
sys.path.insert(0, str(ROOT / 'oci/src'))
try:
from proxmenux_oci.catalog import Catalog
finally:
sys.path.pop(0)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
catalog = Catalog(root, source=Mock())
catalog.apps_dir.mkdir(parents=True)
catalog.overlays_dir.mkdir(parents=True)
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
upstream = 'HedgeDoc gives you access to all your files wherever you are.'
corrected = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
template['catalog_ui']['description']['en_US'] = upstream
template['catalog_ui']['tagline']['en_US'] = upstream
overlay = json.loads((ROOT / 'oci/catalog/overlays/hedgedoc.json').read_text())
self.assertTrue(overlay['catalog_ui']['hidden'])
self.assertEqual(overlay['catalog_ui']['description']['en_US'], corrected)
self.assertEqual(overlay['catalog_ui']['tagline']['en_US'], corrected)
(catalog.overlays_dir / 'hedgedoc.json').write_text(json.dumps(overlay))
catalog._apply_overlay('hedgedoc', template)
self.assertEqual(template['catalog_ui']['description']['en_US'], corrected)
self.assertEqual(template['catalog_ui']['tagline']['en_US'], corrected)
(catalog.apps_dir / 'hedgedoc.json').write_text(json.dumps(template))
payload = {'applications': [{'id': 'hedgedoc', 'description': upstream},
{'id': 'unmodified', 'description': 'Original summary'}]}
catalog._enrich_index_from_templates(payload)
self.assertEqual(payload['applications'][0]['description'], corrected)
self.assertEqual(payload['applications'][1]['description'], 'Original summary')
def test_rclone_guidance_precedes_inputs_and_confirmation(self):
events = []
class UI:
def message(self, text): events.append(('message', text))
def ask(self, label, default=None, required=True):
events.append(('ask', label))
return {'VMID of the Rclone OCI container': '101',
'Exact name of the remote': 'notes',
'Mount name': 'notes'}.get(label, default or '')
def choose(self, label, options, default):
events.append(('choose', label))
return default
def confirm(self, label, default):
events.append(('confirm', label))
return True
schema = {name: {'default': value} for name, value in (
('vfs_cache_mode', 'writes'), ('shared_mount_root_parent', '/mnt/rclone'),
('shared_mount_root', '/mnt/rclone/rw'),
('shared_mount_read_only_root', '/mnt/rclone/ro'))}
schema['vfs_cache_mode']['options'] = ['writes']
template = {'proxmox': {'laboratory_contract': {'configuration_schema': schema}}}
fn = extract_function(INSTALLER, 'build_rclone_mount_deployment',
TerminalUI=UI, DialogUI=UI, translate=lambda text: text,
re=re, InstallError=ValueError, UserCancelled=ValueError)
deployment = fn(template, UI())
self.assertEqual(events[0], ('message', RCLONE))
self.assertEqual(events[1][0], 'ask')
self.assertEqual(events[-1][0], 'confirm')
self.assertEqual(deployment['remote_name'], 'notes')
def test_hedgedoc_detail_uses_corrected_template_and_index(self):
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
item = next(item for item in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
if item['id'] == 'hedgedoc')
self.assertEqual(template['catalog_ui']['tagline']['en_US'], key)
self.assertEqual(template['catalog_ui']['description']['en_US'], key)
self.assertEqual(item['description'], key)
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text',
Any=object)
fn = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
source_text=source, translate=lambda text: text,
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
publisher=lambda _: 'LinuxServer.io',
is_tested=lambda _: False,
_display_architectures=lambda _: 'amd64',
_image_label=lambda text: text,
_recommended_storage=lambda _: 'Container volume')
result = fn(SimpleNamespace(), item, template)
self.assertIn(key, result)
self.assertNotIn('gives you access to all your files', result)
def test_missing_empty_and_synthetic_translation_on_real_consumers(self):
from unittest.mock import Mock
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
scope = {'language': lambda: 'it', '_cache': {}}
lookup = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', **scope)
cache = lookup.__globals__
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
item = next(x for x in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
if x['id'] == 'hedgedoc')
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text')
detail = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
source_text=source, translate=lookup,
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
publisher=lambda _: 'LinuxServer.io', is_tested=lambda _: False,
_display_architectures=lambda _: 'amd64',
_image_label=lambda text: text,
_recommended_storage=lambda _: 'Container volume')
for values, expected_rclone, expected_hedgedoc in (
({}, RCLONE, key),
({RCLONE: '', key: ''}, RCLONE, key),
({RCLONE: 'GUIDA RCLONE', key: 'EDITOR COLLABORATIVO'},
'GUIDA RCLONE', 'EDITOR COLLABORATIVO')):
with self.subTest(values=values):
cache['_cache'] = values
ui = SimpleNamespace(message=Mock(), ask=Mock(side_effect=StopIteration))
rclone = extract_function(INSTALLER, 'build_rclone_mount_deployment',
TerminalUI=object, DialogUI=object, translate=lookup,
re=re, InstallError=ValueError,
UserCancelled=ValueError)
# Stop at the first prompt: no operational path is entered.
with self.assertRaises(StopIteration):
rclone(json.loads((ROOT / 'oci/catalog/apps/rclone.json').read_text()), ui)
ui.message.assert_called_once_with(expected_rclone)
self.assertIn(expected_hedgedoc, detail(SimpleNamespace(), item, template))
def test_extractor_sees_both_new_sources(self):
import runpy
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
keys = extractor['extract_python_texts']([ROOT / 'oci/src'])
self.assertIn(RCLONE, keys)
keys = extractor['extract_catalog_texts']([ROOT / 'oci/catalog'])
self.assertIn('HedgeDoc is a self-hosted collaborative Markdown editor for notes.', keys)
@@ -0,0 +1,223 @@
"""Offline checks of real OCI messages; never import administrative modules."""
import ast
import importlib.util
import json
from pathlib import Path
import re
import runpy
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
REMOVE = ROOT / 'oci/remote/oci_remove.py'
PREVIEW_HOST = 'Host paths found in container configs or saved records (not targeted for removal):'
EMPTY_HOST = 'No host directories found in the available container configs or saved records.'
POST_HOST = 'Host directory listed in saved records (not targeted for removal):'
UPDATE = ('All {count} containers of the application are updated together (main CT: {vmid}). If there are new '
'versions, all images are downloaded and verified, the application is stopped, each container is backed '
'up and replaced with its new image. If the operation fails after a container was changed, the backups are '
'restored. An operation that stops halfway is recovered from the OCI management menu.')
PENDING = ('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack '
'where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.')
KEYS = (PREVIEW_HOST, EMPTY_HOST, POST_HOST, UPDATE, PENDING)
TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
def extracted(path, name, scope):
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]
class InventoryMessages(unittest.TestCase):
def preview(self, configs, records, cache=None, translate=lambda text: text):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
def read(root, vmid):
if vmid not in records:
raise OSError('unreadable record')
return records[vmid]
instances.read = read
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {}, [102, 101])
remover.guest_config = lambda vmid: configs.get(vmid)
remover.host_directories = lambda root, members: cache or []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: None
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
'translate': translate, 're': re}
summary = extracted(MENU, '_removal_summary', scope)
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
return summary(Path('/inert'), 101)
def test_missing_metadata_is_not_reported_as_no_host_usage(self):
text = self.preview({101: None, 102: None}, {})
self.assertIn(EMPTY_HOST, text)
self.assertNotIn('No host directory is used by this application.', text)
def test_discovered_paths_do_not_promise_exhaustive_preservation(self):
text = self.preview({101: b'mp0: /bind/current,mp=/data\n', 102: None}, {}, ['/bind/saved'])
self.assertIn(PREVIEW_HOST + '\n /bind/current\n /bind/saved', text)
self.assertNotIn('Host directories that are kept, with their content:', text)
def test_removal_notice_qualifies_saved_record_source(self):
events = []
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
identity=lambda raw: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'description: owned',
'host_directories': lambda root, members: ['/bind/saved'], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: None,
'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),
'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))),
're': re, 'guest_node': lambda vmid: None,
'remove_host_state': lambda vmid: events.append(('host', vmid)),
'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
remove = extracted(REMOVE, 'remove', scope)
remove(Path('/inert'), 101)
self.assertIn(('info', POST_HOST + ' /bind/saved'), events)
self.assertIn(('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1')), events)
self.assertIn(('log', 101), events)
self.assertIn(('host', 101), events)
scope['translate'] = lambda text: 'Tradotto: ' + text if text == POST_HOST else text
events.clear()
remove(Path('/inert'), 101)
self.assertIn(('info', 'Tradotto: ' + POST_HOST + ' /bind/saved'), events)
class RecoveryMessages(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.journal = Path(self.tmp.name) / 'journal.json'
self.members = [{'vmid': 101, 'deployment': {'mounts': []}}]
self.primary = {'stack': {'members': self.members}}
self.calls = []
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: self.primary
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
'_remove': lambda *args: self.fail('remove must not run'),
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
self.ui = SimpleNamespace(
choose=lambda *args: 'update',
review=lambda message, *args, **kwargs: self.calls.append(('review', message)) or False,
confirm=lambda *args: self.fail('host confirmation must not run'),
message=lambda *args: self.fail('unexpected error'))
def manage(self):
with patch.dict(sys.modules, self.modules):
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
return next(message for kind, message in self.calls if kind == 'review')
def test_update_preview_names_the_stack_and_what_happens(self):
message = self.manage()
self.assertIn(UPDATE.format(count=len(self.members), vmid=101), message)
def test_pending_terminal_and_recovery_failed_states_share_bounded_wording(self):
for phase in ('committed', 'rolled-back', 'recovery-failed'):
with self.subTest(phase=phase):
self.calls.clear()
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
self.assertEqual(self.manage(), PENDING)
self.assertFalse(any(isinstance(item[0], list) for item in self.calls))
def test_pending_confirmation_preserves_recover_command_in_each_state(self):
self.ui.review = lambda message, *args, **kwargs: True
for phase in ('committed', 'recovery-failed'):
with self.subTest(phase=phase):
self.calls.clear()
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
with patch.dict(sys.modules, self.modules):
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
self.assertEqual(self.calls, [
(['python3', '/inert/remote/oci_stack_native.py', '101', '--recover',
'--acknowledge-external-data'], 'Recover OCI stack')])
def test_actual_transaction_distinguishes_terminal_cleanup_from_failed_restore(self):
events = []
scope = {'TERMINAL': {'committed', 'rolled-back'},
'translate': lambda message: message,
'member_tx': SimpleNamespace(log=lambda message: None),
'write': lambda path, state: Path(path).write_text(json.dumps(state)),
'msg_info': lambda message: None, 'msg_ok': lambda message: None,
'member': lambda adapter, vmid: f'CT {vmid}'}
extracted(TRANSACTION, 'save', scope)
recover = extracted(TRANSACTION, 'recover_state', scope)
class Adapter:
def finalize(self, state): events.append('finalize')
def validate(self, plan): events.append('validate')
def restore(self, vmid, backup, txid):
events.append('restore')
raise RuntimeError('restore failed')
def stop(self, vmid): events.append('stop')
plan = {'start_order': [101], 'stop_order': [101]}
terminal = {'phase': 'committed', 'plan': plan}
self.assertIs(recover(self.journal, terminal, Adapter()), terminal)
self.assertEqual(events, ['finalize'])
events.clear()
failed = dict(terminal, phase='recovery-failed', id='tx', running={'101': True},
stop_intent=True, replacement_intent=True, backups={'101': {'archive': 'a'}})
with self.assertRaisesRegex(RuntimeError, 'restore failed'):
recover(self.journal, failed, Adapter())
self.assertEqual(events, ['validate', 'stop', 'restore'])
self.assertEqual(json.loads(self.journal.read_text())['phase'], 'recovery-failed')
def test_new_keys_extract_and_shipped_fallback_and_synthetic_translation(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(KEYS), len(set(KEYS)))
self.assertTrue(set(KEYS) <= set(found), set(KEYS) - set(found))
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for key in KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
setattr(module, '_cache', {})
for key in KEYS:
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
module._language, module._cache = 'it', {key: 'Tradotto: ' + key for key in KEYS}
self.assertEqual(module.translate(EMPTY_HOST), 'Tradotto: ' + EMPTY_HOST)
preview = InventoryMessages().preview({101: None, 102: None}, {}, translate=module.translate)
self.assertIn('Tradotto: ' + EMPTY_HOST, preview)
preview = InventoryMessages().preview({101: b'mp0: /bind,mp=/data'}, {},
translate=module.translate)
self.assertIn('Tradotto: ' + PREVIEW_HOST + '\n /bind', preview)
# Exercise the actual management consumer, not just the provider.
self.scope['translate'] = module.translate
self.assertIn(('Tradotto: ' + UPDATE).format(count=len(self.members), vmid=101), self.manage())
self.journal.write_text(json.dumps({'phase': 'committed', 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
self.calls.clear()
self.assertEqual(self.manage(), 'Tradotto: ' + PENDING)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,379 @@
"""Exercise extracted removal/UI/guard consumers with inert dependencies only."""
import ast
import argparse
from contextlib import nullcontext
import importlib.util
import json
from pathlib import Path
import re
import runpy
import subprocess
import sys
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
REMOVE = ROOT / 'oci/remote/oci_remove.py'
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_FAILURE = 'Could not complete private network release:'
SUCCESS = 'The application was removed'
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
PORT = 'The host-monitor firewall port does not match exactly one TCP port in the container contract'
OPTIONAL = 'A matching managed host firewall rule may also be removed.'
MEMBERS = 'All of them are targeted for removal.'
WHOLE = ('It cannot be removed on its own, because the application would stop '
'working: continuing targets the whole application for removal.')
TARGETS = 'Containers targeted for removal:'
DATA = 'Container data targeted for deletion:'
KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, PORT, OPTIONAL, MEMBERS, WHOLE, TARGETS, DATA)
def extract(path, name, scope):
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]
class RemovalWordings(unittest.TestCase):
def preview(self, bridge='vmbr9', translated=None, firewall=True):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'installation_id': 'owned'}
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {'stack': {}, 'deployment':
{'host_firewall': {'port': 8080}} if firewall else {}}, [102, 101])
remover.guest_config = lambda member: None
remover.host_directories = lambda root, members: []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: bridge
remover.bridge_in_use = lambda bridge, removed: False
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {}
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': translated or (lambda value: value), 're': re}
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
def test_preview_conditions_network_and_discloses_optional_firewall(self):
preview = self.preview()
self.assertIn(PREVIEW + ' vmbr9', preview)
self.assertIn(OPTIONAL, preview)
self.assertIn(MEMBERS, preview)
self.assertIn(TARGETS, preview)
self.assertIn(DATA, preview)
self.assertNotIn('Private network of the application that is released:', preview)
self.assertNotIn(PREVIEW, self.preview(bridge=None))
self.assertNotIn(OPTIONAL, self.preview(firewall=False))
def test_member_preview_does_not_promise_whole_stack_removed(self):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'installation_id': 'owned'}
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {'stack': {}}, [102, 101])
remover.guest_config = lambda member: None
remover.host_directories = lambda root, members: []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: None
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {}
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': lambda value: value, 're': re}
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
self.assertIn(WHOLE, preview)
def test_cancellation_preserves_lifecycle_boundary_and_warns_precisely(self):
calls = []
ui = SimpleNamespace(review=lambda message, title, **kw: calls.append((message, kw)) or False,
message=lambda *args: self.fail('unexpected error'))
scope = {'_removal_summary': lambda project, vmid: 'Preview',
'_run_lifecycle': lambda *args: self.fail('must not run'),
'translate': lambda value: value, 'sys': SimpleNamespace(executable='python3'),
'Path': Path, 'OSError': OSError, 'ValueError': ValueError, 'KeyError': KeyError}
self.assertFalse(extract(MENU, '_remove', scope)(Path('/inert'), ui, 101))
self.assertEqual(calls, [('Preview', {'question': CONFIRM, 'default': False})])
def test_shared_bridge_and_skipped_member_are_not_reported_removed(self):
events = []
records = {102: {'installation_id': 'owned'}, 101: {'installation_id': 'owned'}}
scope = {'members_of': lambda root, vmid: (101, records[101], [102, 101]),
'instances': SimpleNamespace(read=lambda root, vmid: records[vmid],
identity=lambda raw: 'reassigned' if raw == b'reassigned' else 'owned',
location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'reassigned' if vmid == 102 else b'owned',
'host_directories': lambda root, members: [], 'granted_directories': lambda *args: [], 'private_bridge': lambda primary: 'vmbr9',
'bridge_in_use': lambda bridge, removed: True,
'release_bridge': lambda bridge: self.fail('shared bridge release'),
'remove_owned_host_firewall': lambda record: None,
'run': lambda *args: events.append(('run', args)),
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'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: 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: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
remove = extract(REMOVE, 'remove', scope)
for skipped_config in (b'reassigned', None):
with self.subTest(skipped_config=skipped_config):
scope['guest_config'] = lambda vmid: skipped_config if vmid == 102 else b'owned'
events.clear()
remove(Path('/inert'), 101)
self.assertEqual([x for x in events if x[0] == 'run'],
[('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1'))])
self.assertFalse(any(x == ('ok', 'The application was removed') for x in events))
self.assertTrue(any('still used' in x[1] for x in events if x[0] == 'info'))
self.assertTrue(any('no longer exists' in x[1] if skipped_config is None
else 'belongs to another container' in x[1]
for x in events if x[0] == 'warn'))
# The actual main() success line is independently guarded by the expected neutral literal.
tree = ast.parse(REMOVE.read_text())
main = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'main')
self.assertIn(RESULT, [n.value for n in ast.walk(main) if isinstance(n, ast.Constant) and isinstance(n.value, str)])
def test_actual_main_success_is_completion_not_all_members_removed(self):
events = self.lifecycle_events(missing=True)
self.assertIn(('warn', 'The container no longer exists: CT 102'), events)
self.assertEqual(events[-1], ('ok', RESULT))
def lifecycle_events(self, *, missing=False, reassigned=False, bridge='none',
ip_rc=0, pvesh_rc=0, ip_error=False, firewall_failure=False,
firewall_success=False, kept=False):
events, commands = [], []
self.last_commands = commands
record = {'installation_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}
if firewall_failure or firewall_success:
record['deployment'] = {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}
records = {101: record, 102: {'installation_id': 'owned'}}
def fake_command(args, **kwargs):
commands.append(tuple(args))
if ip_error and args[:2] == ['ip', 'link']:
raise OSError('ip unavailable')
if args[:2] == ['pvesh', 'get']:
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + record['installation_id'],
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
'action': 'ACCEPT', 'pos': 2}]), returncode=0)
if args[:2] == ['pvesh', 'delete'] and '/firewall/' in args[2] and firewall_failure:
raise subprocess.CalledProcessError(1, args)
return SimpleNamespace(returncode=ip_rc if args[:2] == ['ip', 'link'] else
pvesh_rc if args[:2] == ['pvesh', 'delete'] else 0)
fake_subprocess = SimpleNamespace(run=fake_command, CalledProcessError=subprocess.CalledProcessError)
scope = {'members_of': lambda root, vmid: (101, record, [102, 101]),
'instances': SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
read=lambda root, vmid: records[vmid],
identity=lambda cfg: record['installation_id'] if cfg == b'primary' else
'other' if cfg == b'reassigned' else 'owned',
location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: (None if missing else b'reassigned' if reassigned else b'owned')
if vmid == 102 else b'primary',
'host_directories': lambda *args: ['/retained'] if kept else [], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: 'vmbr9' if bridge != 'none' else None,
'bridge_in_use': lambda *args: bridge == 'shared',
'run': lambda *args: commands.append(args), 'subprocess': fake_subprocess,
'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json, 're': re,
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'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: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text)),
'msg_error': lambda text: events.append(('error', text)),
'argparse': argparse, 'os': SimpleNamespace(geteuid=lambda: 0),
'sys': SimpleNamespace(argv=['oci_remove.py', '101'])}
scope['release_bridge'] = extract(REMOVE, 'release_bridge', scope)
scope['remove_owned_host_firewall'] = extract(REMOVE, 'remove_owned_host_firewall', scope)
scope['remove'] = extract(REMOVE, 'remove', scope)
with patch.object(sys, 'argv', ['oci_remove.py', '101']):
self.assertEqual(extract(REMOVE, 'main', scope)(), 0)
return events
def test_clean_removal_has_clear_success(self):
events = self.lifecycle_events()
self.assertEqual(events[-1], ('ok', 'The application was removed'))
self.assertFalse(any(kind == 'warn' for kind, _ in events))
def test_skipped_identity_and_kept_bridge_have_partial_result(self):
events = self.lifecycle_events(reassigned=True, bridge='shared')
self.assertIn(('warn', 'The VMID belongs to another container now and is not touched: CT 102'), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_shared_bridge_intentionally_kept_does_not_taint_clean_removal(self):
events = self.lifecycle_events(bridge='shared')
self.assertTrue(any('still used' in text for kind, text in events if kind == 'info'))
self.assertEqual(events[-1], ('ok', 'The application was removed'))
def test_failed_bridge_commands_have_partial_result(self):
for ip_rc, pvesh_rc in ((1, 0), (0, 1), (1, 1)):
with self.subTest(ip_rc=ip_rc, pvesh_rc=pvesh_rc):
events = self.lifecycle_events(bridge='private', ip_rc=ip_rc, pvesh_rc=pvesh_rc)
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
for kind, text in events), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_successful_bridge_release_and_retained_paths_are_clean(self):
events = self.lifecycle_events(bridge='private', kept=True)
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
self.assertTrue(any(kind == 'info' and '/retained' in text for kind, text in events))
self.assertFalse(any(kind == 'warn' for kind, _ in events))
self.assertEqual(events[-1], ('ok', SUCCESS))
def test_bridge_exception_is_reported_but_cleanup_continues(self):
events = self.lifecycle_events(bridge='private', ip_error=True)
self.assertEqual([cmd[:2] for cmd in self.last_commands],
[('pct', 'stop'), ('pct', 'destroy'), ('pct', 'stop'),
('pct', 'destroy'), ('ip', 'link'), ('pvesh', 'delete')])
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
for kind, text in events), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_successful_firewall_delete_is_clean(self):
events = self.lifecycle_events(firewall_success=True)
self.assertTrue(any('Host firewall rule removed:' in text for kind, text in events if kind == 'ok'))
self.assertEqual(events[-1], ('ok', SUCCESS))
def test_firewall_delete_failure_has_partial_result(self):
events = self.lifecycle_events(firewall_failure=True)
self.assertIn(('warn', FIREWALL), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_ignored_bridge_command_failures_do_not_claim_release(self):
events = []
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
identity=lambda cfg: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'owned', 'host_directories': lambda *args: [], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: 'vmbr9', 'bridge_in_use': lambda *args: False,
'release_bridge': lambda bridge: events.append(('attempt', bridge)),
'remove_owned_host_firewall': lambda record: None, 'run': lambda *args: None,
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'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: 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}
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)
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
events = []
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': subprocess, 'translate': lambda text: text,
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
{'installation_id': 'invalid', 'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
self.assertFalse(result)
self.assertEqual(events, [('warn', FIREWALL)])
def test_ambiguous_firewall_match_warns_without_deleting_other_rules(self):
events, commands = [], []
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
rule = {'comment': 'ProxMenux OCI firewall ' + installation_id, 'dport': '8080',
'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in', 'action': 'ACCEPT', 'pos': 2}
def fake_run(args, **kwargs):
commands.append(args)
return SimpleNamespace(stdout=json.dumps([rule, {**rule, 'pos': 3}]))
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
{'installation_id': installation_id,
'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
self.assertFalse(result)
self.assertEqual(events, [('warn', FIREWALL)])
self.assertEqual([command[1] for command in commands], ['get'])
def test_firewall_delete_exception_has_unknown_outcome_not_unchanged_rule(self):
events = []
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
commands = []
def fake_run(args, **kwargs):
commands.append(args)
if args[1] == 'get':
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + installation_id,
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
'action': 'ACCEPT', 'pos': 2}]))
raise subprocess.CalledProcessError(1, args)
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text)), 'OSError': OSError, 'ValueError': ValueError}
extract(REMOVE, 'remove_owned_host_firewall', scope)({'installation_id': installation_id,
'deployment': {'host_firewall': {'port': 8080, 'source': '192.0.2.0/24'}}})
self.assertEqual(commands[-1][1], 'delete')
self.assertEqual(events, [('warn', FIREWALL)])
def test_extractor_and_runtime_lookup_fallback_and_synthetic_translation(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = set(generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote']))
shell_keys = set(generator['extract_translate_texts'](ROOT / 'oci/remote'))
self.assertTrue(set(KEYS) - {PORT} <= found, (set(KEYS) - {PORT}) - found)
self.assertIn(PORT, shell_keys)
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
module._language, module._cache = path.stem, cache
for key in KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key)
module._cache = {}
for key in KEYS:
self.assertEqual(module.translate(key), key)
module._language, module._cache = 'it', {key: 'IT: ' + key for key in KEYS}
self.assertIn('IT: ' + PREVIEW, self.preview(translated=module.translate))
self.assertIn('IT: ' + OPTIONAL, self.preview(translated=module.translate))
def test_actual_shell_guard_fails_for_missing_tcp_contract_not_web_role(self):
source = INSTALL.read_text()
function = source[source.index('validate_host_monitor_firewall() {'):source.index('\napply_host_monitor_firewall() {')]
script = '''set -eu
translate() { printf '%s' "$1"; }
die() { printf '%s' "$1" >&2; exit 7; }
jq() {
case "$*" in
*'.host_firewall == null'*) printf 'false\\n' ;;
*'.host_firewall | type'*) printf 'true\\n' ;;
*'.host_firewall.bridge'*) printf 'vmbr0\\n' ;;
*'.host_firewall.source'*) printf '192.0.2.0/24\\n' ;;
*'.host_firewall.port'*) printf '8080\\n' ;;
*'.proxmox.installer_profile.host_monitor_firewall.web_port'*) printf '8080\\n' ;;
*'.container_contract.ports'*) printf '0\\n' ;;
*) exit 99 ;;
esac
}
ip() { printf '2: vmbr0 inet 192.0.2.1/24 scope global vmbr0\\n'; }
BRIDGE=vmbr0 HOST_MONITOR=netdata DEPLOYMENT_FILE=/inert/deployment TEMPLATE_FILE=/inert/template
''' + function + '\nvalidate_host_monitor_firewall\n'
result = subprocess.run(['bash', '-c', script], text=True, capture_output=True, check=False)
self.assertEqual(result.returncode, 7, result.stderr)
self.assertEqual(result.stderr, PORT)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,124 @@
"""Exercise the real OCI preview/import functions without importing host-management modules."""
import ast
import json
from pathlib import Path
import runpy
import unittest
from typing import Any
import yaml
ROOT = Path(__file__).resolve().parents[3]
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
ROWS = {
'It needs a privileged container, which is not isolated from the host.':
'This profile requires a privileged LXC, which reduces isolation from the host.',
'It asks for capabilities or a relaxed confinement profile.':
'A relaxed AppArmor or seccomp profile is requested; this may be optional.',
'The image expects files that are given to it one by one:':
'These container mount paths have an extension-like suffix:',
'ProxMenux attaches directories, not single files, so this image cannot be installed yet.':
'This import rejects these paths without checking whether they are files or directories.',
}
class ConversionError(Exception):
pass
def extracted(name, **deps):
node = next(n for n in ast.parse(CUSTOM.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
scope = {'Any': Any, 'Path': Path, 'yaml': yaml, 'ConversionError': ConversionError,
'translate': lambda text: text}
scope.update(deps)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(CUSTOM), 'exec'), scope)
return scope[name]
class SecurityMountWording(unittest.TestCase):
def test_security_warnings_match_their_guards_without_claiming_capabilities(self):
describe = extracted('describe', blocker_text=str)
template = {'container_contract': {'image': {'reference': 'test:latest'},
'volumes': [], 'ports': [], 'environment': []},
'proxmox': {'security_profile': {}}}
for profile, present in (
({}, ()),
({'optional_privileged_lxc': True}, ()),
({'requires_privileged_lxc': True}, (ROWS['It needs a privileged container, which is not isolated from the host.'],)),
({'requires_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
({'source_requests_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
({'requires_privileged_lxc': True, 'source_requests_relaxed_confinement': True},
(ROWS['It needs a privileged container, which is not isolated from the host.'],
ROWS['It asks for capabilities or a relaxed confinement profile.'])),
):
with self.subTest(profile=profile):
template['proxmox']['security_profile'] = profile
text = describe(template)
for warning in (ROWS['It needs a privileged container, which is not isolated from the host.'],
ROWS['It asks for capabilities or a relaxed confinement profile.']):
if warning in present:
self.assertIn(' ' + warning, text)
else:
self.assertNotIn(warning, text)
template['proxmox']['security_profile'] = {}
template['proxmox']['installer_profile'] = {'security': {'required_capabilities': ['NET_ADMIN']}}
text = describe(template)
self.assertNotIn(ROWS['It asks for capabilities or a relaxed confinement profile.'], text)
def test_suffix_hit_rejects_dotted_directory_without_claiming_file_type(self):
fn = extracted('template_from_compose',
_services=lambda d: d['services'], _check_mounts=lambda s: None,
normalize_app_id=lambda s: s, _published_port=lambda s: None,
_dump=lambda s: '', _keep_reference=lambda *a: None,
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
convert_casaos_compose=lambda *args: {'container_contract': {
'image': {'reference': 'example:latest'},
'volumes': [{'container_path': '/srv/data.v2', 'installation_choice': ['managed-volume', 'host-bind']}]},
'proxmox': {}})
with self.assertRaises(ConversionError) as caught:
fn('services:\n app:\n image: example:latest\n')
self.assertEqual(str(caught.exception),
ROWS['The image expects files that are given to it one by one:'] + ' /srv/data.v2. ' +
ROWS['ProxMenux attaches directories, not single files, so this image cannot be installed yet.'])
def test_suffix_free_path_still_accepted(self):
fn = extracted('template_from_compose',
_services=lambda d: d['services'], _check_mounts=lambda s: None,
normalize_app_id=lambda s: s, _published_port=lambda s: None,
_dump=lambda s: '', _keep_reference=lambda *a: None,
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
convert_casaos_compose=lambda *args: {'container_contract': {
'image': {'reference': 'example:latest'},
'volumes': [{'container_path': '/srv/config', 'installation_choice': ['managed-volume', 'host-bind']}]},
'proxmox': {}})
self.assertEqual(fn('services:\n app:\n image: example:latest\n')['container_contract']['volumes'][0]['container_path'], '/srv/config')
def test_all_four_keys_extract_and_fallback_in_seven_shipped_locales(self):
found = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(ROWS), 4)
self.assertTrue(set(ROWS.values()) <= set(found), set(ROWS.values()) - set(found))
import importlib.util
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for new in ROWS.values():
self.assertEqual(module.translate(new), cache.get(new) or new, (path.name, new))
setattr(module, '_cache', {})
for new in ROWS.values():
self.assertEqual(module.translate(new), new, (path.name, new, 'synthetic missing'))
module._language, module._cache = 'it', {new: 'IT: ' + new for new in ROWS.values()}
self.assertIn('IT: ' + ROWS['It needs a privileged container, which is not isolated from the host.'],
extracted('describe', translate=module.translate, blocker_text=str)({
'container_contract': {'image': {'reference': 'test:latest'}, 'volumes': [], 'ports': [], 'environment': []},
'proxmox': {'security_profile': {'requires_privileged_lxc': True}}}))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,152 @@
"""Inert extracted OCI selection/setup consumers; no admin module imports or scripts sourced."""
import ast
import importlib.util
import json
import tempfile
from pathlib import Path
import subprocess
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
OLD_EMPTY = 'No OCI instances are registered.'
EMPTY = 'No registered OCI containers are available for selection on this host.'
OLD_REPLAY = 'This stack requires replaying specific rootfs adaptations. Coordinated updates are not yet enabled for it.'
NO_MEMBERS = 'This stack has no saved members to update.'
REPLAY = 'This stack needs rootfs adaptations that coordinated updates cannot replay yet.'
HTTP = 'The application did not pass its HTTP check:'
def extracted(name, **dependencies):
node = next(n for n in ast.parse(MENU.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
# Drop only infrastructure imports, retaining all selection, guard, and return logic.
node.body = [n for n in node.body if not (isinstance(n, ast.Import) and
any(a.name == 'oci_instances' or a.name == 'oci_stack_replay' for a in n.names))]
for n in ast.walk(node):
if isinstance(n, (ast.If, ast.With)):
n.body = [x for x in n.body if not (isinstance(x, ast.Import) and
any(a.name in ('oci_instances', 'oci_stack_replay') for a in x.names))]
scope = dict(dependencies)
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(MENU), 'exec'), scope)
return scope[name]
class SelectionSetupWording(TestCase):
def test_selector_empty_explains_availability_not_registry_absence(self):
ui = SimpleNamespace(message=Mock(), choose=Mock())
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
_clean_orphans=lambda project: None,
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
carry_records=lambda project, **options: {},
translate=lambda s: s)
fn(Path('/fixture'), ui)
ui.message.assert_called_once_with(EMPTY, 'OCI management')
ui.choose.assert_not_called()
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
class Entry:
name = '101'
class Lock:
def __enter__(self): return None
def __exit__(self, *_): return False
fake = SimpleNamespace(ROOT=SimpleNamespace(iterdir=lambda: [Entry()]),
locked=lambda _: Lock(), has_contract=lambda *args: True,
guest_exists=lambda _: False, read=Mock())
fn = extracted('saved_inventory', sys=SimpleNamespace(path=[]),
instances=fake, public_row=Mock())
self.assertEqual(fn(Path('/fixture')), [])
fake.read.assert_not_called()
def test_stack_without_members_is_not_diagnosed_as_replay_failure(self):
self._stack({}, NO_MEMBERS).choose.assert_not_called()
def test_stack_unsupported_replay_retains_distinct_verdict(self):
# It cannot be updated, but it can still be changed or removed.
ui = self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
ui.choose.assert_called_once()
self.assertEqual([tag for tag, _ in ui.choose.call_args.args[1]], ['modify', 'watchdog', 'remove'])
def _stack(self, stack, expected):
record = {'stack': stack}
fake = SimpleNamespace(ROOT=Path('/fixture'), read=lambda *args: record)
replay = SimpleNamespace(**{n + '_menu_ready': lambda _: False
for n in ('nextcloud', 'paperless', 'tandoor', 'immich')})
ui = SimpleNamespace(message=Mock(), choose=Mock(return_value=None), review=Mock())
fn = extracted('_manage_stack', sys=SimpleNamespace(path=[]), instances=fake,
oci_stack_replay=replay, translate=lambda s: s)
self.assertFalse(fn(Path('/fixture'), ui, {'vmid': 101}))
ui.message.assert_called_once_with(expected, 'OCI stack management')
ui.review.assert_not_called()
return ui
def test_actual_http_failure_seams_preserve_app_and_log_redirect(self):
for app, port in (('paperless', ':8000'), ('tandoor', '')):
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"'))
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
# Actual Bash failure expression, never source the parent installer.
harness = '''set -u
APPLICATION_LAN_IP=192.0.2.1
APPLICATION_ID=101
OCI_LOG=/dev/null
curl() { return 7; }
translate() { printf '%s' "$1"; }
print_first_boot_diagnostics() { :; }
die() { printf 'ERROR:%s\\n' "$1"; }
''' + command + '\n'
run = subprocess.run(['bash', '-c', harness], text=True,
capture_output=True, timeout=3)
self.assertEqual(run.returncode, 0, run.stderr)
self.assertEqual(run.stdout.strip(),
f'ERROR:{HTTP} {"Paperless-ngx" if app == "paperless" else "Tandoor"}')
self.assertIn('2>>"$OCI_LOG"', command)
def test_nextcloud_explicit_setup_guard_kept(self):
source = (ROOT / 'oci/remote/install_nextcloud_stack.sh').read_text()
self.assertIn("jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false'", source)
self.assertIn('die "$(translate "The application did not complete its initial setup:") Nextcloud"', source)
def test_shell_lookup_shipped_missing_and_synthetic_translation(self):
source = (ROOT / 'oci/remote/oci_ui.sh').read_text()
start = source.index('translate() {')
fn = source[start:source.index('\n}', start) + 2]
with tempfile.TemporaryDirectory() as tmp:
missing = Path(tmp) / 'missing.json'
missing.write_text('{}')
translated = Path(tmp) / 'translated.json'
translated.write_text(json.dumps({HTTP: 'VERIFICA HTTP FALLITA:'}))
for path in [*sorted((ROOT / 'lang').glob('*.json')), missing, translated]:
cache = json.loads(path.read_text())
bash = '_OCI_LANGUAGE=it\n_OCI_LANG_FILE="$1"\n' + fn + '\ntranslate "$2"\n'
result = subprocess.run(['bash', '-c', bash, 'fixture', str(path), HTTP],
text=True, capture_output=True, timeout=3)
self.assertEqual(result.returncode, 0, (path, result.stderr))
self.assertEqual(result.stdout, cache.get(HTTP) or HTTP, path)
def test_extraction_and_real_lookup_all_locales(self):
import runpy
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted_keys = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
for key in (EMPTY, NO_MEMBERS, REPLAY):
self.assertIn(key, extracted_keys)
shell_keys = generator['extract_translate_texts'](ROOT / 'oci/remote')
self.assertIn(HTTP, shell_keys)
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
module._language, module._cache = path.stem, cache
for key in (EMPTY, NO_MEMBERS, REPLAY, HTTP):
self.assertEqual(module.translate(key), cache.get(key) or key)
module._cache = {}
self.assertEqual(module.translate(EMPTY), EMPTY)
module._cache = {EMPTY: 'LOCALIZED SELECTOR'}
self.assertEqual(module.translate(EMPTY), 'LOCALIZED SELECTOR')
@@ -0,0 +1,193 @@
"""Inert source-as-key checks for OCI update and recreation; no host modules imported."""
import ast
from contextlib import nullcontext
import copy
import json
from pathlib import Path
import re
import runpy
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
RECREATE = ROOT / 'oci/src/proxmenux_oci/recreation.py'
MANAGE = ROOT / 'oci/src/proxmenux_oci/management.py'
UPDATE = ROOT / 'oci/remote/oci_update_current.py'
OLD_CONFIRM = 'Apply the options from the current catalog template? Your data and configuration are kept.'
CONFIRM = ('Apply options from the current catalog template? New required paths and settings may be requested. '
'Review the resulting configuration before recreating the CT.')
OLD_GUARD = 'The current template changes the image or identity; an explicit migration is required'
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with '
'the new image to update the container. If the update fails after the container was changed, the backup '
'is restored. An update that stops halfway is recovered from the OCI management menu.')
def extracted(path, name, scope):
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]
class RecreationWording(unittest.TestCase):
def refresh(self, mutate, proceed=True):
old = {'id': 'app-old', 'container_contract': {'image': {'repository': 'example/app', 'reference': 'example/app:v1'},
'volumes': [], 'environment': []},
'proxmox': {'installer_profile': {}}, 'catalog_ui': {'title': 'old'}}
latest = copy.deepcopy(old)
mutate(latest)
candidate = {'template': old, 'deployment': {'mounts': [], 'environment': [], 'security': {}}}
events = []
catalog_module = ModuleType('proxmenux_oci.catalog')
class Catalog:
def __init__(self, root):
pass
def load_index(self):
return {'applications': [{'template_id': old['id'], 'id': 'app'}]}
def compose(self, name):
return latest
catalog_module.Catalog = Catalog
ui = SimpleNamespace(confirm=lambda message, default: events.append(('confirm', message, default)) or proceed,
info=lambda message: events.append(('info', message)))
scope = {'__package__': 'proxmenux_oci', '__file__': str(RECREATE), 'Path': Path, 're': re,
'translate': lambda text: text, 'copy': copy}
with patch.dict(sys.modules, {'proxmenux_oci.catalog': catalog_module}):
try:
extracted(RECREATE, 'refresh_template', scope)(candidate, ui)
except ValueError as error:
events.append(('error', str(error)))
return candidate, events
def test_confirmation_describes_followup_without_preservation_guarantee(self):
candidate, events = self.refresh(lambda latest: latest['catalog_ui'].update(title='new'), proceed=False)
self.assertEqual(events, [('confirm', CONFIRM, True)])
self.assertEqual(candidate['template']['catalog_ui']['title'], 'old')
self.assertNotIn(OLD_CONFIRM, str(events))
def test_guard_names_exact_rejected_identity_and_repository_changes(self):
for change in (lambda latest: latest.update(id='app-new'),
lambda latest: latest['container_contract']['image'].update(repository='other/app')):
with self.subTest(change=change):
candidate, events = self.refresh(change)
self.assertEqual(events, [('confirm', CONFIRM, True), ('error', GUARD)])
self.assertEqual(candidate['template']['id'], 'app-old')
def test_same_repository_new_tag_does_not_trigger_migration_guard(self):
candidate, events = self.refresh(lambda latest: latest['container_contract']['image'].update(reference='example/app:v2'))
self.assertEqual(events, [('confirm', CONFIRM, True)])
self.assertEqual(candidate['template']['container_contract']['image']['reference'], 'example/app:v2')
class UpdateWording(unittest.TestCase):
def test_actual_native_backup_verification_succeeds_or_fails_before_replacement(self):
transaction = ROOT / 'oci/remote/oci_instance_transaction.py'
for failures in (1, 2):
with self.subTest(failed_integrity_checks=failures), tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
archive = directory / 'vzdump-lxc-101.tar.zst'
calls = []
def inert_run(*args):
calls.append(args)
if args[0] == 'vzdump':
archive.write_bytes(b'inert fixture')
elif args[0] == 'zstd' and sum(call[0] == 'zstd' for call in calls) <= failures:
raise RuntimeError('invalid backup')
scope = {'run': inert_run, 'log': lambda text: None,
'msg_warn': lambda text: None, 'translate': lambda text: text}
backup = extracted(transaction, 'verified_backup', scope)
if failures == 2:
with self.assertRaisesRegex(RuntimeError, 'invalid backup'):
backup(101, directory, 'zstd', 'missing backup')
else:
self.assertEqual(backup(101, directory, 'zstd', 'missing backup'), archive)
self.assertEqual([call[0] for call in calls], ['vzdump', 'zstd', 'vzdump', 'zstd'])
self.assertTrue(all(call[call.index('--mode') + 1] == 'stop'
for call in calls if call[0] == 'vzdump'))
apply_source = transaction.read_text()
apply_node = next(n for n in ast.parse(apply_source).body
if isinstance(n, ast.FunctionDef) and n.name == 'apply')
calls = [(n.lineno, n.func.id) for n in ast.walk(apply_node) if isinstance(n, ast.Call)
and isinstance(n.func, ast.Name) and n.func.id in ('verified_backup', 'checkpoint', 'install_candidate')]
backup_line = next(line for line, name in calls if name == 'verified_backup')
ready_line = next(line for line, name in calls if name == 'checkpoint' and line > backup_line)
install_line = next(line for line, name in calls if name == 'install_candidate')
self.assertLess(backup_line, ready_line)
self.assertLess(ready_line, install_line)
def test_preview_blocks_lifecycle_when_cancelled_and_keeps_command_on_accept(self):
events = []
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'status': 'installed'}
instances.command = lambda *args: b'description: owned'
reconcile = ModuleType('oci_instance_reconcile')
reconcile.propose = lambda *args: None
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
'translate': lambda text: text, 'check_selected': lambda project, row: row,
'images': SimpleNamespace(offer_removal=lambda *args: None),
'carry_records': lambda project, **options: {},
'_work_backup_ready': lambda project, ui, vmid: True,
'_run_lifecycle': lambda command, title: events.append(('run', command, title)) or True}
row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
decision = False
def review(message, title, **kwargs):
events.append(('review', message, title, kwargs))
return decision
ui = SimpleNamespace(review=review, unattended=True)
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_instance_reconcile': reconcile}):
manage = extracted(MANAGE, 'manage_instance', scope)
self.assertFalse(manage(ROOT / 'oci', ui, row, action='update'))
self.assertEqual(events, [('review', PREVIEW, 'Update OCI', {'question': 'Update now?', 'default': True})])
decision = True
self.assertTrue(manage(ROOT / 'oci', ui, row, action='update'))
self.assertEqual(events[-1], ('run', ['python3', str(ROOT / 'oci/remote/oci_update_current.py'), '101',
'--acknowledge-external-data'], 'Update OCI'))
self.assertNotIn(OLD_PREVIEW, str(events))
def test_noop_image_digest_returns_before_any_backup_or_apply(self):
events = []
record = {'status': 'installed', 'observed': {'image': {'manifest_digest': 'sha256:current'}}}
instances = SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
read=lambda root, vmid: record, command=lambda *args: b'arch: amd64')
transaction = SimpleNamespace(candidate_contract=lambda *args: {'deployment': {}},
external_changes=lambda *args: {}, preflight=lambda *args: None,
require_backup_space=lambda *args: self.fail('backup space checked on no-op'),
apply=lambda *args, **kwargs: self.fail('apply on no-op'))
scope = {'instances': instances, 'transaction': transaction,
'translate': lambda text: text,
'msg_info': lambda text: events.append(text), 'msg_ok': lambda text: events.append(text),
'resolve_archive': lambda desired, config, current, check: (None, current),
'kept_settings': lambda *args: self.fail('kept settings on no-op')}
extracted(UPDATE, 'update', scope)(101, keep_backup='not-a-real-storage')
self.assertIn('The image is already up to date; nothing was changed.', events)
def test_new_keys_extracted_and_shipped_and_missing_locale_fallback(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
for key in (CONFIRM, GUARD, PREVIEW):
self.assertIn(key, found)
scope = {'json': json, 'Path': Path, 'BASE_DIR': ROOT,
'_language': 'it', '_cache': None}
lookup = extracted(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', scope)
scope['language'] = lambda: scope['_language']
for path in sorted((ROOT / 'lang').glob('*.json')):
scope['_language'] = path.stem
scope['_cache'] = None
values = json.loads(path.read_text())
for key in (CONFIRM, GUARD, PREVIEW):
self.assertEqual(lookup(key), values.get(key) or key, (path.name, key))
scope['_language'] = 'it'
for key in (CONFIRM, GUARD, PREVIEW):
scope['_cache'] = {}
self.assertEqual(lookup(key), key, ('missing', key))
scope['_cache'] = {key: 'LOCALIZED: ' + key}
self.assertEqual(lookup(key), 'LOCALIZED: ' + key, ('synthetic', key))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,231 @@
"""OCI validation record: generated list, record checks, credited authors and the report bot."""
import datetime
import importlib.util
import json
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location("oci_validation", ROOT / ".github/scripts/oci_validation.py")
validation = importlib.util.module_from_spec(spec)
spec.loader.exec_module(validation)
TODAY = datetime.date(2026, 9, 28)
REPORT = "https://github.com/MacRimi/ProxMenux/issues/400"
INDEX = {"applications": [
{"id": "glances", "title": "Glances", "category_label": "Monitoring", "automatic_install_candidate": True},
{"id": "2fauth", "title": "2FAuth", "category_label": "Security", "automatic_install_candidate": True},
{"id": "legacy", "title": "Legacy | App", "category_label": "Other", "automatic_install_candidate": False},
{"id": "hidden", "title": "Hidden", "hidden": True},
]}
def form(**answers):
fields = {"Application": "Glances", "Image version": "4.3", "Image digest": "2026-09-06 · B1F339CF",
"Proxmox VE version": "9.1", "Install mode": "Default configuration",
"Data storage": "Volume on Proxmox storage", "Network": "DHCP", "GPU": "No GPU",
"Result": "Works", "Notes": "_No response_"}
fields.update(answers)
return "\n\n".join(f"### {label}\n\n{value}" for label, value in fields.items())
def event(body=None, labels=("oci-validation", "validated"), user="Tester-1"):
return {"issue": {"number": 400, "html_url": REPORT, "user": {"login": user}, "body": form() if body is None else body,
"labels": [{"name": name} for name in labels]}}
class Render(unittest.TestCase):
def test_lists_community_and_installable_lab_entries(self):
record = {"applications": {
"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}},
"2fauth": {"status": "laboratory-validated"},
"legacy": {"status": "laboratory-validated"}}}
text = validation.render(record, INDEX)
self.assertIn("2 of 3 applications", text)
self.assertIn("| Glances | [@Vaso73](https://github.com/Vaso73) | 2026-09-27 | — | — | [Report](" + REPORT + ") |", text)
self.assertIn("| 2FAuth | Security |", text)
# An application that cannot be installed is not listed as verified.
self.assertNotIn("Legacy", text)
def test_empty_record_and_escaped_titles(self):
text = validation.render({"applications": {}}, INDEX)
self.assertIn("No application has been tested by the community yet.", text)
record = {"applications": {"legacy": {"community_tested": {"by": "a", "date": "2026-09-27"}}}}
self.assertIn("| Legacy \\| App |", validation.render(record, INDEX))
def test_repository_record_is_valid_and_current(self):
record = validation.load(validation.VERIFICATION)
index = validation.load(validation.INDEX)
self.assertEqual(validation.problems(record, index, validation.OUTPUT.read_text(encoding="utf-8")), [])
class Problems(unittest.TestCase):
def check(self, entry):
return validation.problems({"applications": {"glances": entry}}, INDEX, today=TODAY)
def test_valid_entries(self):
self.assertEqual(self.check({"status": "laboratory-validated"}), [])
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}}), [])
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27",
"report": "https://github.com/MacRimi/ProxMenux/discussions/392#discussioncomment-18623868"}}), [])
def test_rejected_entries(self):
cases = [
({"status": "works"}, "status can only be"),
({"community_tested": {"by": "bad user", "date": "2026-09-27"}}, "GitHub user name"),
({"community_tested": {"by": "a", "date": "27/09/2026"}}, "YYYY-MM-DD"),
({"community_tested": {"by": "a", "date": "2027-01-01"}}, "in the future"),
({"community_tested": {"by": "a", "date": "2026-09-27", "report": "https://example.com/x"}}, "must link"),
({"community_tested": {"by": "a", "date": "2026-09-27", "extra": 1}}, "unknown community_tested keys"),
({"verified": True}, "unknown keys"),
({}, "non-empty object"),
]
for entry, message in cases:
with self.subTest(entry=entry):
self.assertTrue(any(message in problem for problem in self.check(entry)), self.check(entry))
self.assertTrue(validation.problems({"applications": {"nope": {"status": "laboratory-validated"}}}, INDEX))
def test_stale_list_is_reported(self):
record = {"applications": {"2fauth": {"status": "laboratory-validated"}}}
self.assertTrue(any("out of date" in p for p in validation.problems(record, INDEX, "old", TODAY)))
class Authors(unittest.TestCase):
base = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}},
"2fauth": {"status": "laboratory-validated"}}}
def head(self, **changes):
data = json.loads(json.dumps(self.base))
data["applications"].update(changes)
return data
def test_author_adds_own_test(self):
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems(self.base, head, "F3rs3n"), [])
def test_crediting_someone_else_is_refused(self):
head = self.head(pocketbase={"community_tested": {"by": "Vaso73", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems(self.base, head, "f3rs3n"),
["pocketbase: credits @Vaso73, but the pull request is from @f3rs3n"])
def test_lab_status_and_removals_are_for_the_maintainer(self):
head = self.head(glances={}, pocketbase={"status": "laboratory-validated"})
found = validation.author_problems(self.base, head, "f3rs3n")
self.assertIn("glances: a community test is removed by the maintainer only", found)
self.assertIn("pocketbase: the ProxMenux lab status is changed by the maintainer only", found)
self.assertEqual(validation.author_problems(self.base, head, "MacRimi"), [])
def test_missing_base_file(self):
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems({}, {"applications": {"pocketbase": head["applications"]["pocketbase"]}},
"f3rs3n"), [])
class Record(unittest.TestCase):
def test_parse_form_reads_github_issue_form_body(self):
fields = validation.parse_form(form(Notes="Line one\r\nLine two"))
self.assertEqual(fields["Application"], "Glances")
self.assertEqual(fields["Notes"], "Line one\nLine two")
self.assertEqual(validation.parse_form(form())["Notes"], "")
def test_records_the_report_author(self):
data, app, message = validation.record(event(), {"applications": {"glances": {"status": "laboratory-validated"}}},
INDEX, TODAY)
self.assertEqual(app, "glances")
self.assertEqual(data["applications"]["glances"], {"status": "laboratory-validated", "community_tested": {
"by": "Tester-1", "date": "2026-09-28", "report": REPORT, "digest": "b1f339cf",
"scenario": "Default configuration · Volume on Proxmox storage · DHCP · No GPU · Proxmox VE 9.1 · image 4.3"}})
self.assertIn("@Tester-1", message)
self.assertEqual(validation.problems(data, INDEX, today=TODAY), [])
def test_matches_id_or_title_and_replaces_older_test(self):
record = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}}}}
data, app, message = validation.record(event(form(Application=" glances ")), record, INDEX, TODAY)
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
self.assertIn("replaces the previous one from @Vaso73", message)
self.assertEqual(record["applications"]["glances"]["community_tested"]["by"], "Vaso73")
def test_refusals(self):
for case, message in [
(event(form(Application="Unknown")), "does not match"),
(event(form(Result="Does not work")), "does not work"),
(event(labels=("validated",)), "not an OCI validation report"),
(event(user="bad user"), "could not be read"),
(event(form(**{"Image digest": "latest"})), "does not hold an image digest"),
]:
with self.subTest(message=message):
with self.assertRaisesRegex(ValueError, message):
validation.record(case, {"applications": {}}, INDEX, TODAY)
class ReviewerComment(unittest.TestCase):
allowed = frozenset({"macrimi", "vaso73", "f3rs3n"})
def comment(self, reviewer, author="Tester-1"):
data = event(labels=("oci-validation",), user=author)
data["comment"] = {"user": {"login": reviewer}, "body": "/validated"}
return data
def test_listed_reviewer_validates_and_is_named(self):
data, app, message = validation.record(self.comment("Vaso73"), {"applications": {}}, INDEX, TODAY, self.allowed)
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
self.assertIn("Validated by @Vaso73.", message)
def test_unlisted_reviewer_and_own_report_are_refused(self):
with self.assertRaisesRegex(ValueError, "not in the list of OCI validation reviewers"):
validation.record(self.comment("someone"), {"applications": {}}, INDEX, TODAY, self.allowed)
with self.assertRaisesRegex(ValueError, "other than its author"):
validation.record(self.comment("f3rs3n", author="f3rs3n"), {"applications": {}}, INDEX, TODAY, self.allowed)
def test_maintainer_may_validate_own_report_and_label_needs_no_list(self):
validation.record(self.comment("MacRimi", author="MacRimi"), {"applications": {}}, INDEX, TODAY, self.allowed)
validation.record(event(), {"applications": {}}, INDEX, TODAY)
def test_reviewer_list_file(self):
self.assertEqual(validation.reviewers("# comment\nMacRimi\n Vaso73 # tester\nbad name\n"),
{"macrimi", "vaso73"})
listed = validation.reviewers(validation.REVIEWERS.read_text(encoding="utf-8"))
self.assertEqual(listed, self.allowed)
class ImageAndScenario(unittest.TestCase):
def test_digest_as_the_monitor_shows_it_or_in_full(self):
full = "sha256:" + "ab" * 32
self.assertEqual(validation.image_digest("2026-09-06 · b1f339cf"), "b1f339cf")
self.assertEqual(validation.image_digest(full.upper()), "ab" * 32)
for value in ("", "b1f3", "latest", "sha256:xyz12345"):
self.assertIsNone(validation.image_digest(value))
def test_scenario_names_problems_and_skips_empty_fields(self):
fields = validation.parse_form(form(Result="Works with problems", GPU="_No response_"))
self.assertEqual(validation.scenario(fields), "Works with problems: Default configuration · "
"Volume on Proxmox storage · DHCP · Proxmox VE 9.1 · image 4.3")
def test_list_shows_image_and_scenario(self):
record = {"applications": {"glances": {"community_tested": {
"by": "Vaso73", "date": "2026-09-27", "digest": "b1f339cf08ae", "scenario": "Default | DHCP"}}}}
self.assertIn("| `b1f339cf08ae` | Default \\| DHCP |", validation.render(record, INDEX))
def test_bad_digest_and_long_scenario_are_rejected(self):
found = validation.problems({"applications": {"glances": {"community_tested": {
"by": "a", "date": "2026-09-27", "digest": "XYZ", "scenario": "x" * 301}}}}, INDEX, today=TODAY)
self.assertTrue(any("digest" in f for f in found) and any("scenario" in f for f in found), found)
class IssueForm(unittest.TestCase):
def test_form_labels_match_the_bot(self):
text = (ROOT / ".github/ISSUE_TEMPLATE/oci-validation.yml").read_text(encoding="utf-8")
labels = re.findall(r"^\s+label: (.+)$", text, re.M)
self.assertIn("Application", labels)
self.assertIn("Result", labels)
self.assertIn("Image digest", labels)
for name in validation.SCENARIO_FIELDS:
self.assertIn(name, labels)
self.assertIn("- Does not work", text)
self.assertIn('labels: ["oci-validation"]', text)
self.assertIn("template=oci-validation.yml", validation.FORM)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,80 @@
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
import ast
import json
from pathlib import Path
import re
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
class OciWatchdogWiring(TestCase):
def test_changing_it_needs_an_administrator_and_goes_through_the_engine(self):
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
route = re.search(r"@app\.route\('/api/lxc/<int:vmid>/oci-watchdog', methods=\['POST'\]\)\n@(\w+)\ndef (\w+)", server)
self.assertEqual(route.group(1), 'require_admin_scope')
body = server[route.end():server.index("@app.route('/api/vms/<int:vmid>/logs'")]
self.assertIn("if not isinstance(enabled, bool):", body)
self.assertIn("oci/engine/remote/oci_watchdog.py',", body)
self.assertNotIn('shell=True', body)
self.assertIn('"watchdog": False,', (ROOT / 'AppImage/scripts/oci_instance_info.py').read_text())
def test_the_toggle_is_only_offered_for_an_oci_application(self):
modal = (ROOT / 'AppImage/components/virtual-machines.tsx').read_text()
block = modal[modal.index('{/* Watchdog of an OCI application'):]
self.assertTrue(block.split('\n', 3)[2].strip().startswith('{ociInstance?.oci_instance && ('))
self.assertIn('t("vmLxc.details.watchdog")', block)
self.assertIn('`/api/lxc/${selectedVM.vmid}/oci-watchdog`', modal)
def test_every_language_names_and_explains_it(self):
for locale in LOCALES:
details = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['vmLxc']['details']
self.assertTrue(details['watchdog'].strip(), locale)
self.assertTrue(details['watchdogHelp'].strip(), locale)
def test_the_engine_restarts_its_service_when_proxmenux_replaces_it(self):
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
self.assertIn('systemctl try-restart proxmenux-oci-watchdog.service', (ROOT / installer).read_text(), installer)
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
self.assertIn("UNIT = 'proxmenux-oci-watchdog.service'", engine)
class OciWatchdogInstaller(TestCase):
CLI = (ROOT / 'oci/src/proxmenux_oci/cli.py').read_text()
MENU = (ROOT / 'oci/src/proxmenux_oci/management.py').read_text()
def default(self, template):
node = next(item for item in ast.parse(self.CLI).body
if isinstance(item, ast.FunctionDef) and item.name == 'watchdog_default')
scope = {'Any': object}
exec(compile(ast.Module(body=[node], type_ignores=[]), 'cli.py', 'exec'), scope)
return scope['watchdog_default'](template)
def test_the_recipe_decides_what_is_proposed(self):
self.assertTrue(self.default({'container_contract': {'restart': 'unless-stopped'}}))
self.assertTrue(self.default({'container_contract': {'restart': 'always'}}))
self.assertTrue(self.default({'compose_stack': {'services': [{'compose': {}}, {'compose': {'restart': 'on-failure'}}]}}))
self.assertFalse(self.default({'container_contract': {'restart': None}}))
self.assertFalse(self.default({'container_contract': {'restart': 'no'}, 'compose_stack': {'services': [{'compose': {'restart': 'no'}}]}}))
self.assertFalse(self.default({}))
def test_every_installation_asks_it_and_the_engine_never_receives_the_answer(self):
install = self.CLI[self.CLI.index('def install_template('):self.CLI.index('def _rclone_mount(')]
asked = install.index('deployment["watchdog"] = wizard.confirm(')
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.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
def test_both_management_menus_offer_it(self):
self.assertEqual(self.MENU.count("('watchdog', "), 2)
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
def test_the_service_is_written_through_the_change_journal(self):
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
self.assertIn('pmx_write_file "$2" && systemctl daemon-reload && pmx_enable_service "$3"', engine)
journal = (ROOT / 'scripts/global/pmx_journal.sh').read_text()
for helper in ('pmx_journal_context()', 'pmx_write_file()', 'pmx_enable_service()'):
self.assertIn(helper, journal)
+181
View File
@@ -0,0 +1,181 @@
"""Offline OCI wording seams; compile actual functions, never import operational modules."""
import ast
import importlib.util
import io
import json
import runpy
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest import TestCase
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[3]
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
TRANSACTION = ROOT / 'oci/remote/oci_instance_transaction.py'
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
CHOICE = 'Recover the previous installation'
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
NEW_KEYS = (
'The Compose file describes several services:',
'Only one service at a time can be installed this way.',
'No docker run command was given',
'Image selection was cancelled',
'Could not inspect the image in its registry:',
'Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.',
BEFORE, AFTER,
)
class ConversionError(Exception):
pass
class UserCancelled(Exception):
pass
def function(path, name, **dependencies):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
# Only read_definition's infrastructure import is omitted; keep the function intact.
if name == 'read_definition':
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024)
scope.update(dependencies)
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(path), 'exec'), scope)
return scope[name]
class OciWordingTests(TestCase):
def test_extractor_shipped_locales_and_synthetic_fallback(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(NEW_KEYS), len(set(NEW_KEYS)))
self.assertTrue(set(NEW_KEYS) <= set(extracted), set(NEW_KEYS) - set(extracted))
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for key in NEW_KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
setattr(module, '_cache', {})
for key in NEW_KEYS:
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
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):
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.')
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
for title, expected in ((None, 'No Compose file was given'),
('docker run command of the application', 'No docker run command was given')):
def paste(content):
return function(CUSTOM, '_read_pasted',
console=SimpleNamespace(show_logo=lambda: None, msg_title=lambda *_: None,
msg_info2=lambda *_: None),
sys=SimpleNamespace(stdin=io.StringIO(content)))
with self.assertRaises(UserCancelled) as caught:
paste('')(None, title)
self.assertEqual(str(caught.exception), expected)
self.assertEqual(paste(' literal input\n')(None, title), ' literal input\n')
def test_cancelled_image_menu(self):
with self.assertRaises(UserCancelled) as caught:
function(CUSTOM, 'read_definition')(SimpleNamespace(choose=lambda *_, **__: None))
self.assertEqual(str(caught.exception), 'Image selection was cancelled')
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))
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'],
capture_output=True, text=True, check=False, timeout=120)
run.reset_mock()
run.return_value = SimpleNamespace(returncode=0,
stdout='{"manifests":[{"platform":{"architecture":"amd64"}}]}', stderr='')
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
self.assertEqual(run.call_count, 1)
def test_failed_registry_inspection_has_neutral_advice(self):
ui = SimpleNamespace(message=Mock())
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
ignored_settings=lambda _: [], template_from_compose=lambda _: {
'container_contract': {'image': {'reference': 'example:latest'}}},
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'))
fn(ui)
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n'
'Check the image reference and registry access. If the registry is unavailable, '
'try again later. A private registry needs credentials, which are not supported yet.')
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
# Pin the real translated management choice, not an English AST constant alone.
source = ast.parse(MENU.read_text())
self.assertTrue(any(isinstance(n, ast.Tuple) and len(n.elts) == 2
and isinstance(n.elts[0], ast.Constant) and n.elts[0].value == 'recover'
and isinstance(n.elts[1], ast.Call) and isinstance(n.elts[1].func, ast.Name)
and n.elts[1].func.id == 'translate' and n.elts[1].args
and isinstance(n.elts[1].args[0], ast.Constant)
and n.elts[1].args[0].value == CHOICE for n in ast.walk(source)))
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
warnings = []
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
msg_warn=warnings.append)
hint()
hint(after_recovery=True)
label = module.translate(CHOICE)
self.assertNotEqual(label, CHOICE, path.name)
expected = [(cache.get(key) or key).replace('{choice}', label)
if '{choice}' in (cache.get(key) or key) else key.replace('{choice}', label)
for key in (BEFORE, AFTER)]
self.assertEqual(warnings, expected, path.name)
# An intentionally absent message key proves fallback independently of shipped catalogs.
setattr(module, '_cache', {CHOICE: label})
warnings.clear()
hint()
hint(after_recovery=True)
self.assertEqual(warnings, [BEFORE.replace('{choice}', label),
AFTER.replace('{choice}', label)], (path.name, 'synthetic missing'))
def test_recovery_hint_uses_translated_whole_messages_and_malformed_fallback(self):
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module._language = 'it'
for translation in ('Before: «{choice}».', 'Before: label missing.'):
module._cache = {CHOICE: 'Recupero', BEFORE: translation,
AFTER: 'After: «{choice}».'}
warnings = []
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
msg_warn=warnings.append)
hint()
hint(after_recovery=True)
self.assertEqual(warnings, [
('Before: «Recupero».' if '{choice}' in translation
else BEFORE.replace('{choice}', 'Recupero')),
'After: «Recupero».'])
@@ -0,0 +1,71 @@
"""A preview or a warning promises only what the code always does."""
import ast
import json
from pathlib import Path
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
def oci_menu_texts():
tree = ast.parse((ROOT / 'oci/src/proxmenux_oci/management.py').read_text())
return [node.args[0].value for node in ast.walk(tree)
if isinstance(node, ast.Call) and getattr(node.func, 'id', '') == 'translate'
and node.args and isinstance(node.args[0], ast.Constant)]
class OutcomePromises(TestCase):
def test_a_failure_is_not_promised_to_end_in_a_restore(self):
texts = oci_menu_texts()
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
self.assertEqual(len(previews), 3)
def test_the_recovery_preview_says_when_each_step_happens(self):
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
self.assertIn('If the container had already been changed', preview)
self.assertIn('when the cleanup succeeds', preview)
def test_a_rclone_mount_is_not_listed_as_a_registration_problem(self):
prompt = next(text for text in oci_menu_texts() if text.startswith('Until they are registered again'))
self.assertIn('does not prevent it', prompt)
self.assertIn('not started automatically', prompt)
def test_an_empty_retention_is_the_one_of_the_storage(self):
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
self.assertIn('Leave empty to use the retention of the storage.', menu)
self.assertNotIn('keep every backup', menu)
def test_host_backups_are_said_to_follow_the_storage(self):
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
self.assertNotIn('stop running', menu)
self.assertIn('Another job that writes to the same storage still starts them.', menu)
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
self.assertNotIn('stop running', english['deleteAttached'] + english['storageAttached'])
self.assertIn('scheduled runs', english['disableBody'])
for locale in LOCALES:
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
for key in ('deleteAttached', 'storageAttached'):
self.assertEqual(texts[key].count('{ids}'), 1, f'{locale} {key}')
self.assertEqual(texts['disableBody'].count('. '), 1, f'{locale} disableBody')
def test_the_host_restore_notice_reports_tasks_and_does_not_call_the_node_ready(self):
for locale in LOCALES:
body = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['runtime']['notifications']['templates']['system_restore_completed']['body']
self.assertEqual(body.split('\n')[-1], '{warnings_block}', locale)
self.assertEqual(body.count('\n'), 7, locale)
def test_the_watchdog_failure_covers_an_application_that_did_not_start(self):
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']['oci_watchdog_failed']
self.assertNotIn('keeps stopping', english['title'] + english['body'] + english['label'])
self.assertIn('did not start', english['body'])
def test_other_wording_that_promised_too_much(self):
texts = oci_menu_texts()
self.assertIn('Private network kept, because other guests still use it:', texts)
recreation = (ROOT / 'oci/src/proxmenux_oci/stack_recreation.py').read_text()
self.assertNotIn('if anything fails', recreation)
self.assertIn('a stopped one is left stopped', recreation)
for script in ('scripts/post_install/customizable_post_install.sh', 'scripts/post_install/uninstall-tools.sh'):
self.assertNotIn('leaving it unchanged', (ROOT / script).read_text(), script)
@@ -0,0 +1,178 @@
"""Offline actual scheduler gates; never source the administrative script."""
import importlib.util
import json
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCHEDULER = ROOT / 'scripts/backup_restore/backup_scheduler.sh'
MESSAGES = {
'pbs': 'No matching PVE vzdump jobs were found for the PBS backend.',
'local': 'No matching PVE vzdump jobs were found for the local archive backend.',
}
HINT = 'Create one first in Datacenter → Backup, then return here to attach.'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}', start) + 2] + '\n'
def consumers():
text = SCHEDULER.read_text()
attached = function(text, '_create_job_attached').split(' local pve_prune\n')[0] + '}\n'
# Only the backend/attach selection gate, not ID/config writes or scheduling.
new = function(text, '_create_job')
new = '_create_job() {\n local id=fixture backend on_calendar\n' + new[new.index(' backend=$(dialog'):new.index(' on_calendar=$(dialog')]
new += ' return 42\n}\n'
return attached + new
def fixture(directory, backend='pbs', consumer='attached', rows='', second=None,
locale='en', catalog=None, real_dialog=None):
directory = Path(directory)
(directory / 'xx.json').write_text(json.dumps(catalog or {}))
(directory / 'rows').write_text(rows)
(directory / 'second').write_text(rows if second is None else second)
bindir = directory / 'bin'
bindir.mkdir(exist_ok=True)
# Only jq is executable via PATH; wrapper validates all lookup arguments.
(bindir / 'jq').symlink_to('/usr/bin/jq') if not (bindir / 'jq').exists() else None
translation = function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
filter_code = function((ROOT / 'scripts/backup_restore/lib_host_backup_common.sh').read_text(), 'hb_pve_list_vzdump_jobs_for_backend')
preamble = f'''PATH={shlex.quote(str(bindir))}
LANG_DIR={shlex.quote(str(directory))}
LANGUAGE={shlex.quote(locale)}
FIXTURE={shlex.quote(str(directory))}
BACKEND={backend}
HB_UI_MENU_H=20 HB_UI_MENU_W=84 HB_UI_MENU_LIST=10
command_not_found_handle() {{ printf 'FORBIDDEN:%s\\n' "$*" >> "$FIXTURE/errors"; return 99; }}
jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // .[($text | gsub("\\\\\\\\n"; "\\n"))] // empty' && "$6" == "$FIXTURE/xx.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }}
head() {{ [[ "$*" == '-1' ]] || return 99; local first; IFS= read -r first; printf '%s\\n' "$first"; while IFS= read -r first; do :; done; }}
hb_pve_list_vzdump_jobs() {{
local file="$FIXTURE/rows" line
[[ -f "$FIXTURE/read" ]] && file="$FIXTURE/second"
printf read >> "$FIXTURE/read"
while IFS= read -r line; do printf '%s\\n' "$line"; done < "$file"
}}
dialog() {{
printf '%s\\0' "$@" >> "$FIXTURE/dialogs"
local title='' kind='' message='' previous='' arg
for arg in "$@"; do
[[ "$previous" == --title ]] && title="$arg"
[[ "$previous" == --msgbox || "$previous" == --menu ]] && message="$arg"
[[ "$arg" == --msgbox || "$arg" == --menu ]] && kind="$arg"
previous="$arg"
done
if [[ "$kind" == --msgbox ]]; then
printf '%s' "$message" > "$FIXTURE/message"
{'command ' + shlex.quote(real_dialog) + ' "$@"' if real_dialog else ':'}
return 0
fi
case "$title" in
"$(translate 'Backend')") printf '%s' "$BACKEND" >&2 ;;
"$(translate 'How to schedule')") printf attach >&2 ;;
"$(translate 'Pick PVE vzdump job')") return 1 ;;
*) printf 'unexpected dialog:%s' "$title" >> "$FIXTURE/errors"; return 99 ;;
esac
}}
'''
return preamble + translation + filter_code + consumers() + f'\nif _create_job{"_attached fixture " + backend if consumer == "attached" else ""}; then rc=0; else rc=$?; fi\nprintf "%s" "$rc" > "$FIXTURE/status"\n'
def run_case(**kwargs):
with tempfile.TemporaryDirectory(prefix='scheduler-') as directory:
code = fixture(directory, **kwargs)
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', code],
env={'PATH': '/nonexistent', 'LC_ALL': 'C.UTF-8'}, capture_output=True, text=True)
if result.returncode or result.stderr:
raise AssertionError((result.returncode, result.stderr))
p = Path(directory)
if (p / 'errors').exists():
raise AssertionError((p / 'errors').read_text())
return {name: (p / name).read_text() if (p / name).exists() else ''
for name in ('message', 'status', 'dialogs', 'read')}
class SchedulerMessages(unittest.TestCase):
def test_new_whole_message_and_separate_hint(self):
for backend in MESSAGES:
with self.subTest(backend=backend):
result = run_case(backend=backend, consumer='new')
self.assertEqual(result['message'], MESSAGES[backend] + '\n\n' + HINT)
self.assertEqual(result['status'], '1')
def test_lookup_cache_modes_at_both_consumers(self):
shipped = json.loads((ROOT / 'lang/it.json').read_text())
for backend, message in MESSAGES.items():
for consumer in ('attached', 'new'):
for locale, catalog, expected in (
('en', {message: 'ignored'}, message),
('missing', {}, message),
('xx', {'unrelated': 'unrelated'}, message),
('xx', shipped, shipped.get(message, message)),
('xx', {message: 'SYNTHETIC reordered backend: ' + backend},
'SYNTHETIC reordered backend: ' + backend),
):
with self.subTest(backend=backend, consumer=consumer, locale=locale, expected=expected):
result = run_case(backend=backend, consumer=consumer, locale=locale, catalog=catalog)
suffix = '\n\n' + catalog.get(HINT, HINT) if consumer == 'new' else ''
self.assertEqual(result['message'], expected + suffix)
self.assertEqual(result['status'], '1')
def test_nonempty_menus_disabled_and_second_read_empty(self):
for backend, storage in (('pbs', 'pbs'), ('local', 'nfs')):
for count in (1, 3):
rows = ''.join(f'job{i}\tstore{i}\t{storage}\tdaily\t-\t0\n' for i in range(count))
for consumer in ('attached', 'new'):
with self.subTest(backend=backend, count=count, consumer=consumer):
result = run_case(backend=backend, consumer=consumer, rows=rows)
self.assertEqual(result['message'], '')
self.assertEqual(result['status'], '1') # cancel at actual parent picker
for i in range(count):
self.assertIn(f'job{i} · store{i} · daily (disabled)', result['dialogs'])
result = run_case(backend=backend, consumer='new', rows=rows, second='')
self.assertEqual(result['message'], MESSAGES[backend])
self.assertEqual(result['read'], 'readread')
self.assertEqual(result['status'], '1')
def test_actual_filter_types_and_enabled_states(self):
for backend in MESSAGES:
for storage in ('pbs', 'dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs', 'rbd', '-'):
for enabled in ('0', '1'):
with self.subTest(backend=backend, storage=storage, enabled=enabled):
result = run_case(backend=backend, rows=f'job\tstore\t{storage}\tdaily\t-\t{enabled}\n')
matches = storage == 'pbs' if backend == 'pbs' else storage in ('dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs')
self.assertEqual(result['message'], '' if matches else MESSAGES[backend])
if matches:
self.assertEqual('(disabled)' in result['dialogs'], enabled == '0')
def test_borg_excludes_attach_gate(self):
result = run_case(backend='borg', consumer='new')
self.assertEqual(result['status'], '42') # stopped before real schedule/config work
self.assertEqual(result['message'], '')
self.assertEqual(result['read'], '')
self.assertNotIn('How to schedule', result['dialogs'])
def test_actual_offline_extractor_discovers_whole_messages(self):
spec = importlib.util.spec_from_file_location('scheduler_cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
discovered = module.extract_translate_texts(ROOT / 'scripts')
for message in MESSAGES.values():
self.assertIn(message, discovered)
def test_attached_whole_message(self):
for backend in MESSAGES:
with self.subTest(backend=backend):
result = run_case(backend=backend)
self.assertEqual(result['message'], MESSAGES[backend])
self.assertEqual(result['status'], '1')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,42 @@
"""A script writes a line break as \\n inside the text it translates; the
language files store that text with a real line break."""
import json
from pathlib import Path
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}\n', start) + 3]
class TranslateLineBreaks(TestCase):
def translate(self, text, catalog):
with tempfile.TemporaryDirectory() as folder:
(Path(folder) / 'xx.json').write_text(json.dumps(catalog))
script = (f'LANGUAGE=xx LANG_DIR={folder}\n' + function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
+ 'translate "$1"\n')
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script, 'bash', text],
capture_output=True, text=True, timeout=10)
self.assertEqual((result.returncode, result.stderr), (0, ''))
return result.stdout.rstrip('\n')
def test_a_text_stored_with_a_real_line_break_is_found(self):
catalog = {'Restore this backup?\nThe configuration is overwritten.': '¿Restaurar?\nSe sobrescribe.'}
self.assertEqual(self.translate('Restore this backup?\\nThe configuration is overwritten.', catalog),
'¿Restaurar?\nSe sobrescribe.')
def test_the_exact_text_is_preferred_and_a_missing_one_comes_back_unchanged(self):
catalog = {'one\\ntwo': 'LITERAL', 'one\ntwo': 'REAL', 'plain': 'simple'}
self.assertEqual(self.translate('one\\ntwo', catalog), 'LITERAL')
self.assertEqual(self.translate('plain', catalog), 'simple')
self.assertEqual(self.translate('not\\nthere', catalog), 'not\\nthere')
def test_the_shipped_texts_with_a_line_break_reach_the_spanish_file(self):
spanish = json.loads((ROOT / 'lang/es.json').read_text())
text = 'Are you sure you want to restore this backup?\\nCurrent configuration will be overwritten.'
self.assertEqual(self.translate(text, spanish), spanish[text.replace('\\n', '\n')])
@@ -0,0 +1,152 @@
"""Offline, bounded real Bash consumers: no administrative entrypoints are sourced."""
import importlib.util
import json
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'scripts'
NETWORK = {
'share/iscsi_host.sh': 1,
'share/nfs_client.sh': 1,
'share/nfs_host.sh': 2,
'share/samba_client.sh': 1,
'share/samba_host.sh': 2,
}
MOUNT = 'Mounting existing filesystem ({filesystem})...'
RAID = 'If you are sure you want to use it, remove the RAID metadata or format it manually using external tools.'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}', start) + 2] + '\n'
def network_blocks(path):
lines = path.read_text().splitlines()
blocks = []
for i, line in enumerate(lines):
if re.search(r'^\s*if (?:pct exec .* -- )?nc -z -w 2 ', line):
depth = len(line) - len(line.lstrip())
end = next(j for j in range(i + 1, len(lines))
if lines[j].strip() == 'fi' and len(lines[j]) - len(lines[j].lstrip()) == depth)
block = '\n'.join(lines[i:end + 1])
assert '$(translate "Open")' in block and '$(translate "Closed")' in block or '$(translate "Probe failed")' in block
blocks.append(block)
return blocks
def raid_dialog():
"""The dialog that reports a RAID member, wherever it sits in the script."""
lines = (SCRIPTS / 'storage/disk-passthrough_ct.sh').read_text().splitlines()
message = next(i for i, line in enumerate(lines) if RAID in line)
assert lines[message - 2].lstrip().startswith('dialog ')
return '\n'.join(lines[message - 2:message + 1])
def run(code, translations=None, statuses='1', variables='', dialog=False):
translations = translations or {}
with tempfile.TemporaryDirectory(prefix='tui-wording-') as directory:
root = Path(directory)
(root / 'xx.json').write_text(json.dumps(translations))
body = f'''LANGUAGE=xx LANG_DIR={root!s}
{function((SCRIPTS / 'utils.sh').read_text(), 'translate')}
GN='\\033[32m' RD='\\033[31m' CL='\\033[0m'
{variables}
statuses=({statuses})
nc() {{ local code=${{statuses[0]}}; statuses=("${{statuses[@]:1}}"); return "$code"; }}
pct() {{ [[ "$1" == exec && "$3" == -- && "$4" == nc ]] || return 98; shift 3; nc "$@"; }}
msg_info() {{ printf 'INFO:%s\\n' "$1"; }}
msg_error() {{ printf 'ERROR:%s\\n' "$1"; }}
msg_ok() {{ printf 'OK:%s\\n' "$1"; }}
msg_title() {{ :; }}
show_proxmenux_logo() {{ :; }}
pmx_journal_context() {{ :; }}
blkid() {{ [[ "$2" == TYPE ]] && printf 'ext4\\n' || printf 'UUID123\\n'; }}
mkdir() {{ :; }}
mount() {{ return 1; }}
dialog() {{ printf 'DIALOG:%s\\n' "$*"; }}
{code}
'''
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', body],
env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C.UTF-8'},
capture_output=True, text=True, timeout=10)
if result.returncode or result.stderr:
raise AssertionError((result.returncode, result.stderr, result.stdout))
return result.stdout
class TuiNetworkStorageWording(unittest.TestCase):
def test_failed_tcp_probe_is_not_declared_closed_at_every_caller(self):
for path, count in NETWORK.items():
blocks = network_blocks(SCRIPTS / path)
self.assertEqual(len(blocks), count, path)
for index, block in enumerate(blocks):
cases = [('0 1', 'Open'), ('1 1', 'Probe failed'), ('124 124', 'Probe failed')]
if 'samba_' in path:
cases.append(('1 0', 'Open'))
for codes, expected in cases:
with self.subTest(path=path, index=index, codes=codes):
output = run(block, statuses=codes, translations={'Probe failed': 'SONDAGGIO FALLITO'})
self.assertIn('SONDAGGIO FALLITO' if expected == 'Probe failed' else 'Open', output)
self.assertNotIn('Closed', output)
if expected == 'Open':
self.assertNotIn('SONDAGGIO FALLITO', output)
else:
fallback = run(block, statuses=codes)
self.assertIn('Probe failed', fallback)
self.assertNotIn('Closed', fallback)
def test_mount_progress_is_whole_and_uses_real_lookup_with_fallback(self):
source = (SCRIPTS / 'share/disk_host.sh').read_text()
start = source.index('mount_existing_disk() {')
portion = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\n'
for translation, expected in [({}, MOUNT.replace('{filesystem}', 'ext4')),
({MOUNT: 'SYNTHETIC {filesystem} in corso'}, 'SYNTHETIC ext4 in corso'),
({MOUNT: 'SYNTHETIC malformed'}, MOUNT.replace('{filesystem}', 'ext4'))]:
with self.subTest(translation=translation):
output = run(portion + '\nmount_existing_disk /dev/fixture /mnt/fixture', translations=translation)
self.assertIn('INFO:' + expected, output)
self.assertNotIn('{filesystem}', output)
def test_raid_warning_is_complete_message_with_disk_identifier_and_fallback(self):
invocation = raid_dialog() # the dialog invocation, not the raid detection command
for translation, expected in [({}, RAID), ({RAID: 'SYNTHETIC RAID metadata remains'}, 'SYNTHETIC RAID metadata remains')]:
with self.subTest(translation=translation):
output = run(invocation, translations=translation,
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture 2TiB' UI_RESULT_H=16 UI_RESULT_W=80")
self.assertIn(expected, output)
self.assertIn('/dev/fixture 2TiB', output)
self.assertNotIn('remove the RAID metadata or format it manually using external tools. RAID metadata', output)
def test_shipped_catalogs_use_english_fallback_for_new_keys(self):
catalogs = sorted((ROOT / 'lang').glob('*.json'))
self.assertTrue(catalogs)
block = network_blocks(SCRIPTS / 'share/iscsi_host.sh')[0]
source = (SCRIPTS / 'share/disk_host.sh').read_text()
start = source.index('mount_existing_disk() {')
mount = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\nmount_existing_disk /dev/fixture /mnt/fixture'
raid = raid_dialog()
for path in catalogs:
with self.subTest(locale=path.name):
catalog = json.loads(path.read_text())
self.assertIn(catalog.get('Probe failed') or 'Probe failed', run(block, statuses='1', translations=catalog))
self.assertIn((catalog.get(MOUNT) or MOUNT).replace('{filesystem}', 'ext4'), run(mount, translations=catalog))
self.assertIn(catalog.get(RAID) or RAID, run(raid, translations=catalog,
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture' UI_RESULT_H=16 UI_RESULT_W=80"))
def test_extractor_discovers_whole_keys(self):
spec = importlib.util.spec_from_file_location('cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
keys = module.extract_translate_texts(SCRIPTS)
for key in (MOUNT, RAID, 'Probe failed'):
self.assertTrue(key in keys, key)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,23 @@
"""Run the inert Node TUI consumer fixture in the existing offline i18n lane."""
import os
from pathlib import Path
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[3]
class TuiUpgradeGpuWording(unittest.TestCase):
def test_source_extracted_node_consumer(self):
fixture = ROOT / 'tests/test_tui_upgrade_gpu_wording.cjs'
self.assertTrue(fixture.is_file(), 'Node fixture must be packaged with the candidate')
node = os.environ.get('NODE_BINARY') or 'node'
result = subprocess.run([node, str(fixture)], cwd=ROOT, capture_output=True,
text=True, timeout=60, env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'})
self.assertEqual(result.returncode, 0, result.stdout + '\n' + result.stderr)
self.assertIn('PASS: extracted upgrade/GPU consumers', result.stdout)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,69 @@
"""The VM/CT backup jobs feature is protected, shipped and translated."""
import json
from pathlib import Path
import re
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
ROUTES = ROOT / 'AppImage/scripts/flask_vm_backup_routes.py'
SCRIPT = ROOT / 'scripts/backup_restore/vm_backup_jobs.sh'
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
class VmBackupJobsWiring(TestCase):
def test_every_route_is_protected_and_every_change_needs_an_administrator(self):
source = ROUTES.read_text()
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
self.assertEqual(len(routes), 8)
for path, method, guard, name in routes:
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
self.assertEqual(guard, expected, f'{method} {path} ({name})')
def test_proxmox_is_called_without_a_shell(self):
source = ROUTES.read_text()
self.assertNotIn('shell=True', source)
self.assertNotIn('os.system', source)
self.assertEqual(source.count('subprocess.run('), 1)
self.assertIn('subprocess.run(["pvesh", *args]', source)
def test_the_monitor_ships_and_registers_it(self):
self.assertIn('flask_vm_backup_routes.py', (ROOT / 'AppImage/scripts/build_appimage.sh').read_text())
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
self.assertIn('from flask_vm_backup_routes import vm_backup_bp', server)
self.assertIn('app.register_blueprint(vm_backup_bp)', server)
self.assertIn('<BackupTab ', (ROOT / 'AppImage/components/proxmox-dashboard.tsx').read_text())
tab = (ROOT / 'AppImage/components/backup-tab.tsx').read_text()
self.assertIn('<HostBackup />', tab)
self.assertIn('<VmBackupJobs />', tab)
# The backups of the host and those of the guests are separate views.
self.assertNotIn('VmBackupJobs', (ROOT / 'AppImage/components/host-backup.tsx').read_text())
def test_every_text_of_the_card_exists_in_the_eight_languages(self):
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
used = set(re.findall(r'backup\.vmJobs\.([A-Za-z]+)', component))
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
self.assertEqual(used - set(english), set())
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
for locale in LOCALES:
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
self.assertEqual(set(texts), set(english), locale)
self.assertEqual(set(texts['presets']), set(english['presets']), locale)
switch = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['viewSwitch']
self.assertEqual(set(switch), {'ariaLabel', 'host', 'guests'}, locale)
for key, value in english.items():
if isinstance(value, str):
self.assertTrue(texts[key].strip(), f'{locale} {key}')
self.assertEqual(fields(texts[key]), fields(value), f'{locale} {key}')
def test_the_edit_dialog_follows_the_edit_mode_contrast_rule(self):
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
self.assertIn('bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background', component)
def test_the_menu_runs_a_job_as_a_proxmox_task_and_installs_nothing(self):
source = SCRIPT.read_text()
self.assertNotIn('apt-get', source)
self.assertIn('pvesh create "/nodes/$node/vzdump"', source)
self.assertEqual(re.findall(r'^\s*vzdump ', source, re.MULTILINE), [])
menu = (ROOT / 'scripts/backup_restore/backup_host.sh').read_text()
self.assertIn('bash "$LOCAL_SCRIPTS/backup_restore/vm_backup_jobs.sh"', menu)
+26 -5
View File
@@ -6,9 +6,6 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-22.04
@@ -35,7 +32,11 @@ jobs:
- name: Install Python dependencies
run: |
sudo apt-get update
# Third-party apt repos on the runner (Google Chrome, Microsoft)
# sometimes serve a hash-mismatched index and abort the refresh.
# They are irrelevant here, so a failed refresh must not stop the
# build: python3 comes from the Ubuntu repos, which fetch fine.
sudo apt-get update || true
sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable
@@ -45,6 +46,26 @@ jobs:
- name: Build AppImage
working-directory: AppImage
run: ./scripts/build_appimage.sh
- name: Set up Python 3.11 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify bundled Python 3.11 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Set up Python 3.13 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Verify bundled Python 3.13 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Get version from package.json
id: version
@@ -59,7 +80,7 @@ jobs:
cat ProxMenux-Monitor.AppImage.sha256
- name: Upload AppImage artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: |
+26 -5
View File
@@ -6,9 +6,6 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-22.04
@@ -35,7 +32,11 @@ jobs:
- name: Install Python dependencies
run: |
sudo apt-get update
# Third-party apt repos on the runner (Google Chrome, Microsoft)
# sometimes serve a hash-mismatched index and abort the refresh.
# They are irrelevant here, so a failed refresh must not stop the
# build: python3 comes from the Ubuntu repos, which fetch fine.
sudo apt-get update || true
sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable
@@ -45,6 +46,26 @@ jobs:
- name: Build AppImage
working-directory: AppImage
run: ./scripts/build_appimage.sh
- name: Set up Python 3.11 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify bundled Python 3.11 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Set up Python 3.13 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Verify bundled Python 3.13 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Get version from package.json
id: version
@@ -59,7 +80,7 @@ jobs:
cat ProxMenux-Monitor.AppImage.sha256
- name: Upload AppImage artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-beta-AppImage
path: |
+28 -4
View File
@@ -13,10 +13,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
@@ -30,7 +30,11 @@ jobs:
- name: Install Python dependencies
run: |
sudo apt-get update
# Third-party apt repos on the runner (Google Chrome, Microsoft)
# sometimes serve a hash-mismatched index and abort the refresh.
# They are irrelevant here, so a failed refresh must not stop the
# build: python3 comes from the Ubuntu repos, which fetch fine.
sudo apt-get update || true
sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable
@@ -40,6 +44,26 @@ jobs:
- name: Build AppImage
working-directory: AppImage
run: ./scripts/build_appimage.sh
- name: Set up Python 3.11 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify bundled Python 3.11 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Set up Python 3.13 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Verify bundled Python 3.13 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Get version from package.json
id: version
@@ -47,7 +71,7 @@ jobs:
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: AppImage/dist/*.AppImage
+26 -5
View File
@@ -9,9 +9,6 @@ on:
paths: [ 'AppImage/**' ]
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-22.04
@@ -35,7 +32,11 @@ jobs:
- name: Install Python dependencies
run: |
sudo apt-get update
# Third-party apt repos on the runner (Google Chrome, Microsoft)
# sometimes serve a hash-mismatched index and abort the refresh.
# They are irrelevant here, so a failed refresh must not stop the
# build: python3 comes from the Ubuntu repos, which fetch fine.
sudo apt-get update || true
sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable
@@ -45,6 +46,26 @@ jobs:
- name: Build AppImage
working-directory: AppImage
run: ./scripts/build_appimage.sh
- name: Set up Python 3.11 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify bundled Python 3.11 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Set up Python 3.13 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Verify bundled Python 3.13 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Get version from package.json
id: version
@@ -52,7 +73,7 @@ jobs:
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Upload AppImage artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: AppImage/dist/*.AppImage
+138
View File
@@ -0,0 +1,138 @@
name: Build i18n messages
# Auto-translate missing keys in AppImage/messages/<locale>/common.json
# against the English source whenever the source changes.
#
# The Monitor's i18n layer (AppImage/lib/i18n/provider.tsx) does its own
# runtime fallback (locale → en → key), so this workflow doesn't break
# anything if it misses a key: it just eliminates the visible-English
# blocks in non-en locales.
#
# Guardrails baked into build_i18n_messages.py:
# - Never overwrites a key whose target value differs from EN (i.e.
# already translated by a human). This is what makes it safe to
# include sk in the default set: Vaso73's curated strings are
# protected end-to-end; auto only fills keys still on the EN
# fallback.
# - `{placeholder}` tokens are protected end-to-end.
#
# Triggers:
# - push to develop touching AppImage/messages/en/common.json
# - manual via workflow_dispatch
on:
push:
branches: [develop]
paths:
# Only en/ triggers the run — it's the source of truth. Other
# locales are destinations, and the bot auto-commits them at
# the end of every run; if they were in the trigger too, each
# auto-commit would fire another (empty) run.
# Manual edits to a curated locale that empty a key for the
# workflow to refill are the exception — dispatch this
# workflow manually from Actions in that case, or piggy-back
# a trivial en/ change onto the commit.
- 'AppImage/messages/en/common.json'
- '.github/scripts/build_i18n_messages.py'
- '.github/workflows/build-i18n-messages.yml'
workflow_dispatch:
inputs:
refresh:
description: 'Re-translate every key (overwrites human translations!)'
type: boolean
default: false
languages:
description: 'Comma-separated locales. Default: es,de,fr,it,pt,sk,sv (guardrail protects Vaso73 sk).'
default: 'es,de,fr,it,pt,sk,sv'
# Prevent two runs from racing on the same branch and fighting over the
# auto-commit. cancel-in-progress:false because a full first-run may take
# ~30 min for the initial bootstrap and interrupting mid-flight would
# waste the calls already made.
concurrency:
group: build-i18n-messages-${{ github.ref }}
cancel-in-progress: false
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write # auto-commit AppImage/messages/*/common.json to develop
# First-run bootstrap of ~5 locales × 3.8k keys can take a while at
# 0.15s/call with rate-limit backoffs. 90 min headroom.
timeout-minutes: 90
steps:
- name: Checkout develop
uses: actions/checkout@v4
with:
ref: develop
# Full history so the auto-commit doesn't drift when another
# push landed between trigger and this job start.
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install googletrans
run: |
python -m pip install --upgrade pip
# Same pinning as build-translation-cache.yml so the two
# workflows share behavior. Bump both in lockstep.
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
- name: Translate missing keys
run: |
REFRESH_FLAG=""
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
REFRESH_FLAG="--refresh"
fi
LANGS="${{ github.event.inputs.languages }}"
# Keep this fallback in sync with the workflow_dispatch input
# default above AND with DEFAULT_LANGUAGES in the Python script —
# `push` triggers hit this branch (inputs are empty on push).
LANGS="${LANGS:-es,de,fr,it,pt,sk,sv}"
python .github/scripts/build_i18n_messages.py \
--source AppImage/messages/en/common.json \
--messages-dir AppImage/messages \
--languages "$LANGS" \
--provider googletrans \
$REFRESH_FLAG
- name: Commit + push if changed
run: |
if git diff --quiet -- AppImage/messages/; then
echo "No translation changes — skipping commit."
exit 0
fi
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add AppImage/messages/
git commit -m "chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ${GITHUB_SHA::7}
Triggered by: ${{ github.event_name }}"
# Rebase-and-retry against develop: between the initial
# checkout and this push another workflow (e.g.
# build-translation-cache auto-commit) or a manual push can
# land on develop first, and a naked push fails with
# non-fast-forward — losing the freshly generated translation
# commit. Rebasing the local i18n commit on top of the newer
# HEAD is safe: paths don't overlap with cache/script
# workflows, and the same-branch collisions between two i18n
# runs are already blocked by the concurrency group above.
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
echo "push succeeded on attempt ${attempt}"
exit 0
fi
echo "push attempt ${attempt} failed, retrying..."
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
@@ -0,0 +1,129 @@
name: Build translation cache
# Regenerates lang/*.json whenever a bash script under scripts/ changes.
# The runtime translate() in scripts/utils.sh reads these JSON files and
# falls back to the English source on miss, so keeping them up-to-date is
# what makes ProxMenux multilingual without any runtime googletrans
# dependency on the user's host.
#
# Triggers:
# - push to develop touching scripts/**/*.sh
# - manual via workflow_dispatch (force --refresh)
on:
push:
branches: [develop]
paths:
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
- 'scripts/**/*.py'
- 'menu'
- 'install_proxmenux.sh'
- 'install_proxmenux_beta.sh'
- 'oci/src/**/*.py'
- 'oci/remote/*.sh'
- 'oci/remote/*.py'
- 'oci/catalog/**/*.json'
- '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-translation-cache.yml'
workflow_dispatch:
inputs:
refresh:
description: 'Re-translate every entry (ignore cached values)'
type: boolean
default: false
# Avoid two cache rebuilds from racing each other on the same branch and
# fighting over the auto-commit.
concurrency:
group: build-translation-cache-${{ github.ref }}
cancel-in-progress: false
jobs:
rebuild-cache:
runs-on: ubuntu-latest
permissions:
contents: write # auto-commit lang/*.json back to develop
steps:
- name: Checkout develop
uses: actions/checkout@v4
with:
ref: develop
# Need full history so the auto-commit doesn't fail when the
# cache job runs minutes after the trigger push (GH default
# fetch-depth=1 sometimes diverges from origin/develop after a
# quick follow-up push).
fetch-depth: 0
# Use a PAT (or default GITHUB_TOKEN if branch protections allow
# it) so the push back to develop actually fires later steps
# (workflow runs from auto-commits) if you ever need them.
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install googletrans
run: |
python -m pip install --upgrade pip
# 4.0.0-rc1 is the same pin that build_translation_cache.py
# was written against. Bump in lockstep with the script.
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
- name: Regenerate lang/*.json
run: |
REFRESH_FLAG=""
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
REFRESH_FLAG="--refresh"
fi
# Extra files outside scripts/ that also contain translate "..."
# calls. Keep this list in sync with the `paths` trigger above.
python .github/scripts/build_translation_cache.py \
--scripts-dir scripts \
--extra-file menu \
--extra-file install_proxmenux.sh \
--extra-file install_proxmenux_beta.sh \
--extra-dir oci/remote \
--python-dir scripts \
--python-dir oci/src \
--python-dir oci/remote \
--catalog-dir oci/catalog \
--output-dir lang \
--provider googletrans \
$REFRESH_FLAG
- name: Commit + push if changed
run: |
if git diff --quiet -- lang/; then
echo "No translation changes — skipping commit."
exit 0
fi
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add lang/
git commit -m "chore(lang): auto-rebuild translation cache
Source: ${GITHUB_SHA::7}
Triggered by: ${{ github.event_name }}"
# Rebase-and-retry against develop: between the initial
# checkout and this push another workflow (e.g.
# build-i18n-messages auto-commit) or a manual push can land
# on develop first, and a naked push fails with
# non-fast-forward — losing the freshly built cache commit.
# Paths don't overlap with the other workflows, and
# same-branch collisions between two cache runs are already
# blocked by the concurrency group.
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
echo "push succeeded on attempt ${attempt}"
exit 0
fi
echo "push attempt ${attempt} failed, retrying..."
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
+119
View File
@@ -0,0 +1,119 @@
name: Build web documentation translations
on:
push:
branches: [develop]
paths:
- 'web/messages/en/*.json'
- 'web/messages/en/**/*.json'
- '.github/scripts/build_web_docs_i18n.py'
- '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-web-docs-i18n.yml'
workflow_dispatch:
inputs:
languages:
description: 'Comma-separated locales'
default: 'es,de,fr,it,pt,sk,sv'
section:
description: 'File or directory below web/messages/en'
default: '.'
max_files:
description: 'Maximum pending files per locale; 0 means all'
default: '0'
refresh:
description: 'Overwrite existing translations in the selected scope'
type: boolean
default: false
dry_run:
description: 'Report pending coverage without writing files'
type: boolean
default: false
concurrency:
group: build-web-docs-i18n-${{ github.ref }}
cancel-in-progress: false
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
timeout-minutes: 120
steps:
- name: Checkout develop
uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install translation provider
run: |
python -m pip install --upgrade pip
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
- name: Build missing translations
shell: bash
run: |
LANGUAGES="${{ github.event.inputs.languages }}"
LANGUAGES="${LANGUAGES:-es,de,fr,it,pt,sk,sv}"
SECTION="${{ github.event.inputs.section }}"
SECTION="${SECTION:-.}"
MAX_FILES="${{ github.event.inputs.max_files }}"
MAX_FILES="${MAX_FILES:-0}"
EXTRA_ARGS=()
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
EXTRA_ARGS+=(--refresh)
fi
if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then
EXTRA_ARGS+=(--dry-run)
fi
python .github/scripts/build_web_docs_i18n.py \
--source-dir web/messages/en \
--messages-dir web/messages \
--languages "$LANGUAGES" \
--section "$SECTION" \
--max-files "$MAX_FILES" \
--provider googletrans \
--workers 4 \
"${EXTRA_ARGS[@]}"
- name: Validate catalogs
run: |
python .github/scripts/build_web_docs_i18n.py \
--source-dir web/messages/en \
--messages-dir web/messages \
--languages "${{ github.event.inputs.languages || 'es,de,fr,it,pt,sk,sv' }}" \
--check
- name: Commit and push changes
if: ${{ github.event.inputs.dry_run != 'true' }}
shell: bash
run: |
if git diff --quiet -- web/messages/; then
echo "No documentation translations changed."
exit 0
fi
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add web/messages/
git commit -m "docs(i18n): update documentation translations"
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
exit 0
fi
git rebase --abort 2>/dev/null || true
sleep $((attempt * 3))
done
exit 1
+9 -9
View File
@@ -6,7 +6,7 @@ on:
- "main"
paths:
- "web/**"
- "guides/**"
- "lang/**"
- "CHANGELOG.md"
workflow_dispatch:
@@ -24,22 +24,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: 'web/package.json'
cache-dependency-path: 'web/package-lock.json'
- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@v6
- name: Install dependencies and generate lock file
- name: Install dependencies
run: |
cd web
npm install
npm ci
- name: Build with Next.js
run: |
@@ -47,7 +47,7 @@ jobs:
npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: web/out
@@ -60,4 +60,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
@@ -0,0 +1,25 @@
name: Label OCI validation pull requests
# A pull request that changes verification.json gets the "oci-validation"
# label, which adds it to the ProxMenux Roadmap. The pull request's code is
# never checked out: only its list of changed files is read.
on:
pull_request_target:
types: [opened, reopened, synchronize]
paths:
- 'oci/catalog/verification.json'
permissions:
issues: write
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.pull_request.number }}
run: gh api "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels" -f "labels[]=oci-validation" --silent
@@ -0,0 +1,86 @@
name: Record OCI validation
# When an OCI validation report is validated, the report's author is recorded
# in oci/catalog/verification.json on develop and oci/VALIDATION.md is
# regenerated. A report is validated by the "validated" label, which only users
# with write access can add, or by a /validated comment from a user listed in
# .github/oci-validation-reviewers, checked by the script. The issue body and
# the comment are read from the event file, never interpolated into a shell.
on:
issues:
types: [labeled]
issue_comment:
types: [created]
concurrency:
group: oci-validation-record
cancel-in-progress: false
jobs:
record:
if: >-
contains(github.event.issue.labels.*.name, 'oci-validation') && (
(github.event_name == 'issues' && github.event.label.name == 'validated') ||
(github.event_name == 'issue_comment' && !github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/validated')))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
issues: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Record the report
id: record
run: |
set +e
python3 .github/scripts/oci_validation.py record --event "$GITHUB_EVENT_PATH" --message "$RUNNER_TEMP/reply.md"
echo "code=$?" >> "$GITHUB_OUTPUT"
- name: Commit + push
if: steps.record.outputs.code == '0'
env:
APP: ${{ steps.record.outputs.app }}
NUMBER: ${{ github.event.issue.number }}
run: |
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add oci/catalog/verification.json oci/VALIDATION.md
git commit -m "chore(oci): record the validation of $APP (#$NUMBER)"
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin HEAD:develop; then
exit 0
fi
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
- name: Reply and close
if: steps.record.outputs.code == '0'
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
gh issue close "$NUMBER" --repo "$GITHUB_REPOSITORY" --reason completed
- name: Explain why it was not recorded
if: steps.record.outputs.code == '2'
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
if [ "$GITHUB_EVENT_NAME" = issues ]; then
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels/validated" --silent
fi
- name: Fail on an unexpected error
if: steps.record.outputs.code != '0' && steps.record.outputs.code != '2'
run: exit 1
+49
View File
@@ -0,0 +1,49 @@
name: OCI validation record
# verification.json is the record of OCI applications tested for real, and
# oci/VALIDATION.md is generated from it. A pull request only adds or changes
# the community tests credited to its own author.
on:
pull_request:
paths:
- 'oci/catalog/verification.json'
- 'oci/catalog/index.json'
- 'oci/VALIDATION.md'
- '.github/scripts/oci_validation.py'
- '.github/workflows/oci-validation.yml'
push:
branches: [main, develop]
paths:
- 'oci/catalog/verification.json'
- 'oci/catalog/index.json'
- 'oci/VALIDATION.md'
- '.github/scripts/oci_validation.py'
- '.github/workflows/oci-validation.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check the record and oci/VALIDATION.md
run: python3 .github/scripts/oci_validation.py check
- name: Check who the pull request credits
if: github.event_name == 'pull_request'
env:
AUTHOR: ${{ github.event.pull_request.user.login }}
BASE: ${{ github.event.pull_request.base.sha }}
run: |
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"
+27
View File
@@ -0,0 +1,27 @@
name: Update project growth
on:
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: repo-growth
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: MacRimi/repo-growth@v1
with:
title: ProxMenux growth
output: images/project-growth.svg
history: .github/repo-growth/history.json
metrics: stars,forks,clones
traffic-token: ${{ secrets.REPO_GROWTH_TRAFFIC_TOKEN }}
layout: dashboard
+67
View File
@@ -0,0 +1,67 @@
name: Offline i18n tests
on:
pull_request:
paths:
- '.github/scripts/**'
- '.github/workflows/test-i18n.yml'
- '.github/workflows/build-i18n-messages.yml'
- 'AppImage/messages/**'
- 'AppImage/lib/i18n/**'
- 'AppImage/components/terminal-panel.tsx'
- 'CONTRIBUTING.md'
# Scheduler consumers and the offline extractor's .sh/.func inputs.
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
- 'scripts/**/*.py'
- 'lang/*.json'
- 'oci/src/**/*.py'
- 'oci/catalog/**/*.json'
- 'oci/remote/**/*.sh'
push:
branches: [main, develop]
paths:
- '.github/scripts/**'
- '.github/workflows/test-i18n.yml'
- '.github/workflows/build-i18n-messages.yml'
- 'AppImage/messages/**'
- 'AppImage/lib/i18n/**'
- 'AppImage/components/terminal-panel.tsx'
- 'CONTRIBUTING.md'
# Scheduler consumers and the offline extractor's .sh/.func inputs.
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
- 'scripts/**/*.py'
- 'lang/*.json'
- 'oci/src/**/*.py'
- 'oci/catalog/**/*.json'
- 'oci/remote/**/*.sh'
workflow_dispatch:
permissions:
contents: read
jobs:
offline-i18n:
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: Check scheduler test dependencies
run: |
test -x /bin/bash
test -x /usr/bin/jq
- name: Install OCI wording test dependency
run: python3 -m pip install 'PyYAML>=6,<7'
- name: Run complete offline script test suite
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s .github/scripts/tests -v
@@ -0,0 +1,83 @@
name: Qualified offline tests
on:
pull_request:
paths:
- '.github/workflows/test-offline-qualified.yml'
- '.github/scripts/run_offline_qualified.py'
- '.github/ci/offline-node/**'
- 'CONTRIBUTING.md'
- 'tests/**'
- 'AppImage/package.json'
- 'AppImage/package-lock.json'
- 'AppImage/scripts/*.py'
- 'AppImage/app/**'
- 'AppImage/components/**'
- 'AppImage/hooks/**'
- 'AppImage/lib/**'
- 'AppImage/messages/**'
- 'lang/*.json'
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
push:
branches: [main, develop]
paths:
- '.github/workflows/test-offline-qualified.yml'
- '.github/scripts/run_offline_qualified.py'
- '.github/ci/offline-node/**'
- 'CONTRIBUTING.md'
- 'tests/**'
- 'AppImage/package.json'
- 'AppImage/package-lock.json'
- 'AppImage/scripts/*.py'
- 'AppImage/app/**'
- 'AppImage/components/**'
- 'AppImage/hooks/**'
- 'AppImage/lib/**'
- 'AppImage/messages/**'
- 'lang/*.json'
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
workflow_dispatch:
permissions:
contents: read
jobs:
offline-python:
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: Run eight qualified Python files
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
offline-node:
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 only locked fixture dependencies (no lifecycle scripts)
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules
- name: Run eleven qualified Node files
env:
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane node
@@ -0,0 +1,164 @@
name: Update App Tracking Hints
on:
# Manual trigger from the Actions UI
workflow_dispatch:
# Re-merge whenever the generator, workflow, or the maintainer-
# curated runtime overrides change. `runtime_verified_overrides.json`
# is the file to edit when a real LXC reveals a canonical path the
# community-scripts helper doesn't ship (legacy /app/package.json,
# /opt/vaultwarden/bin/vaultwarden, etc.) — the generator folds it
# into the operational catalog every run.
push:
branches: [main]
paths:
- ".github/scripts/generate_app_tracking_catalog.py"
- ".github/workflows/update-app-tracking-hints.yml"
- "json/runtime_verified_overrides.json"
# Regen every 6h — picks up new community-scripts LXC apps and
# detector-relevant script edits without needing a manual trigger.
schedule:
- cron: "0 */6 * * *"
jobs:
update-hints:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: ⬇️ Checkout the repository
uses: actions/checkout@v6
- name: 🐍 Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The generator writes 4 files; only `.generated.json` is
# consumed downstream by the merge step. The v2 catalog and
# per-app audit are useful for local review but not kept in
# the repo — written under /tmp so they never appear as
# dirty files here.
#
# `--runtime-overrides` folds real-CT evidence into the
# operational hints (canonical paths, cross-method fallbacks
# per app) so the runtime doesn't get fed helper-marker
# false-positives. Modern helper markers are included as their
# official version contract; runtime keeps them distinguishable from
# canonical package/binary/manual detectors for legacy compatibility.
run: |
python .github/scripts/generate_app_tracking_catalog.py \
--helpers-cache json/helpers_cache.json \
--existing json/app_tracking_hints.json \
--runtime-overrides json/runtime_verified_overrides.json \
--include-helper-markers \
--output json/app_tracking_hints.generated.json \
--v2-output /tmp/app_tracking_catalog.v2.json \
--audit-output /tmp/app_tracking_hints.audit.json
- name: 🧬 Smart-merge generated into app_tracking_hints.json
# Single source of truth: `app_tracking_hints.json` is the ONE
# file. It contains 3 kinds of entries:
# 1. Auto-verified from community-scripts (the generator
# manages every "generator-owned" field on these).
# 2. User-edited additions to those entries — extra fields
# the generator doesn't touch (default_ports,
# file_fallbacks, custom logo overrides…).
# 3. User-only entries the generator can't verify (Docker,
# AdGuard, Pi-hole, WireGuard, …) — left alone.
# Merge rule: for slugs the generator produces, refresh only
# the whitelisted fields; preserve everything else. For slugs
# NOT in the generator's output, keep the existing entry
# untouched.
run: |
python - <<'PY'
import json
from pathlib import Path
GEN = Path("json/app_tracking_hints.generated.json")
OUT = Path("json/app_tracking_hints.json")
# Fields owned by the generator — refreshed on every run.
# These are all populated deterministically by the generator
# (the audit script folds `runtime_verified_overrides.json`
# in as it runs), so a local hand-edit for a generator-known
# slug would get overwritten on the next tick. To add a new
# canonical path or a cross-method fallback for a slug the
# generator already knows, edit `runtime_verified_overrides
# .json` — that file IS the maintainer-controlled input.
#
# For user-only slugs (Docker, WireGuard, Pi-hole and any
# other entry not in the generator's output) EVERY field is
# preserved verbatim by the merge below — the whitelist only
# governs generator-covered slugs.
GENERATOR_FIELDS = {
"installed_via", "package", "file_path", "file_regex",
"binary_path", "binary_args", "python_path", "distribution",
"container_name", "label", "command_argv", "installed_version",
"repo", "github_source", "tag_regex", "installed_regex",
# Upstream source discriminator + per-type fields
# (http_json + docker_hub). Kept in the whitelist so a
# curated entry in runtime_verified_overrides.json can
# supply them and the smart merge won't drop them on the
# next regeneration.
"upstream_type", "upstream_url", "upstream_json_path",
"docker_image",
"logo", "website",
"default_ports", "file_fallbacks", "alt_detectors",
}
generated = json.loads(GEN.read_text(encoding="utf-8"))
existing = {}
if OUT.is_file():
try:
existing = json.loads(OUT.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
except json.JSONDecodeError:
existing = {}
merged = {}
for slug, gen_entry in generated.items():
base = dict(existing.get(slug) or {})
# Refresh generator-owned fields (add/update).
for k, v in gen_entry.items():
if k in GENERATOR_FIELDS:
base[k] = v
# Drop generator-owned fields that the generator no
# longer emits for this slug (e.g. path renamed away).
for k in list(base):
if k in GENERATOR_FIELDS and k not in gen_entry:
del base[k]
merged[slug] = base
# Preserve user-only entries the generator can't verify.
for slug, entry in existing.items():
if slug not in generated and isinstance(entry, dict):
merged[slug] = dict(entry)
OUT.write_text(json.dumps(merged, indent=2, sort_keys=True) + "\n", encoding="utf-8")
added = sorted(set(generated) - set(existing))
removed = sorted(set(existing) - set(generated) - {s for s, e in existing.items() if not (
set(e.keys()) - GENERATOR_FIELDS
)})
print(f"merged: {len(merged)} entries "
f"(generated={len(generated)}, existing={len(existing)})")
if added:
print(f" new from generator: {len(added)}")
# Clean up the intermediate file so it doesn't get committed.
GEN.unlink()
PY
- name: 📤 Commit + push if changed
run: |
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add json/app_tracking_hints.json
git diff --cached --quiet || git commit -m "Update app tracking hints"
git push
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: ⬇️ Checkout the repository
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: 🐍 Set up Python
uses: actions/setup-python@v4
+19
View File
@@ -6,6 +6,14 @@ web/out/
web/node_modules/
node_modules/
# Local-only — accidental pagefind install at project root.
# Pagefind is declared and installed from web/package.json; the
# CI build (.github/workflows/deploy.yml) only runs
# `cd web && npm install`, so a root-level package.json/lock is
# never consumed and just adds noise. Keep them ignored.
/package.json
/package-lock.json
# Logs
web/*.log
*.log
@@ -33,6 +41,17 @@ Thumbs.db
/web/.next
/web/out
# Search index written by `build`: pagefind --site out produces it into
# public/pagefind/ and the GitHub Pages CI regenerates it on every deploy,
# so committing it would only bloat the repo and add noise to `git status`.
/web/public/pagefind/
# Left ignored so the copy that `sync:scripts` used to generate disappears
# from a working tree that still has it. The docs link to the scripts on
# GitHub, never to a copy served from the site, and pagefind indexes HTML
# only — so nothing read it and the static export was publishing it anyway.
/web/public/scripts/
# Cache
.cache
/web/.cache
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
db5bc199adba9c231f344428ac902a0cbf7473778e8a79a4535263599d975449 ProxMenux-1.2.0.AppImage
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
+116 -34
View File
@@ -27,19 +27,97 @@ A modern, responsive dashboard for monitoring Proxmox VE systems built with Next
## Overview
**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, and system logs.
**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, backups, health status and system logs — all from a single browser tab.
The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network.
**Full documentation:** [proxmenux.com/docs/monitor](https://proxmenux.com/docs/monitor) — per-feature walkthroughs, API reference and integration guides.
## Screenshots
Get a quick overview of ProxMenux Monitor's main features:
A tour of the Monitor's main areas. See the [public documentation](https://proxmenux.com/docs/monitor) for the full walkthrough.
### Dashboard overview
<p align="center">
<img src="public/images/onboarding/imagen1.png" alt="Overview Dashboard" width="800"/>
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/dashboard-home.png" alt="Dashboard home" width="900"/>
<br/>
<em>System Overview - Monitor CPU, memory, temperature, and uptime in real-time</em>
<em>Real-time CPU, memory, temperature, storage and network activity on one glance-optimised page.</em>
</p>
### Backup & Restore
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/images/docs/backup-restore/scheduled-backup-monitor.png" alt="Scheduled backup jobs" width="900"/>
<br/>
<em>Integrated host backup & restore — Local, PBS or Borg destinations; own timer or attached to a PVE vzdump job with live-inherited retention; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore.</em>
</p>
### Storage & SMART
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/storage-top-row.png" alt="Storage top row" width="900"/>
<br/>
<em>Per-disk cards with capacity palette shared across Storage and Backups. USB-NVMe enclosures (ASMedia / JMicron / Realtek) show the drive's real identity and temperature, not the bridge.</em>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/disk-modal-smart.png" alt="Disk SMART modal" width="900"/>
<br/>
<em>Per-disk detail modal — SMART attributes, temperature history, observations and downloadable PDF report.</em>
</p>
### Network — live topology
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/network-flow-overview.png" alt="Network Flow diagram" width="900"/>
<br/>
<em>Network Flow — live view of NICs → host → bridges → guests with animated rx / tx pulses on every link. Bridges without active guests are hidden.</em>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/network-latency-historical.png" alt="Network latency historical" width="900"/>
<br/>
<em>Latency modal — historical view and real-time ping test against Gateway / Cloudflare / Google, with a downloadable PDF report.</em>
</p>
### Virtual machines & LXCs
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/vms-top-row.png" alt="VMs & LXCs overview" width="900"/>
<br/>
<em>Inventory of running VMs and containers with resource usage and per-guest controls.</em>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/vms-modal-status.png" alt="VM detail modal — status" width="900"/>
<br/>
<em>Per-guest modal with status, backups, mounts and (for LXC) apt / apk / community-scripts update inventory.</em>
</p>
### Health Monitor
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/health-monitor.png" alt="Health Monitor" width="900"/>
<br/>
<em>Ten categories of proactive health checks with hysteresis, per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke.</em>
</p>
### System overview — top processes
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/system-overview-top-processes.png" alt="Top processes" width="900"/>
<br/>
<em>Per-process CPU / memory / I/O sorted by consumer, with a click-through detail modal.</em>
</p>
### Mobile
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/mobile-home.png" alt="Mobile responsive layout" width="360"/>
<br/>
<em>Fully responsive — every panel adapts to phone and tablet layouts.</em>
</p>
@@ -47,18 +125,21 @@ Get a quick overview of ProxMenux Monitor's main features:
## Features
- **System Overview**: Real-time monitoring of CPU, memory, temperature, and system uptime
- **Storage Management**: Visual representation of storage distribution, disk health, and SMART data
- **Network Monitoring**: Network interface statistics, real-time traffic graphs, and bandwidth usage
- **Virtual Machines & LXC**: Comprehensive view of all VMs and containers with resource usage and controls
- **Hardware Information**: Detailed hardware specifications including CPU, GPU, PCIe devices, and disks
- **System Logs**: Real-time system log monitoring with filtering and search capabilities
- **Health Monitoring**: Proactive system health checks with persistent error tracking
- **Authentication & 2FA**: Optional password protection with TOTP-based two-factor authentication
- **RESTful API**: Complete API access for integrations with Homepage, Home Assistant, and custom dashboards
- **Dark/Light Theme**: Toggle between themes with Proxmox-inspired design
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
- **Release Notes**: Automatic notifications of new features and improvements
- **Host Backup & Restore** — integrated section covering Local, PBS and Borg destinations; schedule with a systemd timer or attach to an existing PVE vzdump job; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore with kernel-agnostic hydration (IOMMU / VFIO / GRUB), cascade-safe package replay, and NIC auto-remap by MAC after a motherboard swap
- **System Overview** — real-time CPU / memory / temperature / uptime with a per-process drill-down (Top Processes view) and click-through detail modal
- **Storage & SMART** — per-disk cards in a responsive grid; temperature history, SMART attributes and a downloadable PDF SMART report per drive. USB-NVMe bridges (ASMedia / JMicron / Realtek) show the real drive's identity and temperature instead of the enclosure's
- **Network** — live topology (Network Flow) with animated rx / tx pulses, per-interface RRD charts, latency modal against Gateway / Cloudflare / Google with a downloadable PDF report
- **Virtual Machines & LXCs** — inventory, per-guest metrics and controls, per-LXC update inventory (apt / apk / community-scripts), backup state, mount inspection
- **Hardware** — CPU / GPU / PCIe / disks / NICs, with correct SSD vs HDD classification even behind USB-SATA bridges
- **System Logs** — journalctl with severity / since / free-text filters and a download-as-text action
- **Health Monitor** — ten categories of proactive checks with hysteresis and configurable thresholds; per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke
- **Notifications** — five channels (Telegram, Discord, Gotify, Email, Apprise for ~80 endpoints), per-event toggles, Quiet Hours, Daily Digest, optional AI enrichment (Groq / OpenAI / Ollama / Gemini / Anthropic / OpenRouter)
- **Authentication & 2FA** — optional password protection with TOTP-based two-factor authentication and long-lived API tokens (365 days) for integrations
- **RESTful API** — complete access with JWT auth; ready-made recipes for Homepage, Home Assistant and custom dashboards; Prometheus scrape endpoint
- **Reports (PDF)** — SMART report per-disk and Network Latency report per-target, ready to send to a vendor or an ISP
- **Dark / Light Theme** — Proxmox-inspired palette with live switch
- **Responsive Design** — desktop, tablet and mobile layouts
- **Release Notes** — automatic notification of new features on each Monitor upgrade
## Technology Stack
@@ -106,7 +187,7 @@ On first launch, you'll be presented with three options:
2. **Enable 2FA** - Add TOTP-based two-factor authentication for enhanced security
3. **Skip** - Continue without authentication (not recommended for production environments)
![Authentication Setup](AppImage/public/images/docs/auth-setup.png)
![Authentication Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/auth-setup.png)
### Two-Factor Authentication (2FA)
@@ -118,7 +199,21 @@ After setting up your password, you can enable 2FA using any TOTP authenticator
4. Enter the 6-digit code to verify
5. Save your backup codes in a secure location
![2FA Setup](AppImage/public/images/docs/2fa-setup.png)
![2FA Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/2fa-setup.png)
### Embedding in Trusted Iframes
By default, ProxMenux Monitor blocks embedding in iframes with `frame-ancestors 'none'` and `X-Frame-Options: DENY`.
If you run a trusted local portal or monitoring page and need to embed the Monitor, set `PROXMENUX_ALLOWED_FRAME_ANCESTORS` to the exact parent origins that may frame it:
```bash
PROXMENUX_ALLOWED_FRAME_ANCESTORS="https://portal.example.com http://raspberrypi.local:8080"
```
Only exact `http://` or `https://` origins are accepted. Paths, wildcards, broad schemes, credentials, and malformed values are ignored. When this setting is present, the Monitor sends a matching CSP `frame-ancestors` allowlist and omits `X-Frame-Options`, because that legacy header cannot express multiple allowed parents.
`ALLOWED_FRAME_ANCESTORS` is also accepted as a compatibility alias when `PROXMENUX_ALLOWED_FRAME_ANCESTORS` is not set.
### Security Best Practices for API Tokens
@@ -254,7 +349,7 @@ The easiest way to generate an API token is through the ProxMenux Monitor web in
6. Click **Generate Token**
7. Copy the token immediately - it will not be shown again
![Generate API Token](AppImage/public/images/docs/generate-api-token.png)
![Generate API Token](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/api-tokens.png)
The token will be valid for **365 days** (1 year) and can be used for integrations with Homepage, Home Assistant, or any custom application.
@@ -643,8 +738,6 @@ Finally, reference the secret in your `services.yaml`:
format: bytes
```
![Homepage Integration Example](AppImage/public/images/docs/homepage-integration.png)
### Home Assistant Integration
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform.
@@ -727,24 +820,13 @@ entities:
icon: mdi:clock-outline
```
![Home Assistant Integration Example](AppImage/public/images/docs/homeassistant-integration.png)
---
## License
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**.
This project is licensed under the **GNU General Public License, version 3 (GPL-3.0)**.
You are free to:
- Share — copy and redistribute the material in any medium or format
- Adapt — remix, transform, and build upon the material
Under the following terms:
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made
- NonCommercial — You may not use the material for commercial purposes
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
You are free to use, study, share and modify the software under the terms of the licence. Any distributed derivative work must be licensed under the same terms and include the full source code — see the [full licence text](https://www.gnu.org/licenses/gpl-3.0.html) or the [`LICENSE`](https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) file at the repository root.
+11 -4
View File
@@ -3,6 +3,9 @@ import type { Metadata, Viewport } from "next"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import { ThemeProvider } from "../components/theme-provider"
import { PwaRegister } from "../components/pwa-register"
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
import { I18nProvider } from "../lib/i18n/provider"
import { Suspense } from "react"
import "./globals.css"
@@ -41,11 +44,15 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
<Suspense fallback={<div>Loading...</div>}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
<Suspense fallback={null}>
<I18nProvider>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
<PwaInstallPrompt />
</I18nProvider>
</Suspense>
<PwaRegister />
</body>
</html>
)
+43 -5
View File
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
import { Login } from "../components/login"
import { AuthSetup } from "../components/auth-setup"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
export default function Home() {
const t = useT()
const [authStatus, setAuthStatus] = useState<{
loading: boolean
authEnabled: boolean
@@ -29,21 +31,57 @@ export default function Home() {
const response = await fetch(getApiUrl("/api/auth/status"), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
// 401 here means the token is present but invalid — typically signed
// under a previous jwt_secret (rotated on AppImage upgrade or fresh
// install). If we let this fall into the catch below, the dashboard
// would render and every authenticated component would fire its own
// 401 in parallel, flooding the backend logs and looping reloads.
// Drop the dead token and force the Login screen instead.
if (response.status === 401) {
try {
localStorage.removeItem("proxmenux-auth-token")
} catch {
// private browsing — best-effort
}
setAuthStatus({
loading: false,
authEnabled: true,
authConfigured: true,
authenticated: false,
})
return
}
// Check if response is valid JSON before parsing
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const contentType = response.headers.get("content-type")
if (!contentType || !contentType.includes("application/json")) {
throw new Error("Response is not JSON")
}
const data = await response.json()
const authenticated = data.auth_enabled ? data.authenticated : true
// Clear the 401 cascade-prevention flag when we successfully end
// up in the authenticated state. The flag is meant to dedupe a
// burst of 401s during a single page load; once we've confirmed
// the user is in, a future 401 (token rotation, restart, etc.)
// should be allowed to reload again. Without this, a stale flag
// can prevent the post-2FA dashboard from recovering from any
// transient 401 and leaves the UI blocked.
if (authenticated) {
try {
sessionStorage.removeItem("proxmenux-auth-401-handled")
} catch {
// private browsing — best-effort
}
}
setAuthStatus({
loading: false,
authEnabled: data.auth_enabled,
@@ -77,8 +115,8 @@ export default function Home() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading...</div>
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
</div>
</div>
)
+234
View File
@@ -0,0 +1,234 @@
"use client"
import Image from "next/image"
import {
Github,
Heart,
BookOpen,
MessageSquare,
Bug,
Sparkles,
Scale,
ExternalLink,
} from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { APP_VERSION } from "./release-notes-modal"
import { useT } from "../lib/i18n/provider"
// Issue #191: a dedicated About tab. Centralises project metadata
// (version, license, author) and every external link the project
// already exposes — GitHub, docs, donation. Replaces the lone
// "Support and contribute to the project" footer link with a proper
// information surface that's easy to extend with new social channels
// without re-cluttering the dashboard footer.
interface LinkRow {
labelKey: string
descriptionKey: string
href: string
Icon: React.ComponentType<{ className?: string }>
accent?: keyof typeof ACCENT_CLASSES
}
// Tailwind only emits classes that appear as literal strings in the
// source. A dynamic `bg-${accent}/10` template does not survive the
// purge step, so each accent maps to a fully-spelled class pair below.
const ACCENT_CLASSES = {
gray: "bg-gray-500/10 text-gray-400",
blue: "bg-blue-500/10 text-blue-500",
purple: "bg-purple-500/10 text-purple-400",
red: "bg-red-500/10 text-red-500",
pink: "bg-pink-500/10 text-pink-500",
} as const
const PROJECT_LINKS: LinkRow[] = [
{
labelKey: "about.links.repository.label",
descriptionKey: "about.links.repository.description",
href: "https://github.com/MacRimi/ProxMenux",
Icon: Github,
accent: "gray",
},
{
labelKey: "about.links.documentation.label",
descriptionKey: "about.links.documentation.description",
href: "https://proxmenux.com",
Icon: BookOpen,
accent: "blue",
},
{
labelKey: "about.links.discussions.label",
descriptionKey: "about.links.discussions.description",
href: "https://github.com/MacRimi/ProxMenux/discussions",
Icon: MessageSquare,
accent: "purple",
},
{
labelKey: "about.links.issues.label",
descriptionKey: "about.links.issues.description",
href: "https://github.com/MacRimi/ProxMenux/issues",
Icon: Bug,
accent: "red",
},
]
const SUPPORT_LINKS: LinkRow[] = [
{
labelKey: "about.links.support.label",
descriptionKey: "about.links.support.description",
href: "https://ko-fi.com/macrimi",
Icon: Heart,
accent: "pink",
},
]
function LinkCard({ row }: { row: LinkRow }) {
const t = useT()
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
// translucent background by default, slightly lighter on hover, no
// accent-coloured borders or text colour changes — keeps the look
// consistent with the rest of the project.
return (
<a
href={row.href}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer flex items-start gap-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
>
<span
className={`inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md ${accentClass}`}
>
<row.Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{t(row.labelKey)}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{t(row.descriptionKey)}</p>
</div>
</a>
)
}
export function About() {
const t = useT()
return (
<div className="space-y-4 md:space-y-6">
{/* Hero — logo, name, version, one-line description. */}
<Card>
<CardContent className="pt-6 pb-6">
<div className="flex flex-col md:flex-row items-center md:items-start gap-4 md:gap-6">
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
<Image
src="/images/proxmenux-logo.png"
alt={t("about.logoAlt")}
fill
priority
className="object-contain"
/>
</div>
<div className="text-center md:text-left flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-semibold text-foreground">
ProxMenux Monitor
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("about.heroDescription")}
</p>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
<Sparkles className="h-3 w-3" />
v{APP_VERSION}
</span>
{/* Beta versions surface their pre-release notes on the
GitHub Releases page (where each beta is tagged + signed);
stable versions point at the canonical web changelog
which only carries shipped releases. Detection: the
APP_VERSION string carries a "-beta" / "-rc" /
"-alpha" suffix for any non-stable build. */}
{(() => {
const isPrerelease = /-(beta|rc|alpha)/i.test(APP_VERSION)
const href = isPrerelease
? "https://github.com/MacRimi/ProxMenux/releases"
: "https://proxmenux.com/en/changelog"
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md bg-muted hover:bg-muted/70 transition-colors text-foreground border border-border px-2.5 py-1 text-xs"
>
{label}
<ExternalLink className="h-3 w-3" />
</a>
)
})()}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Project links — GitHub, docs, discussions, bug tracker. */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Github className="h-4 w-4 text-muted-foreground" />
{t("about.project.title")}
</CardTitle>
<CardDescription>{t("about.project.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{PROJECT_LINKS.map(row => (
<LinkCard key={row.href} row={row} />
))}
</div>
</CardContent>
</Card>
{/* Support + License combined — donation link and licensing
info in one card. The previous layout had a separate "Author"
block that has been removed by request. */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Heart className="h-4 w-4 text-pink-500" />
{t("about.support.title")}
</CardTitle>
<CardDescription>
{t("about.support.description")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-2">
{SUPPORT_LINKS.map(row => (
<LinkCard key={row.href} row={row} />
))}
<a
href="https://github.com/MacRimi/ProxMenux/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer flex items-start gap-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
>
<span className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md bg-gray-500/10 text-gray-400">
<Scale className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{t("about.license.label")}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
{t("about.license.description")}
</p>
</div>
</a>
</div>
</CardContent>
</Card>
</div>
)
}
+148
View File
@@ -0,0 +1,148 @@
"use client"
import { useId, useState } from "react"
import { Info, ExternalLink, Check, Loader2, Trash2 } from "lucide-react"
import { Button } from "./ui/button"
import { Textarea } from "./ui/textarea"
import { Label } from "./ui/label"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { useT } from "@/lib/i18n/provider"
export type AppUpdateMethod = "none" | "helper" | "custom"
export function AppUpdaterEditor({ method, command, helperAvailable, helperSlug, configured, saving, changed,
onMethodChange, onCommandChange, onSave, onCancel, onRemove }: {
method: AppUpdateMethod
command: string
helperAvailable: boolean
helperSlug?: string
configured: boolean
saving: boolean
changed: boolean
onMethodChange: (method: AppUpdateMethod) => void
onCommandChange: (command: string) => void
onSave: () => void
onCancel: () => void
onRemove: () => void
}) {
const t = useT()
const commandId = useId()
const [help, setHelp] = useState<"helper" | "custom" | null>(null)
const scriptUrl = helperSlug && /^[a-z0-9][a-z0-9._-]*$/.test(helperSlug)
? `https://github.com/community-scripts/ProxmoxVE/blob/main/ct/${helperSlug}.sh` : null
// Keep the editable command readable. Download guards belong to the runner,
// which also protects this literal launcher when saved as a custom command.
const helperCommand = scriptUrl
? `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${helperSlug}.sh)"`
: null
const valid = method === "helper" ? helperAvailable && !!helperCommand : method === "custom" && !!command.trim()
const displayedCommand = method === "helper" ? (helperCommand || "") : command
const editCommand = (value: string) => {
// Edited launchers belong to the existing custom-command execution path.
// Never leave the method as "helper": saving it would discard the edits.
if (method === "helper") {
if (value.trim() === (helperCommand || "").trim()) return
onMethodChange("custom")
}
onCommandChange(value)
}
const customExamples = [
{ title: "customScriptTitle", description: "customScriptDescription", command: "/opt/my-app/update.sh" },
{ title: "customPackageTitle", description: "customPackageDescription", command: "apt-get update &&\napt-get install -y --only-upgrade my-package" },
{ title: "customBinaryTitle", description: "customBinaryDescription", command: "install -b -m 0755 /tmp/my-app.new /opt/my-app/my-app &&\nsystemctl restart my-app" },
]
const onlineExampleCommand = `script=$(mktemp) || exit 1
trap 'rm -f "$script"' EXIT
curl -fsSL 'https://example.com/my-app/update.sh' -o "$script" &&
bash "$script"`
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.updaterChoiceHint")}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={t("vmLxc.updates.updaterMethodLabel")}>
{([...(helperAvailable ? ["helper" as const] : []), "custom" as const]).map((choice) => (
<div key={choice} className="flex items-center gap-1">
<Button type="button" size="sm" variant={method === choice ? "default" : "outline"}
aria-pressed={method === choice} disabled={saving} onClick={() => onMethodChange(choice)}>
{t(`vmLxc.updates.${choice === "helper" ? "helperMethod" : "customMethod"}`)}
</Button>
<Button type="button" size="icon" variant="ghost" className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400" onClick={() => setHelp(choice)}
aria-label={t(`vmLxc.updates.${choice === "helper" ? "helperMethodHelp" : "customMethodHelp"}`)}>
<Info className="h-4 w-4" />
</Button>
</div>
))}
</div>
{helperAvailable && <p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperDetectedChoice")}</p>}
{method === "helper" && (!helperAvailable || !helperCommand) && (
<p className="text-xs text-amber-500">{t("vmLxc.updates.helperUnavailableChoice")}</p>
)}
{(method === "helper" || method === "custom") && (
<div>
<Label htmlFor={commandId} className="text-xs uppercase tracking-wider text-muted-foreground">
{t(`vmLxc.updates.${method === "helper" ? "helperCommandLabel" : "customCommandLabel"}`)}
</Label>
<Textarea id={commandId} value={displayedCommand} onChange={(event) => editCommand(event.target.value)}
placeholder={t("vmLxc.updates.customCommandPlaceholder")} disabled={saving}
className="font-mono text-xs mt-2 min-h-[100px]" maxLength={4096} />
{method === "helper" && helperCommand && (
<p className="mt-2 text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandEditHint")}</p>
)}
</div>
)}
<div className="flex items-center justify-between gap-2">
<div>{configured && (
<Button type="button" size="sm" variant="outline" className="text-red-400" disabled={saving} onClick={onRemove}>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />{t("vmLxc.updates.disableUpdater")}
</Button>
)}</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="outline" disabled={saving} onClick={onCancel}>{t("vmLxc.updates.cancelButton")}</Button>
<Button type="button" size="sm" className="bg-blue-500 hover:bg-blue-600 text-white" disabled={saving || !valid || !changed} onClick={onSave}>
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1.5" />}
{t("vmLxc.updates.saveButton")}
</Button>
</div>
</div>
<Dialog open={help !== null} onOpenChange={(open) => { if (!open) setHelp(null) }}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t(`vmLxc.updates.${help === "helper" ? "helperMethod" : "customMethod"}`)}</DialogTitle>
<DialogDescription>{t(`vmLxc.updates.${help === "helper" ? "helperMethodDescription" : "customMethodDescription"}`)}</DialogDescription>
</DialogHeader>
{help === "helper" ? (
<div className="space-y-3 text-sm">
{helperCommand && <div className="min-w-0 space-y-2">
<p className="font-medium">{t("vmLxc.updates.helperCommandLabel")}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{helperCommand}</code></pre>
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandFallback")}</p>
</div>}
<p>{t("vmLxc.updates.updaterInstructions")}</p>
<a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href="https://community-scripts.org/docs/tools/pve/update-apps" target="_blank" rel="noopener noreferrer">
{t("vmLxc.updates.helperDocumentation")}<ExternalLink className="h-4 w-4" />
</a>
{scriptUrl && <div><a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href={scriptUrl} target="_blank" rel="noopener noreferrer">
{t("vmLxc.updates.helperSource")}<ExternalLink className="h-4 w-4" />
</a></div>}
</div>
) : (
<div className="space-y-4 text-sm">
<p className="font-medium">{t("vmLxc.updates.customExamplesHeading")}</p>
{customExamples.map((example) => <div key={example.title} className="min-w-0 space-y-2">
<p className="font-medium">{t(`vmLxc.updates.${example.title}`)}</p>
<p className="text-muted-foreground">{t(`vmLxc.updates.${example.description}`)}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{example.command}</code></pre>
{example.title === "customScriptTitle" && <>
<p className="text-muted-foreground">{t("vmLxc.updates.customOnlineDescription")}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{onlineExampleCommand}</code></pre>
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.customOnlineExampleNote")}</p>
</>}
</div>)}
<p className="text-muted-foreground">{t("vmLxc.updates.customMethodExample")}</p>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
+752
View File
@@ -0,0 +1,752 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import useSWR from "swr"
import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { ThemeAwareLogo } from "./lxc-app-panel"
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
import { Button } from "./ui/button"
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { getCategoryLabel } from "../lib/category-label"
// ─── Local subset of /api/vms shape ─────────────────────────────
// Kept narrow on purpose — this component only needs what feeds a
// launcher card. Full VMData / LxcAppWatch types live in
// virtual-machines.tsx.
interface AppPort {
port: number
description?: string
scheme?: "http" | "https"
web_path?: string
logo_url?: string | null
category?: string
custom_url?: string
}
interface AppWatch {
id: string
name: string | null
logo_url?: string | null
ports?: AppPort[]
installed_version: string | null
latest_version: string | null
update_available: boolean | null
managed_oci_app_id?: string | null
helper_slug?: string
}
interface DockerImageUpdate {
reference: string
display_name?: string | null
used_by?: string[]
update_available: boolean | null
}
// Locate the docker_inventory image whose lifecycle matches a given
// Web Link. The port's `description` is a user-typed label (e.g.
// "Paperless") so exact match on `used_by` (real container names
// like "paperless-webserver-1") almost never hits. Fall back through:
// 1. exact match in `used_by`
// 2. case-insensitive substring either way in `used_by`
// 3. substring in `display_name`
// 4. substring in `reference` (the full image path)
// Returns undefined when nothing matches — the caller treats that as
// "no upstream update signal for this port".
function findDockerImageForPort(
port: AppPort,
images: DockerImageUpdate[],
): DockerImageUpdate | undefined {
const desc = (port.description || "").trim().toLowerCase()
if (!desc || !images.length) return undefined
const exact = images.find((i) =>
(i.used_by || []).some((c) => c.toLowerCase() === desc),
)
if (exact) return exact
const inclUsedBy = images.find((i) =>
(i.used_by || []).some((c) => {
const cl = c.toLowerCase()
return cl.includes(desc) || desc.includes(cl)
}),
)
if (inclUsedBy) return inclUsedBy
const byDisplay = images.find((i) => {
const d = (i.display_name || "").toLowerCase()
return !!d && (d.includes(desc) || desc.includes(d))
})
if (byDisplay) return byDisplay
return images.find((i) => (i.reference || "").toLowerCase().includes(desc))
}
interface VM {
vmid: number
name: string
ip?: string
type: string
app_watches?: AppWatch[]
docker_inventory?: { images?: DockerImageUpdate[] }
}
interface LaunchLink {
key: string
// Present for LXC-registered apps and for custom links with a
// guest binding. Absent when the link is an unbound custom entry
// (e.g. an external service).
vmid: number | null
guestType: "lxc" | "qemu" | null
ctName: string
appName: string
logoUrl: string | null
weblink: string
category: string
updateAvailable: boolean
// Set for user-defined custom links so the card can offer edit
// and delete actions in edit mode.
isCustom: boolean
customId?: string
}
// ─── Helpers ─────────────────────────────────────────────────────
// Same URL construction as the Web Link row in the App tab.
// Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx
// is scoped to that module, and copying keeps this component free of
// hidden cross-file dependencies. A per-port `custom_url` overrides
// the ip:port composition entirely — used for apps served behind a
// reverse-proxy domain.
function buildWebUrl(ip: string | undefined, port: AppPort): string | null {
const custom = (port.custom_url || "").trim()
if (custom) return custom
const raw = (ip || "").trim().split("/")[0]
if (!raw || raw === "DHCP" || !port?.port) return null
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
return `${scheme}://${host}:${port.port}${path}`
}
type SortMode = "name" | "ct" | "category"
const SORT_STORAGE_KEY = "proxmenux-apps-sort"
const ALL_CATEGORIES = "__all__"
const fetcher = async (url: string) => fetchApi(url)
// ─── Component ───────────────────────────────────────────────────
export function AppsDashboard() {
const t = useT()
const { data: vms } = useSWR<VM[]>("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false })
// Custom links persisted in /etc/proxmenux/custom_links.json. Small
// and rarely changes, so we don't poll on an interval — mutate() is
// called explicitly after create / update / delete.
const { data: customLinks, mutate: mutateCustomLinks } = useSWR<CustomLink[]>(
"/api/apps/custom-links", fetcher, { revalidateOnFocus: false },
)
// Category presets for the "+ Add link" modal.
const { data: categoryPresets } = useSWR<string[]>(
"/api/apps/categories", fetcher, { revalidateOnFocus: false },
)
const isLightTheme = useIsLightTheme()
// Flatten VMs → LaunchLinks. One card per (app × port with weblink)
// for LXC-registered apps, plus one card per user-defined custom
// link. A custom link with a binding resolves its ctName from the
// matching guest in `vms` so renames stay in sync automatically.
const links = useMemo<LaunchLink[]>(() => {
const out: LaunchLink[] = []
const vmsList = Array.isArray(vms) ? vms : []
for (const vm of vmsList) {
const apps = vm.app_watches || []
if (!apps.length) continue
for (const app of apps) {
// Skip the synthetic entry ProxMenux inserts for managed
// OCI apps (Secure Gateway) — it has no user-assigned
// Web Link and doesn't belong in a launcher.
if (app.managed_oci_app_id) continue
// `app.update_available` refers to the app itself. For a
// Docker registration that app is the Docker engine, and its
// ports are containers running INSIDE Docker (Portainer,
// Frigate…) — each with an independent image update
// lifecycle in `vm.docker_inventory.images[]`. Propagating
// the engine-level flag to every container card would falsely
// mark Portainer/Frigate as updatable when only the engine
// needs bumping; missing the per-image flag would hide real
// Portainer/Frigate updates that ARE tracked in the App tab
// and fire notifications. Resolution: for each Docker port,
// find the image entry whose `used_by` includes the port's
// container name (== `port.description`) and use THAT image's
// update_available. Engine update stays out of the port cards
// — it belongs in the Updates tab.
const isDockerApp = app.helper_slug === "docker"
const dockerImages = vm.docker_inventory?.images || []
for (const port of app.ports || []) {
const url = buildWebUrl(vm.ip, port)
if (!url) continue
let updateAvailable = false
if (isDockerApp) {
const img = findDockerImageForPort(port, dockerImages)
updateAvailable = img?.update_available === true
} else {
updateAvailable = app.update_available === true
}
out.push({
key: `lxc-${vm.vmid}-${app.id}-${port.port}`,
vmid: vm.vmid,
guestType: "lxc",
ctName: vm.name,
appName: (port.description || app.name || vm.name || "").trim(),
logoUrl: port.logo_url || app.logo_url || null,
weblink: url,
category: (port.category || "").trim(),
updateAvailable,
isCustom: false,
})
}
}
}
// Merge user-defined custom links. Their `binding` decides how the
// CT/VM reference renders and where clicking it navigates.
const guestByVmid = new Map<number, { name: string; type: string }>()
for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type })
for (const link of customLinks || []) {
let ctName = ""
let vmid: number | null = null
let guestType: "lxc" | "qemu" | null = null
if (link.binding) {
const guest = guestByVmid.get(link.binding.vmid)
vmid = link.binding.vmid
guestType = link.binding.guest_type
ctName = guest?.name || ""
}
out.push({
key: `custom-${link.id}`,
vmid,
guestType,
ctName,
appName: link.name,
logoUrl: link.logo_url || null,
weblink: link.url,
category: (link.category || "").trim(),
updateAvailable: false,
isCustom: true,
customId: link.id,
})
}
return out
}, [vms, customLinks])
// Category list for the filter dropdown — built from the data so
// it always reflects reality (presets and custom-entered names).
const categoryCounts = useMemo(() => {
const map = new Map<string, number>()
for (const l of links) {
const key = l.category || t("apps.uncategorized")
map.set(key, (map.get(key) || 0) + 1)
}
return map
}, [links, t])
const sortedCategoryEntries = useMemo(
() => Array.from(categoryCounts.entries()).sort((a, b) => getCategoryLabel(t, a[0]).localeCompare(getCategoryLabel(t, b[0]))),
[categoryCounts, t],
)
// ─── Controls state ────────────────────────────────────────────
const [query, setQuery] = useState("")
const [currentCat, setCurrentCat] = useState<string>(ALL_CATEGORIES)
const [sortMode, setSortMode] = useState<SortMode>("name")
const [searchExpanded, setSearchExpanded] = useState(false)
const searchInputRef = useRef<HTMLInputElement | null>(null)
// Restore sort from localStorage on mount.
useEffect(() => {
try {
const saved = localStorage.getItem(SORT_STORAGE_KEY)
if (saved === "name" || saved === "ct" || saved === "category") {
setSortMode(saved)
}
} catch (_) { /* private mode / storage disabled — silent */ }
}, [])
// Persist sort choice — only this one preference survives reload;
// category filter and search reset each visit so the dashboard
// always opens showing every app.
useEffect(() => {
try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {}
}, [sortMode])
// Filter category resets if the user removes/renames the currently
// selected one and it disappears from the list.
useEffect(() => {
if (currentCat === ALL_CATEGORIES) return
if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES)
}, [currentCat, categoryCounts])
// ─── Custom link editor state ──────────────────────────────────
const [editorOpen, setEditorOpen] = useState(false)
const [editingLink, setEditingLink] = useState<CustomLink | null>(null)
const [editMode, setEditMode] = useState(false)
// Guest list feeds the binding dropdown in the editor modal.
const guestOptions = useMemo<GuestOption[]>(() => {
if (!Array.isArray(vms)) return []
return vms
.filter((v) => v.type === "lxc" || v.type === "qemu")
.map((v) => ({
vmid: v.vmid,
name: v.name,
type: v.type as "lxc" | "qemu",
}))
}, [vms])
const openNewLink = () => {
setEditingLink(null)
setEditorOpen(true)
}
const openEditForLink = (customId: string) => {
const found = (customLinks || []).find((l) => l.id === customId)
if (!found) return
setEditingLink(found)
setEditorOpen(true)
}
// ─── Filter + sort ─────────────────────────────────────────────
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const uncatKey = t("apps.uncategorized")
let filtered = links
if (currentCat !== ALL_CATEGORIES) {
filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat)
}
if (q) {
filtered = filtered.filter((l) =>
l.appName.toLowerCase().includes(q) ||
(l.ctName || "").toLowerCase().includes(q) ||
(l.vmid != null && String(l.vmid).includes(q)) ||
(l.category || "").toLowerCase().includes(q) ||
getCategoryLabel(t, l.category || "").toLowerCase().includes(q)
)
}
const sorted = [...filtered]
sorted.sort((a, b) => {
if (sortMode === "name") return a.appName.localeCompare(b.appName)
if (sortMode === "ct") {
// Unbound custom links have no vmid; sort them after every
// bound entry, ordered alphabetically by app name.
if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName)
if (a.vmid == null) return 1
if (b.vmid == null) return -1
return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName)
}
// category — grouped alphabetically, then by app name inside
const catA = a.category || uncatKey
const catB = b.category || uncatKey
const c = getCategoryLabel(t, catA).localeCompare(getCategoryLabel(t, catB))
return c !== 0 ? c : a.appName.localeCompare(b.appName)
})
return sorted
}, [links, query, currentCat, sortMode, t])
// ─── Empty state ───────────────────────────────────────────────
const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0)
if (vms && !hasAnyData) {
return (
<>
<div className="text-center text-muted-foreground py-16">
<div className="text-sm">{t("apps.emptyTitle")}</div>
<div className="text-xs mt-1 opacity-80">{t("apps.emptyHint")}</div>
<Button
onClick={openNewLink}
variant="outline"
className="mt-4"
>
<Plus className="h-4 w-4 mr-1.5" />
{t("apps.customLinkAdd")}
</Button>
</div>
<CustomLinkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editingLink}
guests={guestOptions}
categoryPresets={categoryPresets || []}
onSaved={() => mutateCustomLinks()}
/>
</>
)
}
// ─── Render ────────────────────────────────────────────────────
const countLabel = shown.length === 1
? t("apps.countOne")
: t("apps.countMany", { n: shown.length })
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
{/* Search — icon-only until tapped on narrow screens */}
<div className={`relative ${searchExpanded ? "flex-1 min-w-full sm:min-w-0 sm:flex-none sm:w-72" : "sm:flex-1 sm:min-w-40 sm:max-w-xs"}`}>
{!searchExpanded && (
<button
type="button"
onClick={() => {
setSearchExpanded(true)
requestAnimationFrame(() => searchInputRef.current?.focus())
}}
className="sm:hidden inline-flex items-center justify-center w-9 h-9 rounded-md border border-border bg-card text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
aria-label={t("apps.searchAriaLabel")}
>
<Search className="h-4 w-4" />
</button>
)}
<div className={`relative ${searchExpanded ? "block" : "hidden sm:block"}`}>
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
ref={searchInputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onBlur={() => { if (!query.trim()) setSearchExpanded(false) }}
placeholder={t("apps.searchPlaceholder")}
aria-label={t("apps.searchAriaLabel")}
className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
{/* Category filter */}
<select
value={currentCat}
onChange={(e) => setCurrentCat(e.target.value)}
aria-label={t("apps.filterAriaLabel")}
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[9.5rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
style={{
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
}}
>
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
{sortedCategoryEntries.map(([cat, n]) => (
<option key={cat} value={cat}>{`${getCategoryLabel(t, cat)} · ${n}`}</option>
))}
</select>
{/* Sort */}
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
aria-label={t("apps.sortAriaLabel")}
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[8rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
style={{
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
}}
>
<option value="name">{t("apps.sortName")}</option>
<option value="ct">{t("apps.sortId")}</option>
<option value="category">{t("apps.sortCategory")}</option>
</select>
{/* Count — desktop only. Mobile gives the horizontal room to
the + button instead so everything stays on one line. */}
<span className="ml-auto hidden sm:inline-flex items-center h-9 px-3 text-xs text-muted-foreground rounded-md bg-card border border-border font-mono tabular-nums">
{countLabel}
</span>
{/* + Add custom link. Icon-only on mobile (mirrors the search
icon-toggle pattern) so the toolbar fits in one line even
in the narrowest viewport. On desktop shows label + icon. */}
<Button
type="button"
variant="outline"
onClick={openNewLink}
className="sm:ml-2 ml-auto h-9 px-2.5 sm:px-3 flex-shrink-0"
aria-label={t("apps.customLinkAdd")}
>
<Plus className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">{t("apps.customLinkAdd")}</span>
</Button>
{/* Edit mode toggle — only shown when at least one custom link
exists, since it's the only card type that carries per-card
edit/delete actions. LXC-registered apps are edited in the
LXC App tab of their guest modal. */}
{(customLinks && customLinks.length > 0) && (
<Button
type="button"
variant="outline"
onClick={() => setEditMode((v) => !v)}
className={`h-9 px-2.5 sm:px-3 flex-shrink-0 ${editMode ? "border-blue-500/60 text-blue-400" : ""}`}
aria-pressed={editMode}
aria-label={t("apps.editModeToggle")}
>
{editMode ? <Check className="h-4 w-4 sm:mr-1.5" /> : <Pencil className="h-4 w-4 sm:mr-1.5" />}
<span className="hidden sm:inline">{editMode ? t("apps.editModeDone") : t("apps.editModeToggle")}</span>
</Button>
)}
</div>
{/* Grid — grouped headers when sorted by category */}
<CardsGrid
links={shown}
grouped={sortMode === "category"}
uncategorizedLabel={t("apps.uncategorized")}
openLabel={t("apps.openAriaLabel")}
isLightTheme={isLightTheme}
editMode={editMode}
onEditCustom={openEditForLink}
/>
<CustomLinkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editingLink}
guests={guestOptions}
categoryPresets={categoryPresets || []}
onSaved={() => mutateCustomLinks()}
/>
</div>
)
}
// ─── Cards grid + card ───────────────────────────────────────────
function CardsGrid({
links,
grouped,
uncategorizedLabel,
openLabel,
isLightTheme,
editMode,
onEditCustom,
}: {
links: LaunchLink[]
grouped: boolean
uncategorizedLabel: string
openLabel: string
isLightTheme: boolean
editMode: boolean
onEditCustom: (customId: string) => void
}) {
const t = useT()
if (!links.length) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
{/* No results after filter/search */}
</div>
</div>
)
}
if (!grouped) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{links.map((link) => (
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
))}
</div>
)
}
// Group by category, insert header rows spanning the full grid width.
const groups: Array<[string, LaunchLink[]]> = []
let currentCat: string | null = null
let bucket: LaunchLink[] = []
for (const link of links) {
const cat = link.category || uncategorizedLabel
if (cat !== currentCat) {
if (bucket.length) groups.push([currentCat!, bucket])
currentCat = cat
bucket = []
}
bucket.push(link)
}
if (bucket.length) groups.push([currentCat!, bucket])
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{groups.map(([cat, items]) => (
<div key={`grp-${cat}`} className="contents">
<h3 className="col-span-full uppercase text-xs tracking-wider text-muted-foreground font-semibold pt-3 pb-1.5 border-b border-border/60 flex items-center gap-2">
<span>{getCategoryLabel(t, cat)}</span>
<span className="font-mono tabular-nums text-[10px] px-1.5 py-0.5 rounded bg-card border border-border/60 font-normal">{items.length}</span>
</h3>
{items.map((link) => (
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
))}
</div>
))}
</div>
)
}
// hueForCategory / categoryChipStyle / readIsLightTheme moved to
// lib/category-color.ts so the LXC App tab can render the same chip.
// Dispatch the pair of events that jumps from the Apps dashboard to
// the VMs modal on the App tab for a given CT. Two events by design:
// `changeTab` switches the outer tab (dashboard-level) and
// `openLxcAppModal` tells VirtualMachines which guest to open and on
// which inner tab to land. Both fire in the same tick.
function openLxcModalOnAppTab(vmid: number, tab: "app" | "updates" = "app") {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid, tab } }))
}
// Same pattern for a QEMU guest: land on the modal's Status tab
// (QEMU guests don't have the App tab). Used by custom links whose
// binding is a VM instead of an LXC.
function openVmModalOnStatusTab(vmid: number) {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } }))
}
function AppCard({
link,
openLabel,
isLightTheme,
editMode,
onEditCustom,
}: {
link: LaunchLink
openLabel: string
isLightTheme: boolean
editMode: boolean
onEditCustom: (customId: string) => void
}) {
const t = useT()
// Navigate to the bound guest's modal on the appropriate inner tab:
// LXC → App tab (where the weblink was registered), VM → Status tab
// (VMs don't have an App tab). Unbound custom links have no CT ref
// to click, so this handler is only wired when `link.vmid` exists.
const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.vmid == null) return
if (link.guestType === "qemu") {
openVmModalOnStatusTab(link.vmid)
} else {
openLxcModalOnAppTab(link.vmid)
}
}
// The update icon leads to where the update is applied: the Updates
// tab of the container.
const goToUpdates = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.vmid == null) return
openLxcModalOnAppTab(link.vmid, "updates")
}
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.customId) onEditCustom(link.customId)
}
const guestPrefix = link.guestType === "qemu" ? "VM" : "LXC"
const hasBinding = link.vmid != null
return (
<a
href={link.weblink}
target="_blank"
rel="noopener noreferrer"
aria-label={openLabel.replace("{name}", link.appName)}
className="group relative flex flex-col gap-2 p-3.5 bg-card border border-border rounded-xl no-underline text-foreground hover:bg-white/5 hover:border-border/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:translate-y-px"
>
{/* Head: logo + name (+ update icon) */}
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-md bg-muted/40 grid place-items-center flex-shrink-0 overflow-hidden">
{link.logoUrl ? (
<ThemeAwareLogo src={link.logoUrl} className="w-9 h-9 object-contain" />
) : (
<span className="text-[10px] font-mono text-muted-foreground uppercase">{link.appName.slice(0, 2)}</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-foreground truncate leading-tight">{link.appName}</div>
</div>
{/* Edit mode on a custom card takes over the update-icon slot
with a proper edit button — custom links never carry the
update signal, so nothing is displaced. Falls back to the
update icon in every other case. */}
{editMode && link.isCustom ? (
<button
type="button"
onClick={goToEditor}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToEditor(e) }}
className="h-8 w-8 rounded-md border border-border bg-background hover:bg-muted flex items-center justify-center flex-shrink-0 self-start text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("apps.customLinkEditAria", { name: link.appName })}
title={t("actions.edit")}
>
<Pencil className="h-4 w-4" />
</button>
) : link.updateAvailable && (
link.vmid != null && link.guestType !== "qemu" ? (
<button
type="button"
onClick={goToUpdates}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToUpdates(e) }}
className="rounded-full flex-shrink-0 self-start mt-0.5 text-purple-400 hover:text-purple-300 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("apps.openUpdatesAria", { name: link.appName })}
title={t("apps.openUpdatesTitle")}
>
<ArrowUpCircle className="h-5 w-5" />
</button>
) : (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
)
)}
</div>
{/* Foot: weblink + CT ref + category chip */}
<div className="flex flex-col gap-1.5 mt-auto pt-2 border-t border-dashed border-border/60">
<div className="flex items-center gap-1.5 text-blue-400 text-sm font-mono truncate">
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 opacity-80" />
<span className="truncate">{link.weblink}</span>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
{/* Guest ref → click opens that guest's modal (LXC → App
tab, VM → Status tab). stopPropagation keeps the outer
anchor from firing at the same time. Unbound custom
links: in edit mode show the edit button here, otherwise
show nothing. */}
{hasBinding && (
<button
type="button"
onClick={goToBoundGuest}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToBoundGuest(e) }}
className="inline-flex items-center gap-1.5 min-w-0 rounded hover:text-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
aria-label={t("apps.openGuestAriaLabel", {
name: link.ctName || String(link.vmid),
type: guestPrefix,
id: link.vmid!,
})}
>
<span className="font-mono px-1.5 py-0.5 rounded bg-muted/40 text-muted-foreground/90 flex-shrink-0">{link.vmid}</span>
<span className="truncate min-w-0">{link.ctName || `${guestPrefix} ${link.vmid}`}</span>
</button>
)}
{link.category && (
<span
style={categoryChipStyle(link.category, isLightTheme)}
className="ml-auto px-1.5 py-0.5 border rounded text-[10px] font-medium flex-shrink-0 truncate max-w-[45%]"
title={getCategoryLabel(t, link.category)}
>
{getCategoryLabel(t, link.category)}
</span>
)}
</div>
</div>
</a>
)
}
+489
View File
@@ -0,0 +1,489 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import {
ChevronDown, ChevronRight, FileCode, HelpCircle, Loader2, Package,
Play, Settings2, Sparkles, Terminal, Wrench,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
/**
* What ProxMenux changed on this host.
*
* The complaint this answers is not that the tool changes things: it is
* that afterwards nobody can say what it changed. Showing the script
* does not answer it either — a function of four hundred lines may alter
* two values, and the reader cannot tell which two. So what is shown
* here is the difference and nothing else.
*
* Two distinctions are kept in front of the reader, because both bear on
* what they can do about what they are looking at: whether ProxMenux
* authored the change or merely ran something the user asked for, and
* how much of the previous state is actually known.
*/
interface Change {
id: number
recorded_at: number
class: string
operation: string
source: string
function: string
function_version: string
target: string
before_ref: string
after_ref: string
capture: string
revert: string
exactness: string
result: string
recoverable: boolean
detail: Record<string, unknown>
diff?: {
available: boolean; reason?: string
added?: number; removed?: number; truncated?: boolean; hunks?: string[]
} | null
}
interface Summary {
total: number
by_class: Record<string, number>
functions: Array<{
function: string; source: string; version: string
changes: number; last_change: number; first_change: number
}>
journal_started: number | null
}
// A file that did not exist before was created, not replaced — a sysadmin
// reading this must see "new file", not "file replaced". `capture` carries
// that distinction: "created" for a file born here, "present" for one that
// already had contents we captured before overwriting them.
function operationLabelKey(change: { operation: string; capture: string }): string {
// Two truthful labels for a file: created if it did not exist, modified if
// it did. The diff below shows exactly what changed either way, so there is
// no need to distinguish write/edit/append in the label.
if (["write_file", "edit_file", "append_file"].includes(change.operation)) {
return change.capture === "created" ? "operation.file_created" : "operation.file_modified"
}
return `operation.${change.operation}`
}
// Undoing follows `revert`, not `exactness`: a created file is undone by
// deleting it (there was nothing before), an overwritten one by restoring
// what we captured.
function undoKey(change: { revert: string; exactness: string }): string {
if (change.revert === "remove") return "undo.remove"
if (change.revert === "restore" && change.exactness === "exact") return "undo.restore"
if (change.revert === "purge") return "undo.purge"
return `exactness.${change.exactness}`
}
const FN_LABEL: Record<string, string> = {
// Internal helpers of a post-install feature show its menu name, not their
// raw name — and never the source ("auto"/"customizable").
_update_existing_log2ram_auto: "Install and configure Log2RAM",
_update_existing_log2ram_custom: "Install and configure Log2RAM",
update_snapshot_schedule: "Install ZFS auto-snapshot",
apply_amd_fixes: "Apply AMD CPU fixes",
apply_network_optimizations: "Apply network optimizations",
apt_upgrade: "Update and upgrade system",
cleanup_duplicate_repos_pve9: "Configure Proxmox APT repositories",
configure_fastfetch: "Install and configure Fastfetch",
configure_figurine: "Install Figurine",
configure_kernel_panic: "Enable restart on kernel panic",
configure_log2ram: "Install and configure Log2RAM",
configure_pigz: "Use pigz for faster gzip compression",
configure_time_sync: "Synchronize time automatically",
customize_bashrc: "Customize bashrc",
disable_rpc: "Disable portmapper/rpcbind",
enable_ha: "Enable High Availability services",
enable_kexec: "Enable fast reboots",
enable_tcp_fast_open: "Enable TCP BBR/Fast Open control",
enable_vfio_iommu: "Enable VFIO IOMMU support",
enable_zfs_autotrim: "Enable ZFS autotrim (SSD/NVMe pools)",
force_apt_ipv4: "Force APT to use IPv4",
increase_system_limits: "Increase various system limits",
install_ceph: "Add latest Ceph support",
install_guest_agent: "Install relevant guest agent",
install_log2ram: "Install and configure Log2RAM",
install_log2ram_auto: "Install and configure Log2RAM",
install_openvswitch: "Install Open vSwitch",
install_ovh_rtm: "Install OVH Real Time Monitoring",
install_system_utils: "Install common system utilities",
install_zfs_auto_snapshot: "Install ZFS auto-snapshot",
optimize_journald: "Optimize journald",
optimize_logrotate: "Optimize logrotate",
optimize_memory_settings: "Optimize Memory",
optimize_vzdump: "Increase vzdump backup speed",
optimize_zfs_arc: "Optimize ZFS ARC size",
remove_subscription_banner: "Remove subscription banner",
setup_motd: "Set up custom MOTD banner",
setup_persistent_network: "Interface Names (persistent)",
setup_proxmox_repositories: "Configure Proxmox APT repositories",
skip_apt_languages: "Skip downloading additional languages",
update_pve8: "Update and upgrade system",
update_pve9: "Update and upgrade system",
update_pve_appliance_manager: "Update Proxmox VE Appliance Manager",
}
// Post-install functions run from the auto/customizable scripts; everything
// else is a general host script (nvidia/tpu installers, PVE update, vfio…).
const POST_INSTALL_SOURCES = new Set(["auto", "customizable"])
// A few sources deserve a friendly name instead of a raw script identifier;
// everything else shows the script it came from.
const FRIENDLY_SOURCE = new Set(["install_proxmenux", "monitor"])
// Which of the three sections a change belongs to: installations are their
// own block, post-install optimizations another, general scripts the rest.
function blockOf(c: { class: string; source: string }): "installs" | "postInstall" | "scripts" {
if (c.class === "installation") return "installs"
if (POST_INSTALL_SOURCES.has(c.source)) return "postInstall"
return "scripts"
}
// A post-install function shows its menu name; anything else shows the script
// that made the change.
function groupLabel(fn: string, source: string): string {
// A post-install change shows its feature's menu name; if the function is
// not a known optimization, its own name — never the bare source.
return FN_LABEL[fn] || fn || source || "—"
}
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof Settings2 }> = {
configuration: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Settings2 },
installation: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: Package },
execution: { chip: "bg-muted text-muted-foreground border-border", Icon: Play },
registration: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
}
function ChangeCard({ change, expanded, onToggle, t, when }: {
change: Change; expanded: boolean; onToggle: () => void
t: (k: string, params?: Record<string, string>) => string
when: (n: number) => string
}) {
const style = CLASS_STYLE[change.class] || CLASS_STYLE.registration
const Icon = style.Icon
const installed = String(change.detail?.installed || "")
return (
<Card className="bg-card border-border">
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="w-full text-left p-3 flex flex-wrap items-center gap-2
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
{expanded
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className={`${style.chip} gap-1.5 shrink-0`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.changes.${operationLabelKey(change)}`)}
</Badge>
<span className="min-w-0 font-mono text-sm text-foreground break-all">
{change.target}
</span>
{change.diff?.available && (
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
+{change.diff.added} −{change.diff.removed}
</Badge>
)}
{change.capture === "unknown" && (
<Badge variant="outline" className="text-xs shrink-0">
{t("audit.changes.capture.unknown")}
</Badge>
)}
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{when(change.recorded_at)}
</span>
</button>
{expanded && (
<CardContent className="pt-0 pl-10 space-y-3">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>{t("audit.changes.source")}:{" "}
<span className="font-mono">{change.source || "—"}</span></span>
{change.revert && change.revert !== "none" && (
<span>{t("audit.changes.reversibility")}:{" "}
{t(`audit.changes.${undoKey(change)}`)}</span>
)}
</div>
{(change.operation === "enable_service" || change.operation === "disable_service")
&& Boolean(change.detail?.before_state || change.detail?.after_state) && (
<div className="flex items-center gap-2 text-xs">
<span className="font-mono text-muted-foreground">
{String(change.detail?.before_state || "—").replace(/\s+/g, " ")}
</span>
<span className="text-muted-foreground">→</span>
<span className="font-mono text-foreground">
{String(change.detail?.after_state || "—").replace(/\s+/g, " ")}
</span>
</div>
)}
{installed && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.packagesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{installed.split(/\s+/).filter(Boolean).map((pkg) => (
<Badge key={pkg} variant="outline" className="font-mono text-xs">
{pkg}
</Badge>
))}
</div>
</div>
)}
{change.class === "execution" && Boolean(change.detail?.command) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.commandRun")}
</p>
<pre className="text-xs font-mono bg-background border border-border
rounded-md p-2.5 overflow-x-auto">
{String(change.detail.command)}
</pre>
<p className="text-xs text-muted-foreground mt-1">
{t("audit.changes.executionNote")}
</p>
</div>
)}
{change.diff && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.difference")}
</p>
{change.diff.available ? (
<>
<pre className="text-xs font-mono bg-background border border-border
rounded-md p-2.5 overflow-x-auto">
{(change.diff.hunks || []).map((line: string, i: number) => (
<div key={i} className={
line.startsWith("+") ? "text-green-500"
: line.startsWith("-") ? "text-red-400"
: line.startsWith("@@") ? "text-blue-400" : ""
}>{line}</div>
))}
</pre>
{change.diff.truncated && (
<p className="text-xs text-muted-foreground mt-1">
{t("audit.changes.diffTruncated")}
</p>
)}
</>
) : (
<p className="text-xs text-muted-foreground">
{t("audit.changes.diffUnavailable")}
</p>
)}
</div>
)}
{change.capture === "unknown" && (
<p className="text-xs text-muted-foreground">
{t("audit.changes.unknownNote")}
</p>
)}
</CardContent>
)}
</Card>
)
}
function GroupSection({ title, icon: Icon, iconClass, groups, openFn, toggleFn, open, toggle, t, when }: {
title: string
icon: typeof Settings2
iconClass: string
groups: { key: string; label: string; version: string; last: number; items: Change[] }[]
openFn: Set<string>; toggleFn: (k: string) => void
open: Set<number>; toggle: (id: number) => void
t: (k: string, params?: Record<string, string>) => string
when: (n: number) => string
}) {
if (groups.length === 0) return null
return (
// Padding on the child: the container's space-y-4 overrides any mt-*.
<div className="space-y-2 pt-2">
{/* Same type and icon size as CardTitle, so a block heading here reads
exactly like a card heading on the Settings page. */}
<h3 className="px-1 flex items-center gap-2 text-2xl font-semibold
leading-none tracking-tight text-foreground">
<Icon className={`h-5 w-5 shrink-0 ${iconClass}`} />
{title}
</h3>
{groups.map((g) => {
const fnOpen = openFn.has(g.key)
return (
<Card key={g.key} className="bg-card border-border">
<button
type="button"
onClick={() => toggleFn(g.key)}
aria-expanded={fnOpen}
className="w-full text-left p-3 flex flex-wrap items-center gap-2
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
{fnOpen
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<FileCode className="h-4 w-4 shrink-0 text-blue-400" />
<span className="min-w-0 text-sm font-medium text-foreground break-words">
{g.label}
</span>
{g.version && (
<Badge variant="outline" className="text-xs shrink-0">v{g.version}</Badge>
)}
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
{t("audit.changes.count", { count: String(g.items.length) })}
</Badge>
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{when(g.last)}
</span>
</button>
{fnOpen && (
<CardContent className="pt-0 pl-10 pr-3 space-y-2">
{g.items.map((change) => (
<ChangeCard key={change.id} change={change}
expanded={open.has(change.id)} onToggle={() => toggle(change.id)}
t={t} when={when} />
))}
</CardContent>
)}
</Card>
)
})}
</div>
)
}
export function AuditChanges() {
const t = useT()
const { language } = useI18n()
const [changes, setChanges] = useState<Change[]>([])
const [summary, setSummary] = useState<Summary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState<Set<number>>(new Set())
const load = useCallback(async () => {
try {
const res: any = await fetchApi("/api/audit/changes?limit=500")
if (res?.success) {
setChanges(res.changes || [])
setSummary(res.summary || null)
setError(null)
} else {
setError(res?.message || t("audit.changes.failed"))
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => { load() }, [load])
const toggle = (id: number) => setOpen((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const [openFn, setOpenFn] = useState<Set<string>>(new Set())
const toggleFn = (fn: string) => setOpenFn((prev) => {
const next = new Set(prev)
next.has(fn) ? next.delete(fn) : next.add(fn)
return next
})
// A sysadmin reads this in three sections: what ProxMenux optimized
// (post-install), what its other scripts changed, and what it installed.
// Within each, changes are grouped under a card that opens to reveal them.
type Group = { key: string; label: string; version: string; last: number; items: Change[] }
const blocks = useMemo(() => {
const mk = () => new Map<string, Group>()
const post = mk(), scripts = mk(), installs = mk()
const pick = (b: string) => b === "installs" ? installs : b === "postInstall" ? post : scripts
for (const c of changes) {
const b = blockOf(c)
const target = pick(b)
// Post-install groups by function under its menu name; the script and
// install sections group by the script that made the change, and show
// that script's name — not a per-function label.
// The same script can appear in more than one section (it changed
// config and also installed a package), so the accordion key is scoped
// by section — otherwise opening one card opens its twin elsewhere.
const rawKey = b === "postInstall" ? groupLabel(c.function, c.source) : (c.source || c.function || "—")
const key = `${b}:${rawKey}`
const label = b === "postInstall"
? groupLabel(c.function, c.source)
: FRIENDLY_SOURCE.has(c.source)
? t(`audit.changes.scriptLabel.${c.source}`)
: (c.source || c.function || "—")
const g = target.get(key) || { key, label, version: "", last: 0, items: [] }
g.items.push(c)
if (c.recorded_at > g.last) g.last = c.recorded_at
if (b === "postInstall" && c.function_version) g.version = c.function_version
target.set(key, g)
}
const sort = (m: Map<string, Group>) => Array.from(m.values()).sort((a, b) => b.last - a.last)
return { post: sort(post), scripts: sort(scripts), installs: sort(installs) }
}, [changes, t])
const when = (epoch: number) => new Date(epoch * 1000).toLocaleString(language)
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.changes.loading")}
</div>
)
}
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
return (
<div className="space-y-4">
<Card className="bg-card border-border">
<CardContent className="py-4 space-y-3">
<p className="text-sm text-muted-foreground">{t("audit.changes.intro")}</p>
{/* A host with nothing recorded should say why, rather than
looking like a host nothing has touched. */}
{summary && summary.total === 0 && (
<p className="text-sm text-muted-foreground">{t("audit.changes.empty")}</p>
)}
{summary && summary.journal_started && (
<p className="text-xs text-muted-foreground">
{t("audit.changes.since", { date: when(summary.journal_started) })}
</p>
)}
</CardContent>
</Card>
<GroupSection title={t("audit.changes.section.postInstall")}
icon={Sparkles} iconClass="text-orange-500"
groups={blocks.post} openFn={openFn} toggleFn={toggleFn}
open={open} toggle={toggle} t={t} when={when} />
<GroupSection title={t("audit.changes.section.scripts")}
icon={Terminal} iconClass="text-cyan-500"
groups={blocks.scripts} openFn={openFn} toggleFn={toggleFn}
open={open} toggle={toggle} t={t} when={when} />
<GroupSection title={t("audit.changes.section.installs")}
icon={Wrench} iconClass="text-purple-400"
groups={blocks.installs} openFn={openFn} toggleFn={toggleFn}
open={open} toggle={toggle} t={t} when={when} />
{summary && summary.total > 0
&& blocks.post.length + blocks.scripts.length + blocks.installs.length === 0 && (
<p className="text-sm text-muted-foreground px-1">{t("audit.changes.empty")}</p>
)}
</div>
)
}
+276
View File
@@ -0,0 +1,276 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import {
ArrowDownRight, ArrowUpRight, ChevronDown, ChevronRight, Flag, Loader2,
MinusCircle, PlusCircle, ShieldOff, TrendingUp,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
/**
* How this assessment differs from an earlier one.
*
* A single run says what the host is like now. It cannot say whether
* that is better or worse than last week, which is the question anyone
* maintaining a machine actually asks — and the one that turns an audit
* from a snapshot into a record.
*
* The distinction the engine draws and this view keeps: a finding that
* stopped being reported because the host was fixed is not the same as
* one that stopped because somebody accepted it. Both leave the list;
* only the first is progress, and merging them would tell the reader a
* problem went away when the decision was to live with it.
*
* The same care applies to a finding that is still reported. One that
* was already failing has not appeared now, so it is shown as having got
* worse or better with where it came from, rather than as new — reading
* "new" against work that lowered a critical to a warning would punish
* exactly the reader who fixed something.
*
* It sits inside the assessment rather than in a view of its own,
* because "what changed since last time" is context for the run being
* read, not a separate place to visit.
*/
interface Finding {
check_id: string
area: string
classification: string
// Only the findings that moved carry where they came from.
previous_classification?: string
previous_affected?: number
affected_count?: number
}
interface Comparison {
from: string
to: string
new: Finding[]
worse: Finding[]
better: Finding[]
resolved: Finding[]
accepted: Finding[]
unchanged: Finding[]
retired: Finding[]
unverified: Finding[]
}
/** What moved, in the reader's terms: the gravity when that is what
* changed, otherwise how many objects the finding now covers. */
function movement(f: Finding, t: (k: string) => string): string | null {
if (!f.previous_classification) return null
if (f.previous_classification !== f.classification) {
return `${t(`audit.classifications.${f.previous_classification}`)} → ${t(
`audit.classifications.${f.classification}`,
)}`
}
if (f.previous_affected === undefined || f.affected_count === undefined) return null
return `${f.previous_affected} → ${f.affected_count}`
}
const GROUPS = [
{ key: "new", Icon: PlusCircle, tone: "text-amber-500" },
{ key: "worse", Icon: ArrowUpRight, tone: "text-red-400" },
{ key: "better", Icon: ArrowDownRight, tone: "text-emerald-400" },
{ key: "resolved", Icon: MinusCircle, tone: "text-green-500" },
{ key: "accepted", Icon: ShieldOff, tone: "text-indigo-400" },
{ key: "retired", Icon: Flag, tone: "text-muted-foreground" },
] as const
export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
runId: string
isBaseline: boolean
onBaselineSet: () => void
}) {
const t = useT()
const { language } = useI18n()
const [comparison, setComparison] = useState<Comparison | null>(null)
const [baseline, setBaseline] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [open, setOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
// fetchApi turns a non-2xx response into an Error whose message is the
// backend's own English prose and whose `body` carries the parsed
// payload. Both paths therefore go through here: only the reason code
// crosses into a view that exists in eight languages.
const reason = (source: any): string => {
const code = String(source?.reason ?? source?.body?.reason ?? "")
const key = `audit.comparison.reasons.${code}`
const translated = t(key)
return translated !== key ? translated : t("audit.comparison.failed")
}
const load = useCallback(async () => {
setLoading(true)
try {
// Asked for separately: a host with a single run answers the
// comparison with an error, and inside a Promise.all that error
// takes the status with it — leaving no way to tell a first
// assessment from a comparison that genuinely failed, which is
// how "two runs are required" reached a reader who had simply
// never chosen a reference.
const status: any = await fetchApi("/api/audit/status").catch(() => null)
setBaseline(status?.baseline || null)
// With no reference chosen there is nothing to compare against,
// and asking anyway answered 400 — an error in the browser console
// for the ordinary state of a host assessed for the first time.
let diff: any = null
let failure: unknown = null
if (status?.baseline) {
try {
diff = await fetchApi(`/api/audit/compare?to=${encodeURIComponent(runId)}`)
} catch (e) {
failure = e
}
}
setComparison(diff?.success ? diff : null)
// Having no reference run yet is the ordinary state of a host
// assessed for the first time, and the view already says so.
const failed = failure ?? (diff?.success === false ? diff : null)
setError(failed && status?.baseline ? reason(failed) : null)
} finally {
setLoading(false)
}
}, [runId])
useEffect(() => { load() }, [load])
const markBaseline = async () => {
setSaving(true)
try {
const res: any = await fetchApi("/api/audit/baseline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_id: runId }),
})
if (res?.success) { onBaselineSet(); await load() }
else setError(reason(res))
} catch (e) {
setError(reason(e))
} finally {
setSaving(false)
}
}
if (loading) {
return (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("audit.comparison.loading")}
</p>
)
}
const counts = comparison
? GROUPS.map((g) => ({ ...g, n: (comparison[g.key] || []).length }))
.filter((g) => g.n > 0)
: []
const comparable = comparison && comparison.from !== comparison.to
return (
<div className="space-y-2">
{error && <p className="text-xs text-red-400">{error}</p>}
{!comparable ? (
<p className="text-sm text-muted-foreground">
{baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")}
</p>
) : (
<>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full flex-wrap items-center gap-2 rounded-md -mx-2 px-2 py-1
text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
<TrendingUp className="h-4 w-4 shrink-0 text-blue-500" />
<span className="text-sm text-muted-foreground">
{t("audit.comparison.since", {
date: baseline?.started_at
? new Date(baseline.started_at * 1000).toLocaleDateString(language)
: t("audit.comparison.previousRun"),
})}
</span>
{counts.length === 0 ? (
<Badge variant="outline" className="text-sm">
{t("audit.comparison.noChange")}
</Badge>
) : counts.map(({ key, Icon, tone, n }) => (
<Badge key={key} variant="outline" className={`text-sm gap-1.5 ${tone}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.comparison.${key}`)}
<span className="tabular-nums font-semibold">{n}</span>
</Badge>
))}
</button>
{open && (
<div className="space-y-3 pl-6">
{GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map(
({ key, Icon, tone }) => (
<div key={key}>
<p className={`flex items-center gap-1.5 text-sm font-medium mb-1 ${tone}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.comparison.${key}`)}
<span className="font-normal text-muted-foreground">
— {t(`audit.comparison.${key}Note`)}
</span>
</p>
<div className="flex flex-wrap gap-1.5">
{(comparison[key] || []).map((f) => {
const moved = movement(f, t)
return (
<Badge key={f.check_id} variant="outline" className="text-xs">
{t(`audit.checks.${f.check_id}.title`)}
{moved && (
<span className="ml-1.5 font-normal text-muted-foreground tabular-nums">
{moved}
</span>
)}
</Badge>
)
})}
</div>
</div>
),
)}
{(comparison.unchanged || []).length > 0 && (
<p className="text-sm text-muted-foreground">
{t("audit.comparison.unchanged", {
count: String(comparison.unchanged.length),
})}
</p>
)}
</div>
)}
</>
)}
{/* Choosing a reference is what makes every later run comparable,
so the action lives beside the comparison it enables. */}
{!isBaseline && (
<Button
variant="outline"
size="sm"
onClick={markBaseline}
disabled={saving}
className="h-7 text-xs"
>
{saving
? <Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
: <Flag className="h-3 w-3 mr-1.5" />}
{t("audit.comparison.setBaseline")}
</Button>
)}
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
"use client"
import { parseEvidence, type EvidenceBlock } from "../lib/evidence-format"
/**
* Renders a finding's evidence as tables and labelled values.
*
* The evidence exists so a reader can verify the conclusion for
* themselves. A JSON dump technically contains the same facts but asks
* the reader to parse it first, which is the part they came here to
* avoid.
*/
export function AuditEvidence({ evidence, locale }: {
evidence: string | null
locale: string
}) {
const blocks = parseEvidence(evidence, locale)
if (blocks.length === 0) return null
return (
<div className="space-y-3">
{blocks.map((block, i) => <Block key={i} block={block} />)}
</div>
)
}
function Block({ block }: { block: EvidenceBlock }) {
const title = block.title
? <p className="text-xs font-medium text-foreground mb-1">{block.title}</p>
: null
if (block.kind === "table") {
return (
<div>
{title}
{/* Wide evidence scrolls inside its own box so the page itself
never scrolls sideways. */}
{/* Evidence is the widest thing on the page; on a narrow screen
it stacks like the rest rather than scrolling sideways. */}
<div className="sm:hidden space-y-2">
{block.rows.map((row, i) => (
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.map((cell, j) => cell === "—" || cell === "" ? null : (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">
{block.columns[j]}</span>
<span className="text-xs text-foreground tabular-nums break-words min-w-0">
{cell}</span>
</div>
))}
</div>
))}
</div>
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/50">
{block.columns.map((c) => (
<th key={c} className="text-left font-medium px-3 py-1.5
text-muted-foreground whitespace-nowrap">
{c}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i} className="border-t border-border">
{row.map((cell, j) => (
<td key={j} className="px-3 py-1.5 align-top tabular-nums">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
if (block.kind === "pairs") {
return (
<div>
{title}
<dl className="grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1 text-xs
rounded-md border border-border px-3 py-2">
{block.entries.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-muted-foreground whitespace-nowrap">{label}</dt>
<dd className="text-foreground break-words tabular-nums">{value}</dd>
</div>
))}
</dl>
</div>
)
}
return (
<div>
{title}
{block.lines.length > 0 && (
<ul className="text-xs text-muted-foreground space-y-0.5 rounded-md
border border-border px-3 py-2">
{block.lines.map((line, i) => (
<li key={i} className="break-words">{line}</li>
))}
</ul>
)}
</div>
)
}
@@ -0,0 +1,34 @@
"use client"
import { presentFinding, type PresentedFinding, type AuditTranslate } from "../lib/audit-presentation"
export function AuditFindingData({ finding, t, locale }: {
finding: PresentedFinding; t: AuditTranslate; locale: string
}) {
return <div className="space-y-4">{presentFinding(finding, t, locale).map((group, index) =>
<section key={index}>
<p className="text-sm font-medium mb-2">{group.title}</p>
{group.note && <p className="text-sm text-muted-foreground mb-2">{group.note}</p>}
{/* Wide on a screen that has the width; stacked where it does
not, so a heading stays beside the value it belongs to instead
of scrolling away from it. */}
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground"><tr>{group.columns.map((column, i) =>
<th key={i} className="px-3 py-2 text-left font-medium">{column}</th>)}</tr></thead>
<tbody>{group.rows.map((row, i) => <tr key={i} className="border-t border-border">
{row.cells.map((cell, j) => <td key={j} className="px-3 py-2 align-top break-words tabular-nums">{cell}</td>)}
</tr>)}</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">{group.rows.map((row, i) =>
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.cells.map((cell, j) => cell === "" || cell === "—" ? null : (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">{group.columns[j]}</span>
<span className="text-sm text-foreground tabular-nums break-words min-w-0">{cell}</span>
</div>
))}
</div>)}</div>
</section>)}</div>
}
+760
View File
@@ -0,0 +1,760 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import {
Activity, Boxes, ChevronDown, ChevronRight, CircuitBoard, Cpu, HardDrive,
Loader2, MemoryStick, Network, Package, Plug, Server, Share2, Wrench,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
import { subscriptionLabel } from "../lib/audit-presentation"
interface UplinkHop { kind: string; id: string; mode?: string; role?: string }
interface GuestDisk {
slot: string; storage: string | null; volume: string
size: string; passthrough?: boolean
}
interface GuestNic {
slot: string; name: string; bridge: string; mac: string; vlan: string
uplink: UplinkHop[] | null
}
interface GuestBackup { job: string; storage: string; schedule: string; retention: string }
interface Guest {
vmid: number; type: string; name: string; cores: string; memory: string
ostype: string; onboot: boolean; tags: string; protected: boolean
unprivileged: boolean | null; features: string | null
agent: boolean | null; cpu: string | null
disks: GuestDisk[]; interfaces: GuestNic[]; backups: GuestBackup[]
}
interface Inventory {
collected_at: number
node: string
unavailable: Record<string, string>
sections: {
identity?: Record<string, string | null>
cluster?: any
hardware?: any
storages?: any[]
guests?: Guest[]
passthrough?: any[]
applications?: any[]
custom_links?: any[]
proxmenux?: { optimizations: any[]; pending_updates: any[] }
network?: { bridges: Record<string, any> } | null
latency?: { window: string; targets: any[] } | null
}
}
const GiB = 1024 ** 3
function bytes(value: number | null | undefined): string {
if (!value || value <= 0) return "—"
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
let n = value, i = 0
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}`
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === "") return null
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm text-foreground break-words">{value}</p>
</div>
)
}
/**
* One table shape for the whole view.
*
* The inventory is read across as much as down — a disk's model beside
* its bus beside its wear — so the sections that enumerate things share
* a single table rather than each inventing its own row layout.
*
* On a narrow screen the same rows are stacked instead. A disk table is
* eight columns wide; sideways scrolling technically fits it on a phone,
* but reading a row then means dragging back and forth to pair each
* value with its heading. Stacked, the heading travels with the value.
*/
function DataTable({ columns, rows }: {
columns: string[]
rows: React.ReactNode[][]
}) {
if (rows.length === 0) return null
return (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
{columns.map((c, i) => (
<th key={i} className="pb-2 pr-4 font-medium whitespace-nowrap">{c}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-t border-border">
{row.map((cell, j) => (
<td key={j} className="py-2 pr-4 align-top tabular-nums">{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{rows.map((row, i) => (
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.map((cell, j) => {
// A cell with nothing in it would leave a heading standing
// alone, which reads as missing data rather than as absent.
if (cell === null || cell === undefined || cell === "" || cell === "—") return null
return (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">{columns[j]}</span>
<span className="text-sm text-foreground tabular-nums break-words min-w-0">
{cell}
</span>
</div>
)
})}
</div>
))}
</div>
</>
)
}
function Mono({ children }: { children: React.ReactNode }) {
return <span className="font-mono text-xs text-muted-foreground">{children}</span>
}
function Section({
icon, title, count, children, note,
}: {
icon: React.ReactNode; title: string; count?: number
children: React.ReactNode; note?: string
}) {
const [open, setOpen] = useState(true)
return (
<Card className="bg-card border-border">
<CardHeader className="pb-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 rounded-md -mx-2 -my-1 px-2 py-1
text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<CardTitle className="flex min-w-0 items-center gap-2 text-base font-semibold text-foreground">
{icon}<span className="min-w-0 break-words">{title}</span>
</CardTitle>
{count !== undefined && (
<Badge variant="outline" className="ml-auto shrink-0 text-xs tabular-nums">{count}</Badge>
)}
</button>
</CardHeader>
{open && (
<CardContent className="pt-0 space-y-3">
{note && <p className="text-xs text-muted-foreground">{note}</p>}
{children}
</CardContent>
)}
</Card>
)
}
/** A heading inside a section, matching the printed document's. */
function Sub({ icon, title, note }: {
icon: React.ReactNode; title: string; note?: string
}) {
return (
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-foreground mt-4 mb-1 first:mt-0">
{icon}{title}
{note && <span className="font-normal text-muted-foreground">— {note}</span>}
</p>
)
}
// The uplink is the point of the network section: a bridge on its own
// says nothing, the path from it to the wire is what an operator needs.
function Uplink({ hops }: { hops: UplinkHop[] | null }) {
const t = useT()
if (hops === null) {
return <span className="text-xs text-muted-foreground italic">{t("audit.inventory.unresolved")}</span>
}
if (hops.length === 0) {
return <span className="text-xs text-muted-foreground">{t("audit.inventory.noUplink")}</span>
}
return (
<span className="flex flex-wrap items-center gap-1 text-xs">
{hops.map((h, i) => (
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground">→</span>}
<Badge variant="outline" className="font-mono text-xs">
{h.id}{h.mode ? ` · ${h.mode}` : ""}
</Badge>
</span>
))}
</span>
)
}
export function AuditInventory({ profile = "full" }: { profile?: string }) {
const t = useT()
const { language } = useI18n()
const [data, setData] = useState<Inventory | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openGuest, setOpenGuest] = useState<Set<number>>(new Set())
const load = useCallback(async () => {
try {
const res: any = await fetchApi(
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
if (res?.success) { setData(res.inventory); setError(null) }
else setError(res?.message || t("audit.inventory.failed"))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally { setLoading(false) }
}, [t, profile])
useEffect(() => { load() }, [load])
const apps = useMemo(() => {
const byGuest = new Map<number, any[]>()
for (const a of data?.sections.applications || []) {
if (!byGuest.has(a.vmid)) byGuest.set(a.vmid, [])
byGuest.get(a.vmid)!.push(a)
}
return byGuest
}, [data])
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.inventory.loading")}
</div>
)
}
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
if (!data) return null
const s = data.sections
const hw = s.hardware || {}
const mem = hw.memory || {}
const when = (value: number | string | null | undefined) => {
if (!value) return "—"
const date = typeof value === "number"
? new Date(value * 1000)
: new Date(/[Z+]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value}Z`)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(language)
}
const toggle = (vmid: number) => setOpenGuest((prev) => {
const next = new Set(prev)
next.has(vmid) ? next.delete(vmid) : next.add(vmid)
return next
})
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground px-1">
{t("audit.inventory.collectedAt", {
when: new Date(data.collected_at * 1000).toLocaleString(language),
})}
</p>
{/* Sections that could not be read are named, so an empty list is
never mistaken for a section that was read and found nothing. */}
{Object.keys(data.unavailable || {}).length > 0 && (
<Card className="bg-card border-border">
<CardContent className="py-3 space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{t("audit.inventory.unavailable")}
</p>
{Object.entries(data.unavailable).map(([k, v]) => (
<p key={k} className="text-xs text-muted-foreground">
<span className="font-mono">{k}</span> — {v}
</p>
))}
</CardContent>
</Card>
)}
{s.identity && (
<Section icon={<Server className="h-4 w-4 text-blue-500" />} title={t("audit.inventory.identity")}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("audit.inventory.node")} value={s.identity?.node} />
<Field label={t("audit.inventory.pveVersion")} value={s.identity?.pve_version} />
<Field label={t("audit.inventory.kernel")} value={s.identity?.kernel} />
<Field label={t("audit.inventory.subscription")} value={subscriptionLabel(t, s.identity?.subscription)} />
<Field label={t("audit.inventory.cluster")}
value={s.identity?.cluster || t("audit.inventory.standalone")} />
<Field label={t("audit.document.system")}
value={[hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ")} />
</div>
</Section>
)}
{"cluster" in s && (
<Section icon={<Share2 className="h-4 w-4 text-cyan-500" />} title={t("audit.document.cluster")}
count={s.cluster ? (s.cluster.nodes || []).length : undefined}
note={s.cluster ? undefined : t("audit.document.standaloneNote")}>
{s.cluster && (
<>
<div className="grid gap-3 sm:grid-cols-3">
<Field label={t("audit.inventory.cluster")} value={s.cluster.name} />
<Field label={t("audit.document.quorum")}
value={s.cluster.quorate == null ? "—" : (
<Badge variant="outline" className={s.cluster.quorate
? "bg-green-500/10 text-green-500 border-green-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20"}>
{t(s.cluster.quorate ? "audit.document.quorate"
: "audit.document.inquorate")}
</Badge>
)} />
<Field label={t("audit.document.votes")}
value={`${s.cluster.total_votes ?? "—"} / ${s.cluster.expected_votes ?? "—"}`} />
</div>
<DataTable
columns={[t("audit.document.nodeName"), "nodeid", "ring0", "ring1",
t("audit.document.state")]}
rows={(s.cluster.nodes || []).map((n: any) => [
<span className="font-medium text-foreground">
{n.name}
{n.local && <span className="text-muted-foreground">
{" "}({t("audit.document.thisNode")})</span>}
</span>,
<Mono>{n.nodeid || "—"}</Mono>,
<Mono>{n.ring0_addr || "—"}</Mono>,
<Mono>{n.ring1_addr || "—"}</Mono>,
n.online === false
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
{t("audit.document.unreachable")}</Badge>
: n.online === true
? <Badge variant="outline">{t("audit.document.member")}</Badge>
: "—",
])}
/>
</>
)}
</Section>
)}
{s.hardware && (
<Section icon={<Cpu className="h-4 w-4 text-indigo-500" />} title={t("audit.document.architecture")}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("audit.inventory.cpu")} value={hw.cpu?.model} />
<Field label={t("audit.inventory.topology")}
value={hw.cpu ? t("audit.inventory.cpuLayout", {
sockets: String(hw.cpu.sockets), cores: String(hw.cpu.cores_per_socket),
threads: String(hw.cpu.threads),
}) : ""} />
<Field label={t("audit.inventory.memory")}
value={hw.memory_bytes ? `${(hw.memory_bytes / GiB).toFixed(0)} GiB` : ""} />
<Field label={t("audit.inventory.virtualisation")} value={hw.cpu?.virtualisation} />
<Field label={t("audit.inventory.serial")} value={hw.system?.serial} />
<Field label={t("audit.document.board")}
value={[hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ")} />
<Field label={t("audit.inventory.bios")}
value={[hw.bios?.vendor, hw.bios?.version, hw.bios?.date].filter(Boolean).join(" · ")} />
<Field label={t("audit.inventory.iommuGroups")} value={hw.iommu_groups} />
</div>
{(mem.modules || []).length > 0 && (
<>
<Sub icon={<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.memoryModules")}
note={t("audit.document.slotsFilled", {
used: String(mem.populated ?? 0),
total: String(mem.slots ?? mem.populated ?? 0),
})} />
<DataTable
columns={[t("audit.document.slot"), t("audit.document.size"),
t("audit.document.type"), t("audit.document.formFactor"),
t("audit.document.speed"), t("audit.document.manufacturer")]}
rows={(mem.modules || []).map((m: any) => [
<Mono>{m.locator || "—"}</Mono>, m.size || "—", m.type || "—",
m.form_factor || "—", m.speed || "—",
[m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—",
])}
/>
</>
)}
{(hw.controllers || []).length > 0 && (
<>
<Sub icon={<CircuitBoard className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.controllers")} />
<DataTable
columns={["PCI", t("audit.document.class"), t("audit.document.device")]}
rows={(hw.controllers || []).map((c: any) => [
<Mono>{c.slot}</Mono>, c.class, c.name,
])}
/>
</>
)}
</Section>
)}
{(hw.disks || []).length > 0 && (
<Section icon={<HardDrive className="h-4 w-4 text-amber-500" />} title={t("audit.document.storageDevices")}
count={(hw.disks || []).length}>
<DataTable
columns={[t("audit.document.device"), t("audit.document.model"),
t("audit.document.serial"), t("audit.document.size"),
t("audit.document.bus"), "SMART", t("audit.document.serviceLife"),
t("audit.document.events")]}
rows={(hw.disks || []).map((d: any) => {
const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase())
return [
<span className="font-medium text-foreground">{d.name}</span>,
d.model || "—",
<Mono>{d.serial || "—"}</Mono>,
bytes(d.size_bytes),
`${(d.bus || "—").toUpperCase()} · ${d.rotational ? "HDD" : "SSD"}`,
ok
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("audit.document.healthy")}</Badge>
: d.health && d.health !== "unknown"
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
{d.health}</Badge>
: "—",
typeof d.power_on_hours === "number" && d.power_on_hours > 0
? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) })
: "—",
(d.observations || []).length
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
{d.observations.length}</Badge>
: <span className="text-muted-foreground">—</span>,
]
})}
/>
{(hw.disks || []).some((d: any) => (d.observations || []).length > 0) && (
<>
<Sub icon={<Activity className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.observations")}
note={t("audit.document.observationsNote")} />
{(hw.disks || []).filter((d: any) => (d.observations || []).length).map((d: any) => (
<div key={d.name} className="mt-2">
<p className="text-xs font-medium text-foreground mb-1">
{d.name} <span className="font-normal text-muted-foreground">{d.model}</span>
</p>
<DataTable
columns={[t("audit.document.event"), t("audit.document.severity"),
t("audit.document.occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), t("audit.document.detail")]}
rows={d.observations.map((o: any) => [
o.type || "—",
<Badge variant="outline" className={o.severity === "critical"
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-amber-500/10 text-amber-500 border-amber-500/20"}>
{o.severity
? t(`audit.classifications.${
o.severity === "critical" ? "critical" : "warning"}`)
: "—"}</Badge>,
String(o.count ?? "—"),
when(o.first_seen), when(o.last_seen),
<span className="text-muted-foreground break-words">{o.message || ""}</span>,
])}
/>
</div>
))}
</>
)}
</Section>
)}
{(s.network || (hw.adapters || []).length > 0) && (
<Section icon={<Network className="h-4 w-4 text-green-500" />} title={t("audit.inventory.network")}
count={Object.keys(s.network?.bridges || {}).length || undefined}>
{(hw.adapters || []).length > 0 && (
<>
<Sub icon={<Plug className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.physicalAdapters")} />
<DataTable
columns={[t("audit.document.interface"), t("audit.document.state"),
t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"]}
rows={(hw.adapters || []).map((a: any) => [
<span className="font-medium text-foreground">{a.name}</span>,
a.state === "up"
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{a.state}</Badge>
: <Badge variant="outline">{a.state || "—"}</Badge>,
a.speed_mbps
? (a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`)
: "—",
<Mono>{a.mac || "—"}</Mono>, a.driver || "—", <Mono>{a.pci || "—"}</Mono>,
])}
/>
</>
)}
{s.network && (
<>
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.bridges")} />
<div className="space-y-2">
{Object.entries(s.network.bridges || {}).map(([id, b]: [string, any]) => (
<div key={id} className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant="outline" className="font-mono">{id}</Badge>
<span className="text-muted-foreground">→</span>
<Uplink hops={b.uplink} />
{b.vlan_interface && (
<Badge variant="outline" className="text-xs">VLAN {b.vlan_interface}</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{(s.guests || []).filter((g) =>
(g.interfaces || []).some((n) => n.bridge === id)).length}
{" "}{t("audit.inventory.guests").toLowerCase()}
</span>
</div>
))}
</div>
</>
)}
</Section>
)}
{s.latency?.targets?.length ? (
<Section icon={<Activity className="h-4 w-4 text-sky-500" />} title={t("audit.document.latency")}
note={t("audit.document.latencyNote")}>
<DataTable
columns={[t("audit.document.target.label"), t("audit.document.minimum"),
t("audit.document.average"), t("audit.document.maximum"),
t("audit.document.packetLoss"), t("audit.document.samples")]}
rows={s.latency.targets.map((target: any) => [
<span className="font-medium text-foreground">
{t(`audit.document.target.${target.target}`)}</span>,
target.min_ms != null ? `${target.min_ms} ms` : "—",
target.avg_ms != null ? `${target.avg_ms} ms` : "—",
target.max_ms != null ? `${target.max_ms} ms` : "—",
target.packet_loss != null ? `${target.packet_loss} %` : "—",
String(target.samples),
])}
/>
</Section>
) : null}
{s.storages && (
<Section icon={<HardDrive className="h-4 w-4 text-purple-500" />} title={t("audit.inventory.storage")}
count={(s.storages || []).length}>
<DataTable
columns={[t("audit.inventory.name"), t("audit.inventory.type"),
t("audit.inventory.content"), t("audit.inventory.shared"),
t("audit.document.location"), t("audit.inventory.guests")]}
rows={(s.storages || []).map((st: any) => [
<Mono>{st.id}</Mono>, st.type,
<span className="text-muted-foreground text-xs">{st.content}</span>,
st.shared
? <Badge variant="outline">{t("audit.inventory.yes")}</Badge>
: <span className="text-muted-foreground">—</span>,
<span className="text-muted-foreground text-xs break-all">
{st.server || st.path || "—"}</span>,
String((s.guests || []).filter((g) =>
(g.disks || []).some((d) => d.storage === st.id)).length),
])}
/>
</Section>
)}
{s.guests && (
<Section icon={<Boxes className="h-4 w-4 text-emerald-500" />} title={t("audit.inventory.guests")}
count={(s.guests || []).length}>
<div className="space-y-2">
{(s.guests || []).map((g) => {
const open = openGuest.has(g.vmid)
const guestApps = apps.get(g.vmid) || []
return (
<div key={g.vmid} className="rounded-md border border-border">
<button
type="button"
onClick={() => toggle(g.vmid)}
aria-expanded={open}
className="flex w-full flex-wrap items-center gap-2 rounded-md p-3 text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className="font-mono text-xs">{g.vmid}</Badge>
<span className="font-medium text-foreground">{g.name || "—"}</span>
<Badge variant="outline" className="text-xs uppercase">{g.type}</Badge>
{/* Whether a guest needs a backup is not visible from
here, so its absence is stated, not flagged. */}
{g.backups.length === 0 && (
<Badge variant="outline" className="text-xs">
{t("audit.inventory.noBackup")}
</Badge>
)}
<span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
{g.cores}c · {g.memory}MB
</span>
</button>
{open && (
<div className="border-t border-border p-3 space-y-4">
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Field label={t("audit.inventory.ostype")} value={g.ostype} />
<Field label={t("audit.inventory.onboot")}
value={g.onboot ? t("audit.inventory.yes") : t("audit.inventory.no")} />
<Field label={t("audit.inventory.tags")} value={g.tags} />
{g.type === "lxc" && (
<Field label={t("audit.inventory.privilege")}
value={g.unprivileged ? t("audit.inventory.unprivileged")
: t("audit.inventory.privileged")} />
)}
{g.type === "lxc" && <Field label={t("audit.inventory.features")} value={g.features} />}
{g.type === "qemu" && (
<Field label={t("audit.inventory.agent")}
value={g.agent ? t("audit.inventory.yes") : t("audit.inventory.no")} />
)}
{g.type === "qemu" && <Field label={t("audit.inventory.cpuModel")} value={g.cpu} />}
</div>
{g.disks.length > 0 && (
<div>
<Sub icon={<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.disks")} />
<DataTable
columns={[t("audit.document.slot"), t("audit.document.storage"),
t("audit.inventory.name"), t("audit.document.size")]}
rows={g.disks.map((d) => [
<Mono>{d.slot}</Mono>,
d.storage
? <Badge variant="outline" className="font-mono text-xs">{d.storage}</Badge>
: <Badge variant="outline" className="text-xs">
{t("audit.inventory.passthrough")}</Badge>,
<span className="font-mono text-xs break-all">{d.volume}</span>,
d.size || "—",
])}
/>
</div>
)}
{g.interfaces.length > 0 && (
<div>
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.interfaces")} />
<div className="space-y-1">
{g.interfaces.map((n) => (
<div key={n.slot} className="flex flex-wrap items-center gap-1.5 text-xs">
<Badge variant="outline" className="font-mono">{n.slot}</Badge>
<span className="text-muted-foreground">→</span>
<Badge variant="outline" className="font-mono">{n.bridge}</Badge>
<span className="text-muted-foreground">→</span>
<Uplink hops={n.uplink} />
{n.vlan && <Badge variant="outline">VLAN {n.vlan}</Badge>}
{n.mac && <Mono>{n.mac}</Mono>}
</div>
))}
</div>
</div>
)}
<div>
<Sub icon={<Package className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.protection")} />
{g.backups.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("audit.inventory.noBackupDetail")}</p>
) : (
<DataTable
columns={[t("audit.document.backup"), t("audit.document.storage"),
t("audit.inventory.type"), t("audit.document.content")]}
rows={g.backups.map((b) => [
<Mono>{b.job}</Mono>,
<Badge variant="outline" className="font-mono text-xs">{b.storage}</Badge>,
b.schedule || "—", b.retention || "—",
])}
/>
)}
</div>
{guestApps.length > 0 && (
<div>
<Sub icon={<Wrench className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.applications")} />
<div className="space-y-1">
{guestApps.map((a: any, i: number) => (
<div key={`${a.slug}-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-foreground">{a.name}</span>
<Mono>{a.version || t("audit.inventory.versionUnknown")}</Mono>
{a.update_available && (
<Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
{a.available}
</Badge>
)}
{(a.ports || []).map((p: any, j: number) => (
<Badge key={j} variant="outline" className="font-mono">
{p.scheme}:{p.port}{p.path}
</Badge>
))}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</Section>
)}
{(s.passthrough || []).length > 0 && (
<Section icon={<Plug className="h-4 w-4 text-violet-500" />} title={t("audit.inventory.passthroughTitle")}
count={(s.passthrough || []).length}>
<DataTable
columns={[t("audit.document.vmid"), t("audit.document.name"),
t("audit.document.slot"), t("audit.document.device"),
t("audit.document.iommuGroup"), t("audit.inventory.sharedGroup")]}
rows={(s.passthrough || []).map((p: any) => [
<Mono>{p.vmid}</Mono>,
p.guest || "—",
<Mono>{p.slot || "—"}</Mono>,
<Mono>{p.address || "—"}</Mono>,
(p.iommu_groups || []).join(", ") || "—",
// Everything in a group moves together, so a shared group is
// what decides whether the passthrough is possible.
(p.shared_group_devices || []).length
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
{p.shared_group_devices.length}</Badge>
: <span className="text-muted-foreground">—</span>,
])}
/>
</Section>
)}
{s.proxmenux && (
<Section icon={<Wrench className="h-4 w-4 text-orange-500" />} title={t("audit.inventory.proxmenux")}
count={(s.proxmenux.optimizations || []).length}>
<DataTable
columns={[t("audit.document.name"), t("audit.document.version"),
t("audit.document.state")]}
rows={(s.proxmenux.optimizations || []).map((o: any) => {
const pending = (s.proxmenux!.pending_updates || [])
.find((u: any) => u.key === o.key)
return [
<Mono>{o.key}</Mono>,
o.version || "—",
pending
? <Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
{t("audit.document.updateAvailable", { version: String(pending.available) })}
</Badge>
: <Badge variant="outline">{t("audit.document.current")}</Badge>,
]
})}
/>
</Section>
)}
</div>
)
}
+419
View File
@@ -0,0 +1,419 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Boxes, CheckCircle2, HardDrive, Loader2, Settings2, SlidersHorizontal } from "lucide-react"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
/**
* Declares what is expected of this host.
*
* An assessment can see what the host does; it cannot see what it is
* for. Everything on this page answers a question the host has no way of
* answering itself — does this guest need a backup, must this one come
* back by itself, is this storage essential — and each answer is what
* turns an observation in the report into a warning, or takes it out of
* the count entirely.
*
* Nothing here is required. A host with no declaration produces a
* complete report; it just describes rather than judges.
*/
interface Guest { vmid: number; name: string; type: string }
interface Storage { id: string; type: string }
interface GuestRule {
backup?: string; autostart?: string
recovery_objective_hours?: number; note?: string
}
interface Policy {
guests: Record<string, GuestRule>
storages: Record<string, { role?: string }>
defaults: Record<string, unknown>
thresholds: Record<string, number>
}
interface Vocabulary {
expectations: string[]
roles: string[]
thresholds: Record<string, number>
}
const EMPTY: Policy = { guests: {}, storages: {}, defaults: {}, thresholds: {} }
function PolicySelect({ value, options, prefix, onChange, inherited, inheritedKey, label, disabled }: {
value: string; options: string[]; prefix: string
onChange: (value: string) => void; inherited: string; inheritedKey?: string
label: (key: string) => string; disabled?: boolean
}) {
// One component behind every dropdown on this tab, so it is also the
// one place that decides they look like the rest of the interface.
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full min-w-0 text-foreground sm:w-[12.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">{inherited}</SelectItem>
{/* Declaring here what the default already says would be the same
entry twice, reading the same. */}
{options.filter((option) => option !== inheritedKey).map((option) => (
<SelectItem key={option} value={option}>{label(`${prefix}.${option}`)}</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function AuditPolicy() {
const t = useT()
const [policy, setPolicy] = useState<Policy>(EMPTY)
const [vocabulary, setVocabulary] = useState<Vocabulary | null>(null)
const [guests, setGuests] = useState<Guest[]>([])
const [storages, setStorages] = useState<Storage[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [revision, setRevision] = useState<string | null>(null)
const [conflict, setConflict] = useState(false)
// The declaration is what turns an observation into a warning, so the
// form stays locked until the reader says they are changing it.
const [editing, setEditing] = useState(false)
const locked = !editing || saving || !revision || conflict
const load = useCallback(async () => {
setLoading(true)
setRevision(null)
try {
const [current, inventory]: any[] = await Promise.all([
fetchApi("/api/audit/policy"),
// The declaration is about this host's own guests and storages,
// so they are listed rather than typed in by identifier.
fetchApi("/api/audit/inventory?profile=inventory"),
])
if (current?.success) {
setPolicy({ ...EMPTY, ...current.policy })
setVocabulary(current.vocabulary)
setRevision(current.summary.revision)
setDirty(false); setSaved(false); setConflict(false)
setError(null)
} else {
setError(current?.message || t("audit.policy.failed"))
}
const sections = inventory?.inventory?.sections
setGuests((sections?.guests || []).map((g: any) => ({
vmid: g.vmid, name: g.name, type: g.type,
})))
setStorages((sections?.storages || []).map((s: any) => ({
id: s.id, type: s.type,
})))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => { load() }, [load])
const setGuestRule = (vmid: number, field: keyof GuestRule, value: unknown) => {
setPolicy((prev) => {
const guests = { ...prev.guests }
const rule: GuestRule = { ...(guests[String(vmid)] || {}) }
// Absence inherits; explicit "unspecified" overrides the site default.
if (value === "inherit" || value === "" || value === undefined) {
delete rule[field]
} else {
;(rule as Record<string, unknown>)[field] = value
}
if (Object.keys(rule).length === 0) delete guests[String(vmid)]
else guests[String(vmid)] = rule
return { ...prev, guests }
})
setDirty(true); setSaved(false)
}
const setStorageRole = (id: string, role: string) => {
setPolicy((prev) => {
const storages = { ...prev.storages }
if (role === "inherit" || !role) delete storages[id]
else storages[id] = { role }
return { ...prev, storages }
})
setDirty(true); setSaved(false)
}
const setThreshold = (name: string, raw: string) => {
setPolicy((prev) => {
const thresholds = { ...prev.thresholds }
const value = Number(raw)
if (!raw.trim()) delete thresholds[name]
else thresholds[name] = value
return { ...prev, thresholds }
})
setDirty(true); setSaved(false)
}
const save = async () => {
if (!revision || saving || conflict) return
setSaving(true)
try {
const res: any = await fetchApi("/api/audit/policy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...policy, expected_revision: revision }),
})
if (res?.success) {
setRevision(res.summary.revision)
setDirty(false); setSaved(true); setError(null); setEditing(false)
}
else setError(res?.message || t("audit.policy.failed"))
} catch (e) {
if ((e as { status?: number }).status === 409) {
setConflict(true)
setError(t("audit.policy.conflict"))
} else setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const declared = useMemo(
() => Object.keys(policy.guests).length + Object.keys(policy.storages).length
+ Object.keys(policy.thresholds).length + Object.keys(policy.defaults).length,
[policy],
)
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.policy.loading")}
</div>
)
}
const expectations = vocabulary?.expectations || ["required", "not_required", "unspecified"]
const roles = vocabulary?.roles || ["essential", "optional", "unspecified"]
return (
<form onSubmit={(event) => { event.preventDefault(); void save() }}>
{error && <p className="text-sm text-red-400 px-1">{error}</p>}
{(conflict || !revision) && <Button type="button" onClick={() => void load()}>
{t("audit.policy.reload")}
</Button>}
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardContent className="py-4 space-y-3">
<p className="text-sm text-muted-foreground">{t("audit.policy.intro")}</p>
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline" className="tabular-nums">
{t("audit.policy.declaredCount", { count: String(declared) })}
</Badge>
{saved && <span className="text-sm text-green-500">{t("audit.policy.saved")}</span>}
{editing ? (
<div className="ml-auto flex items-center gap-2">
<button
type="button"
className="h-7 px-3 text-xs rounded-md border border-border bg-background
hover:bg-muted transition-colors text-muted-foreground"
onClick={() => { setEditing(false); void load() }}
disabled={saving}
>
{t("actions.cancel")}
</button>
<button
type="submit"
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"
disabled={!dirty || saving}
>
{saving
? <Loader2 className="h-3 w-3 animate-spin" />
: <CheckCircle2 className="h-3 w-3" />}
{t("actions.save")}
</button>
</div>
) : (
<button
type="button"
className="ml-auto 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={() => { setEditing(true); setSaved(false) }}
disabled={!revision || conflict}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
</CardContent>
</Card>
<fieldset disabled={locked} className="space-y-4 min-w-0">
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<Boxes className="h-4 w-4" />{t("audit.inventory.guests")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.guestsNote")}</p>
{guests.map((guest) => {
const rule = policy.guests[String(guest.vmid)] || {}
return (
<div key={guest.vmid}
className="rounded-md border border-border p-3 space-y-2
sm:flex sm:flex-wrap sm:items-center sm:gap-3 sm:space-y-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{guest.vmid}
</Badge>
<span className="truncate font-medium text-foreground">
{guest.name || "—"}
</span>
<Badge variant="outline" className="text-xs uppercase shrink-0">
{guest.type}
</Badge>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.backup")}</span>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.backup
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.backup}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.backup ? undefined : "unspecified"}
value={rule.backup || "inherit"}
options={expectations}
prefix="audit.policy.expectation"
onChange={(v) => setGuestRule(guest.vmid, "backup", v)}
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.autostart")}</span>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.autostart
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.autostart}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.autostart ? undefined : "unspecified"}
value={rule.autostart || "inherit"}
options={expectations}
prefix="audit.policy.expectation"
onChange={(v) => setGuestRule(guest.vmid, "autostart", v)}
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.objective")}</span>
<input
type="number"
min={0.000001}
step="any"
inputMode="numeric"
value={rule.recovery_objective_hours ?? ""}
placeholder={policy.defaults.recovery_objective_hours
? String(policy.defaults.recovery_objective_hours) : t("audit.policy.objectivePlaceholder")}
onChange={(e) => setGuestRule(
guest.vmid, "recovery_objective_hours",
e.target.value ? Number(e.target.value) : undefined)}
className="w-full min-w-0 rounded-md border border-border bg-background
px-2 py-1.5 text-sm text-foreground focus:outline-none
focus:ring-1 focus:ring-ring sm:w-24"
/>
{rule.recovery_objective_hours == null && policy.defaults.recovery_objective_hours != null && (
<span>{t("audit.policy.inherit", { value: `${policy.defaults.recovery_objective_hours} ${t("audit.policy.objectivePlaceholder")}` })}</span>
)}
</label>
</div>
)
})}
{guests.length === 0 && (
<p className="text-sm text-muted-foreground">{t("audit.policy.noGuests")}</p>
)}
</CardContent>
</Card>
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<HardDrive className="h-4 w-4" />{t("audit.inventory.storage")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.storagesNote")}</p>
{storages.map((storage) => (
<div key={storage.id}
className="rounded-md border border-border p-3 space-y-2
sm:flex sm:items-center sm:gap-3 sm:space-y-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{storage.id}
</Badge>
<span className="text-sm text-muted-foreground">{storage.type}</span>
</div>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.storage_role
? t("audit.policy.inherit", { value: t(`audit.policy.role.${policy.defaults.storage_role}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.storage_role ? undefined : "unspecified"}
value={policy.storages[storage.id]?.role || "inherit"}
options={roles}
prefix="audit.policy.role"
onChange={(v) => setStorageRole(storage.id, v)}
/>
</div>
))}
</CardContent>
</Card>
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<SlidersHorizontal className="h-4 w-4" />{t("audit.policy.thresholds")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.thresholdsNote")}</p>
<div className="grid gap-2 sm:grid-cols-2">
{Object.entries(vocabulary?.thresholds || {}).map(([name, shipped]) => (
<label key={name}
className="flex flex-col items-end gap-1.5 rounded-md border
border-border p-2.5 sm:flex-row sm:items-center sm:gap-2">
<span className="w-full min-w-0 text-left text-sm text-foreground sm:flex-1">
{t(`audit.policy.threshold.${name}`)}
</span>
<input
type="number"
min={0.000001}
max={name.endsWith("_percent") ? 100 : undefined}
step="any"
inputMode="decimal"
value={policy.thresholds[name] ?? ""}
placeholder={String(shipped)}
onChange={(e) => setThreshold(name, e.target.value)}
className="w-24 shrink-0 rounded-md border border-border bg-background
px-2 py-1.5 text-sm text-foreground tabular-nums
focus:outline-none focus:ring-1 focus:ring-ring"
/>
</label>
))}
</div>
</CardContent>
</Card>
</fieldset>
</form>
)
}
+938
View File
@@ -0,0 +1,938 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Button } from "./ui/button"
import { Badge } from "./ui/badge"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
DialogTitle,
} from "./ui/dialog"
import {
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
FileText, HelpCircle, Info, Loader2, MinusCircle, Play, RotateCcw,
ShieldOff, XCircle,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { AuditPolicy } from "./audit-policy"
import { AuditChanges } from "./audit-changes"
import { AuditComparison } from "./audit-comparison"
import { Label } from "./ui/label"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select"
import { unreadSources } from "../lib/audit-presentation"
import { openAuditDocument, openReportWindow } from "../lib/audit-document"
import { AuditEvidence } from "./audit-evidence"
import { AuditFindingData } from "./audit-finding-data"
import { affectedDescription, resultBreakdown } from "../lib/audit-presentation"
import { useI18n } from "../lib/i18n/provider"
interface Finding {
check_id: string
area: string
severity: string
classification: string
decision?: string
summary_key: string | null
summary_params: Record<string, string | number>
affected: Array<Record<string, unknown>>
evidence: string | null
remediable_by: string | null
incomplete?: boolean
collected_at?: number
check_version?: number
sources?: Array<{ source: string; collected_at: number; error?: string }>
exception?: {
reason: string; accepted_by: string; accepted_at: number
expires_at?: number | null
// Asks for the decision to be looked at again on this date. It does
// not withdraw it: an acceptance can stand indefinitely and still
// come back for review.
review_at?: number | null
} | null
}
interface Run {
run_id: string
profile: string
started_at: number
finished_at: number | null
status: string
checks_total: number
checks_expected: number
error?: string | null
is_baseline?: number | boolean
// Recorded by the engine: the sources it read and the declaration it
// judged against. The document states the latter in its scope.
metadata?: { policy?: {
declared?: boolean; guests_declared?: number
storages_declared?: number; thresholds_declared?: string[]
} } | null
}
// Findings are ordered by how much they demand attention, not by area.
// Someone triaging wants the worst thing first regardless of where it
// lives; grouping by area is the reading order of the printed document.
//
// One scale, worst first. There is no second ordering by severity any
// more: gravity is the classification, so a finding cannot be a critical
// observation or an informational failure.
const CLASS_RANK: Record<string, number> = {
critical: 0, warning: 1, observation: 2, unverified: 3,
accepted: 4, conformant: 5, not_applicable: 6,
}
// Only the first two are problems. An observation is drawn in a neutral
// tone on purpose: colouring planning information like a fault is what
// made ordinary configurations read as defects.
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
critical: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
warning: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
observation: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Info },
unverified: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
accepted: { chip: "bg-indigo-500/10 text-indigo-400 border-indigo-400/20", Icon: ShieldOff },
conformant: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 },
not_applicable: { chip: "bg-muted text-muted-foreground border-border", Icon: MinusCircle },
}
/** What a finding reads as once the reader's decision is applied. */
function shownAs(f: { classification: string; decision?: string }): string {
return f.decision === "accepted" ? "accepted" : f.classification
}
// An assessment older than this stops describing the current system, so
// the age is surfaced before any count rather than as a footnote.
const STALE_AFTER_DAYS = 30
const SUMMARY_BADGE_CLASS = "h-6 gap-1.5 whitespace-nowrap px-2.5 py-0 text-xs"
export function AuditReport() {
const t = useT()
const { language } = useI18n()
// Assessment and inventory answer different questions and are
// read differently: one is triaged, the other is read through.
const [view, setView] = useState<"assessment" | "changes" | "policy">("assessment")
const [running, setRunning] = useState(false)
const [latest, setLatest] = useState<Run | null>(null)
const [findings, setFindings] = useState<Finding[]>([])
const [summary, setSummary] = useState<Record<string, number>>({})
const [areaFilter, setAreaFilter] = useState<string>("all")
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState<Finding | null>(null)
const [reason, setReason] = useState("")
const [expiryDays, setExpiryDays] = useState<string>("")
const [reviewDays, setReviewDays] = useState<string>("")
const [saving, setSaving] = useState(false)
const [progress, setProgress] = useState({ completed: 0, total: 0 })
// The profile decides which question the page answers, so it governs
// both what an assessment runs and what the inventory documents.
const [profile, setProfile] = useState("full")
const [profiles, setProfiles] = useState<Array<{ id: string; runs_checks: boolean; areas: string[] | null; include: string[] }>>([])
// Set when a Lynis-bearing assessment is about to run and the stored
// report is missing or stale: the user decides whether to run Lynis now.
const [lynisPrompt, setLynisPrompt] = useState<null | { ageDays: number | null; stale: boolean }>(null)
const [building, setBuilding] = useState(false)
const loadRun = useCallback(async (runId: string) => {
try {
const data: any = await fetchApi(`/api/audit/runs/${runId}?effective=1`)
if (data?.success) setFindings(data.findings || [])
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
const refresh = useCallback(async () => {
try {
const data: any = await fetchApi("/api/audit/status")
if (!data?.success) return
setRunning(Boolean(data.running))
setProgress({ completed: data.progress?.completed || 0, total: data.progress?.total || 0 })
setSummary(data.summary || {})
setLatest(data.latest || null)
if (data.latest?.run_id) await loadRun(data.latest.run_id)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [loadRun])
useEffect(() => {
fetchApi("/api/audit/profiles")
.then((d: any) => { if (d?.success) setProfiles(d.profiles || []) })
.catch(() => { /* the page works on the default profile */ })
}, [])
useEffect(() => { refresh() }, [refresh])
// Expiry changes a decision, not the assessment. One local timer and
// a focus refresh keep it current without periodic scans or idle polling.
useEffect(() => {
const now = Date.now() / 1000
const due = findings.flatMap((f) => [f.exception?.expires_at, f.exception?.review_at]
.filter((t): t is number => !!t && t > now))
if (!due.length) return
const delay = Math.max(100, Math.min(2147483647, Math.min(...due) * 1000 - Date.now() + 100))
const id = setTimeout(refresh, delay)
return () => clearTimeout(id)
}, [findings, refresh])
useEffect(() => {
const onFocus = () => { void refresh() }
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [refresh])
// While an assessment is in flight the page polls; once it settles the
// interval is dropped so an idle tab does not keep waking the backend.
useEffect(() => {
if (!running) return
const id = setInterval(refresh, 2000)
return () => clearInterval(id)
}, [running, refresh])
const doRun = async (runLynis: boolean) => {
setLynisPrompt(null)
setError(null)
try {
const data: any = await fetchApi("/api/audit/run", {
method: "POST",
body: JSON.stringify({ profile, run_lynis: runLynis }),
})
if (data?.success) setRunning(true)
else setError(data?.message || t("audit.errors.runFailed"))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
// A profile that includes the Lynis check asks the user before running,
// since producing a fresh Lynis report takes a few minutes. When a
// recent report already exists — or Lynis is not installed — the run
// starts straight away and reuses it.
const startRun = async () => {
setError(null)
const spec = profiles.find((p) => p.id === profile)
const runsLynis = !!spec && (spec.areas === null || spec.areas.includes("security"))
if (runsLynis) {
try {
const r: any = await fetchApi("/api/audit/lynis-readiness")
if (r?.success && r.installed && (!r.has_report || r.stale)) {
setLynisPrompt({ ageDays: r.age_days ?? null, stale: !!r.stale })
return
}
} catch {
/* Readiness is advisory; on failure run without Lynis rather than block. */
}
}
doRun(false)
}
// Accepting or revoking changes which findings are active, so the run
// is re-read afterwards rather than patched in place: the stored
// finding is what the next report will show.
const submitAcceptance = async () => {
if (!accepting || !reason.trim()) return
setSaving(true)
try {
const body: Record<string, unknown> = {
check_id: accepting.check_id,
run_id: latest?.run_id,
reason: reason.trim(),
}
if (expiryDays) body.expires_in_days = Number(expiryDays)
if (reviewDays) body.review_in_days = Number(reviewDays)
const data: any = await fetchApi("/api/audit/exceptions", {
method: "POST",
body: JSON.stringify(body),
})
if (!data?.success) throw new Error(data?.message || "")
setAccepting(null)
setReason("")
setExpiryDays("")
setReviewDays("")
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const revokeAcceptance = async (checkId: string) => {
try {
await fetchApi(`/api/audit/exceptions/${checkId}`, { method: "DELETE" })
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const areas = useMemo(
() => Array.from(new Set(findings.map((f) => f.area))).sort(),
[findings],
)
// Every check is listed, worst first. Hiding what passed made the
// reader guess whether a check was clean or had not run, which is
// exactly the distinction this page exists to keep.
const visible = useMemo(() =>
findings
.filter((f) => areaFilter === "all" || f.area === areaFilter)
.sort((a, b) =>
(CLASS_RANK[shownAs(a)] ?? 9) - (CLASS_RANK[shownAs(b)] ?? 9) ||
a.check_id.localeCompare(b.check_id)),
[findings, areaFilter])
const acceptedCount = summary.accepted || 0
const reviewDueCount = findings.filter(
(f) => f.exception?.review_at && f.exception.review_at * 1000 <= Date.now(),
).length
const unverifiedChecks = findings.filter(
(f) => f.classification === "unverified" || f.incomplete)
const ageDays = latest?.finished_at
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
: null
const stale = ageDays !== null && ageDays >= STALE_AFTER_DAYS
// The backend stores which sentence applies and its numbers, not the
// sentence itself, so a finding recorded under one language still reads
// correctly under another. A check that failed to evaluate has no
// per-check entry, hence the shared fallback.
const summaryOf = (f: Finding) => {
if (f.check_id === "backup.last_backup_age" && f.affected.length) return resultBreakdown(f, t)
if (!f.summary_key) return ""
const params = Object.fromEntries(
Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]),
)
const key = `audit.checks.${f.check_id}.summary.${f.summary_key}`
const text = t(key, params)
return text === key ? t("audit.summaryFallback") : text
}
// A check's plain-language texts are optional. Those that do not carry
// them yet render nothing, rather than the raw key a missing lookup
// returns, so the section can gain them one area at a time.
const optional = (key: string) => {
const text = t(key)
return text === key ? null : text
}
// What to do about a finding depends on what was found, not on what was
// checked: an outcome that is not a problem has no next step, and one
// that could not be evaluated has nothing to act on either.
const nextStepOf = (f: Finding) => {
if (f.classification === "not_applicable") return null
if (f.classification === "unverified" || f.incomplete) return t("audit.couldNotEvaluate")
if (f.classification !== "critical" && f.classification !== "warning") {
return t("audit.noActionNeeded")
}
return f.summary_key ? optional(`audit.checks.${f.check_id}.nextStep.${f.summary_key}`) : null
}
const notApplicableText = (f: Finding) => {
if (f.summary_key) return ""
return f.classification === "not_applicable" ? t("audit.notApplicableScope") : ""
}
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
{t("audit.loading")}
</div>
)
}
// The document carries both halves, so the inventory is fetched at the
// moment it is produced rather than kept in memory for a button that
// may never be pressed.
const generateDocument = async () => {
// The window is opened on the click itself, before the inventory is
// fetched, so the popup blocker sees the user gesture. It shows a
// spinner while the document is composed.
const target = openReportWindow(t("audit.document.building"))
setBuilding(true)
try {
const inv: any = await fetchApi(
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
// A focused report shows only the checks its profile runs, even
// when the findings on screen came from a full assessment: the
// report is scoped to its question, not to whichever run produced
// the data. `areas: null` (full, diagnostic) keeps everything.
const spec = profiles.find((p) => p.id === profile)
const scopedFindings = !spec || spec.areas === null
? findings
: findings.filter((f) => spec.areas!.includes(f.area) || spec.include.includes(f.check_id))
openAuditDocument({
profile,
run: latest,
findings: scopedFindings,
inventory: inv?.success ? inv.inventory : null,
t,
locale: language,
}, target)
} catch (e) {
target?.close()
setError(e instanceof Error ? e.message : String(e))
} finally {
setBuilding(false)
}
}
const documentButton = (
<Button
variant="outline"
size="sm"
onClick={generateDocument}
disabled={building}
// Related to running an assessment, and secondary to it: the same
// hue as that button, at the translucent weight the chips use.
// Green is taken — here it means a conformant result, and this
// report may be full of critical ones.
className="shrink-0 border-blue-500/20 bg-blue-500/10 text-blue-500
hover:bg-blue-500/20 hover:text-blue-500"
>
{building
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
: <FileText className="h-4 w-4 mr-2" />}
{/* The icon carries the meaning where the width is short. */}
<span className="sm:hidden">{t("audit.document.actionShort")}</span>
<span className="hidden sm:inline">{t("audit.document.action")}</span>
</Button>
)
const profilePicker = profiles.length > 0 ? (
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm sm:w-auto sm:flex-none">
<Label htmlFor="audit-profile" className="hidden shrink-0 text-muted-foreground sm:inline">
{t("audit.profile.label")}
</Label>
<Select value={profile} onValueChange={setProfile}>
<SelectTrigger id="audit-profile" className="min-w-0 flex-1 sm:w-56 sm:flex-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>{t(`audit.profile.${p.id}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null
const viewTabs = (
<div
role="group"
aria-label={t("audit.viewSwitch.ariaLabel")}
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
sm:inline-flex sm:w-auto"
>
{(["assessment", "changes", "policy"] as const).map((key) => (
<button
key={key}
type="button"
aria-pressed={view === key}
onClick={() => setView(key)}
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
view === key
? "bg-blue-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`audit.viewSwitch.${key}`)}
</button>
))}
</div>
)
// Changes and policy carry no profile: one is what was done to this
// host, the other what is expected of it, and neither narrows by report.
if (view === "changes") {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
{viewTabs}
</div>
</div>
<AuditChanges />
</div>
)
}
if (view === "policy") {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
{viewTabs}
</div>
</div>
<AuditPolicy />
</div>
)
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
<div className="flex items-center gap-2 sm:contents">
{profilePicker}{documentButton}
</div>
{viewTabs}
</div>
</div>
<Card className="bg-card border-border">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
{/* Stated before any count: an assessment nobody has run, or
one run months ago, does not describe this host today. */}
{!latest ? (
<p className="text-sm text-muted-foreground">{t("audit.neverRun")}</p>
) : (
<p className={`text-sm ${stale ? "text-amber-500" : "text-muted-foreground"}`}>
{t("audit.lastRun", {
when: new Date((latest.finished_at || latest.started_at) * 1000)
.toLocaleString(),
})}
{stale && ` — ${t("audit.stale", { days: String(ageDays) })}`}
</p>
)}
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
{running && <p role="status" className="text-sm text-muted-foreground">
{t("audit.progress", { completed: String(progress.completed), total: String(progress.total) })}
</p>}
{/* A reading that could not be taken is information, not an
alarm: it says the report is narrower than usual, and
colouring it like a finding puts it above warnings the
reader has to act on. A run that failed outright is the
one thing here that does interrupt. */}
{latest && (latest.status === "partial" || latest.status === "failed") && (
<div
{...(latest.status === "failed" ? { role: "alert" as const } : {})}
className={`space-y-1 text-sm ${
latest.status === "failed" ? "text-amber-500" : "text-muted-foreground"
}`}
>
<p>{t(`audit.runStates.${latest.status}`)}</p>
{unverifiedChecks.length > 0 && <p className="text-xs">
{t("audit.unverifiedChecks", { checks: unverifiedChecks
.map((f) => t(`audit.checks.${f.check_id}.title`)).join(" · ") })}
</p>}
</div>
)}
{latest?.error && <p className="text-xs text-amber-500">{latest.error}</p>}
</div>
<Button
onClick={startRun}
disabled={running}
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
>
{running
? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t("audit.running")}</>
: <><Play className="h-4 w-4 mr-2" />{t("audit.run")}</>}
</Button>
</CardHeader>
{latest && (
<CardContent className="pt-0 space-y-3">
{/* One row of counters on one scale. Gravity is the
classification itself, so there is nothing left to
reconcile between two sets of numbers. */}
<div role="group" aria-label={t("audit.results")}
className="flex max-w-full flex-wrap items-center gap-2">
{(["critical", "warning", "observation", "unverified",
"accepted", "conformant", "not_applicable"] as const)
.filter((c) => summary[c])
.map((c) => {
const { chip, Icon } = CLASS_STYLE[c]
return (
<Badge key={c} variant="outline" className={`${SUMMARY_BADGE_CLASS} ${chip}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.classifications.${c}`)}
<span className="tabular-nums font-semibold">{summary[c]}</span>
</Badge>
)
})}
</div>
{/* What changed since a reference run: context for the
assessment being read, not a place of its own. */}
<AuditComparison
runId={latest.run_id}
isBaseline={Boolean(latest.is_baseline)}
onBaselineSet={refresh}
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setAreaFilter("all")}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
areaFilter === "all"
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t("audit.areas.all")}
</button>
{areas.map((a) => (
<button
key={a}
type="button"
onClick={() => setAreaFilter(a)}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
areaFilter === a
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`audit.areas.${a}`)}
</button>
))}
</div>
{/* The count of accepted risks stays visible even when the
findings themselves are filtered out of the list, so a
decision to live with something is never silently lost. */}
{acceptedCount > 0 && (
<p className="text-xs text-muted-foreground">
{t("audit.acceptedNotice", { count: String(acceptedCount) })}
</p>
)}
{/* A decision that asked to be revisited says so here, where it
is read without going to look for it. The acceptance still
stands; this is a reminder, not a lapse. */}
{reviewDueCount > 0 && (
<p className="text-xs text-amber-500">
{t("audit.reviewDueNotice", { count: String(reviewDueCount) })}
</p>
)}
</CardContent>
)}
</Card>
{error && (
<p className="text-sm text-red-400 px-1">{error}</p>
)}
{latest && visible.length === 0 && (
<Card className="bg-card border-border">
<CardContent className="py-10 text-center text-muted-foreground">
{t("audit.noFindings")}
</CardContent>
</Card>
)}
<div className="space-y-2">
{visible.map((f) => {
const shown = shownAs(f)
const { chip, Icon } = CLASS_STYLE[shown] || CLASS_STYLE.not_applicable
const open = expanded.has(f.check_id)
const muted = shown === "accepted" || shown === "not_applicable"
return (
<Card
key={f.check_id}
className={`bg-card border-border ${muted ? "opacity-70" : ""}`}
>
<button
type="button"
onClick={() => toggle(f.check_id)}
aria-expanded={open}
className="w-full text-left p-4 flex max-[359px]:flex-wrap items-start gap-3 rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
{open
? <ChevronDown className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className={`${chip} gap-1.5 shrink-0`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.classifications.${shown}`)}
</Badge>
<div className="min-w-0 flex-1 max-[359px]:basis-full">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">
{t(`audit.checks.${f.check_id}.title`)}
</span>
<Badge variant="outline" className="text-xs">
{t(`audit.areas.${f.area}`)}
</Badge>
{f.incomplete && <Badge variant="outline" className="text-xs text-amber-500">
{t("audit.incomplete")}
</Badge>}
{f.affected.length > 0 && (
<Badge variant="outline" className="text-xs tabular-nums">
{affectedDescription(f, t)}
</Badge>
)}
</div>
{(f.summary_key || notApplicableText(f)) && (
<p className="text-sm text-muted-foreground mt-1">
{f.summary_key ? summaryOf(f) : notApplicableText(f)}
</p>
)}
{/* "Could not be evaluated" describes the assessment, not
the host. What could not be read is recorded against
each source, and belongs here rather than two
collapsed panels below. */}
{unreadSources(f.sources, t) && (
<p className="text-xs text-muted-foreground mt-1">
{unreadSources(f.sources, t)}
</p>
)}
</div>
</button>
{open && (
<CardContent className="pt-0 pl-11 space-y-4">
{optional(`audit.checks.${f.check_id}.explanation`) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.whatItMeans")}
</p>
<p className="text-sm text-foreground">
{optional(`audit.checks.${f.check_id}.explanation`)}
</p>
</div>
)}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.why")}
</p>
<p className="text-sm text-foreground">
{t(`audit.checks.${f.check_id}.rationale`)}
</p>
</div>
{nextStepOf(f) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.whatToDo")}
</p>
<p className="text-sm text-foreground">{nextStepOf(f)}</p>
</div>
)}
{f.exception && (
<div className="rounded-md border border-border bg-background p-3">
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.acceptedRisk")}
</p>
<p className="text-sm text-foreground">{f.exception.reason}</p>
<p className="text-xs text-muted-foreground mt-1">
{f.exception.accepted_by} ·{" "}
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
{f.exception.expires_at && <> · {t("audit.expires", {
when: new Date(f.exception.expires_at * 1000).toLocaleString(),
})}</>}
{f.exception.review_at && <> · {t("audit.reviewOn", {
when: new Date(f.exception.review_at * 1000).toLocaleDateString(),
})}</>}
</p>
{!!f.exception.review_at && f.exception.review_at * 1000 <= Date.now() && (
<p className="text-xs text-amber-500 mt-1">{t("audit.reviewDue")}</p>
)}
</div>
)}
{/* Some checks carry a useful, structured positive reading
in their evidence even when nothing is affected. The
presenter returns no groups for checks without such a
view, so rendering it unconditionally does not create
empty space. */}
<AuditFindingData finding={f} t={t} locale={language} />
{f.evidence && (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground mb-2">
{t("audit.presentation.technical")}
</summary>
<AuditEvidence evidence={f.evidence} locale={language} />
</details>
)}
{f.sources && f.sources.length > 0 && <details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">{t("audit.detail.sources")}</summary>
<ul className="mt-2 space-y-1">
{f.sources.map((source) => <li key={source.source} className="break-all">
{source.source} · {new Date(source.collected_at * 1000).toLocaleString()}
{source.error && <span className="text-amber-500"> · {source.error}</span>}
</li>)}
</ul>
</details>}
{/* Only an active finding can be accepted, and only an
accepted one can be returned to the active set. */}
{(f.classification === "critical" || f.classification === "warning")
&& !f.decision && !f.incomplete && (
<Button
variant="outline"
size="sm"
onClick={() => { setAccepting(f); setReason(""); setExpiryDays(""); setReviewDays("") }}
>
<ShieldOff className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.action")}
</Button>
)}
{f.decision === "accepted" && (
<Button
variant="outline"
size="sm"
onClick={() => revokeAcceptance(f.check_id)}
>
<RotateCcw className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.revoke")}
</Button>
)}
</CardContent>
)}
</Card>
)
})}
</div>
<Dialog open={lynisPrompt !== null} onOpenChange={(o) => !o && setLynisPrompt(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("audit.lynis.title")}</DialogTitle>
<DialogDescription>
{lynisPrompt?.stale
? t("audit.lynis.bodyStale", { days: String(lynisPrompt?.ageDays ?? "") })
: t("audit.lynis.bodyNotRun")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => setLynisPrompt(null)}>
{t("audit.lynis.cancel")}
</Button>
<Button variant="outline" onClick={() => doRun(false)}>
{t("audit.lynis.withoutLynis")}
</Button>
<Button onClick={() => doRun(true)}>
{t("audit.lynis.withLynis")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={accepting !== null} onOpenChange={(o) => !o && setAccepting(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("audit.acceptRisk.title")}</DialogTitle>
<DialogDescription>
{accepting && t(`audit.checks.${accepting.check_id}.title`)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label htmlFor="audit-reason" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.reasonLabel")}
</label>
{/* The reason is required, not encouraged. An acceptance
without one cannot be told apart later from having
silenced the check. */}
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.reasonHelp")}
</p>
<textarea
id="audit-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("audit.acceptRisk.reasonPlaceholder")}
/>
</div>
<div>
<label htmlFor="audit-expiry" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.expiryLabel")}
</label>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.expiryHelp")}
</p>
<Select value={expiryDays || "none"}
onValueChange={(v) => setExpiryDays(v === "none" ? "" : v)}>
<SelectTrigger id="audit-expiry" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("audit.acceptRisk.expiryNever")}</SelectItem>
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Separate from the expiry on purpose: this one asks to look at
the decision again without withdrawing it, so "remind me in a
year" no longer has to be spelled as "stop accepting this in a
year". */}
<div>
<label htmlFor="audit-review" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.reviewLabel")}
</label>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.reviewHelp")}
</p>
<Select value={reviewDays || "none"}
onValueChange={(v) => setReviewDays(v === "none" ? "" : v)}>
<SelectTrigger id="audit-review" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("audit.acceptRisk.reviewNever")}</SelectItem>
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAccepting(null)}>
{t("audit.acceptRisk.cancel")}
</Button>
<Button
onClick={submitAcceptance}
disabled={!reason.trim() || saving}
className="bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{t("audit.acceptRisk.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+198 -34
View File
@@ -1,18 +1,20 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Shield, Lock, User, AlertCircle, Eye, EyeOff } from "lucide-react"
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthSetupProps {
onComplete: () => void
}
export function AuthSetup({ onComplete }: AuthSetupProps) {
const t = useT()
const [open, setOpen] = useState(false)
const [step, setStep] = useState<"choice" | "setup">("choice")
const [username, setUsername] = useState("")
@@ -22,6 +24,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
// Profile (Fase 2 — v1.2.2). Both optional decorations on top of the
// mandatory username + password. Persisted via PUT /api/auth/profile
// and POST /api/auth/profile/avatar after the user lands a successful
// /api/auth/setup so we don't change the setup endpoint's contract.
const [displayName, setDisplayName] = useState("")
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const checkOnboardingStatus = async () => {
@@ -58,58 +68,65 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setError("")
try {
console.log("[v0] Skipping authentication setup...")
const response = await fetch(getApiUrl("/api/auth/skip"), {
method: "POST",
headers: { "Content-Type": "application/json" },
})
const data = await response.json()
console.log("[v0] Auth skip response:", data)
if (!response.ok) {
throw new Error(data.error || "Failed to skip authentication")
throw new Error(data.error || t("authSetup.skipFailed"))
}
if (data.auth_declined) {
console.log("[v0] Authentication skipped successfully - APIs should be accessible without token")
}
console.log("[v0] Authentication skipped successfully")
localStorage.setItem("proxmenux-auth-declined", "true")
localStorage.removeItem("proxmenux-auth-token") // Remove any old token
setOpen(false)
onComplete()
} catch (err) {
console.error("[v0] Auth skip error:", err)
setError(err instanceof Error ? err.message : "Failed to save preference")
console.error("Auth skip error:", err)
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
} finally {
setLoading(false)
}
}
const handleAvatarPick = () => fileInputRef.current?.click()
const handleAvatarChange = (file: File | null) => {
// Revoke the previous local preview so we don't leak blob URLs while
// the user picks another file before submitting.
if (avatarPreviewUrl) {
URL.revokeObjectURL(avatarPreviewUrl)
}
setAvatarFile(file)
setAvatarPreviewUrl(file ? URL.createObjectURL(file) : null)
}
const handleSetupAuth = async () => {
setError("")
if (!username || !password) {
setError("Please fill in all fields")
setError(t("authSetup.fillFields"))
return
}
if (password !== confirmPassword) {
setError("Passwords do not match")
setError(t("authSetup.passwordMismatch"))
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
setError(t("authSetup.passwordTooShort"))
return
}
setLoading(true)
try {
console.log("[v0] Setting up authentication...")
const response = await fetch(getApiUrl("/api/auth/setup"), {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -120,23 +137,76 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
})
const data = await response.json()
console.log("[v0] Auth setup response:", data)
if (!response.ok) {
throw new Error(data.error || "Failed to setup authentication")
throw new Error(data.error || t("authSetup.setupFailed"))
}
if (data.token) {
localStorage.setItem("proxmenux-auth-token", data.token)
localStorage.removeItem("proxmenux-auth-declined")
console.log("[v0] Authentication setup successful")
}
// Profile decorations (Fase 2). Sent as a follow-up to the setup
// call so the /api/auth/setup endpoint stays minimal (username +
// password only) — these calls reuse the existing profile
// endpoints and the JWT we just received. Failures here are
// non-fatal: the user is already authenticated and can finish
// configuring the profile from the /profile page.
const token = data.token
if (token) {
const trimmedDisplayName = displayName.trim()
if (trimmedDisplayName) {
try {
await fetch(getApiUrl("/api/auth/profile"), {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ display_name: trimmedDisplayName }),
})
} catch (e) {
console.warn("[auth-setup] failed to save display_name:", e)
}
}
if (avatarFile) {
try {
await fetch(getApiUrl("/api/auth/profile/avatar"), {
method: "POST",
headers: {
"Content-Type": avatarFile.type,
Authorization: `Bearer ${token}`,
},
body: avatarFile,
})
} catch (e) {
console.warn("[auth-setup] failed to upload avatar:", e)
}
}
}
// Release the local preview blob now that the file has been
// uploaded (or skipped). The header avatar pulls a fresh copy
// from the backend.
if (avatarPreviewUrl) {
URL.revokeObjectURL(avatarPreviewUrl)
setAvatarPreviewUrl(null)
}
// Notify the header AvatarMenu (mounted on dashboard load with
// auth_enabled=false) to re-fetch its status + profile so the
// avatar appears immediately after first-time setup instead of
// requiring a page refresh.
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("proxmenux:profile-changed"))
}
setOpen(false)
onComplete()
} catch (err) {
console.error("[v0] Auth setup error:", err)
setError(err instanceof Error ? err.message : "Failed to setup authentication")
console.error("Auth setup error:", err)
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
} finally {
setLoading(false)
}
@@ -146,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogTitle className="sr-only">
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
</DialogTitle>
{step === "choice" ? (
<div className="space-y-6 py-2">
@@ -154,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</h2>
<p className="text-muted-foreground text-sm">
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
{t("authSetup.protectDescription")}
</p>
</div>
<div className="space-y-3">
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
<Lock className="h-4 w-4 mr-2" />
Yes, Setup Password
{t("authSetup.setupPassword")}
</Button>
<Button
onClick={handleSkipAuth}
@@ -172,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
size="lg"
disabled={loading}
>
No, Continue Without Protection
{t("authSetup.skipProtection")}
</Button>
</div>
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
</div>
) : (
<div className="space-y-6 py-2">
@@ -184,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Lock className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Setup Authentication</h2>
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
</div>
{error && (
@@ -198,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-sm">
Username
{t("authSetup.username")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder="Enter username"
placeholder={t("authSetup.usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base"
@@ -217,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Label htmlFor="password" className="text-sm">
Password
{t("authSetup.password")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter password"
placeholder={t("authSetup.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base"
@@ -244,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-sm">
Confirm Password
{t("authSetup.confirmPassword")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password"
placeholder={t("authSetup.confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10 text-base"
@@ -268,14 +338,108 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
</Button>
</div>
</div>
{/* Optional profile decorations (Fase 2). Visually
separated from the mandatory credential fields by a
divider + a small heading so the operator understands
they can skip everything below and still complete the
setup. Both are saved with follow-up calls after the
setup endpoint returns the JWT. */}
<div className="pt-3 border-t border-border/60 space-y-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
{t("authSetup.profileOptional")}
</p>
<div className="space-y-2">
<Label htmlFor="display-name" className="text-sm">
{t("authSetup.displayName")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="display-name"
type="text"
placeholder={t("authSetup.displayNamePlaceholder")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={64}
className="pl-10 text-base"
disabled={loading}
/>
</div>
<p className="text-[11px] text-muted-foreground">
{t("authSetup.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">{t("authSetup.avatar")}</Label>
<div className="flex items-center gap-3">
{avatarPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarPreviewUrl}
alt=""
className="w-14 h-14 rounded-full object-cover border border-border bg-cyan-500/5 shrink-0"
/>
) : (
<span className="w-14 h-14 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-xl font-semibold border border-border shrink-0">
{(displayName || username || "U").trim().charAt(0).toUpperCase() || "U"}
</span>
)}
<div className="flex flex-col gap-1.5 min-w-0">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0] || null
handleAvatarChange(file)
if (fileInputRef.current) fileInputRef.current.value = ""
}}
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAvatarPick}
disabled={loading}
className="h-7 text-xs"
>
<Upload className="h-3 w-3 mr-1.5" />
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
</Button>
{avatarFile && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAvatarChange(null)}
disabled={loading}
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
>
<Trash2 className="h-3 w-3 mr-1.5" />
{t("authSetup.clear")}
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">
{t("authSetup.avatarHint")}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Setting up..." : "Setup Authentication"}
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
</Button>
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
Back
{t("authSetup.back")}
</Button>
</div>
</div>
+284
View File
@@ -0,0 +1,284 @@
"use client"
import { useEffect, useState } from "react"
import { User, Shield, LogOut } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthStatus {
auth_enabled?: boolean
username?: string | null
}
interface ProfileData {
success: boolean
username?: string | null
display_name?: string | null
has_avatar?: boolean
avatar_mtime?: number | null
}
interface AvatarMenuProps {
/** Size of the avatar circle in the header trigger. */
size?: "md" | "lg"
/**
* Callback used by the Security menu item. The Monitor renders its
* Settings/Security panels inside the same dashboard route, not on
* a separate URL, so navigation is handled by the parent that knows
* how to switch tabs. Optional — when omitted the menu item is hidden.
*/
onOpenSecurity?: () => void
/**
* Callback for "View profile". Same rationale: the parent decides how
* to route there (modal, page, tab switch). Until Fase 2 lands the
* caller typically passes an alert/toast that the page is coming.
*/
onOpenProfile?: () => void
}
/**
* AvatarMenu — user/account dropdown for the header.
*
* Self-fetches the current auth status to derive the username and the
* initial that fills the avatar circle. Stays silent (renders nothing)
* when authentication is disabled on this install — no point showing
* an account menu for a "Sign out" that doesn't apply.
*
* Sign out clears the token from localStorage and reloads, mirroring
* the existing `handleLogout` in `security.tsx`. That keeps a single
* source of truth for the logout flow until Fase 2 introduces a
* proper /api/auth/logout that revokes the JWT server-side too.
*/
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
const t = useT()
// IMPORTANT — all hooks must run unconditionally on every render. The
// previous version short-circuited with `if (!auth_enabled) return null`
// BEFORE the avatar blob hooks, so the hook count changed between
// renders the moment auth status loaded → React error #310 ("rendered
// more hooks than during the previous render"). All `useState` and
// `useEffect` calls now live above any early return; the null branch
// is at the very end after the hooks.
const [status, setStatus] = useState<AuthStatus | null>(null)
const [profile, setProfile] = useState<ProfileData | null>(null)
const [open, setOpen] = useState(false)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
// Load both auth_status (to decide whether to render at all) and the
// profile (to render display_name + avatar). Profile is fetched only
// when auth is enabled — saves one roundtrip on installs without
// auth where the menu won't show anyway.
useEffect(() => {
let cancelled = false
fetchApi<AuthStatus>("/api/auth/status")
.then(data => {
if (cancelled) return
setStatus(data)
if (data?.auth_enabled && data?.username) {
fetchApi<ProfileData>("/api/auth/profile")
.then(p => {
if (!cancelled) setProfile(p)
})
.catch(() => {
// Profile fetch is best-effort. Falls back to username + initials.
})
}
})
.catch(() => {
if (!cancelled) setStatus(null)
})
// Reload status + profile when the user updates the profile from
// the /profile page OR completes first-time auth setup. Refreshing
// status is what flips the menu visible after setup (when the
// initial mount saw auth_enabled=false); refreshing profile is
// what makes a new avatar/display name appear without a full
// browser refresh.
const handler = () => {
fetchApi<AuthStatus>("/api/auth/status")
.then(s => {
if (cancelled) return
setStatus(s)
if (s?.auth_enabled && s?.username) {
fetchApi<ProfileData>("/api/auth/profile")
.then(p => {
if (!cancelled) setProfile(p)
})
.catch(() => {})
}
})
.catch(() => {})
}
if (typeof window !== "undefined") {
window.addEventListener("proxmenux:profile-changed", handler)
}
return () => {
cancelled = true
if (typeof window !== "undefined") {
window.removeEventListener("proxmenux:profile-changed", handler)
}
}
}, [])
// Avatar fetch — the endpoint requires the Bearer header, which
// <img src=…> can't send, so we fetch as a blob and convert it to a
// local object URL for rendering. The blob URL is revoked on cleanup
// and on every refetch to avoid leaking memory.
useEffect(() => {
let cancelled = false
let currentBlobUrl: string | null = null
if (profile?.has_avatar) {
const token = getAuthToken()
const url = `${getApiUrl("/api/auth/profile/avatar")}?v=${profile.avatar_mtime || ""}`
fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
.then(r => (r.ok ? r.blob() : null))
.then(blob => {
if (cancelled || !blob) return
currentBlobUrl = URL.createObjectURL(blob)
setAvatarBlobUrl(currentBlobUrl)
})
.catch(() => {
if (!cancelled) setAvatarBlobUrl(null)
})
} else {
setAvatarBlobUrl(null)
}
return () => {
cancelled = true
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl)
}
}, [profile?.has_avatar, profile?.avatar_mtime])
// ── Hooks finished. Safe to early-return now. ──
// Hide the avatar entirely when auth isn't enabled on this install —
// there's no user identity to surface and no Sign out to offer.
if (!status?.auth_enabled || !status?.username) return null
const username = status.username
const displayName = profile?.display_name || username
const initial = displayName.trim().charAt(0).toUpperCase() || "U"
const handleSignOut = () => {
try {
localStorage.removeItem("proxmenux-auth-token")
localStorage.removeItem("proxmenux-auth-setup-complete")
} catch {
// localStorage may be unavailable (private mode); fall through.
}
window.location.reload()
}
// Avatar size in the header trigger. The trigger has no chevron now —
// removing it freed enough horizontal space to bump the avatar a
// notch up (40 → 44 / 32 → 36) without nudging the Refresh / Theme
// buttons sitting to its left.
const avatarSize = size === "lg" ? "w-11 h-11 text-lg" : "w-9 h-9 text-sm"
return (
<>
{/* Backdrop overlay — dim only (no blur). Mounted while the
dropdown is open. `bg-black/40` dims the page enough to focus
attention on the dropdown without distorting the content
behind, which testers found annoying when full backdrop blur
was used (especially on wider desktop viewports). `z-40`
places it above the dashboard content but below the dropdown
portal (`DropdownMenuContent` lands on z-[60]) and below the
header (which stays on z-50 so the avatar trigger remains
clickable). Clicking the backdrop closes the menu — the
explicit `onClick` mirrors Radix's outside-click handler. */}
{open && (
<div
aria-hidden="true"
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 bg-black/40 animate-in fade-in-0 duration-150"
/>
)}
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
aria-label={t("actions.openUserMenu")}
// WebKit ignores `outline` for the tap-highlight overlay
// shown on iOS / Android Chrome after a touch. That overlay
// was the white border that lingered on the avatar after
// dismissing the dropdown without picking anything. Setting
// `-webkit-tap-highlight-color` to transparent suppresses
// it without affecting keyboard focus visibility (handled
// separately by `focus-visible:outline-none` above).
style={{ WebkitTapHighlightColor: "transparent" }}
>
{avatarBlobUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarBlobUrl}
alt=""
className={`${avatarSize} rounded-full object-cover bg-cyan-500/10`}
/>
) : (
<span
className={`${avatarSize} rounded-full flex items-center justify-center font-semibold bg-cyan-500/15 text-cyan-600 dark:text-cyan-300`}
>
{initial}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 z-[60]">
<DropdownMenuLabel>
<div className="flex items-center gap-3 py-1">
{avatarBlobUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarBlobUrl}
alt=""
className="w-20 h-20 rounded-full object-cover bg-cyan-500/10 shrink-0"
/>
) : (
<span className="w-20 h-20 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-3xl font-semibold shrink-0">
{initial}
</span>
)}
<div className="min-w-0">
<div className="text-base font-semibold truncate">{displayName}</div>
{profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">{username}</div>
)}
{!profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
)}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{onOpenProfile && (
<DropdownMenuItem onClick={onOpenProfile}>
<User className="h-4 w-4 mr-2" />
{t("account.viewProfile")}
</DropdownMenuItem>
)}
{onOpenSecurity && (
<DropdownMenuItem onClick={onOpenSecurity}>
<Shield className="h-4 w-4 mr-2" />
{t("account.security")}
</DropdownMenuItem>
)}
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={handleSignOut}
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
>
<LogOut className="h-4 w-4 mr-2" />
{t("account.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
+57
View File
@@ -0,0 +1,57 @@
"use client"
// The Backup tab: the backups of the host and the backup jobs of the VMs
// and LXCs, each in its own view.
import { useState } from "react"
import { DatabaseBackup } from "lucide-react"
import { HostBackup } from "./host-backup"
import { VmBackupJobs } from "./vm-backup-jobs"
import { useT } from "../lib/i18n/provider"
type BackupView = "host" | "guests"
export function BackupTab() {
const t = useT()
const [view, setView] = useState<BackupView>("host")
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<DatabaseBackup className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("navigation.backup")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div
role="group"
aria-label={t("backup.viewSwitch.ariaLabel")}
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
sm:inline-flex sm:w-auto"
>
{(["host", "guests"] as const).map((key) => (
<button
key={key}
type="button"
aria-pressed={view === key}
onClick={() => setView(key)}
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
view === key
? "bg-blue-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`backup.viewSwitch.${key}`)}
</button>
))}
</div>
</div>
</div>
{/* The host view stays mounted: it follows a running backup or restore. */}
<div className={view === "host" ? "" : "hidden"}>
<HostBackup />
</div>
{view === "guests" && <VmBackupJobs />}
</div>
)
}
+308
View File
@@ -0,0 +1,308 @@
"use client"
import { useEffect, useState } from "react"
import { cancelButtonClass } from "../lib/utils"
import { Trash2 } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { getCategoryLabel } from "../lib/category-label"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
// Minimal shape we need from the /api/vms poll. Kept narrow so this
// component stays independent of the fuller VMData type used in
// virtual-machines.tsx.
export interface GuestOption {
vmid: number
name: string
type: "lxc" | "qemu"
}
export interface CustomLink {
id: string
name: string
url: string
logo_url: string
category: string
binding: { vmid: number; guest_type: "lxc" | "qemu" } | null
created_at?: number
updated_at?: number
}
export interface DraftCustomLink {
name: string
url: string
logo_url: string
category: string
bindingKey: string
}
const UNBOUND_KEY = "__none__"
function buildKey(binding: CustomLink["binding"]): string {
if (!binding) return UNBOUND_KEY
return `${binding.guest_type}:${binding.vmid}`
}
function parseKey(key: string): CustomLink["binding"] {
if (!key || key === UNBOUND_KEY) return null
const [type, vmid] = key.split(":")
if (type !== "lxc" && type !== "qemu") return null
const n = Number(vmid)
if (!Number.isFinite(n)) return null
return { guest_type: type, vmid: n }
}
export function CustomLinkEditor({
open,
onOpenChange,
editing,
guests,
categoryPresets,
onSaved,
}: {
open: boolean
onOpenChange: (v: boolean) => void
/** null = create; existing link = edit */
editing: CustomLink | null
/** VMs + LXCs from /api/vms so the user can bind a link to a guest */
guests: GuestOption[]
/** Populated from /api/apps/categories */
categoryPresets: string[]
/** Called on successful save/delete so the parent can refresh */
onSaved: () => void
}) {
const t = useT()
const [draft, setDraft] = useState<DraftCustomLink>({
name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY,
})
const [customCategoryMode, setCustomCategoryMode] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset the draft whenever the modal opens with a new target.
useEffect(() => {
if (!open) return
setError(null)
if (editing) {
setDraft({
name: editing.name,
url: editing.url,
logo_url: editing.logo_url || "",
category: editing.category || "",
bindingKey: buildKey(editing.binding),
})
setCustomCategoryMode(
!!editing.category && !categoryPresets.includes(editing.category),
)
} else {
setDraft({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY })
setCustomCategoryMode(false)
}
}, [open, editing, categoryPresets])
const canSave = draft.name.trim() && draft.url.trim() && !saving
const handleSave = async () => {
setError(null)
setSaving(true)
try {
const payload = {
name: draft.name.trim(),
url: draft.url.trim(),
logo_url: draft.logo_url.trim(),
category: draft.category.trim(),
binding: parseKey(draft.bindingKey),
}
if (editing) {
await fetchApi(`/api/apps/custom-links/${editing.id}`, {
method: "PUT",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
})
} else {
await fetchApi("/api/apps/custom-links", {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
})
}
onSaved()
onOpenChange(false)
} catch (e: any) {
setError((e && e.message) || t("apps.customLinkSaveError"))
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!editing) return
setError(null)
setDeleting(true)
try {
await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "DELETE" })
onSaved()
onOpenChange(false)
} catch (e: any) {
setError((e && e.message) || t("apps.customLinkDeleteError"))
} finally {
setDeleting(false)
}
}
// Sort guests by vmid so the dropdown is easy to scan
const sortedGuests = [...guests].sort((a, b) => a.vmid - b.vmid)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px] bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<DialogHeader>
<DialogTitle>
{editing ? t("apps.customLinkEditTitle") : t("apps.customLinkNewTitle")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label htmlFor="cl-name" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkName")}</Label>
<Input
id="cl-name"
autoFocus
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder={t("apps.customLinkNamePlaceholder")}
maxLength={80}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cl-url" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkUrl")}</Label>
<Input
id="cl-url"
type="url"
value={draft.url}
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
placeholder="https://example.com"
maxLength={512}
className="text-sm font-mono"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cl-logo" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkLogo")}</Label>
<Input
id="cl-logo"
type="url"
value={draft.logo_url}
onChange={(e) => setDraft((d) => ({ ...d, logo_url: e.target.value }))}
placeholder={t("apps.customLinkLogoPlaceholder")}
maxLength={512}
className="text-sm font-mono"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkCategory")}</Label>
{customCategoryMode ? (
<Input
autoFocus
value={draft.category}
onChange={(e) => setDraft((d) => ({ ...d, category: e.target.value }))}
placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")}
maxLength={60}
className="text-sm"
onBlur={() => { if (!draft.category.trim()) setCustomCategoryMode(false) }}
/>
) : (
<Select
value={draft.category || "__none__"}
onValueChange={(v) => {
if (v === "__add__") {
setCustomCategoryMode(true)
setDraft((d) => ({ ...d, category: "" }))
} else if (v === "__none__") {
setDraft((d) => ({ ...d, category: "" }))
} else {
setDraft((d) => ({ ...d, category: v }))
}
}}
>
<SelectTrigger className="text-sm h-9">
<SelectValue placeholder={t("vmLxc.appEditor.portCategoryPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
{categoryPresets.map((c) => (
<SelectItem key={c} value={c}>{getCategoryLabel(t, c)}</SelectItem>
))}
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkBinding")}</Label>
<Select
value={draft.bindingKey}
onValueChange={(v) => setDraft((d) => ({ ...d, bindingKey: v }))}
>
<SelectTrigger className="text-sm h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNBOUND_KEY}>{t("apps.customLinkBindingNone")}</SelectItem>
{sortedGuests.map((g) => (
<SelectItem key={`${g.type}:${g.vmid}`} value={`${g.type}:${g.vmid}`}>
{g.type === "qemu" ? "VM" : "LXC"} {g.vmid} · {g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("apps.customLinkBindingHelp")}
</p>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-3 py-2">
{error}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
{editing ? (
<Button
variant="ghost"
onClick={handleDelete}
disabled={deleting || saving}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4 mr-1.5" />
{t("apps.customLinkDelete")}
</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="outline" className={cancelButtonClass} onClick={() => onOpenChange(false)} disabled={saving}>
{t("apps.customLinkCancel")}
</Button>
<Button
onClick={handleSave}
disabled={!canSave}
className="bg-blue-500 hover:bg-blue-600 !text-white"
>
{editing ? t("apps.customLinkSave") : t("apps.customLinkCreate")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+264
View File
@@ -0,0 +1,264 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { Check, HardDrive, Info, Loader2, Settings2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Switch } from "./ui/switch"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface DiskEntry {
name: string
key: string
model: string
serial: string
size_bytes: number
transport: string
rotational: boolean
excluded: boolean
excluded_at: string | null
idle: boolean
present: boolean
}
function formatSize(bytes: number): string {
if (!bytes) return ""
const units = ["B", "KB", "MB", "GB", "TB", "PB"]
let value = bytes
let i = 0
while (value >= 1000 && i < units.length - 1) {
value /= 1000
i++
}
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
}
// Disks the user wants left alone. An excluded disk is never read on a
// schedule — no temperature, no SMART refresh, not even a power-mode query —
// so it can spin down on its own timer. Rotational disks with no I/O are
// already left alone automatically; this is for the ones that should never
// be touched at all, such as a drive handed whole to a VM.
export function DiskExclusions() {
const t = useT()
const [disks, setDisks] = useState<DiskEntry[]>([])
const [loading, setLoading] = useState(true)
const [editMode, setEditMode] = useState(false)
const [pending, setPending] = useState<Map<string, boolean>>(new Map())
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState("")
const load = useCallback(async () => {
try {
const data = await fetchApi<{ disks: DiskEntry[] }>("/api/health/disks")
setDisks(data.disks || [])
} catch {
setDisks([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const cancel = () => {
setPending(new Map())
setError("")
setEditMode(false)
}
const save = async () => {
if (pending.size === 0) {
setEditMode(false)
return
}
setSaving(true)
setError("")
try {
for (const [key, excluded] of pending.entries()) {
const disk = disks.find((d) => d.key === key)
if (!disk) continue
if (excluded) {
await fetchApi("/api/health/disk-exclusions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
disk_key: disk.key,
disk_name: disk.name,
model: disk.model,
serial: disk.serial,
}),
})
} else {
await fetchApi(`/api/health/disk-exclusions/${encodeURIComponent(disk.key)}`, {
method: "DELETE",
})
}
}
setPending(new Map())
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
await load()
} catch {
setError(t("settings.diskExclusions.saveFailed"))
} finally {
setSaving(false)
}
}
const transportLabel = (disk: DiskEntry) => {
const tr = disk.transport.toLowerCase()
if (tr === "usb") return "USB"
if (tr === "nvme") return "NVMe"
if (tr === "sata" || tr === "ata") return disk.rotational ? "HDD" : "SSD"
if (tr) return tr.toUpperCase()
return disk.rotational ? "HDD" : "SSD"
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-amber-500" />
<CardTitle>{t("settings.diskExclusions.title")}</CardTitle>
</div>
{!loading && disks.length > 0 && (
<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={cancel}
disabled={saving}
>
{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={save}
disabled={saving || pending.size === 0}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <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.diskExclusions.description")}</CardDescription>
</CardHeader>
<CardContent className={editMode ? "bg-accent" : undefined}>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
</div>
) : disks.length === 0 ? (
<div className="text-center py-8">
<HardDrive className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">{t("settings.diskExclusions.empty")}</p>
</div>
) : (
<div className="space-y-0">
<div className="grid grid-cols-[1fr_auto] gap-4 pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">
{t("settings.diskExclusions.disk")}
</span>
<span className="text-xs font-medium text-muted-foreground text-center w-24">
{t("settings.diskExclusions.periodicReads")}
</span>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border/50">
{disks.map((disk) => {
const excluded = pending.has(disk.key) ? pending.get(disk.key)! : disk.excluded
return (
<div key={disk.key} className="grid grid-cols-[1fr_auto] gap-4 py-3 items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`font-medium ${excluded ? "text-muted-foreground" : ""}`}>
{disk.name || "—"}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{transportLabel(disk)}
</Badge>
{excluded && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
{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")}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground break-all">
{[disk.model, formatSize(disk.size_bytes), disk.serial].filter(Boolean).join(" · ")}
</span>
</div>
<div className="flex justify-center w-24">
<Switch
checked={!excluded}
disabled={!editMode || saving}
onCheckedChange={(checked) => {
setPending((m) => {
const next = new Map(m)
if (!checked === disk.excluded) next.delete(disk.key)
else next.set(disk.key, !checked)
return next
})
}}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`}
/>
</div>
</div>
)
})}
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<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>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,178 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Thermometer } from "lucide-react"
import { Badge } from "./ui/badge"
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
import { fetchApi } from "@/lib/api-config"
import { useDiskTempThresholds } from "@/lib/health-thresholds"
import { useT } from "@/lib/i18n/provider"
interface TempPoint {
timestamp: number
value: number
}
interface DiskTemperatureCardProps {
diskName: string
liveTemperature: number
/** Disk class — "HDD" | "SSD" | "NVMe" | "SAS". Drives the threshold colors. */
diskType: string
/** Click handler — opens the full timeframe-selector modal as drill-down. */
onOpenDetail?: () => void
}
// Disk-temperature thresholds come from the user-configurable backend
// (lib/health-thresholds.ts). The classifier here takes the resolved
// pair so the consumer can read it from the hook once per render.
function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
}
const MiniTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const ts = payload[0].payload?.timestamp
const date = ts ? new Date(ts * 1000) : null
return (
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-md px-2 py-1 shadow-xl">
{date && (
<p className="text-[10px] text-gray-300">
{date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
)}
<p className="text-xs font-semibold text-white">{payload[0].value}°C</p>
</div>
)
}
return null
}
export function DiskTemperatureCard({
diskName,
liveTemperature,
diskType,
onOpenDetail,
}: DiskTemperatureCardProps) {
const t = useT()
const [data, setData] = useState<TempPoint[]>([])
const [loading, setLoading] = useState(true)
const cancelled = useRef(false)
useEffect(() => {
cancelled.current = false
const fetchHistory = async () => {
setLoading(true)
try {
// 24-h timeframe gives a more useful "is this drive trending
// up over a day" view; the 1-h window was too short to spot
// anything that mattered.
const result = await fetchApi<{ data: TempPoint[] }>(
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=day`,
)
if (cancelled.current) return
setData(result?.data || [])
} catch {
if (!cancelled.current) setData([])
} finally {
if (!cancelled.current) setLoading(false)
}
}
fetchHistory()
// Refresh once a minute so the inline chart tracks the collector
// without needing the user to reopen the modal.
const id = setInterval(fetchHistory, 60_000)
return () => {
cancelled.current = true
clearInterval(id)
}
}, [diskName])
const allThresholds = useDiskTempThresholds()
const dt = (() => {
const t = (diskType || "").toUpperCase()
if (t === "HDD") return allThresholds.HDD
if (t === "NVME") return allThresholds.NVMe
if (t === "SAS") return allThresholds.SAS
return allThresholds.SSD
})()
const status = statusFor(liveTemperature, dt)
const lineColor = status.color
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
const samples = data.length
const interactive = !!onOpenDetail
const Wrapper: any = interactive ? "button" : "div"
return (
<Wrapper
type={interactive ? "button" : undefined}
onClick={interactive ? onOpenDetail : undefined}
className={[
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
].join(" ")}
title={interactive ? t("details.temperature.openHistory") : undefined}
>
<div className="flex items-start justify-between gap-3 mb-1.5">
<div className="min-w-0">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("details.temperature.diskTitle")}</p>
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
{tempDisplay}
</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
{t(status.labelKey)}
</Badge>
</div>
</div>
<div className="h-[40px] -mx-1">
{loading ? (
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
) : samples < 2 ? (
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
{t("details.temperature.collectingSamples")}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 2, right: 4, left: 4, bottom: 0 }}>
<defs>
<linearGradient id={`diskTempCardGrad-${diskName}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={lineColor} stopOpacity={0.35} />
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
</linearGradient>
</defs>
{/* Y domain is computed the same way the detail modal
does — floor(min−3) / ceil(max+3), floor at 0 — so
the line shape here matches the bigger chart instead
of recharts' auto-domain collapsing 24 → 29 °C into
a near-flat line that doesn't look like the modal. */}
<YAxis
hide
domain={[
(dataMin: number) => Math.max(0, Math.floor(dataMin - 3)),
(dataMax: number) => Math.ceil(dataMax + 3),
]}
/>
<Tooltip content={<MiniTooltip />} cursor={{ stroke: lineColor, strokeOpacity: 0.3, strokeWidth: 1 }} />
<Area
type="monotone"
dataKey="value"
stroke={lineColor}
strokeWidth={1.6}
fill={`url(#diskTempCardGrad-${diskName})`}
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</Wrapper>
)
}
@@ -0,0 +1,269 @@
"use client"
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
import { useT } from "@/lib/i18n/provider"
const TIMEFRAME_OPTIONS = [
{ value: "hour", labelKey: "details.temperature.timeframes.hour" },
{ value: "day", labelKey: "details.temperature.timeframes.day" },
{ value: "week", labelKey: "details.temperature.timeframes.week" },
{ value: "month", labelKey: "details.temperature.timeframes.month" },
]
interface TempHistoryPoint {
timestamp: number
value: number
min?: number
max?: number
}
interface TempStats {
min: number
max: number
avg: number
current: number
}
interface DiskTemperatureDetailModalProps {
open: boolean
onOpenChange: (open: boolean) => void
diskName: string
diskModel?: string
liveTemperature?: number
diskType?: "HDD" | "SSD" | "NVMe" | "SAS" | string
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
<p className="text-sm font-semibold text-white mb-2">{label}</p>
<div className="space-y-1.5">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
<span className="text-sm font-semibold text-white">{entry.value}°C</span>
</div>
))}
</div>
</div>
)
}
return null
}
// Per-disk-class thresholds come from the user-configurable backend
// (lib/health-thresholds.ts), so the chart line color stays in sync
// with whatever the user sets in Settings → Health Monitor Thresholds.
function colorFor(temp: number, t: DiskTempThreshold): string {
if (temp >= t.hot) return "#ef4444"
if (temp >= t.warn) return "#f59e0b"
return "#22c55e"
}
function statusInfoFor(temp: number, t: DiskTempThreshold) {
if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" }
if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { color: "bg-green-500/10 text-green-500 border-green-500/20" }
}
export function DiskTemperatureDetailModal({
open,
onOpenChange,
diskName,
diskModel,
liveTemperature,
diskType,
}: DiskTemperatureDetailModalProps) {
const t = useT()
const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState<TempHistoryPoint[]>([])
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
const [loading, setLoading] = useState(true)
const isMobile = useIsMobile()
useEffect(() => {
if (open && diskName) {
fetchHistory()
}
}, [open, timeframe, diskName])
const fetchHistory = async () => {
setLoading(true)
try {
const result = await fetchApi<{ data: TempHistoryPoint[]; stats: TempStats }>(
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=${timeframe}`,
)
if (result && result.data) {
setData(result.data)
setStats(result.stats)
} else {
setData([])
setStats({ min: 0, max: 0, avg: 0, current: 0 })
}
} catch (err) {
console.error("[ProxMenux] Failed to fetch disk temperature history:", err)
setData([])
} finally {
setLoading(false)
}
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp * 1000)
if (timeframe === "hour" || timeframe === "day") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
return date.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
}
const chartData = data.map((d) => ({ ...d, time: formatTime(d.timestamp) }))
const currentTemp = liveTemperature && liveTemperature > 0 ? Math.round(liveTemperature * 10) / 10 : stats.current
const allThresholds = useDiskTempThresholds()
const dt: DiskTempThreshold = (() => {
const t = (diskType || "").toUpperCase()
if (t === "HDD") return allThresholds.HDD
if (t === "NVME") return allThresholds.NVMe
if (t === "SAS") return allThresholds.SAS
return allThresholds.SSD
})()
const chartColor = colorFor(currentTemp, dt)
const currentStatus = statusInfoFor(currentTemp, dt)
const values = data.map((d) => d.value)
const yMin = values.length > 0 ? Math.max(0, Math.floor(Math.min(...values) - 3)) : 0
const yMax = values.length > 0 ? Math.ceil(Math.max(...values) + 3) : 100
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-card border-border px-3 sm:px-6">
<DialogHeader>
{/*
Header layout mirrors temperature-detail-modal exactly so the
mobile breakpoints behave the same. Earlier we tried to inline
the model name in the DialogTitle, but the long WD/Samsung
strings broke `truncate` and pushed the dialog past the
viewport — clipping the timeframe selector and the right two
stat cards. Keeping the title short and parking the model in
a second line (DialogDescription) lets the standard mobile
grid render correctly.
*/}
<div className="flex items-center justify-between pr-6">
<DialogTitle className="text-foreground flex items-center gap-2">
<Thermometer className="h-5 w-5" />
/dev/{diskName}
</DialogTitle>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-card border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{diskModel && (
<p className="text-xs text-muted-foreground truncate pr-6 mt-0.5">{diskModel}</p>
)}
</DialogHeader>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
</div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
</div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
</div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
</div>
</div>
<div className="h-[300px] lg:h-[350px]">
{loading ? (
<div className="h-full flex items-center justify-center">
<div className="space-y-3 w-full animate-pulse">
<div className="h-4 bg-muted rounded w-1/4 mx-auto" />
<div className="h-[250px] bg-muted/50 rounded" />
</div>
</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t("details.temperature.noData")}</p>
<p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`diskTempGradient-${diskName}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={chartColor} stopOpacity={0.3} />
<stop offset="100%" stopColor={chartColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis
dataKey="time"
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
interval="preserveStartEnd"
minTickGap={isMobile ? 40 : 60}
/>
<YAxis
domain={[yMin, yMax]}
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
tickFormatter={(v) => `${v}°`}
width={isMobile ? 40 : 45}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="value"
name={t("details.temperature.seriesName")}
stroke={chartColor}
strokeWidth={2}
fill={`url(#diskTempGradient-${diskName})`}
dot={false}
activeDot={{ r: 4, fill: chartColor, stroke: "#fff", strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</DialogContent>
</Dialog>
)
}
@@ -1,6 +1,7 @@
"use client"
import { cn } from "@/lib/utils"
import { useT } from "../lib/i18n/provider"
interface SriovInfo {
role: "vf" | "pf-active" | "pf-idle"
@@ -26,6 +27,8 @@ export function GpuSwitchModeIndicator({
className,
sriovInfo,
}: GpuSwitchModeIndicatorProps) {
const t = useT()
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
const isLxcActive = displayMode === "lxc"
@@ -69,15 +72,21 @@ export function GpuSwitchModeIndicator({
// exactly how many VFs are active; for a VF we show its parent PF.
const sriovBadgeText = (() => {
if (!isSriovActive) return ""
if (sriovInfo?.role === "vf") return "SR-IOV VF"
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
return "SR-IOV"
if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf")
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) {
return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount })
}
return t("hardware.gpuSwitch.sriov")
})()
return (
<div
className={cn(
"flex items-center gap-6",
// On very narrow containers (mobile, narrow modal), stack the SVG
// above the status text so the 224px-wide SVG doesn't squeeze the
// text into a 2-character-wide column. At sm+ we go back to the
// original side-by-side layout.
"flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-6",
isEditing && !isSriovActive && "cursor-pointer",
className
)}
@@ -120,7 +129,7 @@ export function GpuSwitchModeIndicator({
className="text-[14px] font-bold transition-all duration-300"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
GPU
{t("hardware.gpuSwitch.gpu")}
</text>
</g>
@@ -264,7 +273,7 @@ export function GpuSwitchModeIndicator({
)}
style={{ fontFamily: 'system-ui, sans-serif' }}
>
LXC
{t("hardware.gpuSwitch.lxc")}
</text>
)}
{isSriovActive && (
@@ -275,7 +284,7 @@ export function GpuSwitchModeIndicator({
className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
LXC
{t("hardware.gpuSwitch.lxc")}
</text>
)}
@@ -328,7 +337,7 @@ export function GpuSwitchModeIndicator({
)}
style={{ fontFamily: 'system-ui, sans-serif' }}
>
VM
{t("hardware.gpuSwitch.vm")}
</text>
)}
{isSriovActive && (
@@ -339,7 +348,7 @@ export function GpuSwitchModeIndicator({
className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
VM
{t("hardware.gpuSwitch.vm")}
</text>
)}
</svg>
@@ -359,34 +368,47 @@ export function GpuSwitchModeIndicator({
)}
>
{isSriovActive
? "SR-IOV active"
? t("hardware.gpuSwitch.sriovActive")
: isLxcActive
? "Ready for LXC containers"
? t("hardware.gpuSwitch.readyForLxc")
: isVmActive
? "Ready for VM passthrough"
: "Mode unknown"}
? t("hardware.gpuSwitch.readyForVm")
: t("hardware.gpuSwitch.modeUnknown")}
</span>
<span className="text-sm text-muted-foreground">
{isSriovActive
? "Virtual Functions managed externally"
? t("hardware.gpuSwitch.virtualFunctionsExternal")
: isLxcActive
? "Native driver active"
? t("hardware.gpuSwitch.nativeDriverActive")
: isVmActive
? "VFIO-PCI driver active"
: "No driver detected"}
? t("hardware.gpuSwitch.vfioDriverActive")
: t("hardware.gpuSwitch.noDriverDetected")}
</span>
{isSriovActive && sriovInfo && (
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
{sriovInfo.role === "vf"
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
? t(
sriovInfo.physfn
? "hardware.gpuSwitch.virtualFunctionWithParent"
: "hardware.gpuSwitch.virtualFunction",
{ parent: sriovInfo.physfn || "" },
)
: sriovInfo.vfCount !== undefined
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
? t(
sriovInfo.totalvfs
? "hardware.gpuSwitch.physicalFunctionWithMax"
: "hardware.gpuSwitch.physicalFunction",
{
count: sriovInfo.vfCount,
max: sriovInfo.totalvfs || "",
},
)
: null}
</span>
)}
{hasChanged && (
<span className="text-sm text-amber-500 font-medium animate-pulse">
Change pending...
{t("hardware.gpuSwitch.changePending")}
</span>
)}
</div>
File diff suppressed because it is too large Load Diff
+365 -170
View File
@@ -7,6 +7,14 @@ import { getAuthToken } from "@/lib/api-config"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Loader2,
CheckCircle2,
@@ -24,6 +32,7 @@ import {
FileText,
RefreshCw,
Shield,
ArrowUpCircle,
X,
Clock,
BellOff,
@@ -31,6 +40,8 @@ import {
Settings2,
HelpCircle,
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { useT } from "@/lib/i18n/provider"
interface CategoryCheck {
status: string
@@ -94,19 +105,20 @@ interface HealthStatusModalProps {
}
const CATEGORIES = [
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu },
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick },
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc },
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network },
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box },
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings },
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText },
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw },
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield },
{ key: "cpu", category: "temperature", Icon: Cpu },
{ key: "memory", category: "memory", Icon: MemoryStick },
{ key: "storage", category: "storage", Icon: HardDrive },
{ key: "disks", category: "disks", Icon: Disc },
{ key: "network", category: "network", Icon: Network },
{ key: "vms", category: "vms", Icon: Box },
{ key: "services", category: "pve_services", Icon: Settings },
{ key: "logs", category: "logs", Icon: FileText },
{ key: "updates", category: "updates", Icon: RefreshCw },
{ key: "security", category: "security", Icon: Shield },
]
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const t = useT()
const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
@@ -114,14 +126,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const [error, setError] = useState<string | null>(null)
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
const fetchHealthDetails = useCallback(async () => {
const fetchHealthDetails = useCallback(async (force = false) => {
setLoading(true)
setError(null)
try {
let newOverallStatus = "OK"
// Use the new combined endpoint for fewer round-trips
const token = getAuthToken()
const authHeaders: Record<string, string> = {}
@@ -129,13 +142,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
authHeaders["Authorization"] = `Bearer ${token}`
}
const response = await fetch(getApiUrl("/api/health/full"), { headers: authHeaders })
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders })
let infoCount = 0
if (!response.ok) {
// Fallback to legacy endpoint
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
if (!legacyResponse.ok) throw new Error("Failed to fetch health details")
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
const data = await legacyResponse.json()
setHealthData(data)
setDismissedItems([])
@@ -192,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
})
window.dispatchEvent(event)
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error")
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
} finally {
setLoading(false)
}
}, [getApiUrl])
}, [getApiUrl, t])
// Tick counter to force re-render every 30s so "X minutes ago" stays current
const [, setTick] = useState(0)
@@ -211,7 +224,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
if (open) {
fetchHealthDetails()
// Auto-refresh every 5 minutes while modal is open
const refreshInterval = setInterval(fetchHealthDetails, 300000)
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
return () => clearInterval(refreshInterval)
}
}, [open, fetchHealthDetails])
@@ -266,21 +279,159 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
const getStatusBadge = (status: string) => {
const statusUpper = status?.toUpperCase()
switch (statusUpper) {
case "OK":
return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge>
case "INFO":
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">Info</Badge>
case "WARNING":
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge>
case "CRITICAL":
return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge>
case "UNKNOWN":
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">UNKNOWN</Badge>
default:
return <Badge>Unknown</Badge>
const s = status?.toUpperCase()
const label =
s === "OK" ? t("healthStatus.status.ok") :
s === "INFO" ? t("healthStatus.status.info") :
s === "WARNING" ? t("healthStatus.status.warning") :
s === "CRITICAL" ? t("healthStatus.status.critical") :
t("healthStatus.status.unknown")
return <Badge variant="outline" className={getOutlineBadgeStyle(status)}>{label}</Badge>
}
const formatStatus = (status: string) => {
const key = status?.toLowerCase()
return ["ok", "info", "warning", "critical", "unknown"].includes(key)
? t(`healthStatus.status.${key}`)
: status
}
const translateHealthText = (value?: string): string => {
if (!value) return ""
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"),
"Temperature elevated": t("healthStatus.details.temperatureElevated"),
"No swap configured": t("healthStatus.details.noSwapConfigured"),
"High RAM usage sustained": t("healthStatus.details.ramHighSustained"),
"Swap nearly full with RAM tight": t("healthStatus.details.swapRamPressure"),
"Failed to check for updates (apt-get error)": t("healthStatus.details.updatesAptError"),
"apt-get timed out - repository may be unreachable": t("healthStatus.details.updatesAptTimeout"),
"Unknown": t("healthStatus.details.unknownDetail"),
"No failed login attempts in 24h": t("healthStatus.details.noFailedLogins"),
"Fail2Ban installed but service not active": t("healthStatus.details.fail2banInactive"),
"Unable to determine uptime": t("healthStatus.details.uptimeUnavailable"),
"Unable to check login attempts": t("healthStatus.details.loginCheckUnavailable"),
"Certificate expired": t("healthStatus.details.certificateExpired"),
"Certificate check inconclusive": t("healthStatus.details.certificateInconclusive"),
"All systems operational": t("healthStatus.details.allOperational"),
"Normal": t("healthStatus.details.normal"),
"No I/O errors in dmesg": t("healthStatus.details.noIoErrors"),
"Mounted read-write, space OK": t("healthStatus.details.rootFilesystemOk"),
"No SMART warnings in journal": t("healthStatus.details.noSmartWarnings"),
"No critical errors": t("healthStatus.details.noCriticalErrors"),
"No cascading errors": t("healthStatus.details.noCascadingErrors"),
"No error spikes": t("healthStatus.details.noErrorSpikes"),
"No persistent patterns": t("healthStatus.details.noPersistentPatterns"),
"Certificate valid": t("healthStatus.details.certificateValid"),
"Cluster detected (corosync.conf present)": t("healthStatus.details.clusterDetected"),
"Active": t("healthStatus.details.active"),
"UP": t("healthStatus.details.up"),
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
"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"),
"No OOM events detected": t("healthStatus.details.noOomEvents"),
"No QMP timeouts detected": t("healthStatus.details.noQmpTimeouts"),
"No VM startup failures": t("healthStatus.details.noVmFailures"),
"Dismissed by user": t("healthStatus.details.dismissedByUser"),
}
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
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] })
match = value.match(/^(\d+) IP\(s\) currently banned by Fail2Ban \(jails: (.+)\)$/)
if (match) return t("healthStatus.details.fail2banBannedIps", { count: match[1], jails: match[2] })
match = value.match(/^Uptime (\d+) days?$/)
if (match) return t("healthStatus.details.uptimeDays", { count: match[1] })
match = value.match(/^(\d+) package\(s\) pending$/)
if (match) return t("healthStatus.details.pendingPackages", { count: match[1] })
match = value.match(/^Last updated (\d+) day\(s\) ago$/)
if (match) return t("healthStatus.details.updatedDaysAgo", { count: match[1] })
match = value.match(/^Storage: \d+ Proxmox storages unavailable: (.+) \(startup\)$/)
if (match) return t("healthStatus.details.startupStoragesChecking", { storages: match[1] })
match = value.match(/^Storage: (.+) not yet available \(startup\)$/)
if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] })
match = value.match(/^(.+) not yet available \(startup\)$/)
if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] })
match = value.match(/^\[Startup\] Storage '(.+)' is configured but not found on the server\. \(checking\.\.\.\)$/)
if (match) return t("healthStatus.details.startupStorageNotFound", { storage: match[1] })
match = value.match(/^\[Startup\] Storage '(.+)' is not available \(connection error or backend issue\)\. \(checking\.\.\.\)$/)
if (match) return t("healthStatus.details.startupStorageUnavailable", { storage: match[1] })
match = value.match(/^\[Startup\] Storage '(.+)' has status: (.+)\. \(checking\.\.\.\)$/)
if (match) return t("healthStatus.details.startupStorageStatus", { storage: match[1], status: match[2] })
match = value.match(/^([\d.]+)% used$/)
if (match) return t("healthStatus.details.storageUsage", { percent: match[1] })
match = value.match(/^(.+) storage available$/)
if (match) return t("healthStatus.details.storageAvailable", { type: match[1] })
match = value.match(/^(.+) mount reachable$/)
if (match) return t("healthStatus.details.mountReachable", { type: match[1] })
match = value.match(/^rootfs ([\d.]+)% used \((.+)\)$/)
if (match) return t("healthStatus.details.rootfsUsed", { percent: match[1], size: match[2] })
match = value.match(/^(\d+) running CT\(s\) within safe rootfs usage$/)
if (match) return t("healthStatus.details.runningCtsSafe", { count: match[1] })
match = value.match(/^(\d+) PVE block storage\(s\) within safe usage$/)
if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] })
match = value.match(/^(\d+) remote mount\(s\) healthy$/)
if (match) return t("healthStatus.details.remoteMountsHealthy", { count: match[1] })
match = value.match(/^CPU >(\d+(?:\.\d+)?)% sustained for (\d+)s$/)
if (match) return t("healthStatus.details.cpuSustained", { threshold: match[1], seconds: match[2] })
match = value.match(/^CPU check failed: ([\s\S]*)$/)
if (match) return t("healthStatus.details.cpuCheckFailed", { error: match[1] })
match = value.match(/^Sensor temperature (\d+(?:\.\d+)?)°C >80°C; high samples span ((?:\d+m )?\d+s)$/)
if (match) return t("healthStatus.details.sensorHighSamplesSpan", { temperature: match[1], duration: match[2] })
match = value.match(/^RAM >(\d+(?:\.\d+)?)% sustained for (\d+)s$/)
if (match) return t("healthStatus.details.ramSustained", { threshold: match[1], seconds: match[2] })
match = value.match(/^Memory pressure: swap (\d+)% used and only (\d+)% RAM available$/)
if (match) return t("healthStatus.details.memoryPressure", { swapPercent: match[1], ramAvailablePercent: match[2] })
match = value.match(/^Memory check failed: ([\s\S]*)$/)
if (match) return t("healthStatus.details.memoryCheckFailed", { error: match[1] })
match = value.match(/^(\d+) security update\(s\) pending$/)
if (match) return t("healthStatus.details.pendingSecurityUpdates", { count: match[1] })
match = value.match(/^(\d+) security update\(s\) pending \((\d+) days unpatched\)$/)
if (match) return t("healthStatus.details.pendingSecurityUpdatesUnpatched", { count: match[1], days: match[2] })
match = value.match(/^(\d+) security update\(s\) pending for (\d+) days$/)
if (match) return t("healthStatus.details.pendingSecurityUpdatesDays", { count: match[1], days: match[2] })
match = value.match(/^System not updated in (\d+) days \(>18 months\)$/)
if (match) return t("healthStatus.details.systemNotUpdatedMonths", { days: match[1] })
match = value.match(/^System not updated in (\d+) days \(>1 year\)$/)
if (match) return t("healthStatus.details.systemNotUpdatedYear", { days: match[1] })
match = value.match(/^Kernel update available for running ([^\s()]+)$/)
if (match) return t("healthStatus.details.runningKernelUpdate", { kernel: match[1] })
match = value.match(/^(\d+) kernel \+ (\d+) Proxmox update\(s\) available$/)
if (match) return t("healthStatus.details.kernelPveUpdates", { kernelCount: match[1], pveCount: match[2] })
match = value.match(/^(\d+) package update\(s\) pending$/)
if (match) return t("healthStatus.details.pendingPackageUpdates", { count: match[1] })
match = value.match(/^([a-z0-9][a-z0-9+.-]+(?::[a-z0-9]+)?(?: [^\s()]+ -> [^\s()]+)?) \(\+(\d+) more\)$/)
if (match) return t("healthStatus.details.kernelPackagesMore", { technicalIdentity: match[1], count: match[2] })
match = value.match(/^(\d+) kernel update\(s\) available \(none for running kernel( [^\s()]+)?\)$/)
if (match) return t("healthStatus.details.nonRunningKernelUpdates", { count: match[1], optionalKernel: match[2] || "" })
match = value.match(/^PVE ([^\s()]+) -> ([^\s()]+) available$/)
if (match) return t("healthStatus.details.pveVersionAvailable", { current: match[1], new: match[2] })
match = value.match(/^Updates check unavailable: ([\s\S]*)$/)
if (match) return t("healthStatus.details.updatesCheckUnavailable", { error: match[1] })
match = value.match(/^Unable to check Fail2Ban: ([\s\S]*)$/)
if (match) return t("healthStatus.details.fail2banCheckUnavailable", { error: match[1] })
match = value.match(/^Uptime (\d+) days \(>1 year, consider updating kernel\/system\)$/)
if (match) return t("healthStatus.details.uptimeKernelAdvice", { days: match[1] })
match = value.match(/^(\d+) failed attempts in 24h \(within threshold\)$/)
if (match) return t("healthStatus.details.failedLoginsWithinThreshold", { count: match[1] })
match = value.match(/^Security check unavailable: ([\s\S]*)$/)
if (match) return t("healthStatus.details.securityCheckUnavailable", { error: match[1] })
match = value.match(/^Certificate expires in (\d+) days$/)
if (match) return t("healthStatus.details.certificateExpiresDays", { days: match[1] })
return value
}
const formatDuration = (hours: number) => {
if (hours === -1) return t("healthStatus.permanent")
if (hours >= 8760) return t("healthStatus.duration.years", { count: Math.floor(hours / 8760) })
if (hours >= 720) return t("healthStatus.duration.months", { count: Math.floor(hours / 720) })
if (hours >= 168) return t("healthStatus.duration.weeks", { count: Math.floor(hours / 168) })
if (hours >= 24) return t("healthStatus.duration.days", { count: Math.floor(hours / 24) })
return t("healthStatus.duration.hours", { count: Math.round(hours) })
}
// Get categories that have dismissed items (to show as INFO)
@@ -357,8 +508,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
}
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
e.stopPropagation()
// `suppressionHours` overrides the category default for this dismiss:
// - undefined → backend uses the category's configured suppression
// - 24, 168 (7 days) → silence for that many hours
// - -1 → permanent dismiss; only revertible from
// Settings → Active Suppressions
const handleAcknowledge = async (
errorKey: string,
suppressionHours?: number,
) => {
setDismissingKey(errorKey)
try {
@@ -369,10 +527,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
headers["Authorization"] = `Bearer ${token}`
}
const body: Record<string, unknown> = { error_key: errorKey }
if (suppressionHours !== undefined) {
body.suppression_hours = suppressionHours
}
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ error_key: errorKey }),
body: JSON.stringify(body),
})
const responseData = await response.json().catch(() => ({}))
@@ -390,13 +553,24 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
severity: responseData.result?.original_severity || 'WARNING',
reason: 'Dismissed by user',
dismissed: true,
acknowledged_at: new Date().toISOString()
// Surface the chosen duration so the row shows the right badge
// (countdown vs. "Permanent") without waiting for the refetch.
permanent: suppressionHours === -1,
suppression_remaining_hours: suppressionHours === -1 ? -1 : undefined,
suppression_hours: suppressionHours,
acknowledged_at: new Date().toISOString(),
}
setDismissedItems(prev => [...prev, dismissedItem])
}
// Fetch fresh data in background (non-blocking)
fetchHealthDetails().catch(() => {})
// Notify other mounted views (e.g. Settings → Active Suppressions
// panel) that the suppression set has changed so they can refresh.
try {
window.dispatchEvent(new CustomEvent("health-suppression-changed"))
} catch {}
} catch (err) {
console.error("Error dismissing:", err)
} finally {
@@ -410,11 +584,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const now = new Date()
const diffMs = now.getTime() - checkTime.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return "just now"
if (diffMin === 1) return "1 minute ago"
if (diffMin < 60) return `${diffMin} minutes ago`
if (diffMin < 1) return t("healthStatus.time.justNow")
if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo")
if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin })
const diffHours = Math.floor(diffMin / 60)
return `${diffHours}h ${diffMin % 60}m ago`
return t("healthStatus.time.hoursMinutesAgo", { hours: diffHours, minutes: diffMin % 60 })
}
const getCategoryRowStyle = (status: string) => {
@@ -437,49 +611,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
const formatCheckLabel = (key: string): string => {
const labels: Record<string, string> = {
// CPU
cpu_usage: "CPU Usage",
cpu_temperature: "Temperature",
// Memory
ram_usage: "RAM Usage",
swap_usage: "Swap Usage",
// Disk I/O
root_filesystem: "Root Filesystem",
smart_health: "SMART Health",
io_errors: "I/O Errors",
zfs_pools: "ZFS Pools",
lvm_volumes: "LVM Volumes",
lvm_check: "LVM Status",
// Network
connectivity: "Connectivity",
// VMs & CTs
qmp_communication: "QMP Communication",
container_startup: "Container Startup",
vm_startup: "VM Startup",
oom_killer: "OOM Killer",
// Services
cluster_mode: "Cluster Mode",
// Logs (prefixed with log_)
log_error_cascade: "Error Cascade",
log_error_spike: "Error Spike",
log_persistent_errors: "Persistent Errors",
log_critical_errors: "Critical Errors",
// Updates
pve_version: "Proxmox VE Version",
security_updates: "Security Updates",
system_age: "System Age",
pending_updates: "Pending Updates",
kernel_pve: "Kernel / PVE",
// Security
uptime: "Uptime",
certificates: "Certificates",
login_attempts: "Login Attempts",
fail2ban: "Fail2Ban",
// Storage (Proxmox)
proxmox_storages: "Proxmox Storages",
}
if (labels[key]) return labels[key]
const knownKeys = new Set([
"cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem",
"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",
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
])
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
// Convert snake_case or camelCase to Title Case
return key
.replace(/_/g, " ")
@@ -509,34 +649,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1">
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span>
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{translateHealthText(checkData.detail)}</span>
{checkData.dismissed && (
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
Dismissed
</Badge>
checkData.permanent ? (
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
{t("healthStatus.permanent")}
</Badge>
) : (
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
{t("healthStatus.dismissed")}
</Badge>
)
)}
</div>
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
{(checkStatus === "WARNING" || checkStatus === "CRITICAL" || checkStatus === "UNKNOWN") && isDismissable && !checkData.dismissed && (
<Button
size="sm"
variant="outline"
className="h-5 px-1 sm:px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
disabled={dismissingKey === (checkData.error_key || checkKey)}
onClick={(e) => {
e.stopPropagation()
handleAcknowledge(checkData.error_key || checkKey, e)
}}
>
{dismissingKey === (checkData.error_key || checkKey) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<X className="h-3 w-3 sm:mr-0.5" />
<span className="hidden sm:inline">Dismiss</span>
</>
)}
</Button>
<DismissDropdown
onSelect={(hours) =>
handleAcknowledge(checkData.error_key || checkKey, hours)
}
busy={dismissingKey === (checkData.error_key || checkKey)}
t={t}
/>
)}
</div>
</div>
@@ -555,12 +689,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="flex items-center justify-between gap-3">
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
<span className="truncate text-base sm:text-lg">System Health Status</span>
<span className="truncate text-base sm:text-lg">{t("healthStatus.title")}</span>
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
</DialogTitle>
</div>
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
<span>Detailed health checks for all system components</span>
<span>{t("healthStatus.description")}</span>
{getTimeSinceCheck() && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
@@ -578,7 +712,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
<p className="font-medium">Error loading health status</p>
<p className="font-medium">{t("healthStatus.errors.loading")}</p>
<p className="text-sm">{error}</p>
</div>
)}
@@ -589,47 +723,47 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<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">
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Total</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.total")}</div>
</div>
<div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Healthy</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.healthy")}</div>
</div>
{stats.info > 0 && (
<div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Info</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.info")}</div>
</div>
)}
<div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Warn</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.warning")}</div>
</div>
<div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Critical</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.critical")}</div>
</div>
{stats.unknown > 0 && (
<div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Unknown</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.unknown")}</div>
</div>
)}
</div>
{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">{healthData.summary}</p>
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
</div>
)}
{/* Category List */}
<div className="space-y-2">
{CATEGORIES.map(({ key, label, Icon }) => {
{CATEGORIES.map(({ key, Icon }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
const originalStatus = categoryData?.status || "UNKNOWN"
const status = getEffectiveStatus(key, originalStatus)
const reason = categoryData?.reason
const reason = translateHealthText(categoryData?.reason)
const checks = categoryData?.checks
const isExpanded = expandedCategories.has(key)
const hasChecks = checks && Object.keys(checks).length > 0
@@ -650,7 +784,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 sm:gap-2">
<p className="font-medium text-xs sm:text-sm truncate">{label}</p>
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
{hasChecks && (
<span className="text-[10px] text-muted-foreground shrink-0">
({Object.values(checks).filter(c => c.installed !== false).length})
@@ -663,7 +797,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
{status}
{formatStatus(status)}
</Badge>
<ChevronRight
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
@@ -681,25 +815,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<p className="text-xs text-muted-foreground break-words whitespace-pre-wrap flex-1">{reason}</p>
{/* Show dismiss button for UNKNOWN status at category level when dismissable */}
{status === "UNKNOWN" && categoryData?.dismissable && !hasChecks && (
<Button
size="sm"
variant="outline"
className="h-5 px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
disabled={dismissingKey === `category_${key}`}
onClick={(e) => {
e.stopPropagation()
handleAcknowledge(`category_${key}_unknown`, e)
}}
>
{dismissingKey === `category_${key}` ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<X className="h-3 w-3 sm:mr-0.5" />
<span className="hidden sm:inline">Dismiss</span>
</>
)}
</Button>
<DismissDropdown
onSelect={(hours) =>
handleAcknowledge(`category_${key}_unknown`, hours)
}
busy={dismissingKey === `category_${key}_unknown`}
t={t}
/>
)}
</div>
)}
@@ -708,7 +830,24 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
No issues detected
{t("healthStatus.noIssues")}
</div>
)}
{/* Only offer "Update Now" when the category is not
already OK — hiding it when there's nothing
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">
<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"
>
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
{t("healthStatus.updateNow")}
</Button>
</div>
)}
</div>
@@ -727,12 +866,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Dismissed Items ({filteredDismissed.length})
{t("healthStatus.dismissedItems", { count: filteredDismissed.length })}
</div>
{filteredDismissed.map((item) => {
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
const CatIcon = catMeta?.Icon || BellOff
const catLabel = catMeta?.label || item.category
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
return (
@@ -747,34 +886,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="flex items-start justify-between gap-2 mb-1">
<div className="min-w-0 flex-1 overflow-hidden">
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{item.reason}</p>
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{translateHealthText(item.reason)}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{isPermanent ? (
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
Permanent
{t("healthStatus.permanent")}
</Badge>
) : (
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
Dismissed
{t("healthStatus.dismissed")}
</Badge>
)}
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
was {item.severity}
{t("healthStatus.wasStatus", { status: formatStatus(item.severity) })}
</Badge>
</div>
</div>
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{isPermanent
? "Permanently suppressed"
: `Suppressed for ${
item.suppression_remaining_hours < 24
? `${Math.round(item.suppression_remaining_hours)}h`
: item.suppression_remaining_hours < 720
? `${Math.round(item.suppression_remaining_hours / 24)} days`
: `${Math.round(item.suppression_remaining_hours / 720)} month(s)`
} more`
? t("healthStatus.permanentlySuppressed")
: t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) })
}
</p>
</div>
@@ -790,30 +923,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Custom Suppression Settings
{t("healthStatus.customSuppressionSettings")}
</div>
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
<div className="space-y-1.5">
{customSuppressions.map((cs) => {
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label)
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category)
const CatIcon = catMeta?.Icon || Settings2
const durationLabel = cs.hours === -1
? "Permanent"
: cs.hours >= 8760
? `${Math.floor(cs.hours / 8760)} year(s)`
: cs.hours >= 720
? `${Math.floor(cs.hours / 720)} month(s)`
: cs.hours >= 168
? `${Math.floor(cs.hours / 168)} week(s)`
: cs.hours >= 72
? `${Math.floor(cs.hours / 24)} days`
: `${cs.hours}h`
const durationLabel = formatDuration(cs.hours)
return (
<div key={cs.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{cs.label}</span>
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
</div>
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
{durationLabel}
@@ -823,7 +946,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
})}
</div>
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
Alerts in these categories are auto-suppressed when detected.
{t("healthStatus.autoSuppressedHint")}
</p>
</div>
</div>
@@ -831,12 +954,84 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{healthData.timestamp && (
<div className="text-xs text-muted-foreground text-center pt-2">
Last updated: {new Date(healthData.timestamp).toLocaleString()}
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
</div>
)}
</div>
)}
</DialogContent>
<ScriptTerminalModal
open={showUpdateTerminal}
onClose={() => {
setShowUpdateTerminal(false)
// Force a fresh read (cache-busting via ?refresh=1) so the
// "System Updates" row reflects the state right after the
// update finished, instead of the pre-update cached value.
fetchHealthDetails(true).catch(() => {})
}}
scriptPath="/usr/local/share/proxmenux/scripts/utilities/proxmox_update.sh"
scriptName="proxmox_update"
params={{
EXECUTION_MODE: "web",
}}
title={t("healthStatus.updateTerminalTitle")}
description={t("healthStatus.updateTerminalDescription")}
/>
</Dialog>
)
}
// Small split button: the visible click opens a 3-option menu so the user
// chooses how long this specific alert stays silenced. ``-1`` is the
// permanent sentinel — backend stores it as `suppression_hours = -1` and
// the alert can only be brought back from Settings → Active Suppressions.
function DismissDropdown({
onSelect,
busy,
t,
}: {
onSelect: (suppressionHours: number) => void
busy: boolean
t: ReturnType<typeof useT>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-5 px-1 sm:px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
disabled={busy}
onClick={(e) => e.stopPropagation()}
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<X className="h-3 w-3 sm:mr-0.5" />
<span className="hidden sm:inline">{t("healthStatus.dismiss")}</span>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("healthStatus.silenceFor")}
</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.24hours")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs">
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.7days")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => onSelect(-1)}
className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10"
>
<BellOff className="h-3 w-3 mr-2" /> {t("healthStatus.permanently")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+871
View File
@@ -0,0 +1,871 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Input } from "./ui/input"
import {
SlidersHorizontal,
Cpu,
MemoryStick,
HardDrive,
Server,
Thermometer,
Settings2,
Check,
Loader2,
RotateCcw,
AlertCircle,
FolderOpen,
Database,
Waves,
} from "lucide-react"
import { getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
// Local fetch wrapper that *preserves* the JSON body on non-2xx
// responses so we can surface backend validation messages
// (e.g. "critical must be >= warning") to the user. The shared
// `fetchApi` throws a generic "API request failed: 400" on any
// non-OK response, eating the body.
async function fetchJson<T>(endpoint: string, init?: RequestInit): Promise<T> {
const token = getAuthToken()
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((init?.headers as Record<string, string>) || {}),
}
if (token) headers["Authorization"] = `Bearer ${token}`
const res = await fetch(getApiUrl(endpoint), {
...init,
headers,
cache: "no-store",
})
let data: any = null
try {
data = await res.json()
} catch {
// empty body — fall through with raw status
}
if (!res.ok) {
if (res.status === 401 && typeof window !== "undefined") {
try {
localStorage.removeItem("proxmenux-auth-token")
} catch {}
const path = window.location.pathname
if (!path.startsWith("/auth") && !path.startsWith("/login")) {
window.location.assign("/")
}
}
const msg =
(data && (data.message || data.error)) ||
`${res.status} ${res.statusText}`
throw new Error(msg)
}
return data as T
}
// ─── Types ───────────────────────────────────────────────────────────────────
//
// The backend returns a tree of leaves. Each leaf carries the metadata
// the UI needs to render an input + the recommended/customised flags.
// We mirror the shape rather than hand-coding it to keep the contract
// in one place — the backend is the source of truth.
interface ThresholdLeaf {
value: number
recommended: number
customised: boolean
unit: string
min: number
max: number
step: number
}
interface ThresholdsTree {
cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf }
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
disk_temperature: {
hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
ssd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
nvme: { warning: ThresholdLeaf; critical: ThresholdLeaf }
sas: { warning: ThresholdLeaf; critical: ThresholdLeaf }
}
// Phase 3 additions
lxc_mount: { warning: ThresholdLeaf; critical: ThresholdLeaf }
pve_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
zfs_pool: { warning: ThresholdLeaf; critical: ThresholdLeaf }
}
// Pending edits: { "section/key" : "76" } — kept as raw strings while
// the user types so partial input ("8" mid-type) doesn't fail the
// numeric coercion. Coerced + validated on Save.
type PendingEdits = Record<string, string>
// ─── Section descriptors ─────────────────────────────────────────────────────
//
// Drives both the render order and the labels. Keeping it data-only
// means adding a new section later (Phase 4) is one entry, not a JSX
// surgery.
interface SectionField {
// Path in the thresholds tree, e.g. ["cpu", "warning"] or
// ["disk_temperature", "nvme", "critical"].
path: string[]
label: string
}
interface SectionDef {
id: string // Backend section key — used by the reset endpoint
title: string
icon: React.ComponentType<{ className?: string }>
description?: string
fields: SectionField[]
// For tabular sections (disk temperature) we group by sub-key. When
// present, fields are rendered in a 2-column grid (warning, critical)
// labelled by sub-key (HDD / SSD / NVMe / SAS).
rowGroups?: Array<{ subKey: string; label: string }>
}
// Order: compute → heat → storage capacity. Reading top-to-bottom
// flows naturally with no domain jumps:
// • Compute (CPU usage, RAM/Swap)
// • Heat (CPU temp, then disk temp — both °C)
// • Storage capacity (host → LXC rootfs → LXC mounts → PVE → ZFS,
// i.e. concrete to abstract)
const SECTIONS: SectionDef[] = [
// ── Compute ─────────────────────────────────────────────────────
{
id: "cpu",
title: "CPU usage",
icon: Cpu,
fields: [
{ path: ["cpu", "warning"], label: "Warning" },
{ path: ["cpu", "critical"], label: "Critical" },
],
},
{
id: "memory",
title: "Memory & Swap",
icon: MemoryStick,
fields: [
{ path: ["memory", "warning"], label: "Memory warning" },
{ path: ["memory", "critical"], label: "Memory critical" },
{ path: ["memory", "swap_high"], label: "Swap high" },
{ path: ["memory", "available_min"], label: "Memory available minimum" },
],
},
// ── Heat ────────────────────────────────────────────────────────
{
id: "cpu_temperature",
title: "CPU temperature",
icon: Thermometer,
fields: [
{ path: ["cpu_temperature", "warning"], label: "Warning" },
{ path: ["cpu_temperature", "critical"], label: "Critical" },
],
},
{
id: "disk_temperature",
title: "Disk temperature",
icon: Thermometer,
description:
"Per-class thresholds. Same units (°C) — different defaults because each class tolerates a different envelope.",
rowGroups: [
{ subKey: "hdd", label: "HDD" },
{ subKey: "ssd", label: "SSD" },
{ subKey: "nvme", label: "NVMe" },
{ subKey: "sas", label: "SAS" },
],
// For row-group sections, `fields` is unused — we generate per-row
// path lookups from the rowGroups + a hardcoded ["warning","critical"].
fields: [],
},
// ── Storage capacity ────────────────────────────────────────────
{
id: "host_storage",
title: "Disk space — host",
icon: HardDrive,
description: "Applies to / and every mountpoint under /var/lib/vz, /mnt/* etc.",
fields: [
{ path: ["host_storage", "warning"], label: "Warning" },
{ path: ["host_storage", "critical"], label: "Critical" },
],
},
{
id: "lxc_rootfs",
title: "Disk space — LXC rootfs",
icon: Server,
description: "Per-container root disk, evaluated against the rootfs size from PVE.",
fields: [
{ path: ["lxc_rootfs", "warning"], label: "Warning" },
{ path: ["lxc_rootfs", "critical"], label: "Critical" },
],
},
{
id: "lxc_mount",
title: "LXC mount points",
icon: FolderOpen,
description:
"Capacity of mountpoints inside running CTs (mp0, mp1, NFS, bind mounts). Excludes the rootfs — that's covered above.",
fields: [
{ path: ["lxc_mount", "warning"], label: "Warning" },
{ path: ["lxc_mount", "critical"], label: "Critical" },
],
},
{
id: "pve_storage",
title: "PVE storage capacity",
icon: Database,
description:
"Block-style PVE storages: LVM, LVM-thin, ZFS-pool, RBD/Ceph, PBS. Filesystem-style (dir/nfs/cifs) is already covered by host disk thresholds.",
fields: [
{ path: ["pve_storage", "warning"], label: "Warning" },
{ path: ["pve_storage", "critical"], label: "Critical" },
],
},
{
id: "zfs_pool",
title: "ZFS pool capacity",
icon: Waves,
description:
"ZFS pools at the host level — independent of PVE registration so rpool and dedicated backup pools are also monitored.",
fields: [
{ path: ["zfs_pool", "warning"], label: "Warning" },
{ path: ["zfs_pool", "critical"], label: "Critical" },
],
},
]
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getLeaf(tree: ThresholdsTree | null, path: string[]): ThresholdLeaf | null {
if (!tree) return null
let node: any = tree
for (const p of path) {
if (node == null || typeof node !== "object") return null
node = node[p]
}
return node as ThresholdLeaf | null
}
function pathKey(path: string[]): string {
return path.join("/")
}
// Trim the visible slider range to a window around the saved +
// recommended values so the track has usable resolution (e.g. CPU
// 60–100 instead of the backend's 0–100). Derived from stable inputs
// so the range does NOT shift under an active drag.
function computeVisualRange(
values: number[],
backendMin: number,
backendMax: number,
step: number,
): { min: number; max: number } {
const totalRange = Math.max(1, backendMax - backendMin)
// Margin ≈ 25% of total range, clamped to at least 5 steps so tiny
// step sizes (e.g. step=1 on 0–100) still get a usable window.
const rawMargin = Math.max(step * 5, Math.round(totalRange * 0.25))
const lo = Math.min(...values)
const hi = Math.max(...values)
const snap = (n: number) => Math.round(n / step) * step
let visMin = Math.max(backendMin, snap(lo - rawMargin))
let visMax = Math.min(backendMax, snap(hi + rawMargin))
// Ensure the window is at least 4 steps wide so the slider has
// room to move even if all inputs collapse to one value.
if (visMax - visMin < step * 4) {
const mid = (visMax + visMin) / 2
visMin = Math.max(backendMin, snap(mid - step * 2))
visMax = Math.min(backendMax, snap(mid + step * 2))
}
return { min: visMin, max: visMax }
}
// ─── Component ───────────────────────────────────────────────────────────────
export function HealthThresholds() {
const t = useT()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
}
const [tree, setTree] = useState<ThresholdsTree | null>(null)
const [loading, setLoading] = useState(true)
const [editMode, setEditMode] = useState(false)
const [saving, setSaving] = useState(false)
const [savedFlash, setSavedFlash] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState<PendingEdits>({})
// Load on mount + auto-refresh after each save
const fetchTree = async () => {
try {
setLoading(true)
const res = await fetchJson<{ success: boolean; thresholds: ThresholdsTree }>(
"/api/health/thresholds",
)
if (res?.success && res.thresholds) setTree(res.thresholds)
} catch (err) {
setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed"))
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTree()
}, [])
const hasPendingChanges = Object.keys(pending).length > 0
// Build the partial payload from pending. Any blank or unparseable
// entry is skipped — the backend will reject anything malformed
// anyway, but we want to fail fast on the UI side too.
const buildPayload = (): Record<string, any> | null => {
const payload: Record<string, any> = {}
for (const [key, raw] of Object.entries(pending)) {
const parts = key.split("/")
const trimmed = raw.trim()
if (trimmed === "") continue
const num = Number(trimmed)
if (!isFinite(num)) {
setError(t("settings.healthThresholds.invalidValue", { key }))
return null
}
// Walk into payload mirroring the path
let cur: any = payload
for (let i = 0; i < parts.length - 1; i++) {
cur[parts[i]] = cur[parts[i]] || {}
cur = cur[parts[i]]
}
cur[parts[parts.length - 1]] = num
}
return payload
}
const handleEdit = () => {
setEditMode(true)
setError(null)
}
const handleCancel = () => {
setEditMode(false)
setPending({})
setError(null)
}
const handleSave = async () => {
const payload = buildPayload()
if (payload === null) return
if (Object.keys(payload).length === 0) {
setEditMode(false)
return
}
try {
setSaving(true)
setError(null)
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
"/api/health/thresholds",
{ method: "PUT", body: JSON.stringify(payload) },
)
if (!data.success || !data.thresholds) {
setError(data.message || t("status.saveFailed"))
return
}
setTree(data.thresholds)
setPending({})
setEditMode(false)
setSavedFlash(true)
setTimeout(() => setSavedFlash(false), 2000)
} catch (err) {
setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving"))
} finally {
setSaving(false)
}
}
const handleResetSection = async (sectionId: string) => {
const section = SECTIONS.find((s) => s.id === sectionId)
const sectionTitle = section ? tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title) : sectionId
if (!confirm(t("settings.healthThresholds.resetSectionConfirm", { section: sectionTitle })))
return
try {
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
`/api/health/thresholds/reset?section=${encodeURIComponent(sectionId)}`,
{ method: "POST" },
)
if (!data.success || !data.thresholds) {
setError(data.message || t("settings.healthThresholds.resetFailed"))
return
}
setTree(data.thresholds)
// Drop any pending edits within this section so the UI stays
// consistent — the values were just reset on the server.
setPending((p) => {
const next: PendingEdits = {}
for (const [k, v] of Object.entries(p)) {
if (!k.startsWith(sectionId + "/")) next[k] = v
}
return next
})
} catch (err) {
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
}
}
const handleResetAll = async () => {
if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return
try {
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
"/api/health/thresholds/reset",
{ method: "POST" },
)
if (!data.success || !data.thresholds) {
setError(data.message || t("settings.healthThresholds.resetFailed"))
return
}
setTree(data.thresholds)
setPending({})
} catch (err) {
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
}
}
const renderField = (path: string[], label: string) => {
// Kept for single-value leaves that don't have a warn/crit pair
// (e.g. Memory's swap_critical). The pair-cases route to
// renderThresholdRange below.
const leaf = getLeaf(tree, path)
if (!leaf) return null
const key = pathKey(path)
const editingValue = pending[key] ?? String(leaf.value)
const last = path[path.length - 1] || ""
const isCritical = last.toLowerCase().includes("critical")
const isWarning = last.toLowerCase().includes("warning")
const severityClass = isCritical
? "border-red-500/70 bg-red-500/10 focus-visible:border-red-500"
: isWarning
? "border-amber-500/70 bg-amber-500/10 focus-visible:border-amber-500"
: "border-input"
const isCustomised = leaf.customised && !(key in pending)
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
const fieldClass = isCustomised ? customisedClass : severityClass
const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}`
return (
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
<span className="text-xs sm:text-sm text-foreground/90 min-w-0">
{label}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<Input
type="number"
min={leaf.min}
max={leaf.max}
step={leaf.step}
disabled={!editMode}
value={editingValue}
title={recommendedTooltip}
onChange={(e) =>
setPending((p) => ({ ...p, [key]: e.target.value }))
}
className={`w-20 h-7 text-xs text-right tabular-nums border ${fieldClass} ${
!editMode ? "disabled:opacity-100 disabled:cursor-default" : ""
}`}
/>
<span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span>
</div>
</div>
)
}
// Single-handle slider for thresholds that don't have a warn/crit
// pair (only Memory's swap_critical today). Same visual language as
// the dual-handle: red handle, value above, OK / CRIT zones below
// — so the operator doesn't read it as a different control.
const renderSingleThresholdSlider = (path: string[], severity: "warning" | "critical" = "critical") => {
const leaf = getLeaf(tree, path)
if (!leaf) return null
const key = pathKey(path)
const val = Number(pending[key] ?? leaf.value)
const step = leaf.step || 1
const unit = leaf.unit || ""
const { min, max } = computeVisualRange(
[leaf.value, leaf.recommended],
leaf.min,
leaf.max,
step,
)
const pct = ((Math.max(min, Math.min(max, val)) - min) / (max - min)) * 100
const custom = leaf.customised && !(key in pending)
const color = severity === "critical" ? "red" : "amber"
const handleClass = severity === "critical"
? "[&::-webkit-slider-thumb]:bg-red-500 [&::-moz-range-thumb]:bg-red-500"
: "[&::-webkit-slider-thumb]:bg-amber-500 [&::-moz-range-thumb]:bg-amber-500"
const numberColor = custom
? "text-blue-400"
: severity === "critical"
? "text-red-500"
: "text-amber-500"
const fillColor = severity === "critical" ? "bg-red-500/30" : "bg-amber-500/30"
return (
<div className="px-1 py-3">
<div className="relative h-6 sm:h-5 mb-1 select-none">
<span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${numberColor}`}
style={{ left: `${pct}%` }}
>
{val}{unit}
</span>
</div>
<div className="relative h-9 sm:h-6">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
<div
className={`absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full ${fillColor}`}
style={{ left: `${pct}%`, right: 0 }}
/>
<input
type="range"
min={min}
max={max}
step={step}
disabled={!editMode}
value={val}
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`}
/>
</div>
<div className="grid grid-cols-2 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>{t("settings.healthThresholds.ok")} &lt; {val}{unit}</span>
<span className="text-right">{severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} &gt; {val}{unit}</span>
</div>
</div>
)
}
// Dual-handle range slider replacing the two stacked number inputs
// for warn/crit pairs. Visual model: a horizontal track split into
// three zones — OK (left of warning, muted), WARN→CRIT (between
// handles, amber-to-red gradient), and OVER-CRIT (right of critical,
// dark red overlay). The handles themselves stay coloured (amber for
// warning, red for critical) so the operator reads the same severity
// mapping they're used to from the old inputs. Numbers ride above
// each handle and double as a click-to-edit affordance — clicking a
// number swaps it for a tight `<Input type="number">` so precise
// values are still keyboard-friendly. Customised values surface as a
// small blue dot on the affected handle (same signal as the old blue
// ring, less visual weight).
const renderThresholdRange = (
basePath: string[],
options?: { hideLabels?: boolean }
) => {
const wPath = [...basePath, "warning"]
const cPath = [...basePath, "critical"]
const wLeaf = getLeaf(tree, wPath)
const cLeaf = getLeaf(tree, cPath)
if (!wLeaf || !cLeaf) return null
const wKey = pathKey(wPath)
const cKey = pathKey(cPath)
const wVal = Number(pending[wKey] ?? wLeaf.value)
const cVal = Number(pending[cKey] ?? cLeaf.value)
// Backend validates warning <= critical on save.
const step = Math.max(wLeaf.step, cLeaf.step) || 1
const backendMin = Math.min(wLeaf.min, cLeaf.min)
const backendMax = Math.max(wLeaf.max, cLeaf.max)
const { min, max } = computeVisualRange(
[wLeaf.value, cLeaf.value, wLeaf.recommended, cLeaf.recommended],
backendMin,
backendMax,
step,
)
const pct = (v: number) => ((Math.max(min, Math.min(max, v)) - min) / (max - min)) * 100
const wPct = pct(wVal)
const cPct = pct(cVal)
const unit = wLeaf.unit || cLeaf.unit || ""
const wCustom = wLeaf.customised && !(wKey in pending)
const cCustom = cLeaf.customised && !(cKey in pending)
const setVal = (key: string, value: number, peer: number, isWarn: boolean) => {
// Clamp on the fly: warning can't cross critical and vice-versa,
// matching the backend invariant so the user can't drag into an
// invalid state.
let v = value
if (isWarn && v >= peer) v = peer - step
if (!isWarn && v <= peer) v = peer + step
setPending((p) => ({ ...p, [key]: String(v) }))
}
return (
<div className="px-1 py-3">
{/* Numeric labels above each handle, positioned absolutely so
they ride above the corresponding thumb regardless of the
slider width. Pointer events disabled so they don't steal
clicks from the underlying range inputs. */}
<div className="relative h-6 sm:h-5 mb-1 select-none">
<span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${wCustom ? "text-blue-400" : "text-amber-500"}`}
style={{ left: `${wPct}%` }}
>
{wVal}{unit}
</span>
<span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${cCustom ? "text-blue-400" : "text-red-500"}`}
style={{ left: `${cPct}%` }}
>
{cVal}{unit}
</span>
</div>
{/* Two range inputs stacked. Mobile track box is taller so the
enlarged thumbs (h-7) have room to sit without clipping. */}
<div className="relative h-9 sm:h-6">
{/* Background track: OK zone (muted) running the full width */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
{/* Warn-to-Crit gradient between the two handles */}
<div
className="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
style={{
left: `${wPct}%`,
width: `${Math.max(0, cPct - wPct)}%`,
background: "linear-gradient(90deg, rgb(245 158 11), rgb(239 68 68))",
}}
/>
{/* OVER-CRIT zone (right of critical) — solid red dim */}
<div
className="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full bg-red-500/30"
style={{ left: `${cPct}%`, right: 0 }}
/>
{/* Two superposed range inputs. Pointer-events on the thumb
only, so the inert track bar above stays visible. */}
<input
type="range"
min={min}
max={max}
step={step}
disabled={!editMode}
value={wVal}
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`}
/>
<input
type="range"
min={min}
max={max}
step={step}
disabled={!editMode}
value={cVal}
onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`}
/>
</div>
{/* Zone labels — explicit ranges so the operator knows where
"warn" starts and ends without having to read the handles. */}
{!options?.hideLabels && (
<div className="grid grid-cols-3 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>{t("settings.healthThresholds.ok")} &lt; {wVal}{unit}</span>
<span className="text-center">{t("settings.healthThresholds.warn")} {wVal}–{cVal}{unit}</span>
<span className="text-right">{t("settings.healthThresholds.crit")} &gt; {cVal}{unit}</span>
</div>
)}
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
<CardTitle>{t("settings.healthThresholds.title")}</CardTitle>
</div>
{!loading && (
<div className="flex items-center gap-2">
{savedFlash && (
<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}
disabled={saving}
>
{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={saving || !hasPendingChanges}
>
{saving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<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 text-muted-foreground flex items-center gap-1.5"
onClick={handleResetAll}
title={t("settings.healthThresholds.resetAllTitle")}
>
<RotateCcw className="h-3 w-3" />
{t("actions.resetAll")}
</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={handleEdit}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
</>
)}
</div>
)}
</div>
<CardDescription>
{t("settings.healthThresholds.description")}
</CardDescription>
</CardHeader>
{/* Intentional exception to the global "edit mode contrast"
rule: the numeric inputs already carry semantic colored
backgrounds (red critical, amber warning, blue customised)
which are meaningful and more graphical than a plain form.
A sunken selector would either erase those tints or force
us to `!important` every one — cleaner to keep this card
untouched in edit mode. */}
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : !tree ? (
<div className="text-sm text-muted-foreground">{t("settings.healthThresholds.loadFailed")}</div>
) : (
<div>
{error && (
<div className="mb-4 flex items-start gap-2 p-2.5 rounded-md bg-red-500/10 border border-red-500/30 text-red-500 text-xs">
<AlertCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="flex-1">{error}</div>
</div>
)}
{/*
Masonry-style flow via CSS columns: cards keep their natural
height (CPU = 2 rows, Disk temperature = 8 rows) and the
browser packs them top-to-bottom into 1/2/3 columns based on
viewport. `break-inside-avoid` keeps each card whole.
Mobile (<md) stays single-column as today.
*/}
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
{SECTIONS.map((section) => {
const Icon = section.icon
return (
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<h4 className="text-sm font-medium">{tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title)}</h4>
</div>
{editMode && (
<button
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
onClick={() => handleResetSection(section.id)}
title={t("settings.healthThresholds.resetSectionTitle")}
>
<RotateCcw className="h-3 w-3" />
</button>
)}
</div>
{section.description && (
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
{tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}
</p>
)}
<div>
{section.rowGroups ? (
// Per-class disk temperature: one slider per row
// (HDD / SSD / NVMe / SAS). Group label sits on
// top of each slider so the operator scans the
// column from top down without losing context.
section.rowGroups.map((group) => (
<div key={group.subKey} className="py-1.5 border-b border-border/40 last:border-b-0">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
{group.label}
</div>
{renderThresholdRange([section.id, group.subKey])}
</div>
))
) : section.id === "memory" ? (
// Memory & Swap is special: warn/crit pair for
// RAM, plus the swap-pressure pair. Swap
// CRITICAL fires only when both conditions hold
// — swap file above `swap_high` AND available
// RAM below `available_min`. Rendering the two
// sliders one under the other under a shared
// header reads as "the two knobs of one signal".
<>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
{t("settings.healthThresholds.ram")}
</div>
{renderThresholdRange(["memory"])}
<div className="border-t border-border/40 pt-1.5 mt-1.5">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
{t("settings.healthThresholds.swapPressure")}
</div>
<p className="text-[11px] text-muted-foreground px-1 pt-1 pb-1 leading-snug">
{t("settings.healthThresholds.swapPressureHint")}
</p>
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1 pt-1">
{t("settings.healthThresholds.swapHighLabel")}
</div>
{renderSingleThresholdSlider(["memory", "swap_high"], "critical")}
</div>
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1">
{t("settings.healthThresholds.availableMinLabel")}
</div>
{renderSingleThresholdSlider(["memory", "available_min"], "warning")}
</div>
</div>
</>
) : section.fields.length === 2 &&
section.fields[0].path[section.fields[0].path.length - 1] === "warning" &&
section.fields[1].path[section.fields[1].path.length - 1] === "critical" ? (
// Generic warn+crit pair (CPU, CPU temp, storage
// capacities …) → single slider.
renderThresholdRange([section.id])
) : (
// Fallback for any future section shape — keep
// the original per-field number inputs.
<div className="divide-y divide-border/40">
{section.fields.map((f) => renderField(f.path, f.label))}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</CardContent>
</Card>
)
}
File diff suppressed because it is too large Load Diff
+216 -142
View File
@@ -9,19 +9,22 @@ import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText, S
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useT } from "../lib/i18n/provider"
type TFunction = (key: string, params?: Record<string, string | number>) => string
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "6hour", label: "6 Hours" },
{ value: "day", label: "24 Hours" },
{ value: "3day", label: "3 Days" },
{ value: "week", label: "7 Days" },
{ value: "hour", labelKey: "network.latency.timeframes.hour" },
{ value: "6hour", labelKey: "network.latency.timeframes.sixHours" },
{ value: "day", labelKey: "network.latency.timeframes.day" },
{ value: "3day", labelKey: "network.latency.timeframes.threeDays" },
{ value: "week", labelKey: "network.latency.timeframes.week" },
]
const TARGET_OPTIONS = [
{ value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false },
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true },
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true },
{ value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false },
{ value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true },
{ value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", realtime: true },
]
// Realtime test configuration
@@ -60,7 +63,22 @@ interface LatencyDetailModalProps {
currentLatency?: number
}
const CustomTooltip = ({ active, payload, label }: any) => {
const getLatencyTimeframeLabel = (value: string, t: TFunction): string =>
TIMEFRAME_OPTIONS.find((option) => option.value === value)
? t(TIMEFRAME_OPTIONS.find((option) => option.value === value)!.labelKey)
: value
const getLatencyTargetLabel = (value: string, t: TFunction): string =>
TARGET_OPTIONS.find((option) => option.value === value)
? t(TARGET_OPTIONS.find((option) => option.value === value)!.labelKey)
: value
const getLatencyTargetShortLabel = (value: string, t: TFunction): string =>
TARGET_OPTIONS.find((option) => option.value === value)
? t(TARGET_OPTIONS.find((option) => option.value === value)!.shortLabelKey)
: value
const CustomTooltip = ({ active, payload, label, t }: any) => {
if (active && payload && payload.length) {
const entry = payload[0]
const data = entry?.payload
@@ -76,17 +94,17 @@ const CustomTooltip = ({ active, payload, label }: any) => {
<>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Min:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.min")}:</span>
<span className="text-sm font-semibold text-green-400">{data.min} ms</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Avg:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.avg")}:</span>
<span className="text-sm font-semibold text-white">{data.value} ms</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Max:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.max")}:</span>
<span className="text-sm font-semibold text-red-400">{data.max} ms</span>
</div>
</>
@@ -94,14 +112,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
// Simple latency display for single data points
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Latency:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.latency")}:</span>
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
</div>
)}
{packetLoss !== undefined && packetLoss > 0 && (
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Pkt Loss:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.packetLossShort")}:</span>
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
</div>
)}
@@ -118,20 +136,38 @@ const getStatusColor = (latency: number) => {
return "#22c55e"
}
const getStatusInfo = (latency: number | null) => {
if (latency === null || latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" }
const getStatusInfo = (latency: number | null, t: TFunction) => {
if (latency === null || latency === 0) return { status: t("common.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (latency < 50) return { status: t("network.latency.status.excellent"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 100) return { status: t("network.latency.status.good"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 200) return { status: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: t("network.latency.status.poor"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
}
const getStatusText = (latency: number | null): string => {
if (latency === null || latency === 0) return "N/A"
if (latency < 50) return "Excellent"
if (latency < 100) return "Good"
if (latency < 200) return "Fair"
return "Poor"
const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => {
if (latency === null || latency === 0) return "na"
if (latency < 50) return "excellent"
if (latency < 100) return "good"
if (latency < 200) return "fair"
return "poor"
}
const getStatusText = (latency: number | null, t: TFunction): string => {
const key = getStatusKey(latency)
return key === "na" ? t("common.notAvailable") : t(`network.latency.status.${key}`)
}
const formatReportDuration = (seconds: number | undefined, t: TFunction, compact = false): string => {
if (!seconds || seconds <= 0) {
return compact ? t("network.latency.report.realTime") : t("network.latency.report.testPeriod")
}
if (seconds < 60) {
return t(compact ? "network.latency.report.secondsShort" : "network.latency.report.seconds", { count: seconds })
}
const minutes = Math.max(1, Math.round(seconds / 60))
return t(compact ? "network.latency.report.minutesShort" : "network.latency.report.minutes", { count: minutes })
}
interface ReportData {
@@ -145,9 +181,10 @@ interface ReportData {
testDuration?: number
}
const generateLatencyReport = (report: ReportData) => {
const generateLatencyReport = (report: ReportData, t: TFunction) => {
const now = new Date().toLocaleString()
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
const htmlLang = document.documentElement.lang || "en"
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
@@ -160,29 +197,57 @@ const generateLatencyReport = (report: ReportData) => {
} : null
const statusText = report.isRealtime
? getStatusText(realtimeStats?.current ?? null)
: getStatusText(report.stats.current)
? getStatusText(realtimeStats?.current ?? null, t)
: getStatusText(report.stats.current, t)
// Colors matching Lynis report
const statusColorMap: Record<string, string> = {
"Excellent": "#16a34a",
"Good": "#16a34a",
"Fair": "#ca8a04",
"Poor": "#dc2626",
"N/A": "#64748b"
excellent: "#16a34a",
good: "#16a34a",
fair: "#ca8a04",
poor: "#dc2626",
na: "#64748b",
}
const statusColor = statusColorMap[statusText] || "#64748b"
const statusKey = report.isRealtime
? getStatusKey(realtimeStats?.current ?? null)
: getStatusKey(report.stats.current)
const statusColor = statusColorMap[statusKey] || "#64748b"
const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe
const timeframeLabel = getLatencyTimeframeLabel(report.timeframe, t)
const reportId = `PMXL-${Date.now().toString(36).toUpperCase()}`
const notAvailable = t("common.notAvailable")
const modeLabel = report.isRealtime
? t("network.latency.report.realTimeTest")
: t("network.latency.report.historicalAnalysis")
const realtimeDurationText = formatReportDuration(report.testDuration, t)
const realtimePacketLossText =
realtimeStats && realtimeStats.avgPacketLoss > 0
? `<span style="color:#dc2626">${t("network.latency.report.averagePacketLoss", {
value: realtimeStats.avgPacketLoss.toFixed(1),
})}</span>`
: `<span style="color:#16a34a">${t("network.latency.report.noPacketLoss")}</span>`
const testPeriodValue = report.isRealtime
? formatReportDuration(report.testDuration, t, true)
: timeframeLabel
const targetIpLabel =
report.target === "gateway"
? t("network.latency.report.defaultGateway")
: report.target === "cloudflare"
? "1.1.1.1"
: "8.8.8.8"
const detailSectionNumber =
(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0)
? "6"
: "5"
// Build test results table for realtime mode - each row is now an individual ping measurement
const realtimeTableRows = report.realtimeResults.map((r, i) => `
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</td>
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : 'Failed'}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg)}</span></td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg, t)}</span></td>
</tr>
`).join('')
@@ -199,9 +264,9 @@ const generateLatencyReport = (report: ReportData) => {
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</td>
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : 'Failed'}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${getStatusText(d.value)}</span></td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${getStatusText(d.value, t)}</span></td>
</tr>
`).join('')
@@ -210,7 +275,7 @@ const generateLatencyReport = (report: ReportData) => {
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
: report.data.map(d => d.value || 0)
let chartSvg = '<p style="text-align:center;color:#64748b;padding:20px;">Not enough data points for chart</p>'
let chartSvg = `<p style="text-align:center;color:#64748b;padding:20px;">${t("network.latency.report.notEnoughData")}</p>`
if (chartData.length >= 2) {
const rawMin = Math.min(...chartData)
const rawMax = Math.max(...chartData)
@@ -253,17 +318,17 @@ const generateLatencyReport = (report: ReportData) => {
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${chartData.length} samples</text>
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${t("network.latency.report.samples", { count: chartData.length })}</text>
</svg>
`
}
const html = `<!DOCTYPE html>
<html lang="en">
<html lang="${htmlLang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Network Latency Report - ${report.targetLabel}</title>
<title>${t("network.latency.report.title")} - ${report.targetLabel}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
@@ -298,10 +363,12 @@ const generateLatencyReport = (report: ReportData) => {
.top-bar-title { font-weight: 600; }
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
.top-bar button {
background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer;
background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.top-bar button:hover { background: #0891b2; }
.top-bar .btn-group { display: flex; gap: 8px; }
.top-bar button svg { width: 18px; height: 18px; display: block; }
@media (min-width: 640px) {
.top-bar { padding: 12px 24px; }
.top-bar-subtitle { display: block; }
@@ -463,11 +530,14 @@ const generateLatencyReport = (report: ReportData) => {
<div class="top-bar no-print">
<div class="top-bar-left">
<div>
<div class="top-bar-title">ProxMenux Network Latency Report</div>
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div>
<div class="top-bar-title">${t("network.latency.report.topBarTitle")}</div>
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
</div>
</div>
<button onclick="window.print()">Print / Save as PDF</button>
<div class="btn-group">
<button onclick="window.print()" title="Print" aria-label="Print"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg></button>
<button onclick="window.print()" title="Save as PDF" aria-label="Save as PDF"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg></button>
</div>
</div>
<!-- Header -->
@@ -475,21 +545,21 @@ const generateLatencyReport = (report: ReportData) => {
<div class="rpt-header-left">
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
<div>
<h1>Network Latency Report</h1>
<p>ProxMenux Monitor - Network Performance Analysis</p>
<h1>${t("network.latency.report.title")}</h1>
<p>${t("network.latency.report.subtitle")}</p>
</div>
</div>
<div class="rpt-header-right">
<div><strong>Date:</strong> ${now}</div>
<div><strong>Target:</strong> ${report.targetLabel}</div>
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div>
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
<div><strong>${t("network.latency.report.date")}:</strong> ${now}</div>
<div><strong>${t("network.latency.report.target")}:</strong> ${report.targetLabel}</div>
<div><strong>${t("network.latency.report.mode")}:</strong> ${modeLabel}</div>
<div class="rid">ID: ${reportId}</div>
</div>
</div>
<!-- 1. Executive Summary -->
<div class="section">
<div class="section-title">1. Executive Summary</div>
<div class="section-title">1. ${t("network.latency.report.executiveSummary")}</div>
<div class="exec-box">
<div class="latency-gauge">
<svg viewBox="0 0 120 90" width="160" height="120">
@@ -508,35 +578,41 @@ const generateLatencyReport = (report: ReportData) => {
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
</svg>
<div class="gauge-value" style="color:${statusColor};">
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? 'N/A') : report.stats.avg}</span>
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? notAvailable) : report.stats.avg}</span>
<span class="gauge-unit">ms</span>
</div>
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
</div>
<div class="exec-text">
<h3>Network Latency Assessment${report.isRealtime ? ' (Real-time)' : ''}</h3>
<h3>${t("network.latency.report.assessmentTitle")}${report.isRealtime ? ` (${t("network.latency.report.realTime")})` : ""}</h3>
<p>
${report.isRealtime
? `Real-time latency test to <strong>${report.targetLabel}</strong> with <strong>${report.realtimeResults.length} samples</strong> collected over ${report.testDuration ? Math.round(report.testDuration / 60) + ' minute(s)' : 'the test period'}.
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</strong>.
${realtimeStats && realtimeStats.avgPacketLoss > 0 ? `<span style="color:#dc2626">Average packet loss: ${realtimeStats.avgPacketLoss.toFixed(1)}%.</span>` : '<span style="color:#16a34a">No packet loss detected.</span>'}`
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>.
<strong>${report.data.length} samples</strong> analyzed.
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.`
? `${t("network.latency.report.realtimeSummary", {
target: `<strong>${report.targetLabel}</strong>`,
count: `<strong>${report.realtimeResults.length}</strong>`,
duration: realtimeDurationText,
avg: `<strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? notAvailable} ms</strong>`,
})} ${realtimePacketLossText}`
: `${t("network.latency.report.historicalSummary", {
target: t("network.latency.targets.gatewayShort"),
timeframe: timeframeLabel.toLowerCase(),
count: report.data.length,
avg: report.stats.avg,
})}`
}
</p>
<div class="latency-range">
<div class="range-item">
<span class="range-label">Minimum</span>
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms</span>
<span class="range-label">${t("network.labels.minimum")}</span>
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms</span>
</div>
<div class="range-item">
<span class="range-label">Average</span>
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms</span>
<span class="range-label">${t("network.labels.average")}</span>
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms</span>
</div>
<div class="range-item">
<span class="range-label">Maximum</span>
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms</span>
<span class="range-label">${t("network.labels.maximum")}</span>
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms</span>
</div>
</div>
</div>
@@ -545,42 +621,40 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 2. Statistics -->
<div class="section">
<div class="section-title">2. Latency Statistics</div>
<div class="section-title">2. ${t("network.latency.report.latencyStatistics")}</div>
<div class="grid-4">
<div class="card card-c">
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? 'N/A') : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Current</div>
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? notAvailable) : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">${t("network.labels.current")}</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Minimum</div>
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">${t("network.labels.minimum")}</div>
</div>
<div class="card card-c">
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Average</div>
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">${t("network.labels.average")}</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Maximum</div>
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">${t("network.labels.maximum")}</div>
</div>
</div>
<div class="grid-3">
<div class="card">
<div class="card-label">Sample Count</div>
<div class="card-label">${t("network.latency.report.sampleCount")}</div>
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
</div>
<div class="card">
<div class="card-label">Packet Loss (Avg)</div>
<div class="card-label">${t("network.latency.report.packetLossAvg")}</div>
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
</div>
</div>
<div class="card">
<div class="card-label">Test Period</div>
<div class="card-label">${t("network.latency.report.testPeriodLabel")}</div>
<div class="card-value" style="font-size:11px;">
${report.isRealtime
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
: timeframeLabel}
${testPeriodValue}
</div>
</div>
</div>
@@ -588,7 +662,7 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 3. Latency Graph (always section 3) -->
<div class="section">
<div class="section-title">3. Latency Graph</div>
<div class="section-title">3. ${t("network.latency.report.latencyGraph")}</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
${chartSvg}
</div>
@@ -596,37 +670,37 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 4. Performance Thresholds (always section 4) -->
<div class="section">
<div class="section-title">4. Performance Thresholds</div>
<div class="section-title">4. ${t("network.latency.report.performanceThresholds")}</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#16a34a;"></div>
<p><strong>Excellent (&lt; 50ms):</strong> Optimal for real-time applications, gaming, and video calls.</p>
<p><strong>${t("network.latency.status.excellent")} (&lt; 50ms):</strong> ${t("network.latency.report.thresholdExcellent")}</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#16a34a;"></div>
<p><strong>Good (50-100ms):</strong> Acceptable for most applications with minimal impact.</p>
<p><strong>${t("network.latency.status.good")} (50-100ms):</strong> ${t("network.latency.report.thresholdGood")}</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#ca8a04;"></div>
<p><strong>Fair (100-200ms):</strong> Noticeable delay. May affect VoIP and interactive applications.</p>
<p><strong>${t("network.latency.status.fair")} (100-200ms):</strong> ${t("network.latency.report.thresholdFair")}</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#dc2626;"></div>
<p><strong>Poor (&gt; 200ms):</strong> Significant latency. Investigation recommended.</p>
<p><strong>${t("network.latency.status.poor")} (&gt; 200ms):</strong> ${t("network.latency.report.thresholdPoor")}</p>
</div>
</div>
${report.isRealtime && report.realtimeResults.length > 0 ? `
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
<div class="section">
<div class="section-title">5. Detailed Test Results</div>
<div class="section-title">5. ${t("network.latency.report.detailedTestResults")}</div>
<table class="chk-tbl">
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
<th>${t("network.labels.time")}</th>
<th>${t("network.labels.latency")}</th>
<th>${t("network.labels.packetLoss")}</th>
<th>${t("network.labels.status")}</th>
</tr>
</thead>
<tbody>
@@ -639,15 +713,15 @@ const generateLatencyReport = (report: ReportData) => {
${!report.isRealtime && report.data.length > 0 ? `
<!-- 5. Detailed History (for Gateway) -->
<div class="section">
<div class="section-title">5. Latency History (Last ${Math.min(20, report.data.length)} Records)</div>
<div class="section-title">5. ${t("network.latency.report.latencyHistory", { count: Math.min(20, report.data.length) })}</div>
<table class="chk-tbl">
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
<th>${t("network.labels.time")}</th>
<th>${t("network.labels.latency")}</th>
<th>${t("network.labels.packetLoss")}</th>
<th>${t("network.labels.status")}</th>
</tr>
</thead>
<tbody>
@@ -659,41 +733,41 @@ ${!report.isRealtime && report.data.length > 0 ? `
<!-- Methodology -->
<div class="section">
<div class="section-title">${(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? '6' : '5'}. Methodology</div>
<div class="section-title">${detailSectionNumber}. ${t("network.latency.report.methodology")}</div>
<div class="grid-2">
<div class="card">
<div class="card-label">Test Method</div>
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div>
<div class="card-label">${t("network.latency.report.testMethod")}</div>
<div class="card-value" style="font-size:12px;">${t("network.latency.report.icmpEchoRequest")}</div>
</div>
<div class="card">
<div class="card-label">Samples per Test</div>
<div class="card-value" style="font-size:12px;">3 consecutive pings</div>
<div class="card-label">${t("network.latency.report.samplesPerTest")}</div>
<div class="card-value" style="font-size:12px;">${t("network.latency.report.threeConsecutivePings")}</div>
</div>
<div class="card">
<div class="card-label">Target</div>
<div class="card-label">${t("network.latency.report.target")}</div>
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
</div>
<div class="card">
<div class="card-label">Target IP</div>
<div class="card-value" style="font-size:12px;">${report.target === 'gateway' ? 'Default Gateway' : report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'}</div>
<div class="card-label">${t("network.latency.report.targetIp")}</div>
<div class="card-value" style="font-size:12px;">${targetIpLabel}</div>
</div>
</div>
<div class="info-box">
<h4>Performance Assessment</h4>
<h4>${t("network.latency.report.performanceAssessment")}</h4>
<p>${
statusText === 'Excellent' ? 'Network latency is excellent. No action required.' :
statusText === 'Good' ? 'Network latency is within acceptable parameters.' :
statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' :
statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' :
'Unable to determine network status.'
statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") :
statusKey === 'good' ? t("network.latency.report.assessmentGood") :
statusKey === 'fair' ? t("network.latency.report.assessmentFair") :
statusKey === 'poor' ? t("network.latency.report.assessmentPoor") :
t("network.latency.report.assessmentUnknown")
}</p>
</div>
</div>
<!-- Footer -->
<div class="rpt-footer">
<div>ProxMenux Monitor - Network Performance Report</div>
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
<div>${t("network.latency.report.footerTitle")}</div>
<div>${t("network.latency.report.generated")}: ${now} | ${t("network.latency.report.reportId")}: ${reportId}</div>
</div>
</body>
@@ -706,6 +780,7 @@ ${!report.isRealtime && report.data.length > 0 ? `
}
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
const t = useT()
const [timeframe, setTimeframe] = useState("hour")
const [target, setTarget] = useState("gateway")
const [data, setData] = useState<LatencyHistoryPoint[]>([])
@@ -882,7 +957,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
} : stats
const statusInfo = getStatusInfo(displayStats.current)
const statusInfo = getStatusInfo(displayStats.current, t)
// Calculate test duration for report based on first and last result timestamps
const testDuration = realtimeResults.length >= 2
@@ -897,20 +972,20 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<Wifi className="h-5 w-5 text-blue-500" />
Network Latency
{t("network.cards.latency")}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 mt-1 flex-nowrap">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
<span className="truncate">
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target}
{getLatencyTargetShortLabel(target, t)}
</span>
</SelectTrigger>
<SelectContent>
{TARGET_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -923,7 +998,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<SelectContent>
{TIMEFRAME_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -938,7 +1013,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
>
<Square className="h-3 w-3 fill-current" />
Stop
{t("network.latency.actions.stop")}
</Button>
) : (
<Button
@@ -948,7 +1023,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
className="gap-1.5 shrink-0 h-8 px-3"
>
<RefreshCw className="h-3 w-3" />
Test Again
{t("network.latency.actions.testAgain")}
</Button>
)
)}
@@ -957,19 +1032,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
size="sm"
onClick={() => generateLatencyReport({
target,
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target,
targetLabel: getLatencyTargetLabel(target, t),
isRealtime,
stats,
realtimeResults,
data,
timeframe,
testDuration: isRealtime ? testDuration : undefined,
})}
}, t)}
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
className="gap-1.5 shrink-0 h-8 px-3"
>
<FileText className="h-3.5 w-3.5" />
Report
{t("network.latency.actions.report")}
</Button>
</div>
@@ -977,8 +1052,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{isRealtime && realtimeTesting && (
<div className="mb-4">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>Testing... {Math.round(testProgress)}%</span>
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span>
<span>{t("network.latency.testingProgress", { percent: Math.round(testProgress) })}</span>
<span>{t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
@@ -992,7 +1067,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{/* Stats Cards - Compact single row */}
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
<div className="flex items-center gap-1 min-w-0">
<span className="text-[10px] text-muted-foreground">Current</span>
<span className="text-[10px] text-muted-foreground">{t("network.labels.current")}</span>
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
{displayStats.current || '-'}
</span>
@@ -1000,19 +1075,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
</div>
<div className="flex items-center gap-1 min-w-0">
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
<span className="text-[10px] text-muted-foreground">Min</span>
<span className="text-[10px] text-muted-foreground">{t("network.labels.min")}</span>
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-0">
<Minus className="h-3 w-3 shrink-0" />
<span className="text-[10px] text-muted-foreground">Avg</span>
<span className="text-[10px] text-muted-foreground">{t("network.labels.avg")}</span>
<span className="text-base font-bold">{displayStats.avg || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-0">
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
<span className="text-[10px] text-muted-foreground">Max</span>
<span className="text-[10px] text-muted-foreground">{t("network.labels.max")}</span>
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
@@ -1025,8 +1100,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
</Badge>
{isRealtime && (
<span className="text-xs text-muted-foreground">
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''}
{t("network.latency.samplesCollected", { count: realtimeResults.length })}
{realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""}
</span>
)}
</div>
@@ -1058,7 +1133,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<CustomTooltip t={t} />} />
<Area
type="monotone"
dataKey="value"
@@ -1075,7 +1150,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Activity className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">
{realtimeTesting ? 'Collecting data...' : 'No data yet. Click "Test Again" to start.'}
{realtimeTesting ? t("network.latency.collectingData") : t("network.latency.noRealtimeData")}
</p>
</div>
)
@@ -1107,7 +1182,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<CustomTooltip t={t} />} />
{/* For longer timeframes (6h+), show max values to preserve spikes.
For 1 hour view, show avg values since there's no downsampling */}
<Area
@@ -1123,8 +1198,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Activity className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">No latency data available for this period</p>
<p className="text-xs mt-1">Data is collected every 60 seconds</p>
<p className="text-sm">{t("network.latency.noDataForPeriod")}</p>
<p className="text-xs mt-1">{t("network.latency.collectionInterval")}</p>
</div>
)}
</div>
@@ -1133,8 +1208,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{isRealtime && (
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-xs text-blue-400">
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds.
Click "Test Again" to add more samples. All data is included in the report.
<strong>{t("network.latency.realTimeMode")}:</strong> {t("network.latency.realTimeModeDescription")}
</p>
</div>
)}

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